From c5fba1ff72d08e6e2f463cd10a0b0f509bcbcfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 25 Aug 2026 14:52:50 +0800 Subject: [PATCH 1/7] feat(skill): add ROS agent integration and permission recovery --- .github/workflows/test.yml | 6 + pyproject.toml | 3 + scripts/a2a/e2e/README.md | 99 + scripts/a2a/e2e/README.zh-CN.md | 80 + .../permission_wait_fixture_server.py | 411 ++ .../permission_wait_start_chat_prompt.md | 65 + .../run_permission_wait_restart.py | 530 ++ .../run_start_chat_permission_wait.py | 1294 +++++ .../run_sub_pipeline_permission_timeout.py | 507 ++ skills/alicloud-ros-agent/SKILL.md | 214 + skills/alicloud-ros-agent/agents/openai.yaml | 4 + .../alicloud-ros-agent/requirements-code.txt | 2 + .../alicloud-ros-agent/scripts/ros_agent.py | 4261 +++++++++++++++++ skills/iac-code/SKILL.md | 15 +- skills/iac-code/scripts/iac_code.py | 216 +- src/iac_code/a2a/app.py | 5 + src/iac_code/a2a/backup.py | 3 +- src/iac_code/a2a/events.py | 162 +- src/iac_code/a2a/executor.py | 858 +++- src/iac_code/a2a/input_required.py | 425 +- src/iac_code/a2a/pipeline_executor.py | 339 +- src/iac_code/a2a/pipeline_stream.py | 234 +- src/iac_code/a2a/task_store.py | 9 +- src/iac_code/a2a/transports/dispatcher.py | 152 + src/iac_code/agent/agent_loop.py | 604 ++- src/iac_code/cli/main.py | 2 + .../i18n/locales/de/LC_MESSAGES/messages.po | 5 + .../i18n/locales/es/LC_MESSAGES/messages.po | 5 + .../i18n/locales/fr/LC_MESSAGES/messages.po | 5 + .../i18n/locales/ja/LC_MESSAGES/messages.po | 5 + .../i18n/locales/pt/LC_MESSAGES/messages.po | 5 + .../i18n/locales/zh/LC_MESSAGES/messages.po | 5 + src/iac_code/mcp/manager.py | 22 +- src/iac_code/mcp/oauth.py | 5 +- src/iac_code/mcp/storage.py | 10 +- src/iac_code/mcp/types.py | 21 +- .../pipeline/engine/pipeline_runner.py | 119 +- src/iac_code/pipeline/engine/step_executor.py | 5 +- .../pipeline/selling/tools/ros_deploy_tool.py | 4 + src/iac_code/services/agent_factory.py | 2 +- src/iac_code/services/permission_wait.py | 1280 +++++ src/iac_code/services/permissions/pipeline.py | 38 +- src/iac_code/services/session_layout.py | 8 + src/iac_code/services/session_storage.py | 1 + src/iac_code/tools/cloud/aliyun/aliyun_api.py | 13 +- src/iac_code/types/stream_events.py | 26 +- src/iac_code/utils/state_io.py | 37 + src/iac_code/web/app.py | 171 +- src/iac_code/web/permissions.py | 2 + src/iac_code/web/pipeline_actions.py | 107 +- src/iac_code/web/runtime.py | 252 +- src/iac_code/web/session_manager.py | 398 +- tests/a2a/test_app.py | 75 + tests/a2a/test_events.py | 375 ++ tests/a2a/test_executor.py | 252 +- tests/a2a/test_input_required.py | 316 +- tests/a2a/test_pipeline_executor.py | 335 +- tests/a2a/test_pipeline_stream.py | 121 + tests/a2a/test_transport_dispatcher.py | 88 +- tests/a2a_e2e/test_permission_wait_restart.py | 105 + .../test_start_chat_permission_wait_runner.py | 665 +++ .../test_sub_pipeline_permission_timeout.py | 28 + tests/agent/test_agent_loop_permissions.py | 965 +++- .../test_permission_audit_integration.py | 66 + tests/cli/test_a2a_command.py | 3 + tests/conftest.py | 4 - tests/desktop/test_controller.py | 53 + tests/mcp/test_manager.py | 2 + tests/mcp/test_storage.py | 25 +- tests/mcp/test_types.py | 21 + tests/pipeline/engine/test_pipeline_runner.py | 161 +- tests/services/permissions/test_pipeline.py | 143 +- tests/services/test_permission_wait.py | 911 ++++ .../skill_bridge/start_chat_connect_proxy.py | 142 + tests/skill_bridge/start_chat_relay.py | 820 ++++ .../test_alicloud_ros_agent_bridge.py | 3891 +++++++++++++++ tests/skill_bridge/test_iac_code_bridge.py | 121 +- tests/skill_bridge/test_start_chat_relay.py | 1358 ++++++ tests/tools/cloud/aliyun/test_aliyun_api.py | 87 +- .../aliyun/test_aliyun_api_permissions.py | 24 +- tests/web/test_permission_wait_recovery.py | 556 +++ website/docs/mcp/oauth-and-security.md | 12 +- .../current/mcp/oauth-and-security.md | 14 +- .../current/mcp/oauth-and-security.md | 12 +- .../current/mcp/oauth-and-security.md | 12 +- .../current/mcp/oauth-and-security.md | 10 +- .../current/mcp/oauth-and-security.md | 12 +- .../current/mcp/oauth-and-security.md | 10 +- 88 files changed, 24306 insertions(+), 540 deletions(-) create mode 100644 scripts/a2a/e2e/permission_wait/permission_wait_fixture_server.py create mode 100644 scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md create mode 100644 scripts/a2a/e2e/permission_wait/run_permission_wait_restart.py create mode 100644 scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py create mode 100644 scripts/a2a/e2e/permission_wait/run_sub_pipeline_permission_timeout.py create mode 100644 skills/alicloud-ros-agent/SKILL.md create mode 100644 skills/alicloud-ros-agent/agents/openai.yaml create mode 100644 skills/alicloud-ros-agent/requirements-code.txt create mode 100644 skills/alicloud-ros-agent/scripts/ros_agent.py create mode 100644 src/iac_code/services/permission_wait.py create mode 100644 tests/a2a_e2e/test_permission_wait_restart.py create mode 100644 tests/a2a_e2e/test_start_chat_permission_wait_runner.py create mode 100644 tests/a2a_e2e/test_sub_pipeline_permission_timeout.py create mode 100644 tests/services/test_permission_wait.py create mode 100644 tests/skill_bridge/start_chat_connect_proxy.py create mode 100644 tests/skill_bridge/start_chat_relay.py create mode 100644 tests/skill_bridge/test_alicloud_ros_agent_bridge.py create mode 100644 tests/skill_bridge/test_start_chat_relay.py create mode 100644 tests/web/test_permission_wait_recovery.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb049b65..9c3e32a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,9 @@ on: - "pyproject.toml" - "uv.lock" - "scripts/aliyun/**" + - "scripts/a2a/**" - "skills/iac-code/**" + - "skills/alicloud-ros-agent/**" - "skill-runtime/**" - ".github/workflows/test.yml" pull_request: @@ -24,7 +26,9 @@ on: - "pyproject.toml" - "uv.lock" - "scripts/aliyun/**" + - "scripts/a2a/**" - "skills/iac-code/**" + - "skills/alicloud-ros-agent/**" - "skill-runtime/**" - ".github/workflows/test.yml" @@ -51,6 +55,8 @@ jobs: run: | python -m py_compile skills/iac-code/scripts/iac_code.py python skills/iac-code/scripts/iac_code.py --help + python -m py_compile skills/alicloud-ros-agent/scripts/ros_agent.py + python skills/alicloud-ros-agent/scripts/ros_agent.py --help lint: name: Lint diff --git a/pyproject.toml b/pyproject.toml index 054ef2d0..3c1b8447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,9 @@ default = true [tool.pytest.ini_options] timeout = 30 +markers = [ + "integration: process-level integration tests that use local services only", +] [tool.coverage.run] source_pkgs = ["iac_code"] diff --git a/scripts/a2a/e2e/README.md b/scripts/a2a/e2e/README.md index 2b48c645..1f4975d3 100644 --- a/scripts/a2a/e2e/README.md +++ b/scripts/a2a/e2e/README.md @@ -1,5 +1,104 @@ # A2A E2E Session Recovery and Redaction +## Real StartChat permission-wait matrix + +`run_start_chat_permission_wait.py` is the credential-gated, repeatable chain +for this feature. It runs Qoder's real LLM through the installed +`alicloud-ros-agent` Skill, its Python bridge, the native `aliyun` CLI, the +StartChat-only HTTPS relay, local iac-code A2A servers, and real iac-code +LLM/cloud calls. Before taking a private isolated config copy, it refreshes +OAuth-backed STS in the caller-selected `--source-config-dir` in place. This is +important because OAuth refresh tokens may rotate: refreshing only a disposable +copy can invalidate the source for the next scenario. It then fixes the server +policy at `300 / 300 / 30`, enables the shared-backup commit protocol, uses +unique Stack/VSwitch names, and performs an exact-name cleanup fallback. + +The Qoder flag that bypasses host Bash/file confirmation applies only to the +test driver. It does not approve ROS Agent permissions: the isolated iac-code +settings use the default permission mode, explicitly allow incidental tools, +and ask for cloud-mutating tools. The A2A servers also keep +`auto_approve_permissions: false`, and every non-read-only cloud operation is +answered through the correlated StartChat permission envelope. + +Run one scenario per fresh directory: + +The real headless Qoder turn timeout defaults to 900 seconds so a two-candidate +Pipeline can finish without weakening the scenario. Override it with +`--qoder-turn-timeout` when diagnosing a slower provider. + +```bash +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-normal-before \ + --mode normal + +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-pipeline-before \ + --mode pipeline + +# Answer during the 30-second grace after the 300-second resident deadline. +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-normal-grace \ + --mode normal \ + --answer-delay-seconds 305 + +# Answer after non-failure suspension. +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-pipeline-suspended \ + --mode pipeline \ + --answer-delay-seconds 335 + +# Kill only the selected local A2A process at the first permission boundary, +# restart it with the same persistence/config directories, then answer. +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-normal-restart \ + --mode normal \ + --restart-at-first-permission +``` + +Repeat the grace, suspended, and restart variants for both `normal` and +`pipeline`. The repository prompt is +`permission_wait/permission_wait_start_chat_prompt.md`. Each run writes bounded Qoder turn +summaries, permission observations, relay metrics, a safe result manifest, and +local server logs. Before exit, it derives bounded read-only evidence and removes +the copied credential files and complete session transcripts. A read-only +permission prompt or an out-of-scope cloud target fails the run without being +approved. The final checks require a real non-read-only permission, local and +shared serial checkpoint evidence, no Sub Pipeline checkpoint, native +StartChat usage, exact resource cleanup, and retention of the pre-existing VPC +inventory. + +The controlled Sub-Pipeline fixture uses a real `PipelineRunner` with two real +`AgentLoop` candidates. One candidate parks at an actual permission Future while +the other completes naturally; after the configured hard timeout, the parent +aggregates both conclusions, reaches candidate selection, and completes. The +production A2A backup hook also proves that the Sub permission itself did not +trigger a critical permission backup. The acceptance run uses the production +300-second value: + +```bash +uv run python scripts/a2a/e2e/permission_wait/run_sub_pipeline_permission_timeout.py \ + --run-dir /tmp/iac-pwait-sub-pipeline-300 \ + --timeout-seconds 300 +``` + +Its accelerated regression and the fast deterministic process-restart matrix +are: + +```bash +uv run pytest -q tests/a2a_e2e/test_sub_pipeline_permission_timeout.py +uv run pytest -q tests/a2a_e2e/test_permission_wait_restart.py +``` + +The restart matrix covers Normal/Pipeline × allow/deny without real +credentials. The Sub-Pipeline fixture asserts one denial ToolResult, continued +Agent-loop execution, parent candidate selection/completion, and the absence of +grace, durable permission checkpoints, and permission-critical backup. + This directory contains headless end-to-end checks for A2A pipeline session recovery and redaction regressions. The runner drives the public A2A JSON-RPC streaming endpoint and records SSE events and pipeline snapshots. Recovery diff --git a/scripts/a2a/e2e/README.zh-CN.md b/scripts/a2a/e2e/README.zh-CN.md index a271f5df..9b7efbd5 100644 --- a/scripts/a2a/e2e/README.zh-CN.md +++ b/scripts/a2a/e2e/README.zh-CN.md @@ -1,5 +1,85 @@ # A2A 会话恢复与脱敏 E2E +## 真实 StartChat 权限等待矩阵 + +`run_start_chat_permission_wait.py` 是本功能可重复执行、受凭证开关保护的真实链路:Qoder 真实 LLM +→ 安装后的 `alicloud-ros-agent` Skill → Python bridge → 原生 `aliyun` CLI → 只暴露 StartChat/StopChat +的本地 HTTPS relay → 本地 iac-code A2A server → 真实 iac-code LLM 和云调用。Runner 会先在调用方指定的 +`--source-config-dir` 中原地刷新 OAuth STS,再把最新凭证复制到权限受限的独立 config dir。这样可避免只在 +一次性副本中刷新并轮换 OAuth refresh token,导致源配置在下一个场景失效。随后把服务端策略固定为 +`300 / 300 / 30`,启用共享备份提交协议,使用唯一的 Stack/VSwitch 名称,并在结束时仅按精确名称做兜底清理。 + +Qoder 的 host 权限绕过只用于允许测试驱动执行本地 Bash/文件操作,不会批准 ROS Agent 权限。隔离的 iac-code +配置使用默认权限模式,显式允许辅助工具并要求云资源变更工具确认;A2A server 同时保持 +`auto_approve_permissions: false`,非只读云操作仍必须通过带完整关联字段的 StartChat 权限回答。 + +每个场景使用新的目录: + +真实 headless Qoder 的单轮超时默认是 900 秒,保证双 candidate Pipeline 不会被测试驱动过早终止; +provider 更慢时可通过 `--qoder-turn-timeout` 显式覆盖,不会削减 Pipeline 场景。 + +```bash +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-normal-before \ + --mode normal + +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-pipeline-before \ + --mode pipeline + +# resident 300 秒到期后,在 30 秒 grace 内回答。 +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-normal-grace \ + --mode normal \ + --answer-delay-seconds 305 + +# 等非失败挂起完成后再回答。 +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-pipeline-suspended \ + --mode pipeline \ + --answer-delay-seconds 335 + +# 在首个权限等待点只终止当前模式的本地 A2A 进程,用同一 config/persistence 目录重启后回答。 +uv run python scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py \ + --allow-real-cloud \ + --run-dir /tmp/iac-pwait-normal-restart \ + --mode normal \ + --restart-at-first-permission +``` + +Normal 和 Pipeline 都要分别运行 grace、挂起后恢复和进程重启变体。仓库内 prompt 是 +`permission_wait/permission_wait_start_chat_prompt.md`。每次运行只保存有界 Qoder turn 摘要、权限观察、relay metrics、 +安全结果清单和本地服务日志;退出前会先提取有界只读证据,再删除复制的凭证文件和完整会话 transcript。 +发现只读权限弹窗或范围外云写入时,Runner 会直接失败, +不会替用户批准。最终断言要求:真实非只读权限、Normal/顶层 Pipeline 本地与共享 checkpoint、Sub Pipeline 无 +checkpoint、确实经过原生 StartChat、精确清理本次资源,以及全部原有 VPC 仍存在。 + +受控 Sub Pipeline fixture 使用真实 `PipelineRunner` 和两个真实 `AgentLoop` candidate:一个 candidate +停在真实权限 Future,另一个自然完成;到达配置的硬超时后,父 Pipeline 聚合两个 conclusion、进入 candidate +选择并自然完成。fixture 同时安装生产 A2A 备份 hook,证明 Sub 权限本身不会触发权限关键备份。验收运行使用 +生产环境的 300 秒配置: + +```bash +uv run python scripts/a2a/e2e/permission_wait/run_sub_pipeline_permission_timeout.py \ + --run-dir /tmp/iac-pwait-sub-pipeline-300 \ + --timeout-seconds 300 +``` + +对应的加速回归和无真实凭证的快速进程重启矩阵为: + +```bash +uv run pytest -q tests/a2a_e2e/test_sub_pipeline_permission_timeout.py +uv run pytest -q tests/a2a_e2e/test_permission_wait_restart.py +``` + +进程重启矩阵覆盖 Normal/Pipeline × allow/deny。Sub Pipeline fixture 断言只生成一次拒绝 ToolResult、 +Agent loop 继续、父 Pipeline 进入 candidate 选择并完成,且全程没有 grace、持久化 permission checkpoint +或权限关键备份。 + 本目录包含用于 A2A pipeline 会话恢复和脱敏回归的 headless 端到端检查。Runner 会驱动公开的 A2A JSON-RPC streaming endpoint 并记录 SSE 事件和 pipeline snapshot。恢复场景会用 `SIGKILL` 杀掉 A2A server,再用相同持久化目录重启;`redaction-step4` 则在候选方案选择处停止,不重启、 diff --git a/scripts/a2a/e2e/permission_wait/permission_wait_fixture_server.py b/scripts/a2a/e2e/permission_wait/permission_wait_fixture_server.py new file mode 100644 index 00000000..749cbe43 --- /dev/null +++ b/scripts/a2a/e2e/permission_wait/permission_wait_fixture_server.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +"""Deterministic A2A server fixture for permission-wait restart E2E tests.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +from typing import Any + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--config-dir", type=Path, required=True) + parser.add_argument("--persistence-dir", type=Path, required=True) + parser.add_argument("--artifact-dir", type=Path, required=True) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--execution-log", type=Path, required=True) + parser.add_argument("--mode", choices=("normal", "pipeline"), default="normal") + parser.add_argument("--resident-timeout-seconds", type=float) + parser.add_argument("--sub-pipeline-timeout-seconds", type=float) + parser.add_argument("--timeout-grace-seconds", type=float, default=30.0) + parser.add_argument("--candidate-first", action="store_true") + return parser.parse_args() + + +def _create_fixture_runtime(options: Any, *, execution_log: Path) -> Any: + from iac_code.agent.agent_loop import AgentLoop + from iac_code.providers.base import ToolDefinition + from iac_code.services.agent_factory import AgentRuntime + from iac_code.services.session_storage import SessionStorage + from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult + from iac_code.types.permissions import PermissionResult + from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + TextDeltaEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, + ) + + class FixtureWriteTool(Tool): + @property + def name(self) -> str: + return "fixture_write" + + @property + def description(self) -> str: + return "Record one deterministic pre-authorized write." + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + + async def check_permissions( + self, + input: dict[str, Any], # noqa: A002 - Tool protocol name + context: dict[str, Any] | None = None, + ) -> PermissionResult: + del input, context + return PermissionResult(behavior="ask", message="Allow deterministic fixture write?") + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + del context + execution_log.parent.mkdir(parents=True, exist_ok=True) + with execution_log.open("a", encoding="utf-8") as handle: + handle.write(str(tool_input["value"]) + "\n") + handle.flush() + os.fsync(handle.fileno()) + return ToolResult.success("fixture write completed") + + class FixtureProvider: + def get_model_name(self) -> str: + return "permission-wait-fixture" + + async def stream( + self, + messages: list[Any], + system: str, + tools: list[ToolDefinition] | None = None, + max_tokens: int = 8192, + ): + del system, tools, max_tokens + has_tool_result = any( + getattr(block, "type", None) == "tool_result" + for message in messages + for block in (message.content if isinstance(message.content, list) else []) + ) + if has_tool_result: + yield MessageStartEvent(message_id="fixture-final") + yield TextDeltaEvent(text="fixture recovery completed") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + return + + yield MessageStartEvent(message_id="fixture-permission") + yield TextDeltaEvent(text="fixture permission required") + yield ToolUseStartEvent(tool_use_id="fixture-tool-1", name="fixture_write") + yield ToolUseEndEvent( + tool_use_id="fixture-tool-1", + name="fixture_write", + input={"value": "executed"}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + provider = FixtureProvider() + registry = ToolRegistry() + registry.register(FixtureWriteTool()) + storage = SessionStorage() + storage.ensure_v2_session_dir_for_new_session(str(options.cwd), str(options.session_id)) + loop = AgentLoop( + provider_manager=provider, + system_prompt="Deterministic permission-wait fixture.", + tool_registry=registry, + max_turns=3, + session_storage=storage, + session_id=options.session_id, + resume_messages=options.resume_messages, + cwd=options.cwd, + ) + return AgentRuntime( + agent_loop=loop, + session_id=loop.session_id, + tool_registry=registry, + provider_manager=provider, + command_registry=None, + task_manager=None, + memory_manager=None, + legacy_memory_manager=None, + ) + + +def _create_fixture_pipeline(*, execution_log: Path, candidate_first: bool = False, **kwargs: Any) -> Any: + import asyncio + import time + from types import SimpleNamespace + + from iac_code.agent.message import Message, ToolUseBlock + from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage + from iac_code.services.permission_wait import canonical_digest + from iac_code.services.session_storage import SessionStorage + from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings, PermissionResult + from iac_code.types.stream_events import ( + PermissionRequestEvent, + PermissionWaitOutcome, + PermissionWaitSuspended, + TextDeltaEvent, + ) + + session_id = str(kwargs["session_id"]) + cwd = str(kwargs["cwd"]) + storage = kwargs.get("session_storage") or SessionStorage() + root_session_dir = storage.ensure_v2_session_dir_for_new_session(cwd, session_id) + if root_session_dir is None: + root_session_dir = storage.session_dir(cwd, session_id) + transcript_id = "transcript_att_0001" + transcript_storage = PipelineTranscriptStorage(root_session_dir / "pipeline") + + class FixturePipeline: + pipeline_name = "selling" + emit_stack_events = False + handoff_enabled = False + + def __init__(self) -> None: + self.session = SimpleNamespace(session_dir=root_session_dir / "pipeline") + self.session.session_dir.mkdir(parents=True, exist_ok=True) + self.sidecar_status = None + self.sidecar_restore_result = None + self._loaded = SimpleNamespace( + steps=[SimpleNamespace(step_id="fixture_step", step_type="agent", ui_mode="default")], + sub_pipelines={}, + ) + + async def run(self, prompt: str): + del prompt + if candidate_first: + yield PipelineEvent( + type=PipelineEventType.PIPELINE_STARTED, + step_id=None, + timestamp=time.time(), + data={"total_steps": 2, "step_names": ["confirm_and_select", "fixture_step"]}, + ) + self.sidecar_status = "waiting_input" + yield PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="confirm_and_select", + timestamp=time.time(), + data={ + "kind": "candidate_selection", + "prompt": "Choose the fixture candidate", + "options": [{"id": "0", "label": "Fixture candidate", "candidate_index": 0}], + }, + ) + return + async for event in self._permission_stream(include_start=True): + yield event + + async def _permission_stream(self, *, include_start: bool): + if include_start: + yield PipelineEvent( + type=PipelineEventType.PIPELINE_STARTED, + step_id=None, + timestamp=time.time(), + data={"total_steps": 1, "step_names": ["fixture_step"]}, + ) + yield PipelineEvent( + type=PipelineEventType.STEP_STARTED, + step_id="fixture_step", + timestamp=time.time(), + data={"step_index": 0, "total_steps": 1}, + ) + assistant = Message( + role="assistant", + content=[ToolUseBlock(id="fixture-pipeline-tool-1", name="fixture_write", input={"value": "executed"})], + ) + transcript_storage.append(cwd, transcript_id, assistant) + digest = canonical_digest([block.model_dump(mode="json") for block in assistant.content]) + response_future = asyncio.get_running_loop().create_future() + permission = PermissionRequestEvent( + tool_name="fixture_write", + tool_input={"value": "executed"}, + tool_use_id="fixture-pipeline-tool-1", + response_future=response_future, + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": digest, + "orderedToolUseIds": ["fixture-pipeline-tool-1"], + "currentIndex": 0, + "decisions": [ + { + "toolUseId": "fixture-pipeline-tool-1", + "state": "pending", + "source": None, + "deniedResult": None, + } + ], + }, + audit_context={ + "session_id": transcript_id, + "cwd": cwd, + "root_session_id": session_id, + "transcript_id": transcript_id, + }, + ) + yield permission + outcome = await asyncio.shield(response_future) + if outcome is PermissionWaitOutcome.SUSPEND: + raise PermissionWaitSuspended(permission.boundary_id) + allowed = bool(outcome) + if allowed: + self._record_execution() + async for event in self._finish_stream(): + yield event + + async def resume(self, prompt: str): + if candidate_first and self.sidecar_status == "waiting_input": + self.sidecar_status = "running" + async for event in self._permission_stream(include_start=False): + yield event + return + async for event in self.run(prompt): + yield event + + async def resume_permission_boundary(self, checkpoint: dict[str, Any]): + decision = checkpoint.get("decision") + if not isinstance(decision, dict): + raise ValueError("permission_resume_invalid: fixture decision is missing") + if decision.get("value") == "allow_once": + self._record_execution() + elif decision.get("value") != "deny": + raise ValueError("permission_resume_invalid: fixture decision is invalid") + yield TextDeltaEvent(text="fixture pipeline recovery completed") + async for event in self._finish_stream(): + yield event + + async def rebuild_permission_audit_event(self, checkpoint: dict[str, Any], recovered: Any): + if recovered.audit_context.get("transcript_id") != transcript_id: + raise ValueError("permission_resume_invalid: fixture transcript changed") + if checkpoint.get("toolUseId") != recovered.tool_use_id: + raise ValueError("permission_resume_invalid: fixture tool changed") + metadata = PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="prompt_required", + reason_detail="fixture prompt", + is_read_only=False, + operation={"fixture": "write"}, + ) + return PermissionRequestEvent( + tool_name=recovered.tool_name, + tool_input=recovered.tool_input, + tool_use_id=recovered.tool_use_id, + permission_result=PermissionResult(behavior="ask", audit=metadata), + audit_context={ + **recovered.audit_context, + "metadata": metadata, + "settings": PermissionAuditSettings(), + }, + ) + + async def _finish_stream(self): + yield PipelineEvent( + type=PipelineEventType.STEP_COMPLETED, + step_id="fixture_step", + timestamp=time.time(), + data={"conclusion": {"status": "success"}}, + ) + self.sidecar_status = "completed" + yield PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=time.time(), + data={"total_steps": 1}, + ) + + def _record_execution(self) -> None: + execution_log.parent.mkdir(parents=True, exist_ok=True) + with execution_log.open("a", encoding="utf-8") as handle: + handle.write("executed\n") + handle.flush() + os.fsync(handle.fileno()) + + def continue_from_sidecar(self, user_input: str | None = None): + if candidate_first: + self.sidecar_status = "running" + return self._permission_stream(include_start=False) + return self.run(user_input or "") + + def should_switch_to_normal(self, data: dict[str, Any]) -> bool: + del data + return False + + async def pause_agent_loops(self) -> None: + return None + + async def resume_agent_loops(self) -> None: + return None + + def clear_sidecar(self) -> None: + self.sidecar_status = None + + return FixturePipeline() + + +def main() -> int: + args = _parse_args() + config_dir = args.config_dir.expanduser().resolve() + persistence_dir = args.persistence_dir.expanduser().resolve() + artifact_dir = args.artifact_dir.expanduser().resolve() + workspace = args.workspace.expanduser().resolve() + execution_log = args.execution_log.expanduser().resolve() + for path in (config_dir, persistence_dir, artifact_dir, workspace, execution_log.parent): + path.mkdir(parents=True, exist_ok=True) + + os.environ["IAC_CODE_CONFIG_DIR"] = str(config_dir) + os.environ["IAC_CODE_MODE"] = args.mode + os.environ["IACCODE_A2A_ALLOWED_CWDS"] = str(workspace) + + import uvicorn + + from iac_code.a2a import executor as executor_module + from iac_code.a2a import pipeline_executor as pipeline_executor_module + from iac_code.a2a.app import create_app + + executor_module.create_agent_runtime = lambda options: _create_fixture_runtime( + options, + execution_log=execution_log, + ) + pipeline_executor_module.create_agent_runtime = executor_module.create_agent_runtime + fixture_pipelines: dict[str, Any] = {} + + def create_fixture_pipeline(*unused_args: Any, **kwargs: Any) -> Any: + session_id = str(kwargs["session_id"]) + pipeline = fixture_pipelines.get(session_id) + if pipeline is None: + pipeline = _create_fixture_pipeline( + execution_log=execution_log, + candidate_first=args.candidate_first, + **kwargs, + ) + fixture_pipelines[session_id] = pipeline + return pipeline + + pipeline_executor_module.create_pipeline = create_fixture_pipeline + app = create_app( + host=args.host, + port=args.port, + token=None, + model="permission-wait-fixture", + persistence_dir=persistence_dir, + artifact_dir=artifact_dir, + auto_approve_permissions=False, + permission_wait={ + "resident_timeout_seconds": args.resident_timeout_seconds, + "sub_pipeline_timeout_seconds": args.sub_pipeline_timeout_seconds, + "timeout_grace_seconds": args.timeout_grace_seconds, + }, + ) + uvicorn.run(app, host=args.host, port=args.port, log_level="warning") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md b/scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md new file mode 100644 index 00000000..1a728e75 --- /dev/null +++ b/scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md @@ -0,0 +1,65 @@ +# StartChat permission-wait real E2E prompts + +The runner substitutes `{run_id}`, `{stack_name}`, `{vswitch_name}`, and +`{mode}`. Each scenario uses a new Qoder session and an isolated workspace. + +## Deployment + +```text +请使用 alicloud-ros-agent Skill 的 {mode} 模式完成这个真实测试:在 cn-hangzhou 查询已有 VPC,选择其中一个, +只在该 VPC 内通过 ROS Stack 部署一个新 VSwitch。Stack 名称必须是 {stack_name},VSwitch 名称必须是 +{vswitch_name},CIDR 不得与已有网段冲突。不要创建或删除 VPC,也不要修改其他资源。 + +执行过程中请持续用简短文字解释当前阶段。只读云查询不应申请权限;任何非只读操作都必须等待我明确确认。 +部署确认前必须展示部署摘要和 Mermaid 架构图。Pipeline 模式必须生成恰好两个都满足约束且确有差异的候选 +方案:优先使用不同可用区;若只能使用同一可用区,则使用两个不同且均不冲突的 VSwitch CIDR。不得把同一 +方案仅重命名凑数,也不得在候选选择前合并为一个;最终只部署用户选择的一个方案。Pipeline 模式还必须展示 +step 开始/结束和候选选择,并在完成后保留同一 job 的 Normal handoff。部署完成后先报告 Stack、VSwitch 和 +所选已有 VPC,不要自动清理。 +``` + +## Continue deployment + +```text +请继续同一个 ROS Agent job 完成原任务。若正在等待选择 VPC,请选择返回列表中第一个没有 VSwitch、且能容纳 +不冲突网段的已有 VPC;只需保留该 VPC 的精简摘要,不要再次返回完整 VPC 列表。继续生成并校验只含一个 +VSwitch 的 ROS Stack {stack_name},VSwitch 名称为 {vswitch_name}。在任何部署确认之前,先用简短说明和 +Mermaid 架构图展示已有 VPC 与待建 VSwitch 的关系;确认和非只读云操作都必须等待我的明确回答。 +``` + +## Confirm deployment + +```text +我已经审阅刚才展示的部署摘要和 Mermaid 架构图,确认仅在所选已有 VPC 中通过 Stack {stack_name} 创建 +VSwitch {vswitch_name}。请继续同一个 ROS Agent job;遇到非只读云权限时仍需把权限申请返回给我,不得替我批准。 +``` + +## Cleanup + +```text +请继续同一个 ROS Agent job,清理本次测试创建的 Stack {stack_name} 及其中的 VSwitch {vswitch_name}。 +删除前说明目标并等待我确认;只允许删除这两个本次创建的对象,绝不能删除或修改已有 VPC。Pipeline 场景必须 +复用 Pipeline handoff 的 Normal 会话,不得启动新的 Normal job。清理后用只读查询确认 Stack/VSwitch 已不存在, +并确认原有 VPC 仍可用。先展示精简删除摘要并等待我下一条明确确认。 +``` + +## Confirm cleanup + +```text +我确认删除本次测试创建的 Stack {stack_name},并让其中的 VSwitch {vswitch_name} 随 Stack 删除。请继续同一个 +ROS Agent job;不得删除或修改已有 VPC,遇到非只读云权限时仍需把权限申请返回给我,不得替我批准。 +``` + +## Scripted answers + +The headless runner resumes the same Qoder session with exactly one of these +bounded answers, selected from the current bridge `inputRequired.kind`: + +- permission: `允许当前明确展示且属于本次范围的非只读操作,仅允许一次,然后继续同一个 job。` +- ask_user_question: `选择当前问题中的第一个已有 VPC;如果是部署确认,则确认部署。继续同一个 job。` +- candidate_selection: `选择推荐候选;若没有明确推荐,选择第一个候选。继续同一个 job。` +- active Pipeline follow: `只对当前 job 调用 follow 继续观察,不要发送自然语言 continue 来催促远端。` + +If a permission is read-only, the runner fails instead of answering it. If a +permission target is outside the exact run-scoped Stack/VSwitch, the operator +must deny it and stop the run. diff --git a/scripts/a2a/e2e/permission_wait/run_permission_wait_restart.py b/scripts/a2a/e2e/permission_wait/run_permission_wait_restart.py new file mode 100644 index 00000000..afa1a506 --- /dev/null +++ b/scripts/a2a/e2e/permission_wait/run_permission_wait_restart.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +"""Run the deterministic permission-wait process-restart A2A scenario.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +import threading +import time +import uuid +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +PERMISSION_QUERY_PREFIX = "IAC_CODE_PERMISSION:" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--decision", choices=("allow_once", "deny"), required=True) + parser.add_argument("--timeout", type=float, default=30.0) + parser.add_argument("--mode", choices=("normal", "pipeline"), default="normal") + parser.add_argument("--candidate-first", action="store_true") + return parser.parse_args() + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _decode_response_line(line: bytes) -> dict[str, Any] | None: + text = line.decode("utf-8", errors="replace").strip() + if not text: + return None + if text.startswith("data:"): + text = text[5:].strip() + try: + value = json.loads(text) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) else None + + +def _stream_request( + url: str, + payload: dict[str, Any], + *, + timeout: float, + on_event: Any = None, +) -> list[dict[str, Any]]: + request = Request( + url.rstrip("/") + "/", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json", "A2A-Version": "1.0"}, + method="POST", + ) + events: list[dict[str, Any]] = [] + try: + with urlopen(request, timeout=timeout) as response: + for line in response: + event = _decode_response_line(line) + if event is not None: + events.append(event) + if on_event is not None: + on_event(event) + except HTTPError as exc: + body = exc.read() + event = _decode_response_line(body) + if event is not None: + events.append(event) + else: + raise RuntimeError("A2A request failed with HTTP {}".format(exc.code)) from exc + return events + + +class _BackgroundStream: + def __init__(self, url: str, payload: dict[str, Any], *, timeout: float) -> None: + self.url = url + self.payload = payload + self.timeout = timeout + self.events: list[dict[str, Any]] = [] + self.error: BaseException | None = None + self.done = threading.Event() + self._lock = threading.Lock() + self._thread = threading.Thread(target=self._run, name="permission-wait-pipeline-stream", daemon=True) + + def start(self) -> None: + self._thread.start() + + def snapshot(self) -> list[dict[str, Any]]: + with self._lock: + return list(self.events) + + def wait_for_permission(self, timeout: float) -> dict[str, Any]: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + inputs = [value for value in _iac_code_values(self.snapshot(), "input") if isinstance(value, dict)] + permission = next((value for value in inputs if value.get("kind") == "permission"), None) + if permission is not None: + return permission + if self.done.is_set(): + raise RuntimeError("pipeline stream ended before permission boundary") from self.error + time.sleep(0.02) + raise TimeoutError("timed out waiting for pipeline permission boundary") + + def join(self, timeout: float) -> None: + self._thread.join(timeout) + if self._thread.is_alive(): + raise TimeoutError("pipeline stream did not close after server shutdown") + + def _run(self) -> None: + def capture(event: dict[str, Any]) -> None: + with self._lock: + self.events.append(event) + + try: + _stream_request(self.url, self.payload, timeout=self.timeout, on_event=capture) + except BaseException as exc: + self.error = exc + finally: + self.done.set() + + +def _message_payload( + *, + workspace: Path, + prompt: str, + context_id: str = "", + task_id: str = "", +) -> dict[str, Any]: + message: dict[str, Any] = { + "messageId": str(uuid.uuid4()), + "role": "ROLE_USER", + "parts": [{"text": prompt}], + "metadata": {"iac_code": {"cwd": str(workspace)}}, + } + if context_id: + message["contextId"] = context_id + if task_id: + message["taskId"] = task_id + return { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "SendStreamingMessage", + "params": { + "message": message, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + + +def _walk_dicts(value: Any): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _walk_dicts(child) + elif isinstance(value, list): + for child in value: + yield from _walk_dicts(child) + + +def _iac_code_values(events: list[dict[str, Any]], key: str) -> list[Any]: + values: list[Any] = [] + for event in events: + for item in _walk_dicts(event): + metadata = item.get("metadata") + if not isinstance(metadata, dict): + continue + iac_code = metadata.get("iac_code") + if isinstance(iac_code, dict) and key in iac_code: + values.append(iac_code[key]) + return values + + +def _event_text(events: list[dict[str, Any]]) -> str: + return "\n".join( + str(item["text"]) for event in events for item in _walk_dicts(event) if isinstance(item.get("text"), str) + ) + + +def _task_states(events: list[dict[str, Any]]) -> list[str]: + states: list[str] = [] + for event in events: + for item in _walk_dicts(event): + status = item.get("status") + state = status.get("state") if isinstance(status, dict) else None + if isinstance(state, str): + states.append(state) + return states + + +def _checkpoint_path(config_dir: Path) -> Path: + matches = sorted(config_dir.rglob("permission-waits/pwb_*.json")) + if len(matches) != 1: + raise AssertionError("expected exactly one permission checkpoint, found {}".format(len(matches))) + return matches[0] + + +def _read_checkpoint(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise AssertionError("permission checkpoint is not an object") + return value + + +class _FixtureServer: + def __init__(self, *, run_dir: Path, port: int, repo_root: Path, mode: str, candidate_first: bool) -> None: + self.run_dir = run_dir + self.port = port + self.repo_root = repo_root + self.mode = mode + self.candidate_first = candidate_first + self.process: subprocess.Popen[str] | None = None + self._stdout = None + self._stderr = None + + @property + def url(self) -> str: + return "http://127.0.0.1:{}".format(self.port) + + def start(self, generation: int) -> None: + fixture = self.repo_root / "scripts" / "a2a" / "e2e" / "permission_wait" / "permission_wait_fixture_server.py" + self._stdout = (self.run_dir / "server-{}.stdout.log".format(generation)).open("w", encoding="utf-8") + self._stderr = (self.run_dir / "server-{}.stderr.log".format(generation)).open("w", encoding="utf-8") + env = os.environ.copy() + src = str(self.repo_root / "src") + env["PYTHONPATH"] = os.pathsep.join(part for part in (src, env.get("PYTHONPATH", "")) if part) + command = [ + sys.executable, + str(fixture), + "--port", + str(self.port), + "--config-dir", + str(self.run_dir / "config"), + "--persistence-dir", + str(self.run_dir / "a2a-state"), + "--artifact-dir", + str(self.run_dir / "artifacts"), + "--workspace", + str(self.run_dir / "workspace"), + "--execution-log", + str(self.run_dir / "tool-executions.log"), + "--mode", + self.mode, + ] + if self.candidate_first: + command.append("--candidate-first") + self.process = subprocess.Popen( + command, + cwd=self.repo_root, + env=env, + stdout=self._stdout, + stderr=self._stderr, + text=True, + ) + self._wait_healthy() + + def _wait_healthy(self) -> None: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + if self.process is not None and self.process.poll() is not None: + raise RuntimeError("fixture A2A server exited with code {}".format(self.process.returncode)) + try: + with urlopen(self.url + "/health", timeout=0.5) as response: + if response.status == 200: + return + except (TimeoutError, URLError, OSError): + time.sleep(0.05) + raise TimeoutError("fixture A2A server did not become healthy") + + def stop(self) -> None: + if self.process is not None and self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=10) + for handle in (self._stdout, self._stderr): + if handle is not None and not handle.closed: + handle.close() + self.process = None + + +def _pipeline_journal_events(config_dir: Path) -> list[dict[str, Any]]: + matches = sorted(config_dir.rglob("a2a/pipeline/a2a-events.jsonl")) + if len(matches) != 1: + raise AssertionError("expected exactly one Pipeline journal, found {}".format(len(matches))) + events: list[dict[str, Any]] = [] + for line in matches[0].read_text(encoding="utf-8").splitlines(): + value = json.loads(line) + if isinstance(value, dict): + events.append(value) + return events + + +def run_scenario( + *, + run_dir: Path, + decision: str, + timeout: float, + mode: str, + candidate_first: bool = False, +) -> dict[str, Any]: + run_dir = run_dir.expanduser().resolve() + run_dir.mkdir(parents=True, exist_ok=False) + workspace = run_dir / "workspace" + workspace.mkdir() + repo_root = Path(__file__).resolve().parents[4] + if candidate_first and mode != "pipeline": + raise ValueError("candidate_first requires pipeline mode") + server = _FixtureServer( + run_dir=run_dir, + port=_free_port(), + repo_root=repo_root, + mode=mode, + candidate_first=candidate_first, + ) + checkpoint_path: Path | None = None + background: _BackgroundStream | None = None + try: + server.start(1) + initial_payload = _message_payload(workspace=workspace, prompt="request deterministic write") + if mode == "pipeline" and candidate_first: + candidate_events = _stream_request(server.url, initial_payload, timeout=timeout) + inputs = [value for value in _iac_code_values(candidate_events, "input") if isinstance(value, dict)] + candidate = next((value for value in inputs if value.get("kind") == "candidate_selection"), None) + if candidate is None: + raise AssertionError("initial stream did not expose candidate selection") + background = _BackgroundStream( + server.url, + _message_payload( + workspace=workspace, + prompt="0", + context_id=str(candidate["contextId"]), + task_id=str(candidate["requestTaskId"]), + ), + timeout=timeout, + ) + background.start() + permission = background.wait_for_permission(timeout) + background.join(timeout) + initial_events = candidate_events + background.snapshot() + if background.error is not None: + raise RuntimeError("top-level Pipeline stream failed at the permission boundary") from background.error + elif mode == "pipeline": + background = _BackgroundStream(server.url, initial_payload, timeout=timeout) + background.start() + permission = background.wait_for_permission(timeout) + background.join(timeout) + initial_events = background.snapshot() + if background.error is not None: + raise RuntimeError("top-level Pipeline stream failed at the permission boundary") from background.error + else: + initial_events = _stream_request(server.url, initial_payload, timeout=timeout) + inputs = [value for value in _iac_code_values(initial_events, "input") if isinstance(value, dict)] + permission = next((value for value in inputs if value.get("kind") == "permission"), None) + if permission is None: + raise AssertionError("initial stream did not expose a permission boundary") + checkpoint_path = _checkpoint_path(run_dir / "config") + checkpoint_before = _read_checkpoint(checkpoint_path) + if checkpoint_before.get("phase") != "WAITING": + raise AssertionError("initial checkpoint phase is not WAITING") + if checkpoint_before.get("taskId") != permission.get("requestTaskId"): + raise AssertionError("permission task correlation differs from checkpoint") + expected_class = "pipeline" if mode == "pipeline" else "normal" + if checkpoint_before.get("permissionClass") != expected_class: + raise AssertionError("permission checkpoint class is incorrect") + if mode == "pipeline": + coordinates = checkpoint_before.get("pipelineCoordinates") + if not isinstance(coordinates, dict) or not coordinates.get("step"): + raise AssertionError("Pipeline permission checkpoint lost its step coordinates") + + server.stop() + if background is not None: + background.join(10) + checkpoint_after_stop = _read_checkpoint(checkpoint_path) + if checkpoint_after_stop.get("phase") in {"CANCELED", "RESOLVED"}: + raise AssertionError("server lifecycle shutdown consumed the permission boundary") + + server.start(2) + response_data = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": permission["requestTaskId"], + "contextId": permission["contextId"], + "inputId": permission["inputId"], + "toolUseId": permission["toolUseId"], + "decision": decision, + } + query = PERMISSION_QUERY_PREFIX + " " + json.dumps(response_data, separators=(",", ":")) + recovered_events = _stream_request( + server.url, + _message_payload(workspace=workspace, prompt=query, context_id=permission["contextId"]), + timeout=timeout, + ) + recovered = _iac_code_values(recovered_events, "permissionRecovered") + if not recovered: + raise AssertionError("restarted server did not report permissionRecovered") + acknowledgements = [ + value for value in _iac_code_values(recovered_events, "inputReceived") if isinstance(value, dict) + ] + if not any(value.get("recovered") is True and value.get("duplicate") is False for value in acknowledgements): + raise AssertionError("recovered response did not return the first-consumption acknowledgement") + if "fixture recovery completed" not in _event_text(recovered_events): + expected_output = ( + "fixture pipeline recovery completed" if mode == "pipeline" else "fixture recovery completed" + ) + if expected_output not in _event_text(recovered_events): + raise AssertionError("recovered continuation output is missing") + normal_recovery_checks: dict[str, Any] = {} + if mode == "normal": + assistant_final = [ + value for value in _iac_code_values(recovered_events, "assistantFinal") if isinstance(value, dict) + ] + if not any(value.get("complete") is True for value in assistant_final): + raise AssertionError("Normal recovery did not publish assistantFinal") + states = _task_states(recovered_events) + if not states or states[-1] != "TASK_STATE_INPUT_REQUIRED": + raise AssertionError("Normal recovery did not publish the terminal INPUT_REQUIRED state") + normal_recovery_checks = { + "assistantFinalPublished": True, + "terminalInputRequiredPublished": True, + } + + checkpoint_resolved = _read_checkpoint(checkpoint_path) + if checkpoint_resolved.get("phase") != "RESOLVED": + raise AssertionError("recovered checkpoint was not compacted to RESOLVED") + execution_log = run_dir / "tool-executions.log" + expected_executions = 1 if decision == "allow_once" else 0 + executions_after_recovery = ( + len(execution_log.read_text(encoding="utf-8").splitlines()) if execution_log.exists() else 0 + ) + if executions_after_recovery != expected_executions: + raise AssertionError("unexpected tool execution count after recovery") + + duplicate_events = _stream_request( + server.url, + _message_payload(workspace=workspace, prompt=query, context_id=permission["contextId"]), + timeout=timeout, + ) + duplicate_acks = [ + value for value in _iac_code_values(duplicate_events, "inputReceived") if isinstance(value, dict) + ] + if not any(value.get("duplicate") is True for value in duplicate_acks): + raise AssertionError("same duplicate did not return the durable acknowledgement") + + conflicting = dict(response_data) + conflicting["decision"] = "deny" if decision == "allow_once" else "allow_once" + conflict_query = PERMISSION_QUERY_PREFIX + " " + json.dumps(conflicting, separators=(",", ":")) + conflict_events = _stream_request( + server.url, + _message_payload(workspace=workspace, prompt=conflict_query, context_id=permission["contextId"]), + timeout=timeout, + ) + if "permission_resume_invalid" not in json.dumps(conflict_events, ensure_ascii=False): + raise AssertionError("conflicting duplicate was not rejected") + + executions_final = len(execution_log.read_text(encoding="utf-8").splitlines()) if execution_log.exists() else 0 + if executions_final != expected_executions: + raise AssertionError("duplicate response executed the tool again") + task_ids = { + str(item.get("taskId")) + for event in recovered_events + for item in _walk_dicts(event) + if item.get("taskId") is not None + } + if permission["requestTaskId"] not in task_ids: + raise AssertionError("recovered output did not remain on the original task") + + pipeline_checks: dict[str, Any] = {} + if mode == "pipeline": + journal = _pipeline_journal_events(run_dir / "config") + event_types = [str(event.get("eventType")) for event in journal] + if "permission_requested" not in event_types: + raise AssertionError("Pipeline journal did not persist the permission request") + if "step_completed" not in event_types or "pipeline_completed" not in event_types: + raise AssertionError("Pipeline journal did not continue after permission recovery") + if any(event_type.startswith("rollback_") for event_type in event_types): + raise AssertionError("Pipeline permission recovery triggered rollback") + if event_types.index("permission_requested") > event_types.index("step_completed"): + raise AssertionError("Pipeline journal ordering is invalid") + pipeline_checks = { + "pipelineCoordinatesPreserved": True, + "pipelineJournalOrdered": True, + "pipelineRollbackAbsent": True, + "parentStreamEndedAtPermissionBoundary": True, + } + + return { + "passed": True, + "mode": mode, + "decision": decision, + "taskId": permission["requestTaskId"], + "contextId": permission["contextId"], + "checkpointPhase": checkpoint_resolved["phase"], + "toolExecutions": executions_final, + "duplicateAcknowledged": True, + "conflictRejected": True, + "candidateSelectionBeforePermission": candidate_first, + **normal_recovery_checks, + **pipeline_checks, + } + finally: + server.stop() + + +def main() -> int: + args = _parse_args() + result = run_scenario( + run_dir=args.run_dir, + decision=args.decision, + timeout=args.timeout, + mode=args.mode, + candidate_first=args.candidate_first, + ) + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py b/scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py new file mode 100644 index 00000000..96b90b4c --- /dev/null +++ b/scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py @@ -0,0 +1,1294 @@ +#!/usr/bin/env python3 +"""Credential-gated real Qoder -> StartChat -> iac-code permission-wait E2E.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.request import urlopen + +import yaml + +CONFIG_FILES = (".credentials.yml", ".cloud-credentials.yml", "settings.yml") +TERMINAL_STATES = {"turn-completed", "completed"} +FAILURE_STATES = {"failed", "canceled"} +PROMPT_FILE = Path(__file__).with_name("permission_wait_start_chat_prompt.md") +DEFAULT_QODER_TURN_TIMEOUT_SECONDS = 900.0 + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--allow-real-cloud", action="store_true") + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--mode", choices=("normal", "pipeline"), required=True) + parser.add_argument("--region", default="cn-hangzhou") + parser.add_argument("--answer-delay-seconds", type=float, default=0.0) + parser.add_argument("--restart-at-first-permission", action="store_true") + parser.add_argument("--resident-timeout-seconds", type=float, default=300.0) + parser.add_argument("--sub-pipeline-timeout-seconds", type=float, default=300.0) + parser.add_argument("--timeout-grace-seconds", type=float, default=30.0) + parser.add_argument("--max-qoder-turns", type=int, default=30) + parser.add_argument("--qoder-turn-timeout", type=float, default=DEFAULT_QODER_TURN_TIMEOUT_SECONDS) + parser.add_argument( + "--qoder-cli", + type=Path, + default=Path("/Applications/QoderWork.app/Contents/Resources/bin/qodercli"), + ) + parser.add_argument("--qoder-config-dir", type=Path, default=Path("~/.qoderwork")) + parser.add_argument("--source-config-dir", type=Path, default=Path("~/.iac-code")) + parser.add_argument( + "--skill-root", + type=Path, + action="append", + default=None, + ) + args = parser.parse_args() + if args.skill_root is None: + args.skill_root = [Path("~/.qoder/skills"), Path("~/.qoderwork/skills")] + return args + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def _append_jsonl(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n") + + +def _copy_config(source: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + for name in CONFIG_FILES: + source_path = source / name + if not source_path.is_file(): + raise RuntimeError("required iac-code configuration is missing: {}".format(name)) + destination_path = destination / name + shutil.copy2(source_path, destination_path) + os.chmod(destination_path, 0o600) + + +def _refresh_source_cloud_credentials(source: Path) -> None: + """Refresh OAuth-backed STS in the source config before taking an isolated copy. + + OAuth refresh tokens may rotate. Refreshing only an isolated copy can leave the + configured source with the invalidated predecessor and make the next scenario + fail before its first cloud request. The real-cloud opt-in therefore refreshes + the caller-selected source in place, exactly as a normal iac-code cloud call + would, and only then snapshots it for this run. + """ + + previous = os.environ.get("IAC_CODE_CONFIG_DIR") + os.environ["IAC_CODE_CONFIG_DIR"] = str(source) + try: + from iac_code.services.providers.aliyun import AliyunCredentials + + credential = AliyunCredentials.load_from_iac_code_config() + if credential is None: + raise RuntimeError("Alibaba Cloud credentials are missing from the source config") + if credential.mode == "OAuth": + AliyunCredentials.refresh_oauth_if_needed(credential) + finally: + if previous is None: + os.environ.pop("IAC_CODE_CONFIG_DIR", None) + else: + os.environ["IAC_CODE_CONFIG_DIR"] = previous + + +def _configure_isolated_permissions(config_dir: Path) -> None: + """Auto-allow incidental tools while keeping cloud mutations interactive.""" + + settings_path = config_dir / "settings.yml" + raw = yaml.safe_load(settings_path.read_text(encoding="utf-8")) + settings = dict(raw) if isinstance(raw, dict) else {} + settings["permissions"] = { + "mode": "default", + "allow": [ + "read_file", + "write_file", + "edit_file", + "list_files", + "glob", + "grep", + "web_fetch", + "read_memory", + "write_memory", + "task_list", + "task_get", + "task_stop", + "agent", + "skill", + "aliyun_doc_search", + "aliyun_api_doc", + "ros_validate_template", + "ros_get_template_parameter_constraints", + "ros_preview_template", + "ros_estimate_template_cost", + "infraguard_scan", + "ask_user_question", + "show_architecture_diagram", + "show_candidate_detail", + "complete_step", + ], + # The host Qoder process may use Bash to drive the Skill, but iac-code + # itself must not be able to route cloud mutations around tool-level + # permission checks by invoking the native aliyun CLI through Bash. + "deny": ["bash(*)"], + "ask": [ + "aliyun_api", + "ros_deploy", + "ros_stack_group", + "ros_template", + "ros_template_scratch", + "ros_diagnostic", + "ros_resource_type_registration", + "ros_tag", + "ros_stack", + "ros_stack_instances", + ], + "additional_directories": [], + "audit": { + "include_tool_input": False, + "max_file_bytes": 10 * 1024 * 1024, + "max_files": 5, + }, + } + settings_path.write_text(yaml.safe_dump(settings, allow_unicode=True, sort_keys=False), encoding="utf-8") + os.chmod(settings_path, 0o600) + + +@dataclass(frozen=True) +class _SkillInstallationBackup: + destination: Path + temporary_root: Path + existed: bool + + +def _remove_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink(missing_ok=True) + elif path.is_dir(): + shutil.rmtree(path) + + +def _restore_skill_installations(backups: list[_SkillInstallationBackup]) -> None: + errors: list[tuple[_SkillInstallationBackup, OSError]] = [] + for backup in reversed(backups): + restored = False + try: + _remove_path(backup.destination) + saved = backup.temporary_root / "skill" + if backup.existed: + shutil.copytree(saved, backup.destination, symlinks=True) + restored = True + except OSError as exc: + errors.append((backup, exc)) + finally: + if restored: + shutil.rmtree(backup.temporary_root, ignore_errors=True) + if errors: + failed, cause = errors[0] + raise RuntimeError( + "failed to restore Qoder Skill {}; backup retained at {}".format( + failed.destination, + failed.temporary_root, + ) + ) from cause + + +def _sync_skill( + repo_root: Path, + roots: list[Path], + endpoint: str, + *, + mode: str, +) -> list[_SkillInstallationBackup]: + source = repo_root / "skills" / "alicloud-ros-agent" + backups: list[_SkillInstallationBackup] = [] + config = { + "endpoint": endpoint, + "allowedAgentModes": [mode], + "managerIdleSeconds": 60, + } + try: + destinations: list[Path] = [] + for raw_root in roots: + destination = raw_root.expanduser().resolve() / "alicloud-ros-agent" + existed = destination.exists() or destination.is_symlink() + if existed and (not destination.is_dir() or destination.is_symlink()): + raise RuntimeError("Qoder Skill destination must be a directory: {}".format(destination)) + temporary_root = Path(tempfile.mkdtemp(prefix="iac-code-skill-backup-")) + try: + if existed: + shutil.copytree(destination, temporary_root / "skill", symlinks=True) + except BaseException: + shutil.rmtree(temporary_root, ignore_errors=True) + raise + backups.append(_SkillInstallationBackup(destination, temporary_root, existed)) + destinations.append(destination) + + for destination in destinations: + destination.mkdir(parents=True, exist_ok=True) + for name in ("SKILL.md", "agents", "scripts"): + source_path = source / name + destination_path = destination / name + _remove_path(destination_path) + if source_path.is_dir(): + shutil.copytree(source_path, destination_path, symlinks=True) + else: + shutil.copy2(source_path, destination_path) + _write_json(destination / "config.json", config) + except BaseException: + _restore_skill_installations(backups) + raise + return backups + + +def _prompt_section(name: str, replacements: dict[str, str]) -> str: + text = PROMPT_FILE.read_text(encoding="utf-8") + match = re.search(r"^## {}\s+```text\s+(.*?)\s+```".format(re.escape(name)), text, re.MULTILINE | re.DOTALL) + if match is None: + raise RuntimeError("E2E prompt section is missing: {}".format(name)) + result = match.group(1) + for key, value in replacements.items(): + result = result.replace("{" + key + "}", value) + return result + + +@dataclass +class _Service: + command: list[str] + cwd: Path + env: dict[str, str] + stdout_path: Path + stderr_path: Path + process: subprocess.Popen[str] | None = None + stdout_handle: Any = None + stderr_handle: Any = None + + def start(self) -> None: + self.stdout_path.parent.mkdir(parents=True, exist_ok=True) + self.stdout_handle = self.stdout_path.open("a", encoding="utf-8") + self.stderr_handle = self.stderr_path.open("a", encoding="utf-8") + self.process = subprocess.Popen( + self.command, + cwd=self.cwd, + env=self.env, + stdout=self.stdout_handle, + stderr=self.stderr_handle, + text=True, + start_new_session=True, + ) + + def stop(self) -> None: + if self.process is not None and self.process.poll() is None: + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + except (OSError, ProcessLookupError): + self.process.terminate() + try: + self.process.wait(timeout=15) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) + except (OSError, ProcessLookupError): + self.process.kill() + self.process.wait(timeout=15) + for handle in (self.stdout_handle, self.stderr_handle): + if handle is not None and not handle.closed: + handle.close() + self.process = None + + def restart(self) -> None: + self.stop() + self.start() + + +def _wait_a2a(port: int, service: _Service, timeout: float = 30.0) -> None: + deadline = time.monotonic() + timeout + url = "http://127.0.0.1:{}/.well-known/agent-card.json".format(port) + while time.monotonic() < deadline: + if service.process is not None and service.process.poll() is not None: + raise RuntimeError("A2A server exited before readiness") + try: + with urlopen(url, timeout=1) as response: + if response.status == 200: + return + except OSError: + time.sleep(0.1) + raise TimeoutError("A2A server readiness timed out") + + +def _wait_port(port: int, service: _Service, timeout: float = 30.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if service.process is not None and service.process.poll() is not None: + raise RuntimeError("StartChat relay exited before readiness") + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.1) + raise TimeoutError("StartChat relay readiness timed out") + + +def _a2a_config( + path: Path, + *, + port: int, + persistence: Path, + artifacts: Path, + resident_timeout_seconds: float = 300.0, + sub_pipeline_timeout_seconds: float = 300.0, + timeout_grace_seconds: float = 30.0, +) -> None: + path.write_text( + "\n".join( + [ + "host: 127.0.0.1", + "port: {}".format(port), + "transport: http", + "persistence_dir: {}".format(persistence), + "artifact_dir: {}".format(artifacts), + "auto_approve_permissions: false", + "log_to_stdout: true", + "idle_shutdown_seconds: 0", + "permission_wait:", + " resident_timeout_seconds: {}".format(resident_timeout_seconds), + " sub_pipeline_timeout_seconds: {}".format(sub_pipeline_timeout_seconds), + " timeout_grace_seconds: {}".format(timeout_grace_seconds), + "", + ] + ), + encoding="utf-8", + ) + + +def _generate_certificate(run_dir: Path) -> tuple[Path, Path]: + cert = run_dir / "runtime" / "relay.crt" + key = run_dir / "runtime" / "relay.key" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "2", + "-subj", + "/CN=127.0.0.1", + "-addext", + "subjectAltName=IP:127.0.0.1", + ], + check=True, + capture_output=True, + ) + os.chmod(key, 0o600) + return cert, key + + +def _jobs(state_root: Path) -> list[tuple[Path, dict[str, Any]]]: + results: list[tuple[Path, dict[str, Any]]] = [] + for path in sorted((state_root / "jobs").glob("*/job.json")): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if isinstance(value, dict): + results.append((path, value)) + return results + + +def _run_qoder( + *, + args: argparse.Namespace, + env: dict[str, str], + workspace: Path, + session_id: str, + prompt: str, + turn: int, + resume: bool, + run_dir: Path, +) -> dict[str, Any]: + command = [ + str(args.qoder_cli.expanduser().resolve()), + "-p", + "--output-format", + "stream-json", + "--config-dir", + str(args.qoder_config_dir.expanduser().resolve()), + "--dangerously-skip-permissions", + "--cwd", + str(workspace), + ] + if resume: + command.extend(["--resume", session_id]) + else: + command.extend(["--session-id", session_id]) + command.append(prompt) + started = time.monotonic() + completed = subprocess.run( + command, + cwd=workspace, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=args.qoder_turn_timeout, + ) + stdout_bytes = len(completed.stdout.encode("utf-8")) + stderr_bytes = len(completed.stderr.encode("utf-8")) + assistant_text_blocks = 0 + assistant_mermaid = False + content_block_index = 0 + first_mermaid_block_index: int | None = None + first_cloud_permission_block_index: int | None = None + + def contains_cloud_permission(value: Any) -> bool: + if isinstance(value, dict): + if ( + value.get("kind") == "permission" + and value.get("effect") == "cloud_change" + and value.get("isReadOnly") is False + ): + return True + return any(contains_cloud_permission(child) for child in value.values()) + if isinstance(value, list): + return any(contains_cloud_permission(child) for child in value) + if isinstance(value, str): + return all( + re.search(pattern, value) is not None + for pattern in ( + r'"kind"\s*:\s*"permission"', + r'"effect"\s*:\s*"cloud_change"', + r'"isReadOnly"\s*:\s*false', + ) + ) + return False + + for line in completed.stdout.splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(item, dict): + continue + message = item.get("message") + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + content_block_index += 1 + if item.get("type") == "assistant" and block.get("type") == "text": + text = block.get("text") + if isinstance(text, str) and text.strip(): + assistant_text_blocks += 1 + contains_mermaid = re.search(r"```\s*mermaid\b", text, re.IGNORECASE) is not None + assistant_mermaid = assistant_mermaid or contains_mermaid + if contains_mermaid and first_mermaid_block_index is None: + first_mermaid_block_index = content_block_index + if ( + block.get("type") == "tool_result" + and first_cloud_permission_block_index is None + and contains_cloud_permission(block.get("content")) + ): + first_cloud_permission_block_index = content_block_index + evidence = { + "turn": turn, + "returnCode": completed.returncode, + "elapsedSeconds": round(time.monotonic() - started, 3), + "stdoutBytes": stdout_bytes, + "stderrBytes": stderr_bytes, + "nonWhitespaceOutput": bool(completed.stdout.strip()), + "assistantTextBlocks": assistant_text_blocks, + "assistantMermaid": assistant_mermaid, + "firstMermaidBlockIndex": first_mermaid_block_index, + "firstCloudPermissionBlockIndex": first_cloud_permission_block_index, + "mentionsFollow": " follow" in completed.stdout.casefold(), + } + _append_jsonl(run_dir / "qoder-turns.jsonl", evidence) + if completed.returncode != 0: + raise RuntimeError("Qoder turn {} failed; see bounded qoder-turns.jsonl".format(turn)) + return evidence + + +def _walk(value: Any): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _walk(child) + elif isinstance(value, list): + for child in value: + yield from _walk(child) + + +def _permission_records(config_dir: Path, shared_root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + def load(root: Path) -> list[dict[str, Any]]: + records = [] + for path in sorted(root.rglob("permission-waits/pwb_*.json")): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if isinstance(value, dict): + records.append(value) + return records + + return load(config_dir), load(shared_root) + + +def _read_only_cloud_execution_evidence(config_dir: Path) -> list[dict[str, Any]]: + """Return bounded transcript proof for a read-only call and its ToolResult.""" + + successful_results: set[str] = set() + read_only_calls: dict[str, dict[str, Any]] = {} + for path in config_dir.rglob("session.jsonl"): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for line in lines: + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + continue + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_use" + and block.get("name") == "aliyun_api" + and isinstance(block.get("id"), str) + and isinstance(block.get("input"), dict) + and block["input"].get("action") == "DescribeVpcs" + ): + read_only_calls[block["id"]] = { + "toolUseId": block["id"], + "product": block["input"].get("product"), + "action": block["input"].get("action"), + "source": "session_transcript", + } + if ( + isinstance(block, dict) + and block.get("type") == "tool_result" + and block.get("is_error") is False + and isinstance(block.get("tool_use_id"), str) + ): + successful_results.add(block["tool_use_id"]) + evidence = [ + {**call, "resultPersisted": tool_use_id in successful_results} for tool_use_id, call in read_only_calls.items() + ] + return sorted(evidence, key=lambda item: (str(item.get("action")), str(item.get("toolUseId")))) + + +def _job_event_types(job_path: Path) -> list[str]: + event_types: list[str] = [] + events_path = job_path.with_name("events.jsonl") + try: + lines = events_path.read_text(encoding="utf-8").splitlines() + except OSError: + return event_types + for line in lines: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + for item in _walk(value): + event_type = item.get("eventType") + if isinstance(event_type, str) and event_type: + event_types.append(event_type) + return event_types + + +def _safe_permission_observation( + *, + job: dict[str, Any], + config_dir: Path, + shared_root: Path, + observed_at: float, +) -> dict[str, Any]: + current = job.get("inputRequired") + current = current if isinstance(current, dict) else {} + local, shared = _permission_records(config_dir, shared_root) + input_id = current.get("inputId") + local_match = next((record for record in local if record.get("inputId") == input_id), None) + shared_match = next((record for record in shared if record.get("inputId") == input_id), None) + return { + "observedAt": observed_at, + "inputId": input_id, + "kind": current.get("kind"), + "permissionClass": current.get("permissionClass"), + "toolName": current.get("toolName"), + "toolUseId": current.get("toolUseId"), + "isReadOnly": current.get("isReadOnly"), + "effect": current.get("effect"), + "optionCount": len(current.get("options", [])) if isinstance(current.get("options"), list) else 0, + "localCheckpoint": local_match is not None, + "sharedCheckpoint": shared_match is not None, + "checkpointPhase": local_match.get("phase") if local_match else None, + "checkpointGeneration": local_match.get("generation") if local_match else None, + } + + +def _validate_permission_scope( + current: dict[str, Any], + stack_name: str, + agent_workspace: Path, + *, + allowed_stack_ids: set[str] | None = None, +) -> None: + if current.get("isReadOnly") is not False: + raise AssertionError("permission must explicitly identify a non-read-only operation") + effect = current.get("effect") + target = str(current.get("target") or "") + if effect not in {"cloud_change", "file_change"}: + raise AssertionError("permission effect is not an approved E2E mutation class") + if not target: + raise AssertionError("permission target must be non-empty") + if effect == "cloud_change": + allowed_targets = {stack_name, *(allowed_stack_ids or set())} + identifier_characters = r"A-Za-z0-9_.:-" + if not any( + value + and re.search( + r"(? str: + kind = current.get("kind") + if kind == "permission": + return "允许当前明确展示且属于本次范围的非只读操作,仅允许一次,然后继续同一个 job。" + if kind == "candidate_selection": + return "选择推荐候选;若没有明确推荐,选择第一个候选。继续同一个 job。" + if kind == "ask_user_question": + return "选择当前问题中的第一个已有 VPC;如果这是部署或删除确认,则确认。继续同一个 job。" + return "继续处理当前明确展示的输入,并保持同一个 ROS Agent job。" + + +def _aliyun_json(aliyun: str, arguments: list[str], *, timeout: float = 60.0) -> Any: + completed = subprocess.run( + [aliyun, *arguments], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + if completed.returncode != 0: + raise RuntimeError("native aliyun CLI call failed: {}".format(" ".join(arguments[:3]))) + value = json.loads(completed.stdout) + return value + + +def _objects_with(value: Any, key: str, expected: str) -> list[dict[str, Any]]: + return [item for item in _walk(value) if item.get(key) == expected] + + +def _cloud_inventory(aliyun: str, region: str, stack_name: str, vswitch_name: str) -> dict[str, Any]: + vpcs = _aliyun_json(aliyun, ["vpc", "DescribeVpcs", "--RegionId", region, "--PageSize", "50"]) + stacks = _aliyun_json( + aliyun, + ["ros", "ListStacks", "--RegionId", region, "--StackName.1", stack_name, "--PageSize", "50"], + ) + vswitches = _aliyun_json( + aliyun, + ["vpc", "DescribeVSwitches", "--RegionId", region, "--VSwitchName", vswitch_name, "--PageSize", "50"], + ) + return { + "vpcs": sorted( + {str(item["VpcId"]): str(item.get("Status") or "") for item in _walk(vpcs) if item.get("VpcId")}.items() + ), + "stacks": [ + {"stackId": item.get("StackId"), "stackName": item.get("StackName"), "status": item.get("Status")} + for item in _objects_with(stacks, "StackName", stack_name) + ], + "vswitches": [ + { + "vSwitchId": item.get("VSwitchId"), + "vSwitchName": item.get("VSwitchName"), + "vpcId": item.get("VpcId"), + "status": item.get("Status"), + } + for item in _objects_with(vswitches, "VSwitchName", vswitch_name) + ], + } + + +def _cleanup_exact_stack(aliyun: str, region: str, inventory: dict[str, Any]) -> None: + for stack in inventory.get("stacks", []): + stack_id = stack.get("stackId") + status = str(stack.get("status") or "") + if not stack_id or status == "DELETE_COMPLETE": + continue + subprocess.run( + [aliyun, "ros", "DeleteStack", "--RegionId", region, "--StackId", str(stack_id)], + check=True, + capture_output=True, + timeout=60, + ) + + +def _cleanup_exact_vswitches(aliyun: str, region: str, inventory: dict[str, Any]) -> bool: + succeeded = True + for vswitch in inventory.get("vswitches", []): + vswitch_id = vswitch.get("vSwitchId") + if not vswitch_id: + continue + completed = subprocess.run( + [aliyun, "vpc", "DeleteVSwitch", "--RegionId", region, "--VSwitchId", str(vswitch_id)], + check=False, + capture_output=True, + timeout=60, + ) + succeeded = completed.returncode == 0 and succeeded + return succeeded + + +def _remove_sensitive_run_data(config_dir: Path) -> None: + """Remove copied credentials and full session transcripts from E2E artifacts.""" + + for name in (".credentials.yml", ".cloud-credentials.yml"): + (config_dir / name).unlink(missing_ok=True) + for path in config_dir.rglob("session.jsonl"): + path.unlink(missing_ok=True) + + +def _architecture_preceded_deployment_permission( + qoder_turns: list[dict[str, Any]], + permission_observations: list[dict[str, Any]], +) -> bool: + permission_turns = [ + int(item["qoderTurn"]) + for item in permission_observations + if item.get("permissionClass") in {"normal", "pipeline"} + and item.get("effect") == "cloud_change" + and "qoderTurn" in item + ] + diagram_turns = [int(item["turn"]) for item in qoder_turns if item.get("assistantMermaid")] + if not permission_turns or not diagram_turns: + return False + first_permission_turn = min(permission_turns) + first_diagram_turn = min(diagram_turns) + if first_diagram_turn < first_permission_turn: + return True + if first_diagram_turn > first_permission_turn: + return False + same_turn = next((item for item in qoder_turns if int(item.get("turn", -1)) == first_permission_turn), None) + if same_turn is None: + return False + diagram_index = same_turn.get("firstMermaidBlockIndex") + permission_index = same_turn.get("firstCloudPermissionBlockIndex") + return isinstance(diagram_index, int) and isinstance(permission_index, int) and diagram_index < permission_index + + +def run(args: argparse.Namespace) -> dict[str, Any]: + if not args.allow_real_cloud: + raise SystemExit("Refusing to run real Qoder/LLM/cloud E2E without --allow-real-cloud") + if args.answer_delay_seconds < 0: + raise SystemExit("--answer-delay-seconds must be non-negative") + if args.resident_timeout_seconds <= 0 or args.sub_pipeline_timeout_seconds <= 0: + raise SystemExit("permission resident and Sub Pipeline timeouts must be positive") + if args.timeout_grace_seconds < 0: + raise SystemExit("permission timeout grace must be non-negative") + repo_root = Path(__file__).resolve().parents[4] + run_dir = args.run_dir.expanduser().resolve() + run_dir.mkdir(parents=True, exist_ok=False) + config_dir = run_dir / "iac-code-config" + shared_root = run_dir / "shared-backup" + state_root = run_dir / "ros-agent-state" + agent_workspace = run_dir / "agent-workspace" + qoder_workspace = run_dir / "qoder-workspace" + for path in (shared_root, state_root, agent_workspace, qoder_workspace, run_dir / "runtime", run_dir / "logs"): + path.mkdir(parents=True, exist_ok=True) + source_config_dir = args.source_config_dir.expanduser().resolve() + + aliyun = shutil.which("aliyun") + if aliyun is None: + raise RuntimeError("native aliyun CLI is unavailable") + if not args.qoder_cli.expanduser().is_file(): + raise RuntimeError("Qoder CLI is unavailable") + + run_id = "pwait-{}-{}".format(args.mode, uuid.uuid4().hex[:8]) + stack_name = (run_id + "-stack")[:64] + vswitch_name = (run_id + "-vsw")[:128] + normal_port, pipeline_port, relay_port = _free_port(), _free_port(), _free_port() + endpoint = "127.0.0.1:{}".format(relay_port) + + normal_config = run_dir / "runtime" / "a2a-normal.yml" + pipeline_config = run_dir / "runtime" / "a2a-pipeline.yml" + _a2a_config( + normal_config, + port=normal_port, + persistence=run_dir / "runtime" / "a2a-normal-state", + artifacts=run_dir / "artifacts" / "normal", + resident_timeout_seconds=args.resident_timeout_seconds, + sub_pipeline_timeout_seconds=args.sub_pipeline_timeout_seconds, + timeout_grace_seconds=args.timeout_grace_seconds, + ) + _a2a_config( + pipeline_config, + port=pipeline_port, + persistence=run_dir / "runtime" / "a2a-pipeline-state", + artifacts=run_dir / "artifacts" / "pipeline", + resident_timeout_seconds=args.resident_timeout_seconds, + sub_pipeline_timeout_seconds=args.sub_pipeline_timeout_seconds, + timeout_grace_seconds=args.timeout_grace_seconds, + ) + cert, key = _generate_certificate(run_dir) + + base_env = os.environ.copy() + base_env.update( + { + "IAC_CODE_CONFIG_DIR": str(config_dir), + "IAC_CODE_CONFIG_BACKUP_DIR": str(shared_root), + "IACCODE_A2A_ALLOWED_CWDS": str(agent_workspace), + "ALICLOUD_ROS_AGENT_STATE_DIR": str(state_root), + "PYTHONPATH": os.pathsep.join( + value for value in (str(repo_root / "src"), base_env.get("PYTHONPATH", "")) if value + ), + "PYTHONUTF8": "1", + } + ) + + def a2a_service(mode: str, config_path: Path, port: int) -> _Service: + env = dict(base_env) + env["IAC_CODE_MODE"] = mode + return _Service( + command=[sys.executable, "-m", "iac_code.cli.main", "a2a", "--config", str(config_path)], + cwd=repo_root, + env=env, + stdout_path=run_dir / "logs" / "a2a-{}.stdout.log".format(mode), + stderr_path=run_dir / "logs" / "a2a-{}.stderr.log".format(mode), + ) + + normal = a2a_service("normal", normal_config, normal_port) + pipeline = a2a_service("pipeline", pipeline_config, pipeline_port) + relay = _Service( + command=[ + sys.executable, + str(repo_root / "tests" / "skill_bridge" / "start_chat_relay.py"), + "--a2a-url", + "http://127.0.0.1:{}".format(normal_port), + "--pipeline-a2a-url", + "http://127.0.0.1:{}".format(pipeline_port), + "--workspace", + str(agent_workspace), + "--cert-file", + str(cert), + "--key-file", + str(key), + "--port", + str(relay_port), + "--metrics-file", + str(run_dir / "relay-metrics.json"), + ], + cwd=repo_root, + env=base_env, + stdout_path=run_dir / "logs" / "relay.stdout.log", + stderr_path=run_dir / "logs" / "relay.stderr.log", + ) + selected_server = normal if args.mode == "normal" else pipeline + before_inventory: dict[str, Any] | None = None + deployed_inventory: dict[str, Any] | None = None + after_inventory: dict[str, Any] | None = None + permission_observations: list[dict[str, Any]] = [] + session_id = str(uuid.uuid4()) + first_permission_seen = False + restart_performed = False + cleanup_started = False + cleanup_turn = -1 + deployment_confirmation_attempts = 0 + cleanup_confirmation_attempts = 0 + architecture_seen = False + job_path: Path | None = None + skill_installation_backups: list[_SkillInstallationBackup] = [] + read_only_cloud_evidence: list[dict[str, Any]] = [] + next_prompt = _prompt_section( + "Deployment", + { + "run_id": run_id, + "stack_name": stack_name, + "vswitch_name": vswitch_name, + "mode": "Normal" if args.mode == "normal" else "Pipeline", + }, + ) + try: + _refresh_source_cloud_credentials(source_config_dir) + _copy_config(source_config_dir, config_dir) + _configure_isolated_permissions(config_dir) + before_inventory = _cloud_inventory(aliyun, args.region, stack_name, vswitch_name) + skill_installation_backups = _sync_skill(repo_root, args.skill_root, endpoint, mode=args.mode) + normal.start() + pipeline.start() + _wait_a2a(normal_port, normal) + _wait_a2a(pipeline_port, pipeline) + relay.start() + _wait_port(relay_port, relay) + + for turn in range(args.max_qoder_turns): + qoder_evidence = _run_qoder( + args=args, + env=base_env, + workspace=qoder_workspace, + session_id=session_id, + prompt=next_prompt, + turn=turn, + resume=turn > 0, + run_dir=run_dir, + ) + architecture_seen = architecture_seen or bool(qoder_evidence.get("assistantMermaid")) + jobs = _jobs(state_root) + if len(jobs) != 1: + raise AssertionError("expected exactly one ROS Agent job, found {}".format(len(jobs))) + job_path, job = jobs[0] + if job.get("mode") != args.mode: + raise AssertionError( + "ROS Agent job mode {} does not match requested E2E mode {}".format( + job.get("mode"), + args.mode, + ) + ) + state = str(job.get("state") or "") + if state in FAILURE_STATES: + raise RuntimeError("ROS Agent job ended in {}".format(state)) + current = job.get("inputRequired") + if state == "input-required" and isinstance(current, dict): + input_id = current.get("inputId") + if not any(item.get("inputId") == input_id for item in permission_observations): + observation = _safe_permission_observation( + job=job, + config_dir=config_dir, + shared_root=shared_root, + observed_at=time.time(), + ) + observation["qoderTurn"] = turn + permission_observations.append(observation) + _append_jsonl(run_dir / "permission-observations.jsonl", observation) + if current.get("kind") == "permission": + stack_ids = { + str(item.get("stackId")) + for item in (deployed_inventory or {}).get("stacks", []) + if item.get("stackId") + } + _validate_permission_scope( + current, + stack_name, + agent_workspace, + allowed_stack_ids=stack_ids, + ) + is_target_permission = ( + current.get("permissionClass") in {"normal", "pipeline"} + and current.get("effect") == "cloud_change" + ) + if is_target_permission and not first_permission_seen: + first_permission_seen = True + if args.restart_at_first_permission: + selected_server.restart() + _wait_a2a(normal_port if args.mode == "normal" else pipeline_port, selected_server) + restart_performed = True + if args.answer_delay_seconds: + time.sleep(args.answer_delay_seconds) + observation = _safe_permission_observation( + job=_jobs(state_root)[0][1], + config_dir=config_dir, + shared_root=shared_root, + observed_at=time.time(), + ) + observation["qoderTurn"] = turn + observation["afterDelaySeconds"] = args.answer_delay_seconds + permission_observations.append(observation) + _append_jsonl(run_dir / "permission-observations.jsonl", observation) + next_prompt = _answer_prompt(current) + continue + if state in TERMINAL_STATES and not cleanup_started: + current_inventory = _cloud_inventory(aliyun, args.region, stack_name, vswitch_name) + active_stacks = [ + item for item in current_inventory["stacks"] if item.get("status") != "DELETE_COMPLETE" + ] + if active_stacks and current_inventory["vswitches"]: + deployed_inventory = current_inventory + _write_json(run_dir / "deployed-inventory.json", current_inventory) + cleanup_started = True + cleanup_turn = int(job.get("turn") or 0) + next_prompt = _prompt_section( + "Cleanup", + { + "run_id": run_id, + "stack_name": stack_name, + "vswitch_name": vswitch_name, + "mode": args.mode, + }, + ) + else: + final_text = str(job.get("finalText") or "") + plan_ready = ( + stack_name in final_text + and vswitch_name in final_text + and any(marker in final_text for marker in ("部署参数", "部署摘要", "模板校验", "CreateStack")) + ) + if plan_ready and architecture_seen: + if deployment_confirmation_attempts >= 2: + raise RuntimeError("Qoder did not submit the explicit deployment confirmation") + deployment_confirmation_attempts += 1 + next_prompt = _prompt_section( + "Confirm deployment", + { + "run_id": run_id, + "stack_name": stack_name, + "vswitch_name": vswitch_name, + "mode": args.mode, + }, + ) + else: + next_prompt = _prompt_section( + "Continue deployment", + { + "run_id": run_id, + "stack_name": stack_name, + "vswitch_name": vswitch_name, + "mode": args.mode, + }, + ) + continue + if state in TERMINAL_STATES and cleanup_started and int(job.get("turn") or 0) > cleanup_turn: + break + if state in TERMINAL_STATES and cleanup_started: + if cleanup_confirmation_attempts >= 2: + raise RuntimeError("Qoder did not submit the explicit cleanup confirmation") + cleanup_confirmation_attempts += 1 + next_prompt = _prompt_section( + "Confirm cleanup", + { + "run_id": run_id, + "stack_name": stack_name, + "vswitch_name": vswitch_name, + "mode": args.mode, + }, + ) + continue + next_prompt = "只对当前 job 调用 follow 继续观察,不要发送自然语言 continue 来催促远端。" + else: + raise TimeoutError("Qoder turn limit reached before cleanup completed") + finally: + finalization_error: BaseException | None = None + for service in (relay, normal, pipeline): + try: + service.stop() + except BaseException as exc: + finalization_error = finalization_error or exc + try: + _restore_skill_installations(skill_installation_backups) + except BaseException as exc: + finalization_error = finalization_error or exc + try: + inventory = _cloud_inventory(aliyun, args.region, stack_name, vswitch_name) + _cleanup_exact_stack(aliyun, args.region, inventory) + deadline = time.monotonic() + 600 + last_vswitch_cleanup_attempt = 0.0 + while time.monotonic() < deadline: + after_inventory = _cloud_inventory(aliyun, args.region, stack_name, vswitch_name) + active = [item for item in after_inventory["stacks"] if item.get("status") != "DELETE_COMPLETE"] + now = time.monotonic() + if not active and after_inventory["vswitches"] and now - last_vswitch_cleanup_attempt >= 15: + # ROS can finish deleting a Stack while retaining a failed + # child resource. Inventory is exact-name scoped, so this + # removes only the VSwitch owned by the current E2E run. + # A transient dependency error while Stack deletion is + # converging must not abort polling; retry until the bounded + # cleanup deadline and let the final inventory prove removal. + _cleanup_exact_vswitches(aliyun, args.region, after_inventory) + last_vswitch_cleanup_attempt = now + if not active and not after_inventory["vswitches"]: + break + time.sleep(5) + except Exception as exc: + _write_json(run_dir / "cleanup-error.json", {"type": type(exc).__name__, "message": str(exc)[:500]}) + finally: + read_only_cloud_evidence = _read_only_cloud_execution_evidence(config_dir) + try: + _write_json(run_dir / "read-only-cloud-evidence.json", read_only_cloud_evidence) + finally: + _remove_sensitive_run_data(config_dir) + if finalization_error is not None: + raise finalization_error + + before_vpcs = dict(before_inventory.get("vpcs", [])) if before_inventory else {} + after_vpcs = dict(after_inventory.get("vpcs", [])) if after_inventory else {} + relay_metrics = {} + metrics_path = run_dir / "relay-metrics.json" + if metrics_path.is_file(): + relay_metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + qoder_turns = [] + qoder_path = run_dir / "qoder-turns.jsonl" + if qoder_path.is_file(): + qoder_turns = [json.loads(line) for line in qoder_path.read_text(encoding="utf-8").splitlines() if line] + job_event_types = _job_event_types(job_path) if job_path is not None else [] + deploy_permission_turns = [ + int(item["qoderTurn"]) + for item in permission_observations + if item.get("permissionClass") in {"normal", "pipeline"} + and item.get("effect") == "cloud_change" + and "qoderTurn" in item + ] + delayed_observations = [item for item in permission_observations if "afterDelaySeconds" in item] + local_permission_records, shared_permission_records = _permission_records(config_dir, shared_root) + prompted_tool_use_ids = {str(item.get("toolUseId")) for item in permission_observations if item.get("toolUseId")} + checkpoint_tool_use_ids = { + str(item.get("toolUseId")) + for item in [*local_permission_records, *shared_permission_records] + if item.get("toolUseId") + } + verified_read_only_calls = [ + item + for item in read_only_cloud_evidence + if item.get("action") == "DescribeVpcs" + and item.get("resultPersisted") is True + and item.get("toolUseId") not in prompted_tool_use_ids + and item.get("toolUseId") not in checkpoint_tool_use_ids + ] + deployed_vpc_ids = { + str(item.get("vpcId")) for item in (deployed_inventory or {}).get("vswitches", []) if item.get("vpcId") + } + delayed_phase_ok = True + if args.answer_delay_seconds: + delayed_phase_ok = bool(delayed_observations) + if delayed_observations: + delayed_phase = delayed_observations[-1].get("checkpointPhase") + suspended_threshold = args.resident_timeout_seconds + args.timeout_grace_seconds + 5 + grace_threshold = args.resident_timeout_seconds + 5 + if args.answer_delay_seconds >= suspended_threshold: + delayed_phase_ok = delayed_phase == "SUSPENDED" + elif args.answer_delay_seconds >= grace_threshold: + delayed_phase_ok = delayed_phase == "TIMEOUT_GRACE" + checks = { + "real non-read-only permission observed": any( + item.get("kind") == "permission" and item.get("isReadOnly") is False for item in permission_observations + ), + "real cloud-change permission observed": bool(deploy_permission_turns), + "no read-only permission observed": not any(item.get("isReadOnly") is True for item in permission_observations), + "read-only DescribeVpcs executed without prompt or checkpoint": bool(verified_read_only_calls), + "serial permission checkpoint existed": any( + item.get("permissionClass") in {"normal", "pipeline"} and item.get("localCheckpoint") + for item in permission_observations + ), + "serial permission shared commit existed": any( + item.get("permissionClass") in {"normal", "pipeline"} and item.get("sharedCheckpoint") + for item in permission_observations + ), + "sub pipeline created no durable checkpoint": not any( + item.get("permissionClass") == "sub_pipeline" and item.get("localCheckpoint") + for item in permission_observations + ), + "existing VPC inventory retained": bool(before_vpcs) and set(before_vpcs).issubset(after_vpcs), + "VSwitch was deployed into a pre-existing VPC": bool( + deployed_vpc_ids and deployed_vpc_ids.issubset(before_vpcs) + ), + "run stack and VSwitch existed before cleanup": bool( + deployed_inventory + and [item for item in deployed_inventory["stacks"] if item.get("status") != "DELETE_COMPLETE"] + and deployed_inventory["vswitches"] + ), + "run stack cleaned": bool( + after_inventory is not None + and not [item for item in after_inventory["stacks"] if item.get("status") != "DELETE_COMPLETE"] + ), + "run VSwitch cleaned": bool(after_inventory is not None and not after_inventory["vswitches"]), + "native StartChat relay was used": any( + item.get("action") == "StartChat" for item in relay_metrics.get("requests", []) + ), + "Qoder emitted explanatory assistant text": sum( + int(item.get("assistantTextBlocks") or 0) for item in qoder_turns + ) + >= 3, + "architecture diagram preceded deployment permission": _architecture_preceded_deployment_permission( + qoder_turns, + permission_observations, + ), + "Pipeline step progress was visible": args.mode != "pipeline" + or ("step_started" in job_event_types and "step_completed" in job_event_types), + "Pipeline candidate selection was visible": args.mode != "pipeline" + or any( + item.get("kind") == "candidate_selection" and int(item.get("optionCount") or 0) >= 2 + for item in permission_observations + ), + "Pipeline normal handoff was retained": args.mode != "pipeline" + or bool(_jobs(state_root) and _jobs(state_root)[0][1].get("normalHandoffReady")), + "configured delayed phase was observed": delayed_phase_ok, + "requested A2A restart was performed": not args.restart_at_first_permission or restart_performed, + } + result = { + "schemaVersion": 1, + "runId": run_id, + "mode": args.mode, + "qoderSessionId": session_id, + "answerDelaySeconds": args.answer_delay_seconds, + "restartAtFirstPermission": args.restart_at_first_permission, + "permissionWaitPolicy": [ + args.resident_timeout_seconds, + args.sub_pipeline_timeout_seconds, + args.timeout_grace_seconds, + ], + "checks": checks, + "passed": all(checks.values()), + } + _write_json(run_dir / "result.json", result) + return result + + +def main() -> int: + result = run(_parse_args()) + print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/a2a/e2e/permission_wait/run_sub_pipeline_permission_timeout.py b/scripts/a2a/e2e/permission_wait/run_sub_pipeline_permission_timeout.py new file mode 100644 index 00000000..b7e4df3b --- /dev/null +++ b/scripts/a2a/e2e/permission_wait/run_sub_pipeline_permission_timeout.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Controlled real-AgentLoop and real-parent-Pipeline Sub permission timeout E2E.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from iac_code.a2a.input_required import PermissionInputRegistry +from iac_code.a2a.metrics import NoOpA2AMetrics +from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor +from iac_code.a2a.task_store import A2ATaskStore +from iac_code.agent.agent_loop import AgentLoop +from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.pipeline_runner import PipelineRunner +from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor +from iac_code.pipeline.engine.types import StepResult, StepStatus +from iac_code.providers.base import ToolDefinition +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitCoordinator, + PermissionWaitPolicy, +) +from iac_code.services.session_backup import BackupReason, BackupResult, SessionBackupService +from iac_code.services.session_storage import SessionStorage +from iac_code.services.session_usage import SessionUsageStore +from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult +from iac_code.types.permissions import PermissionResult +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + PermissionRequestEvent, + TextDeltaEvent, + ToolResultEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + + +class _Queue: + def __init__(self) -> None: + self.events: list[Any] = [] + + async def enqueue_event(self, event: Any) -> None: + self.events.append(event) + + +class _AskWriteTool(Tool): + def __init__(self) -> None: + self.execution_count = 0 + + @property + def name(self) -> str: + return "fixture_write" + + @property + def description(self) -> str: + return "Write fixture state." + + @property + def input_schema(self) -> dict[str, Any]: + return {"type": "object", "properties": {"value": {"type": "string"}}} + + async def check_permissions(self, input: dict, context: dict | None = None) -> PermissionResult: + return PermissionResult(behavior="ask", message="Allow fixture write?") + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + self.execution_count += 1 + return ToolResult.success("fixture write executed") + + +class _CandidateProvider: + def __init__(self, *, asks_permission: bool) -> None: + self.asks_permission = asks_permission + self.turn = 0 + + def get_model_name(self) -> str: + return "fixture" + + async def stream( + self, + messages: Any, + system: str, + tools: list[ToolDefinition] | None = None, + max_tokens: int = 8192, + ): + self.turn += 1 + yield MessageStartEvent(message_id="fixture-message-{}".format(self.turn)) + if self.asks_permission and self.turn == 1: + yield TextDeltaEvent(text="candidate A requests one protected write") + yield ToolUseStartEvent(tool_use_id="fixture-tool-a", name="fixture_write") + yield ToolUseEndEvent( + tool_use_id="fixture-tool-a", + name="fixture_write", + input={"value": "candidate-a"}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + text = "candidate A continued after denial" if self.asks_permission else "candidate B completed naturally" + yield TextDeltaEvent(text=text) + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + +class _RecordingBackupService: + """Use the production backup hook while recording its exact policy reasons.""" + + def __init__(self, storage: SessionStorage) -> None: + self._initializer = SessionBackupService(session_storage=storage) + self.calls: list[dict[str, Any]] = [] + self.current_publication: dict[str, Any] | None = None + + def initialize_session(self, cwd: str, session_id: str) -> None: + self._initializer.initialize_session(cwd, session_id) + + def backup_session( + self, + cwd: str, + session_id: str, + *, + reason: BackupReason, + critical: bool, + **_kwargs: Any, + ) -> BackupResult: + publication = self.current_publication or {} + permission = publication.get("permission") + self.calls.append( + { + "reason": reason, + "critical": critical, + "eventType": publication.get("eventType"), + "scope": publication.get("scope"), + "toolUseId": permission.get("toolUseId") if isinstance(permission, dict) else None, + } + ) + return BackupResult(enabled=True, shared_committed=True) + + +class _CandidateStepExecutor: + """Controlled StepExecutor seam; each step itself is a real AgentLoop.""" + + def __init__( + self, + *, + cwd: Path, + run_dir: Path, + projects_dir: Path, + storage: SessionStorage, + permission_visible: asyncio.Event, + denied_results: list[ToolResultEvent], + final_text: dict[int, str], + protected_tool: _AskWriteTool, + ) -> None: + self._cwd = cwd + self._run_dir = run_dir + self._projects_dir = projects_dir + self._storage = storage + self._permission_visible = permission_visible + self._denied_results = denied_results + self._final_text = final_text + self._protected_tool = protected_tool + self.current_agent_loop: AgentLoop | None = None + + def set_telemetry_scope(self, **_kwargs: Any) -> None: + return None + + def set_telemetry_correlation(self, **_kwargs: Any) -> None: + return None + + async def execute(self, step: Any, context: Any, session_id: str, **_kwargs: Any): + candidate = context.get_conclusion("candidate") + if not isinstance(candidate, dict): + raise AssertionError("Sub Pipeline candidate was not injected into its context") + index = int(candidate["fixture_index"]) + asks_permission = bool(candidate["asks_permission"]) + if not asks_permission: + await self._permission_visible.wait() + + tool_registry = ToolRegistry() + candidate_tool = self._protected_tool if asks_permission else _AskWriteTool() + tool_registry.register(candidate_tool) + candidate_session_id = "{}-candidate-{}".format(session_id, index) + candidate_session_dir = self._storage.ensure_v2_session_dir_for_new_session( + str(self._cwd), + candidate_session_id, + ) + loop = AgentLoop( + provider_manager=_CandidateProvider(asks_permission=asks_permission), + system_prompt="controlled candidate fixture", + tool_registry=tool_registry, + max_turns=3, + session_storage=self._storage, + session_usage_store=SessionUsageStore(projects_dir=self._projects_dir), + session_id=candidate_session_id, + cwd=str(self._cwd), + pipeline_mode=True, + result_storage_dir=self._run_dir / "tool-results" / str(index), + audit_log_path=candidate_session_dir / "permission-audit.jsonl", + ) + self.current_agent_loop = loop + text_parts: list[str] = [] + async for event in loop.run_streaming("evaluate candidate {}".format(candidate["name"])): + if isinstance(event, TextDeltaEvent): + text_parts.append(event.text) + if isinstance(event, ToolResultEvent) and event.tool_use_id == "fixture-tool-a": + self._denied_results.append(event) + if isinstance(event, PermissionRequestEvent): + self._permission_visible.set() + yield event + text = "".join(text_parts) + self._final_text[index] = text + conclusion = {"candidateIndex": index, "summary": text} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + +class _ParentStepExecutor: + """Deterministic parent steps around the real parallel PipelineRunner step.""" + + def __init__(self) -> None: + self.selection_inputs: list[dict[str, Any]] = [] + + async def execute(self, step: Any, context: Any, session_id: str, user_message: Any = None, **_kwargs: Any): + if step.step_id == "architecture": + conclusion = { + "candidates": [ + {"name": "Plan A", "fixture_index": 0, "asks_permission": True}, + {"name": "Plan B", "fixture_index": 1, "asks_permission": False}, + ] + } + elif step.step_id == "confirm_and_select": + evaluated = context.get_conclusion("evaluated") + if not isinstance(evaluated, list) or len(evaluated) != 2: + raise AssertionError("Parent selection did not receive both evaluated candidates") + self.selection_inputs = [dict(item) for item in evaluated if isinstance(item, dict)] + options = [ + {"id": "candidate-{}".format(index), "name": item["candidate"]["name"]} + for index, item in enumerate(self.selection_inputs) + ] + conclusion = {"user_prompt": "Choose a candidate", "options": options} + if user_message is not None: + conclusion.update({"selected_candidate_index": 1, "selected_candidate_name": "Plan B"}) + else: + raise AssertionError("Unexpected controlled parent step: {}".format(step.step_id)) + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + +def _write_pipeline_fixture(path: Path) -> None: + (path / "prompts").mkdir(parents=True) + for name in ("architecture", "evaluate", "select"): + (path / "prompts" / "{}.md".format(name)).write_text(name, encoding="utf-8") + (path / "pipeline.yaml").write_text( + """name: permission-timeout-fixture +context_dependencies: + architecture: [] + evaluated: [architecture] + selection: [evaluated] +max_rollbacks: 1 +sub_pipelines: + evaluate_candidate: + max_rollbacks: 1 + iterate_over: architecture.candidates + context_fields_from_parent: [] + steps: + - id: evaluate + conclusion_field: evaluation + forward: null + prompt: prompts/evaluate.md +steps: + - id: architecture + conclusion_field: architecture + forward: evaluate_candidates + prompt: prompts/architecture.md + - id: evaluate_candidates + type: parallel_sub_pipeline + sub_pipeline: evaluate_candidate + conclusion_field: evaluated + forward: confirm_and_select + prompt: prompts/evaluate.md + - id: confirm_and_select + conclusion_field: selection + forward: null + prompt: prompts/select.md + auto_advance: false + ui_mode: candidate_selection +""", + encoding="utf-8", + ) + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +async def run_scenario(*, run_dir: Path, timeout_seconds: float) -> dict[str, Any]: + run_dir = run_dir.resolve() + run_dir.mkdir(parents=True, exist_ok=False) + cwd = run_dir / "workspace" + cwd.mkdir() + (run_dir / "audit").mkdir() + config_dir = run_dir / "config" + projects_dir = config_dir / "projects" + pipeline_dir = run_dir / "pipeline-fixture" + _write_pipeline_fixture(pipeline_dir) + + previous_config_dir = os.environ.get("IAC_CODE_CONFIG_DIR") + os.environ["IAC_CODE_CONFIG_DIR"] = str(config_dir) + original_step_factory = SubPipelineExecutor._make_step_executor + try: + storage = SessionStorage(projects_dir=projects_dir) + backup_service = _RecordingBackupService(storage) + registry = PermissionInputRegistry() + policy = PermissionWaitPolicy(sub_pipeline_timeout_seconds=timeout_seconds) + registry.set_permission_wait_coordinator(PermissionWaitCoordinator(policy)) + task_store = A2ATaskStore(backup_service=backup_service) + context = await task_store.get_or_create_context( + context_id="ctx-1", + cwd=str(cwd), + runtime_factory=lambda _session_id: object(), + ) + session_id = context.session_id + task = await task_store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.state = "working" + context.active_task_id = task.task_id + task_store.mirror_task(task) + task_store.mirror_context(context) + + permission_visible = asyncio.Event() + denied_results: list[ToolResultEvent] = [] + final_text: dict[int, str] = {} + protected_tool = _AskWriteTool() + + def make_candidate_step_executor(_self: SubPipelineExecutor) -> _CandidateStepExecutor: + return _CandidateStepExecutor( + cwd=cwd, + run_dir=run_dir, + projects_dir=projects_dir, + storage=storage, + permission_visible=permission_visible, + denied_results=denied_results, + final_text=final_text, + protected_tool=protected_tool, + ) + + SubPipelineExecutor._make_step_executor = make_candidate_step_executor + + runner = PipelineRunner( + pipeline_dir=pipeline_dir, + provider_manager=object(), + base_tool_registry=ToolRegistry(), + session_storage=storage, + session_id=session_id, + cwd=str(cwd), + surface="a2a", + backup_service=backup_service, + ) + parent_executor = _ParentStepExecutor() + runner._step_executor.execute = parent_executor.execute + + executor = IacCodeA2APipelineExecutor( + task_store=task_store, + model="fixture", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + permission_input_registry=registry, + auto_approve_permissions=False, + thinking_exposure_types=None, + backup_service=backup_service, + ) + queue = _Queue() + publisher = executor._publisher( + event_queue=queue, + pipeline=runner, + task_id=task.task_id, + context_id=context.context_id, + session_id=session_id, + cwd=str(cwd), + ) + executor._install_backup_hook( + publisher, + pipeline=runner, + cwd=str(cwd), + session_id=session_id, + task=task, + ctx=context, + ) + production_before_enqueue = publisher.before_enqueue + + async def record_publication_before_enqueue(envelope: dict[str, Any]) -> bool: + backup_service.current_publication = envelope + try: + if production_before_enqueue is None: + return True + result = production_before_enqueue(envelope) + if asyncio.iscoroutine(result): + result = await result + return result is not False + finally: + backup_service.current_publication = None + + publisher.before_enqueue = record_publication_before_enqueue + + task_while_candidates_finished = None + async for event in runner.run("evaluate both candidates and select one"): + await publisher.publish(event) + if ( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.STEP_COMPLETED + and event.step_id == "evaluate_candidates" + ): + task_while_candidates_finished = await task_store.get_task_record(task.task_id) + + evaluated = runner.context.get_conclusion("evaluated") + async for event in runner.resume(json.dumps({"selected_candidate_index": 1})): + await publisher.publish(event) + + events = publisher.journal.read_all_repairing_tail() + event_types = [str(event.get("eventType")) for event in events] + b_completed = next( + index + for index, event in enumerate(events) + if event.get("eventType") == "candidate_completed" and event.get("candidate", {}).get("index") == 1 + ) + permission_timeout = next( + index + for index, event in enumerate(events) + if event.get("eventType") == "permission_resolved" and event.get("permission", {}).get("timedOut") is True + ) + checkpoint_store = PermissionWaitCheckpointStore(str(cwd), session_id, storage=storage) + permission_backup_calls = [ + call + for call in backup_service.calls + if call["eventType"] == "permission_requested" or call["toolUseId"] == "fixture-tool-a" + ] + selection = runner.context.get_conclusion("selection") + checks = { + "candidate A entered real AgentLoop permission wait": "permission_requested" in event_types, + "candidate B completed before A hard timeout": b_completed < permission_timeout, + "hard timeout delivered exactly one denied ToolResult": len(denied_results) == 1 + and denied_results[0].is_error + and denied_results[0].result == "Permission denied.", + "denied tool did not execute": protected_tool.execution_count == 0, + "candidate A AgentLoop continued after denial": "candidate A continued after denial" + in final_text.get(0, ""), + "real parent Pipeline consumed both candidate conclusions": isinstance(evaluated, list) + and len(evaluated) == 2 + and len(parent_executor.selection_inputs) == 2 + and all(not item.get("failed", True) for item in parent_executor.selection_inputs), + "parent remained working through candidate completion": task_while_candidates_finished is not None + and task_while_candidates_finished.state == "working", + "parent naturally reached candidate selection and completed": "input_required" in event_types + and "input_received" in event_types + and "pipeline_completed" in event_types + and isinstance(selection, dict) + and selection.get("selected_candidate_index") == 1, + "no grace state or durable checkpoint": checkpoint_store.list_active() == [] + and not list(run_dir.rglob("permission-waits/pwb_*.json")), + "production backup hook excluded Sub permission checkpoint": permission_backup_calls == [] + and any(call["eventType"] == "input_required" for call in backup_service.calls), + } + result = { + "schemaVersion": 1, + "timeoutSeconds": timeout_seconds, + "backupCalls": [ + { + **call, + "reason": call["reason"].value, + } + for call in backup_service.calls + ], + "checks": checks, + "passed": all(checks.values()), + } + _write_json(run_dir / "result.json", result) + return result + finally: + SubPipelineExecutor._make_step_executor = original_step_factory + if previous_config_dir is None: + os.environ.pop("IAC_CODE_CONFIG_DIR", None) + else: + os.environ["IAC_CODE_CONFIG_DIR"] = previous_config_dir + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--timeout-seconds", type=float, default=300.0) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + result = asyncio.run(run_scenario(run_dir=args.run_dir, timeout_seconds=args.timeout_seconds)) + print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/alicloud-ros-agent/SKILL.md b/skills/alicloud-ros-agent/SKILL.md new file mode 100644 index 00000000..34b5207c --- /dev/null +++ b/skills/alicloud-ros-agent/SKILL.md @@ -0,0 +1,214 @@ +--- +name: alicloud-ros-agent +description: Use Alibaba Cloud ROS Agent through its StartChat API for remote infrastructure conversations. Trigger when the user explicitly asks for the ROS Agent, its StartChat API, or a remote iac-code conversation through Alibaba Cloud. Supports normal and selling Pipeline conversations, questions, candidate selection, correlated permission approval or denial, and explicit StopChat cancellation. Do not trigger for ordinary Alibaba Cloud infrastructure work that can use the local iac-code Skill, or for unrelated ROS API operations. +--- + +# Alibaba Cloud ROS Agent + +Use the bridge at `scripts/ros_agent.py`. Its default code transport uses the Alibaba Cloud credentials and Core SDKs to sign ROS RPCs, send them directly, and consume StartChat SSE incrementally. Unless local policy pins a CLI Profile, it first uses a complete AK/SK pair from the same environment-variable aliases and precedence as aliyun CLI, including an optional STS token; only when no environment AK/SK is present does it use the selected CLI Profile. A pinned Profile is exclusive and never falls back to environment credentials or another Profile. A direct OAuth Profile reuses its unexpired cached STS credential without starting the CLI; when that credential is missing or expired, native aliyun CLI performs its own expiration check and refresh before the SDK reads the refreshed temporary credential. Credentials exist only inside the request path and are never accepted as bridge arguments, persisted in job state, or returned. An optional compatibility transport lets the native CLI execute the whole RPC without requiring any Python package. Run the bridge with `python3` on macOS/Linux or `py -3` on Windows. + +## Required interaction contract + +Visible narration is part of completing this workflow, not optional styling. Internal reasoning/thinking, a tool description, raw tool output, and the wording of a user question are not substitutes for a user-visible assistant text block. Keep each update concise—normally one to three sentences in the user's language. An update and the following tool call may be in the same assistant response, so do not pause merely to narrate. + +Use these stage gates: + +- After loading this Skill and before the first operational tool call, acknowledge the infrastructure task, identify Normal or Pipeline mode, and state the immediate next phase. +- After `check` succeeds and before local prompt preparation or `start`, report that readiness passed and what ROS Agent will work on next. Do not expose commands, Profile details, or opaque IDs. +- Whenever bridge JSON has `presentationRequired: true`, the next assistant response must begin with a user-visible text block before any further tool call. For every `boundaryReached` result, emit every ready-to-display `userUpdates` string—even after earlier Pipeline updates; skipping a repeated stage gate and going directly to Bash is a protocol violation. For `followTimedOut`, show the `heartbeat` without claiming completion. For `turn-completed`, present the authoritative `finalText` and relevant artifacts before starting another action or asking a follow-up question. +- Before asking the user to confirm a deployment, present the proposed architecture as a fenced Mermaid diagram after the deployment summary. The confirmation question must come after the diagram; tool output or an unrendered diagram field does not satisfy this gate. Follow the architecture rules below. +- When input is required, first explain in a separate visible update what has completed, what ROS Agent is waiting for, and why the answer is needed; then ask the question with every returned option intact using an interaction method appropriate to the host. For permissions, include the safe action, target, and `permissionClass`; for `pendingPermissions`, state how many independent Sub Pipeline steps are waiting. End the agent turn without choosing for the user. +- After the user answers a question, selects a candidate, or allows/denies a permission, begin the next assistant response by acknowledging the choice and saying that ROS Agent is resuming, then call `continue` or `respond`. Before sending any later natural-language request such as a change or cleanup through `continue`, similarly state what the same ROS Agent session will do next. +- On completion or failure, present the authoritative result or concise sanitized error immediately. Do not dump raw JSON, event counts, correlation IDs, or the full prior milestone history, and do not repeat already presented progress. + +Do not call `TodoWrite`, `Task`, or another planning tool merely to track this managed workflow. The preserved `jobId` and `cursor` are its state; report progress directly to the user instead. + +While a Pipeline has `wireState: TASK_STATE_WORKING`, `follow` is the only observation operation. This remains true after `permission-responded` and when a cursor has not advanced. Never invoke or offer `continue` as a retry, poll, nudge, or way to unstick a Pipeline: it sends a real natural-language interrupt. Present a returned heartbeat and keep following; if the bridge reports `state: failed`, present that error rather than inventing a recovery message. + +## Prerequisites + +The selected credential must be allowed to call `ros:StartChat`. Explicit cancellation additionally requires `ros:StopChat`; it is not required for an ordinary completed conversation. The default code transport requires the packages pinned in `requirements-code.txt` to be installed for the Python interpreter that runs the bridge. It does not require Alibaba Cloud CLI when complete environment AK/SK credentials are available; a CLI Profile requires its local configuration, and an expired or missing OAuth STS credential additionally requires the native CLI for refresh. The `aliyun_cli` transport has no Python package dependency and requires the installed CLI. This is installation-time setup; do not install packages or reconfigure credentials during an infrastructure task. Run the bridge check once before the first StartChat call: + +```text +python3 /ros_agent.py check +``` + +The bounded JSON result includes the effective `transport`, endpoint, Agent modes, Thinking policy, configured Profile policy, effective region, and only these non-secret fields from the credential source: `configured`, `name`, `mode`, `regionId`, and `language`. `cli` and `version` are null when the code transport does not need the CLI. In code mode, `mode: Environment` means a complete environment AK/SK pair is selected and no Profile credential is used. Use this result as the sole local readiness source. Never run `aliyun configure`, any `aliyun configure *` subcommand, enumerate profiles, or read Alibaba Cloud CLI configuration files yourself. The check deliberately excludes credential values and does not prove that a token is still accepted by ROS; the StartChat response is authoritative for authentication and authorization failures. + +The returned `transport` is installation policy, not an Agent choice. If `check` fails—especially with `sdk_not_installed` in code mode—report that exact readiness problem and stop. Never edit `config.json`, propose or attempt another transport, pass a transport override, or fall back to `aliyun_cli` to bypass the failure. Only the user or installation administrator may change this policy outside the infrastructure task, after which a new `check` is required. + +Add `--aliyun-path ` to `check` or `start` only when the effective credential path requires native aliyun CLI and it is not on `PATH`; the managed job preserves it for later requests. Use the returned credential source and region without asking the user to choose a Profile. Omit `--profile` unless the user explicitly supplied a Profile and local policy did not pin one; never try to override `aliyunCLIProfile`. Never pass credentials on the command line, put them in prompt files, or expose CLI configuration. + +## Optional local policy + +The bridge reads an optional `config.json` beside this `SKILL.md`. If it is absent, the transport defaults to `code`, the endpoint defaults to `ros.aliyuncs.com`, both Agent modes are allowed, Thinking is enabled, the effective environment/current Profile credential is selected, and the temporary loopback manager exits 60 seconds after the last SSE worker and manager request become idle. The file accepts these settings: + +```json +{ + "transport": "code", + "endpoint": "127.0.0.1:56124", + "allowedAgentModes": ["normal", "pipeline"], + "managerIdleSeconds": 60, + "enableThinking": true, + "aliyunCLIProfile": "" +} +``` + +- `transport` accepts exactly `code` or `aliyun_cli`. `code` is the default: it prefers CLI-compatible environment AK/SK credentials, otherwise loads the selected CLI Profile; an unexpired OAuth STS value is reused locally, expired or missing OAuth STS refresh is delegated to native aliyun CLI, and other supported Profile modes use the credentials SDK. It signs and sends StartChat or StopChat to the configured endpoint while exposing SSE events as they arrive. `aliyun_cli` preserves the dependency-free compatibility path in which the native CLI performs the whole RPC; its output may not become visible until the response stream ends. SDK imports are lazy and never occur in `aliyun_cli` mode. There is no silent fallback between transports. A partial environment AK/SK pair fails closed instead of falling back to another identity. +- `endpoint` fixes the ROS endpoint for every StartChat and StopChat request in a managed job. A conflicting `--endpoint` is rejected, so do not try to override this local policy. Public endpoints must be `*.aliyuncs.com` hostnames. For local integration tests only, `localhost:` and `127.0.0.1:` are accepted; both transports use HTTPS and skip certificate verification only for those loopback addresses. +- `allowedAgentModes` is a non-empty allowlist containing `normal`, `pipeline`, or both. Do not invoke or suggest a mode excluded by this list. +- `managerIdleSeconds` is an integer from 1 through 86400. It defaults to 60. The countdown starts only when no StartChat SSE worker is running—including a concurrent Sub Pipeline permission-response worker—and is refreshed by each manager request; after exit, any managed command starts a new manager automatically while preserving job state. +- `enableThinking` is a boolean and defaults to `true`. It fixes `EnableThinking` for the whole managed job; do not pass `--no-thinking` or try to override it per request. +- `aliyunCLIProfile` is an empty or exact CLI Profile name and defaults to empty. Empty preserves code-mode environment-AK precedence and otherwise selects the CLI's effective current Profile. A non-empty value pins that Profile for both transports, ignores environment AK/Profile selectors, and fails instead of falling back when the Profile is unavailable. Do not pass a conflicting `--profile`. + +Unknown fields, invalid values, and duplicate modes fail closed. Never edit `config.json` during an infrastructure task or store credentials in it; it is an administrator/user installation policy. + +## Managed StartChat workflow + +1. Put the complete user request in a UTF-8 prompt file inside the target workspace. Run `start` with the shell process working directory set to that target workspace while invoking the resolved bridge script by its absolute path. Never change into the Skill directory or copy prompt, answer, or permission files there merely to satisfy workspace validation. +2. Start a normal managed job from the target workspace. The bridge uses its process working directory only for local prompt-file isolation; it never sends a workspace or `cwd` field to StartChat: + + ```text + python3 /ros_agent.py start --prompt-file --mode normal --follow + ``` + + Pass `--region-id` only when the user explicitly supplied a region. Otherwise the bridge uses the first supported region environment variable, then the selected Profile region, then `cn-hangzhou`; do not query CLI configuration to fill it. Use `--mode pipeline` only when the user explicitly wants the candidate-architecture, cost-comparison, confirmation, and deployment Pipeline. Thinking is installation policy from `config.json`, not an Agent choice. Forward underspecified infrastructure requirements to ROS Agent as written so its own `ask_user_question` can gather them. +3. Preserve the returned `jobId` and newest `cursor`. A temporary authenticated loopback manager owns the job, and a detached worker keeps the selected StartChat transport open after the outer tool call returns. In the default code transport, each SSE event is projected as it arrives. `--follow` returns at every step start, step completion/failure, input boundary, completed turn, terminal state, or its bounded wait window so the user can see the Pipeline progressing. A result can contain multiple ordered `userUpdates` when events were already queued, and can also contain `inputRequired` or a terminal result; present all updates first, then handle that result without an extra drain-only `follow`. +4. When the result has `boundaryReached: true`, present every `userUpdates` string to the user, then immediately follow from the returned cursor: + + ```text + python3 /ros_agent.py follow --job-id --cursor --wait-seconds 60 + ``` + + Follow waits at most 120 seconds even if a larger value is requested. If it returns `followTimedOut: true`, present its `heartbeat` as a visible status update and call `follow` again with the newest cursor. A timeout never stops the background worker or sends a new StartChat query. +5. For every natural-language follow-up, answer to `ask_user_question`, or `candidate_selection`, write a new prompt file and continue the same job: + + ```text + python3 /ros_agent.py continue --job-id --prompt-file --follow + ``` + + Do not invent a `SessionId`; the job binds the remote session, mode, endpoint, region, Profile, and workspace. When a completed Pipeline returns `normalHandoffReady: true` or `conversationMode: normal`, its next user message is a Normal chat turn reached through this same `continue` command and `jobId`; the bridge intentionally keeps the StartChat mode while the remote A2A context performs the handoff. Never replace that handoff with `start --mode normal`. Do not start a new job merely to continue the same task. +6. Only when the user explicitly asks to stop or cancel the active ROS Agent operation, cancel that same managed job: + + ```text + python3 /ros_agent.py cancel --job-id + ``` + + This invokes the ROS `StopChat` OpenAPI through the job's selected transport; it does not send a StartChat query or a natural-language cancellation message. Present the returned status immediately. `Stopped` means cancellation completed, `Stopping` means it was accepted and the existing job should be observed with `follow` from its current cursor, and `NoActiveStream` means there was no active remote stream to stop. Never call `cancel` merely because `follow` timed out, a local tool call was interrupted, or the outer Agent turn ended. + +Without a configured endpoint, the bridge defaults to `ros.aliyuncs.com`. Use `--endpoint ` only when the user's ROS region or network requires a different endpoint and `config.json` does not fix one. The code transport sends a generic signed ROS RPC with API version `2019-09-10`, so it does not depend on generated StartChat metadata. The `aliyun_cli` transport retains the CLI's built-in ROS API version and forced-call mechanism because `StartChat` is not in the public CLI metadata. Both transports identify every StartChat and StopChat request with the user-agent segment `AlibabaCloud-Agent-Skills/alibabacloud-ros-agent`. + +## Architecture before deployment confirmation + +Immediately before any create/update deployment confirmation, render one compact `mermaid` `flowchart` showing the resources that would be deployed and their material relationships. This is presentation work by the outer Agent and does not require another StartChat query. + +Use only authoritative data already returned for the current plan, in this order: + +1. A non-empty `architectureDiagram` returned by ROS Agent. +2. The current ROS/Terraform template artifact. If the result exposes a local artifact `sourcePath` and the returned summary is insufficient, read only that artifact; do not inspect manager state, worker logs, or unrelated files. +3. `finalText`, `deploymentSummary`, candidate details, and other bounded result fields. + +For Normal mode, derive the diagram from declared resources and explicit template references or dependencies. For Pipeline mode, render the selected candidate's returned diagram and ensure it still matches the plan being confirmed. Label nodes with user-meaningful resource types or names, group network containment when explicit, and show only relationships supported by the source. Use distinct Mermaid IDs for containers and resource nodes. Keep cloud scopes accurate: an Alibaba Cloud VPC is regional, while a VSwitch belongs to a zone, so put the VSwitch inside the VPC and include its zone in the VSwitch label rather than placing the VPC inside a zone. Collapse large repeated groups to keep the diagram readable. Never invent resources, connections, public exposure, zones, or dependencies. If relationships are unavailable, show a resource inventory diagram without speculative edges and briefly state that the returned plan did not describe the missing relationships. + +Present the deployment summary, fenced Mermaid block, and confirmation question in that order. Do not ask for confirmation first and add the diagram afterward. A later permission prompt may summarize the same plan without regenerating the diagram if the proposed architecture has not changed; if it has changed, render the updated diagram before seeking confirmation again. + +## Optional context and images + +Use `--client-context-file ` for a JSON object accepted by StartChat. Keep this file inside the workspace and exclude secrets. + +Use `--attachments-file ` for up to five OSS-backed images. The file must be a JSON array such as: + +```json +[ + { + "Type": "image", + "MimeType": "image/png", + "Name": "architecture.png", + "OssObjectKey": "user/workspace/architecture.png" + } +] +``` + +The bridge also accepts lower camel case and snake case field names. Do not use local paths, inline image bytes, or secret-bearing URLs. StartChat V2 currently supports `image/png`, `image/jpeg`, `image/webp`, and `image/gif` OSS objects. + +## Interpret the result + +Stdout is one bounded JSON object; diagnostics belong to stderr. + +- `state: turn-completed`: present `finalText` and `artifacts` as the authoritative normal-turn result. +- `state: input-required`: present the prompt, safe action details, and every option from `inputRequired`. Treat correlation fields as bridge-owned opaque data; do not copy or rewrite them. For `ask_user_question` or `candidate_selection`, send the user's answer with `continue` on the same `jobId`. +- For `candidate_selection`, show every option's label, summary, `totalMonthlyCost`, and `costItems`. Render each non-empty `architectureDiagram` as its own fenced `mermaid` block before asking the user to choose; never leave the diagram as escaped JSON or only inside tool output. +- A permission in `inputRequired` includes `permissionClass`: `normal` for a Normal conversation or `pipeline` for a top-level Pipeline permission. A permission in `pendingPermissions` uses `sub_pipeline`. +- `pendingPermissions` contains every currently visible Sub Pipeline step permission. Present them separately; multiple candidate steps may wait for permission at the same time. +- For a terminal Pipeline, present `pipelineResult` and `artifacts` as the authoritative deployment conclusion. `normalHandoffReady: true` or `conversationMode: normal` means later operations must continue this job as Normal chat. Do not claim success from milestones alone. +- `state: failed`: report the sanitized `error`; preserve `requestId` when present for support. +- `milestones` contains bounded Pipeline progress. Show useful step boundaries without treating them as final output. +- `boundaryReached: true` means the result contains transient progress at a step start, completion, or failure. Present every `userUpdates` entry in order. If the same result also contains `inputRequired`, `turn-completed`, or a terminal state, handle it immediately; otherwise call `follow` again with the returned cursor. +- `wireState` preserves the last A2A task state for diagnosis. A normal turn may end with wire state `TASK_STATE_INPUT_REQUIRED` without an input envelope; the bridge reports that case as `turn-completed`, matching the remote agent's conversational boundary. + +The event classes have different execution behavior: + +- `ask_user_question` and `candidate_selection` are business input. The selling Pipeline's top-level scheme confirmation is `candidate_selection`, not a tool permission. Answer both with `continue` on the same `jobId`. For `candidate_selection`, the prompt file must contain only the exact chosen `options[].id` returned by the current envelope (for example `1`), with no label, explanation, deployment request, or surrounding sentence; this avoids the Pipeline interpreting the answer as an unrecognized selection and asking again. +- Never call `respond` for `ask_user_question` or `candidate_selection`, even if their envelope contains `inputId`, `requestTaskId`, or other correlation fields. Put the user's selected option and any parameter choices in a natural-language prompt file and call `continue`. +- A Normal conversation permission has `permissionClass: normal`. It serially pauses the task with `TASK_STATE_INPUT_REQUIRED`. +- A top-level Pipeline tool permission has `permissionClass: pipeline`. It serially pauses the parent Pipeline and its agent loops with `TASK_STATE_INPUT_REQUIRED`. This is distinct from `candidate_selection`. +- A Sub Pipeline step permission has `permissionClass: sub_pipeline`. It is sideband: the parent task remains `TASK_STATE_WORKING`, and multiple candidate steps may have independent pending permissions. + +## Approve or deny a permission + +Do not answer a permission with natural language or create a permission JSON file. The managed job already owns the exact correlation identifiers. When exactly one permission is waiting, call `respond` with only the job and the user's decision: + +```text +python3 /ros_agent.py respond --job-id --decision --follow +``` + +If multiple `pendingPermissions` are waiting, keep each returned `permissionRef` associated with the action shown to the user and include only the selected short reference: + +```text +python3 /ros_agent.py respond --job-id --permission-ref --decision --follow +``` + +Never type, copy, reconstruct, transform, or save `requestTaskId`, `contextId`, `inputId`, or `toolUseId`. Do not use a shell or another script to extract `inputRequired`; `respond` resolves those fields atomically from the current job. Without `--permission-ref`, it fails closed if more than one permission is waiting. A supplied reference must match exactly one still-pending permission. + +The job preserves its original mode and validates the permission class. When the user has already made an explicit `allow_once` or `deny` decision, execute `respond` in that same agent turn. Do not stop after merely announcing that you will run it. + +The bridge selects the pending permission under the job lock, retrieves its original correlation identifiers, and sends the same fixed marker and compact payload as the complete StartChat `Query`: + +```text +IAC_CODE_PERMISSION: {"schemaVersion":1,"kind":"permission","requestTaskId":"","contextId":"","inputId":"","toolUseId":"","decision":""} +``` + +The JSON portion has this schema: + +```json +{ + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "", + "contextId": "", + "inputId": "", + "toolUseId": "", + "decision": "" +} +``` + +`permissionRef` is a short local selector and is never sent to StartChat. Do not add client context or attachments to a permission response. The iac-code A2A server checks the exact `IAC_CODE_PERMISSION:` prefix before decoding JSON, then validates the full payload against an active pending permission. Missing or altered prefixes, extra fields, surrounding text, mismatched context, stale identifiers, and conflicting replies fail closed. + +The three permission classes share this one StartChat `respond` command, but resume differently: + +- `normal`: serial. The StartChat stream that exposed the permission ends naturally. `respond` uses the same correlated ROS Agent session and returns the resumed output on its new stream. +- `pipeline`: while resident, the original parent Pipeline StartChat stream stays alive. `respond` uses ROS active Pipeline reentry only to deliver the correlated decision; resumed progress remains on the parent stream. If `permissionWait.status` is `suspended`, the parent stream has ended and `respond` recovers the same task from its durable boundary on the new stream. +- `sub_pipeline`: sideband. Correlation identifies the waiting candidate step while the parent task remains `TASK_STATE_WORKING`; multiple pending step permissions must be answered separately. The bridge keeps the original parent StartChat SSE worker alive and starts a separate concurrent StartChat worker for each response. That response stream ends after its acknowledgement; it never takes ownership of, drains, or replaces the parent stream. After acknowledgement, another pending permission can become `inputRequired`; otherwise `follow` continues observing the original parent worker until its next Pipeline boundary. + +`permissionResponse` records the bounded correlation payload sent by the bridge. For a live Pipeline reentry, require `permissionAck.accepted: true` before reporting acceptance. For a serial or recovered permission, interpret the resumed stream normally and surface any next `inputRequired` event. Treat `permissionWait.status=suspended` with `resumable=true` as a recoverable pause: ask for the decision against the original `inputRequired` and call `respond` on the same job. Treat `permissionRecovered` as continuation of that same job; never start a replacement session. + +Never use `continue` to poll a working Pipeline after `respond`. StartChat has no status-query operation, and a new natural-language message is a real Pipeline interrupt. Use only `follow` to observe the original parent SSE. If `respond` returns `input-required`, present and answer that newly visible permission. If it returns only `permission-responded` because other already-presented `pendingPermissions` remain, answer those permissions separately; otherwise keep following the current job. Do not ask the user to choose between `follow` and `continue`. + +## Safety and output discipline + +- Never print, persist, or pass AccessKey IDs, secrets, security tokens, signatures, or authorization headers. +- Unit tests and validation must remain offline. Run a live StartChat or cloud deployment test only when the user explicitly authorizes that external action and its cleanup scope. +- Treat `latestText` as progress only. Use `finalText` only when `state` is `turn-completed`. +- Keep `sessionId`, `taskId`, and `iacCodeSessionId` as opaque identifiers. +- Interrupting a `follow` command does not cancel the background StartChat worker. Report the interruption and resume `follow` with the last confirmed cursor. Use `cancel` only after an explicit user cancellation request. If the worker itself fails, report its sanitized error; do not claim the remote task was canceled. +- Treat the bridge JSON as the only job-state interface. Do not inspect `~/.cache/alicloud-ros-agent`, manager records, worker logs, the bridge source, or Alibaba Cloud CLI configuration to diagnose a failed job; present the returned sanitized `error` and let the operator inspect the server side. diff --git a/skills/alicloud-ros-agent/agents/openai.yaml b/skills/alicloud-ros-agent/agents/openai.yaml new file mode 100644 index 00000000..536ac222 --- /dev/null +++ b/skills/alicloud-ros-agent/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Alibaba Cloud ROS Agent" + short_description: "Operate the remote ROS agent through Alibaba Cloud CLI" + default_prompt: "Use $alicloud-ros-agent to handle this Alibaba Cloud infrastructure task with the remote ROS Agent." diff --git a/skills/alicloud-ros-agent/requirements-code.txt b/skills/alicloud-ros-agent/requirements-code.txt new file mode 100644 index 00000000..56d0a683 --- /dev/null +++ b/skills/alicloud-ros-agent/requirements-code.txt @@ -0,0 +1,2 @@ +alibabacloud-credentials>=1.0.8,<2 +aliyun-python-sdk-core>=2.16,<3 diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py new file mode 100644 index 00000000..07098d6c --- /dev/null +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -0,0 +1,4261 @@ +#!/usr/bin/env python3 +"""Bounded Alibaba Cloud ROS Agent bridge using Alibaba Cloud CLI.""" + +import argparse +import contextlib +import errno +import hashlib +import importlib +import json +import os +import pathlib +import re +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple + +MAX_PROMPT_BYTES = 1024 * 1024 +MAX_CONTEXT_BYTES = 64 * 1024 +MAX_CONFIG_BYTES = 16 * 1024 +MAX_CLI_CONFIG_BYTES = 2 * 1024 * 1024 +MAX_SSE_LINE_BYTES = 16 * 1024 * 1024 +MAX_SSE_EVENT_BYTES = 16 * 1024 * 1024 +MAX_FINAL_TEXT_BYTES = 10 * 1024 +MAX_DIAGNOSTIC_BYTES = 64 * 1024 +MAX_RESULT_BYTES = 32 * 1024 +MAX_SPOOL_BYTES = 8 * 1024 * 1024 +MAX_PROJECTION_BYTES = 4096 +MAX_INPUT_PROJECTION_BYTES = 14 * 1024 +MAX_FOLLOW_BYTES = 16 * 1024 +MAX_FOLLOW_EVENTS = 16 +MAX_STEP_CONCLUSION_BYTES = 1800 +MAX_MANAGER_REQUEST_BYTES = 2 * 1024 * 1024 +DEFAULT_FOLLOW_SECONDS = 60.0 +MAX_FOLLOW_SECONDS = 120.0 +DEFAULT_READ_TIMEOUT_SECONDS = 1800 +MANAGER_START_TIMEOUT_SECONDS = 10.0 +STOP_SESSION_WAIT_SECONDS = 10.0 +STOP_REQUEST_TIMEOUT_SECONDS = 60.0 +MANAGER_IDLE_SECONDS = 60 +MAX_MANAGER_IDLE_SECONDS = 24 * 60 * 60 +MANAGER_SCHEMA_VERSION = 3 +JOB_SCHEMA_VERSION = 1 +STATE_DIR_ENV = "ALICLOUD_ROS_AGENT_STATE_DIR" +MAX_ATTACHMENTS = 5 +DEFAULT_ENDPOINT = "ros.aliyuncs.com" +SUPPORTED_AGENT_MODES = {"normal", "pipeline"} +DEFAULT_TRANSPORT = "code" +SUPPORTED_TRANSPORTS = {"code", "aliyun_cli"} +USER_AGENT = "AlibabaCloud-Agent-Skills/alibabacloud-ros-agent" +ACCESS_KEY_ID_ENV_NAMES = ( + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABACLOUD_ACCESS_KEY_ID", + "ALICLOUD_ACCESS_KEY_ID", + "ACCESS_KEY_ID", +) +ACCESS_KEY_SECRET_ENV_NAMES = ( + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + "ALIBABACLOUD_ACCESS_KEY_SECRET", + "ALICLOUD_ACCESS_KEY_SECRET", + "ACCESS_KEY_SECRET", +) +SECURITY_TOKEN_ENV_NAMES = ( + "ALIBABA_CLOUD_SECURITY_TOKEN", + "ALIBABACLOUD_SECURITY_TOKEN", + "ALICLOUD_SECURITY_TOKEN", + "SECURITY_TOKEN", +) +PROFILE_ENV_NAMES = ( + "ALIBABACLOUD_PROFILE", + "ALIBABA_CLOUD_PROFILE", + "ALICLOUD_PROFILE", +) +REGION_ENV_NAMES = ( + "ALIBABA_CLOUD_REGION_ID", + "ALIBABACLOUD_REGION_ID", + "ALICLOUD_REGION_ID", + "REGION_ID", + "REGION", +) +SKILL_CONFIG_PATH = pathlib.Path(__file__).resolve().parent.parent / "config.json" +SUPPORTED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"} +PERMISSION_DECISIONS = {"allow_once", "deny"} +PERMISSION_QUERY_PREFIX = "IAC_CODE_PERMISSION:" +TERMINAL_STATES = {"completed", "failed", "canceled", "rejected"} +PIPELINE_EVENT_TYPES = { + "pipeline_started", + "pipeline_resumed", + "step_started", + "step_completed", + "step_failed", + "candidate_started", + "candidate_step_started", + "candidate_step_completed", + "candidate_step_failed", + "candidate_completed", + "candidate_selected", + "input_required", + "pipeline_completed", + "pipeline_failed", + "pipeline_canceled", + "cleanup_started", + "cleanup_progress", + "cleanup_completed", + "cleanup_failed", +} +STEP_BOUNDARY_EVENT_TYPES = { + "step_started", + "step_completed", + "step_failed", + "candidate_step_started", + "candidate_step_completed", + "candidate_step_failed", +} +SECRET_PATTERN = re.compile( + r"(?i)((?:[\"']?)(?:access[-_ ]?key(?:[-_ ]?id|[-_ ]?secret)?|security[-_ ]?token|signature|" + r"authorization)(?:[\"']?)\s*[:=]\s*(?:[\"']?)(?:bearer\s+)?)([^\"'\s,;&}]+)" +) + + +class BridgeError(Exception): + def __init__(self, code: str, message: str, retryable: bool = False) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retryable = retryable + + +def _json_bytes(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _state_root() -> pathlib.Path: + configured = os.environ.get(STATE_DIR_ENV) + if configured: + return pathlib.Path(os.path.expandvars(os.path.expanduser(configured))).resolve() + return pathlib.Path(os.path.expanduser("~/.cache/alicloud-ros-agent")).resolve() + + +def _secure_directory(path: pathlib.Path) -> None: + path.mkdir(parents=True, exist_ok=True) + if os.name != "nt": + os.chmod(str(path), 0o700) + + +def _atomic_json(path: pathlib.Path, value: Dict[str, Any], mode: int = 0o600) -> None: + _secure_directory(path.parent) + descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + os.chmod(temporary, mode) + os.replace(temporary, str(path)) + finally: + with contextlib.suppress(OSError): + os.unlink(temporary) + + +def _load_state_json(path: pathlib.Path, code: str = "job_not_found") -> Dict[str, Any]: + try: + with path.open("r", encoding="utf-8") as handle: + value = json.load(handle) + except (OSError, ValueError) as exc: + raise BridgeError(code, "Local ROS Agent bridge state is unavailable or invalid.") from exc + if not isinstance(value, dict): + raise BridgeError(code, "Local ROS Agent bridge state is unavailable or invalid.") + return value + + +class StateLock(object): + def __init__(self, path: pathlib.Path, timeout: float = 10.0) -> None: + self.path = path + self.timeout = timeout + self.handle = None # type: Any + + def __enter__(self) -> "StateLock": + _secure_directory(self.path.parent) + self.handle = self.path.open("a+b") + if self.path.stat().st_size == 0: + self.handle.write(b"0") + self.handle.flush() + deadline = time.monotonic() + self.timeout + while True: + try: + if os.name == "nt": + import msvcrt + + self.handle.seek(0) + msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return self + except (IOError, OSError) as exc: + if getattr(exc, "errno", None) not in {None, errno.EACCES, errno.EAGAIN, errno.EDEADLK}: + raise + if time.monotonic() >= deadline: + self.handle.close() + self.handle = None + raise BridgeError("state_locked", "Another ROS Agent bridge process is updating this state.", True) + time.sleep(0.05) + + def __exit__(self, _type: Any, _value: Any, _traceback: Any) -> None: + if self.handle is None: + return + with contextlib.suppress(OSError): + if os.name == "nt": + import msvcrt + + self.handle.seek(0) + msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + + +def _pid_alive(pid: Any) -> bool: + if not isinstance(pid, int) or pid <= 0: + return False + if os.name == "nt": + try: + import ctypes + + handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) + if not handle: + return False + ctypes.windll.kernel32.CloseHandle(handle) + return True + except (AttributeError, OSError): + return False + try: + waited, _status = os.waitpid(pid, os.WNOHANG) + if waited == pid: + return False + except ChildProcessError: + pass + except OSError: + pass + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def _job_paths(job_id: str) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path]: + if not re.fullmatch(r"[0-9a-f]{32}", job_id or ""): + raise BridgeError("job_not_found", "The requested ROS Agent job does not exist.") + root = _state_root() / "jobs" / job_id + return root, root / "job.json", root / "events.jsonl" + + +def _preferred_language(text: str) -> str: + if re.search(r"[\u3400-\u9fff]", text): + return "zh" + return "en" + + +def _endpoint_kind(endpoint: str, error_code: str = "invalid_input") -> str: + if re.fullmatch(r"[A-Za-z0-9.-]+\.aliyuncs\.com", endpoint): + return "aliyun" + match = re.fullmatch(r"(?:localhost|127\.0\.0\.1):([1-9][0-9]{0,4})", endpoint) + if match and int(match.group(1)) <= 65535: + return "loopback" + raise BridgeError( + error_code, + "The endpoint must be an aliyuncs.com hostname or a loopback host and port, without a URL scheme or path.", + ) + + +def load_skill_config(path: Optional[pathlib.Path] = None) -> Dict[str, Any]: + config_path = path if path is not None else SKILL_CONFIG_PATH + try: + data = config_path.read_bytes() + except FileNotFoundError: + return {} + except OSError as exc: + raise BridgeError("invalid_config", "The Skill config.json could not be read.") from exc + if len(data) > MAX_CONFIG_BYTES: + raise BridgeError("invalid_config", "The Skill config.json is too large.") + try: + value = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise BridgeError("invalid_config", "The Skill config.json must contain valid UTF-8 JSON.") from exc + if not isinstance(value, dict): + raise BridgeError("invalid_config", "The Skill config.json must contain a JSON object.") + unknown = set(value) - { + "endpoint", + "allowedAgentModes", + "managerIdleSeconds", + "transport", + "enableThinking", + "aliyunCLIProfile", + } + if unknown: + raise BridgeError("invalid_config", "The Skill config.json contains unsupported fields.") + + result = {} # type: Dict[str, Any] + if "transport" in value: + transport = value["transport"] + if not isinstance(transport, str) or transport not in SUPPORTED_TRANSPORTS: + raise BridgeError("invalid_config", "transport must be code or aliyun_cli.") + result["transport"] = transport + + if "endpoint" in value: + endpoint = value["endpoint"] + if not isinstance(endpoint, str) or not endpoint.strip() or endpoint != endpoint.strip(): + raise BridgeError("invalid_config", "The config endpoint must be a non-empty string without padding.") + _endpoint_kind(endpoint, "invalid_config") + result["endpoint"] = endpoint + + if "allowedAgentModes" in value: + modes = value["allowedAgentModes"] + if not isinstance(modes, list) or not modes: + raise BridgeError("invalid_config", "allowedAgentModes must be a non-empty JSON array.") + if any(not isinstance(mode, str) or mode not in SUPPORTED_AGENT_MODES for mode in modes): + raise BridgeError("invalid_config", "allowedAgentModes may contain only normal and pipeline.") + if len(set(modes)) != len(modes): + raise BridgeError("invalid_config", "allowedAgentModes must not contain duplicates.") + result["allowedAgentModes"] = modes + + if "managerIdleSeconds" in value: + idle_seconds = value["managerIdleSeconds"] + if ( + isinstance(idle_seconds, bool) + or not isinstance(idle_seconds, int) + or not 1 <= idle_seconds <= MAX_MANAGER_IDLE_SECONDS + ): + raise BridgeError( + "invalid_config", + "managerIdleSeconds must be an integer from 1 through {}.".format(MAX_MANAGER_IDLE_SECONDS), + ) + result["managerIdleSeconds"] = idle_seconds + + if "enableThinking" in value: + enable_thinking = value["enableThinking"] + if not isinstance(enable_thinking, bool): + raise BridgeError("invalid_config", "enableThinking must be true or false.") + result["enableThinking"] = enable_thinking + + if "aliyunCLIProfile" in value: + profile = value["aliyunCLIProfile"] + if ( + not isinstance(profile, str) + or profile != profile.strip() + or len(profile.encode("utf-8")) > 200 + or any(character in profile for character in "\r\n\0") + ): + raise BridgeError( + "invalid_config", + "aliyunCLIProfile must be an empty or non-padded Profile name of at most 200 bytes.", + ) + result["aliyunCLIProfile"] = profile + return result + + +def apply_skill_config(args: argparse.Namespace, config: Dict[str, Any]) -> None: + configured_endpoint = config.get("endpoint") + allowed_modes = config.get("allowedAgentModes", sorted(SUPPORTED_AGENT_MODES)) + transport = config.get("transport", DEFAULT_TRANSPORT) + enable_thinking = config.get("enableThinking", True) + configured_profile = config.get("aliyunCLIProfile", "") + args.manager_idle_seconds = config.get("managerIdleSeconds", MANAGER_IDLE_SECONDS) + args.enable_thinking = enable_thinking + args.aliyun_cli_profile = configured_profile + args.profile_pinned = bool(configured_profile) + if args.command == "check": + args.endpoint = configured_endpoint or DEFAULT_ENDPOINT + args.allowed_agent_modes = list(allowed_modes) + args.transport = transport + args.profile = configured_profile or None + return + if args.command not in {"chat", "start"}: + return + requested_endpoint = args.endpoint + if configured_endpoint and requested_endpoint and configured_endpoint != requested_endpoint: + raise BridgeError("config_conflict", "--endpoint conflicts with the endpoint fixed by Skill config.json.") + args.endpoint = configured_endpoint or requested_endpoint or DEFAULT_ENDPOINT + args.transport = transport + _endpoint_kind(args.endpoint, "invalid_config" if configured_endpoint else "invalid_input") + if args.mode not in allowed_modes: + raise BridgeError("mode_not_allowed", "Agent mode {} is not allowed by Skill config.json.".format(args.mode)) + requested_profile = getattr(args, "profile", None) + if configured_profile and requested_profile and requested_profile != configured_profile: + raise BridgeError("config_conflict", "--profile conflicts with aliyunCLIProfile fixed by Skill config.json.") + args.profile = configured_profile or requested_profile + if getattr(args, "no_thinking", False) and enable_thinking: + raise BridgeError("config_conflict", "--no-thinking conflicts with enableThinking fixed by Skill config.json.") + args.no_thinking = not enable_thinking + + +def _truncate_utf8(value: str, maximum: int) -> str: + encoded = value.encode("utf-8") + if len(encoded) <= maximum: + return value + return encoded[:maximum].decode("utf-8", "ignore") + + +def sanitize_text(value: Any, maximum: int = 4000, preserve_lines: bool = False) -> str: + if not isinstance(value, str): + return "" + value = SECRET_PATTERN.sub(lambda match: match.group(1) + "[REDACTED]", value) + value = "".join(character for character in value if character in "\n\r\t" or ord(character) >= 32) + if not preserve_lines: + value = " ".join(value.split()) + return _truncate_utf8(value, maximum) + + +def _workspace(raw_path: Optional[str] = None) -> pathlib.Path: + path = pathlib.Path(raw_path or os.getcwd()).expanduser().resolve() + if not path.is_dir(): + raise BridgeError("invalid_input", "The workspace must be an existing directory.") + return path + + +def _read_workspace_file(workspace: pathlib.Path, raw_path: str, maximum: int, label: str) -> str: + path = pathlib.Path(raw_path).expanduser().resolve() + try: + path.relative_to(workspace) + except ValueError as exc: + raise BridgeError("invalid_input", "{} must be inside the workspace.".format(label)) from exc + try: + data = path.read_bytes() + except OSError as exc: + raise BridgeError("invalid_input", "{} could not be read.".format(label)) from exc + if len(data) > maximum: + raise BridgeError("invalid_input", "{} is too large.".format(label)) + try: + return data.decode("utf-8") + except UnicodeDecodeError as exc: + raise BridgeError("invalid_input", "{} must be UTF-8.".format(label)) from exc + + +def read_prompt(workspace: pathlib.Path, raw_path: str) -> str: + prompt = _read_workspace_file(workspace, raw_path, MAX_PROMPT_BYTES, "The prompt file") + if not prompt.strip(): + raise BridgeError("invalid_input", "The prompt file must not be empty.") + return prompt + + +def _load_json_file(workspace: pathlib.Path, raw_path: str, maximum: int, label: str) -> Any: + text = _read_workspace_file(workspace, raw_path, maximum, label) + try: + return json.loads(text) + except ValueError as exc: + raise BridgeError("invalid_input", "{} must contain valid JSON.".format(label)) from exc + + +def load_client_context(workspace: pathlib.Path, raw_path: Optional[str]) -> Optional[str]: + if not raw_path: + return None + value = _load_json_file(workspace, raw_path, MAX_CONTEXT_BYTES, "The client context file") + if not isinstance(value, dict): + raise BridgeError("invalid_input", "The client context must be a JSON object.") + compact = json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + if len(compact.encode("utf-8")) > MAX_CONTEXT_BYTES: + raise BridgeError("invalid_input", "The compact client context is too large.") + return compact + + +def _attachment_value(value: Dict[str, Any], *names: str) -> Optional[str]: + for name in names: + item = value.get(name) + if isinstance(item, str) and item.strip(): + return item.strip() + return None + + +def load_attachments(workspace: pathlib.Path, raw_path: Optional[str]) -> List[Dict[str, str]]: + if not raw_path: + return [] + value = _load_json_file(workspace, raw_path, MAX_CONTEXT_BYTES, "The attachments file") + if not isinstance(value, list) or len(value) > MAX_ATTACHMENTS: + raise BridgeError("invalid_input", "Attachments must be a JSON array with at most five items.") + result = [] + for index, item in enumerate(value, start=1): + if not isinstance(item, dict): + raise BridgeError("invalid_input", "Attachment {} must be an object.".format(index)) + attachment_type = _attachment_value(item, "Type", "type") or "image" + mime_type = _attachment_value(item, "MimeType", "mimeType", "mime_type") + object_key = _attachment_value(item, "OssObjectKey", "ossObjectKey", "oss_object_key") + name = _attachment_value(item, "Name", "name") + if attachment_type != "image": + raise BridgeError("invalid_input", "Attachment {} must have Type image.".format(index)) + if mime_type not in SUPPORTED_IMAGE_TYPES: + raise BridgeError("invalid_input", "Attachment {} has an unsupported MimeType.".format(index)) + if not object_key: + raise BridgeError("invalid_input", "Attachment {} requires OssObjectKey.".format(index)) + projected = {"Type": attachment_type, "MimeType": mime_type, "OssObjectKey": object_key} + if name: + projected["Name"] = name + result.append(projected) + return result + + +def load_permission_query( + workspace: pathlib.Path, + raw_path: str, + decision: str, + session_id: str, + mode: str, +) -> Tuple[str, Dict[str, str]]: + value = _load_json_file(workspace, raw_path, MAX_CONTEXT_BYTES, "The permission input file") + return build_permission_query(value, decision, session_id, mode) + + +def build_permission_query( + value: Any, + decision: str, + session_id: str, + mode: str, +) -> Tuple[str, Dict[str, str]]: + if not isinstance(value, dict) or value.get("schemaVersion") != 1 or value.get("kind") != "permission": + raise BridgeError("invalid_input", "The pending input must be a schemaVersion 1 permission.") + if decision not in PERMISSION_DECISIONS: + raise BridgeError("invalid_input", "The permission decision must be allow_once or deny.") + correlation = {} + for key in ("requestTaskId", "contextId", "inputId", "toolUseId"): + item = value.get(key) + if not isinstance(item, str) or not item: + raise BridgeError("invalid_input", "The permission input file requires {}.".format(key)) + correlation[key] = item + if correlation["contextId"] != session_id: + raise BridgeError("invalid_input", "The permission contextId must match --session-id.") + permission_class = value.get("permissionClass") + allowed_classes = {"pipeline", "sub_pipeline"} if mode == "pipeline" else {"normal"} + if permission_class is not None and permission_class not in allowed_classes: + raise BridgeError("invalid_input", "The permissionClass does not match --mode.") + payload = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": correlation["requestTaskId"], + "contextId": correlation["contextId"], + "inputId": correlation["inputId"], + "toolUseId": correlation["toolUseId"], + "decision": decision, + } + query = "{} {}".format( + PERMISSION_QUERY_PREFIX, + json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True), + ) + return query, {**correlation, "decision": decision} + + +def resolve_aliyun(raw_path: str) -> str: + expanded = os.path.expanduser(raw_path) + if os.path.dirname(expanded): + path = os.path.abspath(expanded) + if not os.path.isfile(path): + raise BridgeError("cli_not_found", "Alibaba Cloud CLI was not found at the requested path.") + return path + resolved = shutil.which(expanded) + if not resolved: + raise BridgeError("cli_not_found", "Alibaba Cloud CLI is not installed or is not on PATH.") + return resolved + + +def build_start_chat_parameters( + args: argparse.Namespace, + prompt: str, + client_context: Optional[str], + attachments: List[Dict[str, str]], +) -> Dict[str, str]: + parameters = { + "Query": prompt, + "AgentVersion": "V2", + "EnablePartialMessage": "true", + "EnableThinking": "false" if args.no_thinking else "true", + "Mode": "IaCCodePipeline" if args.mode == "pipeline" else "IaCCodeNormal", + } + if args.session_id: + parameters["SessionId"] = args.session_id + if args.region_id: + parameters["RegionId"] = args.region_id + if client_context is not None: + parameters["ClientContext"] = client_context + for index, attachment in enumerate(attachments, start=1): + for field in ("Type", "MimeType", "Name", "OssObjectKey"): + if field in attachment: + parameters["Attachments.{}.{}".format(index, field)] = attachment[field] + return parameters + + +def build_command( + args: argparse.Namespace, + prompt: str, + client_context: Optional[str], + attachments: List[Dict[str, str]], +) -> List[str]: + endpoint_kind = _endpoint_kind(args.endpoint or "") + command = [ + resolve_aliyun(args.aliyun_path), + "ros", + "StartChat", + "--force", + "--method", + "POST", + "--endpoint", + args.endpoint, + "--header", + "Accept=text/event-stream", + "--connect-timeout", + str(args.connect_timeout), + "--read-timeout", + str(args.read_timeout), + "--user-agent", + USER_AGENT, + "--yes", + ] + if endpoint_kind == "loopback": + command.extend(["--secure", "--skip-secure-verify"]) + if args.profile: + command.extend(["--profile", args.profile]) + if args.region_id: + command.extend(["--region", args.region_id]) + for name, value in build_start_chat_parameters(args, prompt, client_context, attachments).items(): + command.extend(["--{}".format(name), value]) + return command + + +def build_stop_command(job: Dict[str, Any], session_id: str) -> List[str]: + endpoint = str(job.get("endpoint") or "") + endpoint_kind = _endpoint_kind(endpoint) + command = [ + resolve_aliyun(str(job.get("aliyunPath") or "aliyun")), + "ros", + "StopChat", + "--force", + "--method", + "POST", + "--endpoint", + endpoint, + "--connect-timeout", + str(max(1, min(int(job.get("connectTimeout") or 10), 30))), + "--read-timeout", + "45", + "--user-agent", + USER_AGENT, + "--yes", + ] + if endpoint_kind == "loopback": + command.extend(["--secure", "--skip-secure-verify"]) + profile = job.get("profile") + if isinstance(profile, str) and profile: + command.extend(["--profile", profile]) + region_id = job.get("regionId") + if isinstance(region_id, str) and region_id: + command.extend(["--region", region_id]) + command.extend(["--AgentVersion", "V2", "--SessionId", session_id]) + return command + + +def _load_code_sdk() -> Dict[str, Any]: + try: + return { + "CLIProfileCredentialsProvider": getattr( + importlib.import_module("alibabacloud_credentials.provider.cli_profile"), + "CLIProfileCredentialsProvider", + ), + "AccessKeyCredential": getattr( + importlib.import_module("aliyunsdkcore.auth.credentials"), "AccessKeyCredential" + ), + "StsTokenCredential": getattr( + importlib.import_module("aliyunsdkcore.auth.credentials"), "StsTokenCredential" + ), + "AcsClient": getattr(importlib.import_module("aliyunsdkcore.client"), "AcsClient"), + "CommonRequest": getattr(importlib.import_module("aliyunsdkcore.request"), "CommonRequest"), + "protocolType": importlib.import_module("aliyunsdkcore.http.protocol_type"), + "methodType": importlib.import_module("aliyunsdkcore.http.method_type"), + "requests": importlib.import_module("aliyunsdkcore.vendored.requests"), + } + except (ImportError, AttributeError) as exc: + raise BridgeError( + "sdk_not_installed", + "The configured code transport requires the packages listed in requirements-code.txt for the Python " + "interpreter running this bridge. Do not switch transports; install them and run check again.", + ) from exc + + +def _first_nonempty_env(names: Tuple[str, ...]) -> Optional[str]: + for name in names: + value = os.environ.get(name) + if value: + return value + return None + + +def _environment_credentials() -> Optional[Tuple[str, str, Optional[str]]]: + access_key_id = _first_nonempty_env(ACCESS_KEY_ID_ENV_NAMES) + access_key_secret = _first_nonempty_env(ACCESS_KEY_SECRET_ENV_NAMES) + security_token = _first_nonempty_env(SECURITY_TOKEN_ENV_NAMES) + if bool(access_key_id) != bool(access_key_secret): + raise BridgeError( + "credential_failed", + "Alibaba Cloud access key environment variables must provide both the access key ID and secret.", + ) + if access_key_id and access_key_secret: + return access_key_id, access_key_secret, security_token + return None + + +def _environment_region() -> Optional[str]: + region_id = _first_nonempty_env(REGION_ENV_NAMES) + if region_id and re.fullmatch(r"[A-Za-z0-9-]+", region_id): + return region_id + return None + + +def _cli_config_path() -> pathlib.Path: + return pathlib.Path(os.path.expanduser("~/.aliyun/config.json")).resolve() + + +def _read_cli_configuration() -> Dict[str, Any]: + path = _cli_config_path() + try: + with path.open("rb") as handle: + raw = handle.read(MAX_CLI_CONFIG_BYTES + 1) + except OSError as exc: + raise BridgeError("credential_failed", "The Alibaba Cloud CLI configuration is unavailable.") from exc + if len(raw) > MAX_CLI_CONFIG_BYTES: + raise BridgeError("credential_failed", "The Alibaba Cloud CLI configuration file is too large.") + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeError, ValueError) as exc: + raise BridgeError("credential_failed", "The Alibaba Cloud CLI configuration file is invalid.") from exc + if not isinstance(value, dict) or not isinstance(value.get("profiles"), list): + raise BridgeError("credential_failed", "The Alibaba Cloud CLI configuration file is invalid.") + return value + + +def _selected_cli_profile_record(profile: Optional[str]) -> Dict[str, Any]: + value = _read_cli_configuration() + profile_name = profile or _first_nonempty_env(PROFILE_ENV_NAMES) or value.get("current") + if not isinstance(profile_name, str) or not profile_name: + raise BridgeError("credential_failed", "The selected Alibaba Cloud CLI Profile is not configured.") + selected = next( + (item for item in value["profiles"] if isinstance(item, dict) and item.get("name") == profile_name), + None, + ) + mode = selected.get("mode") if isinstance(selected, dict) else None + if not isinstance(mode, str) or not mode: + raise BridgeError("credential_failed", "The selected Alibaba Cloud CLI Profile is not configured.") + result = {"name": profile_name, "mode": mode} # type: Dict[str, Any] + region_id = selected.get("region_id") + if isinstance(region_id, str) and re.fullmatch(r"[A-Za-z0-9-]+", region_id): + result["regionId"] = region_id + language = selected.get("language") + if isinstance(language, str) and language: + result["language"] = sanitize_text(language, 50) + return result + + +def _selected_cli_profile(profile: Optional[str]) -> Tuple[str, str]: + selected = _selected_cli_profile_record(profile) + return selected["name"], selected["mode"] + + +def _resolve_start_identity(args: argparse.Namespace) -> None: + environment = None # type: Optional[Tuple[str, str, Optional[str]]] + profile = None # type: Optional[Dict[str, Any]] + if args.transport == "code" and not getattr(args, "profile_pinned", False): + environment = _environment_credentials() + if args.transport == "aliyun_cli" or environment is None: + profile = _selected_cli_profile_record(args.profile) + args.profile = profile["name"] + args.credential_source = "profile" + else: + args.profile = None + args.credential_source = "environment" + + if not args.region_id: + args.region_id = _environment_region() + if not args.region_id and profile is not None: + args.region_id = profile.get("regionId") + if not args.region_id: + args.region_id = "cn-hangzhou" + + +def _refresh_oauth_profile_with_cli( + aliyun_path: str, + profile_name: str, + region_id: Optional[str], +) -> None: + command = [ + resolve_aliyun(aliyun_path), + "ros", + "DescribeRegions", + "--dryrun", + "--yes", + "--user-agent", + USER_AGENT, + "--profile", + profile_name, + ] + if region_id: + command.extend(["--region", region_id]) + try: + result = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=60, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise BridgeError( + "credential_failed", + "Alibaba Cloud CLI could not refresh the selected OAuth Profile.", + True, + ) from exc + if result.returncode != 0: + raise BridgeError( + "credential_failed", + "Alibaba Cloud CLI could not refresh the selected OAuth Profile.", + True, + ) + + +def _read_oauth_profile_credentials(profile_name: str) -> Tuple[str, str, str]: + value = _read_cli_configuration() + selected = next( + ( + item + for item in value["profiles"] + if isinstance(item, dict) + and item.get("name") == profile_name + and isinstance(item.get("mode"), str) + and item["mode"].lower() == "oauth" + ), + None, + ) + if selected is None: + raise BridgeError("credential_failed", "The selected Alibaba Cloud CLI OAuth Profile is unavailable.") + access_key_id = selected.get("access_key_id") + access_key_secret = selected.get("access_key_secret") + security_token = selected.get("sts_token") + expiration = selected.get("sts_expiration") + if ( + not isinstance(access_key_id, str) + or not access_key_id + or not isinstance(access_key_secret, str) + or not access_key_secret + or not isinstance(security_token, str) + or not security_token + or not isinstance(expiration, int) + or isinstance(expiration, bool) + or expiration <= int(time.time()) + ): + raise BridgeError("credential_failed", "Alibaba Cloud CLI OAuth credentials are unavailable or expired.") + return access_key_id, access_key_secret, security_token + + +def _code_credentials( + sdk: Dict[str, Any], + aliyun_path: str, + profile: Optional[str], + region_id: Optional[str], + credential_source: Optional[str] = None, +) -> Any: + if credential_source not in {None, "environment", "profile"}: + raise BridgeError("credential_failed", "The managed Alibaba Cloud credential source is invalid.") + environment = None if credential_source == "profile" else _environment_credentials() + if credential_source == "environment" and environment is None: + raise BridgeError( + "credential_failed", + "The Alibaba Cloud environment credentials selected when this job started are unavailable.", + ) + if environment is not None: + access_key_id, access_key_secret, security_token = environment + else: + profile_name, mode = _selected_cli_profile(profile) + if mode.lower() == "oauth": + try: + access_key_id, access_key_secret, security_token = _read_oauth_profile_credentials(profile_name) + except BridgeError: + _refresh_oauth_profile_with_cli(aliyun_path, profile_name, region_id) + access_key_id, access_key_secret, security_token = _read_oauth_profile_credentials(profile_name) + else: + provider = sdk["CLIProfileCredentialsProvider"](profile_name=profile_name) + credentials = provider.get_credentials() + access_key_id = credentials.get_access_key_id() + access_key_secret = credentials.get_access_key_secret() + security_token = credentials.get_security_token() + if not access_key_id or not access_key_secret: + raise ValueError("empty credentials") + if security_token: + return sdk["StsTokenCredential"](access_key_id, access_key_secret, security_token) + return sdk["AccessKeyCredential"](access_key_id, access_key_secret) + + +class _CodeHttpResponse: + def __init__(self, response: Any, session: Any): + self._response = response + self._session = session + self.headers = response.headers + + def __iter__(self) -> Iterator[bytes]: + # A connection-close SSE response can otherwise buffer complete events + # until the requested chunk fills or the stream ends. + for line in self._response.iter_lines(chunk_size=1, decode_unicode=False): + yield line + b"\n" + + def read(self, maximum: int) -> bytes: + return self._response.raw.read(maximum, decode_content=True) + + def close(self) -> None: + self._response.close() + self._session.close() + + +def _open_code_request( + operation: str, + parameters: Dict[str, str], + endpoint: str, + profile: Optional[str], + region_id: Optional[str], + aliyun_path: str, + connect_timeout: int, + read_timeout: int, + credential_source: Optional[str] = None, + error_code: str = "start_chat_failed", +) -> Any: + sdk = _load_code_sdk() + try: + core_credentials = _code_credentials(sdk, aliyun_path, profile, region_id, credential_source) + client = sdk["AcsClient"]( + region_id=region_id or "cn-hangzhou", + credential=core_credentials, + auto_retry=False, + verify=False if _endpoint_kind(endpoint) == "loopback" else None, + ) + client.append_user_agent("AlibabaCloud-Agent-Skills", "alibabacloud-ros-agent") + request = sdk["CommonRequest"]( + domain=endpoint, + version="2019-09-10", + action_name=operation, + product="ROS", + ) + request.set_protocol_type(sdk["protocolType"].HTTPS) + request.set_method(sdk["methodType"].POST) + request.add_header("Accept-Encoding", "identity") + request.add_header("Accept", "text/event-stream" if operation == "StartChat" else "application/json") + for name, value in parameters.items(): + request.add_query_param(name, value) + signed = client._make_http_response(endpoint, request, read_timeout, connect_timeout) + except BridgeError: + raise + except Exception as exc: + raise BridgeError( + "credential_failed", + "Alibaba Cloud SDK could not load or refresh the selected CLI Profile.", + True, + ) from exc + + session = sdk["requests"].Session() + try: + response = session.request( + method=signed.get_method(), + url="https://{}{}".format(endpoint, signed.get_url()), + data=signed.get_body(), + headers=signed.get_headers(), + timeout=(connect_timeout, read_timeout), + allow_redirects=False, + verify=_endpoint_kind(endpoint) != "loopback", + stream=True, + ) + except Exception as exc: + session.close() + raise BridgeError(error_code, "Alibaba Cloud ROS {} could not be reached.".format(operation), True) from exc + + wrapped = _CodeHttpResponse(response, session) + if 400 <= response.status_code < 600: + raw = wrapped.read(MAX_DIAGNOSTIC_BYTES + 1) + wrapped.close() + message = "Alibaba Cloud ROS rejected the request." + if len(raw) <= MAX_DIAGNOSTIC_BYTES: + with contextlib.suppress(UnicodeError, ValueError): + value = json.loads(raw.decode("utf-8")) + if isinstance(value, dict): + code = value.get("Code", value.get("code")) + detail = value.get("Message", value.get("message")) + if isinstance(code, str) or isinstance(detail, str): + message = "{}: {}".format(code or "{}Failed".format(operation), detail or "Request failed") + raise BridgeError(error_code, sanitize_text(message, 2000), response.status_code >= 500) + return wrapped + + +def _response_text_lines(response: Any) -> Iterator[str]: + for raw_line in response: + if len(raw_line) > MAX_SSE_LINE_BYTES: + raise BridgeError("stream_failed", "A StartChat SSE line exceeded the bridge limit.") + yield raw_line.decode("utf-8", "replace") + + +def iter_sse_payloads(lines: Iterable[str]) -> Iterator[Tuple[Optional[Dict[str, Any]], str]]: + data_lines = [] # type: List[str] + raw_lines = [] # type: List[str] + event_bytes = 0 + + def decode(data: List[str], raw: List[str]) -> Tuple[Optional[Dict[str, Any]], str]: + payload_text = "\n".join(data).strip() if data else "\n".join(raw).strip() + if len(payload_text.encode("utf-8")) > MAX_SSE_EVENT_BYTES: + raise BridgeError("stream_failed", "A StartChat SSE event exceeded the bridge limit.") + try: + value = json.loads(payload_text) + except ValueError: + return None, payload_text + return (value if isinstance(value, dict) else None), payload_text + + for raw_line in lines: + event_bytes += len(raw_line.encode("utf-8")) + if event_bytes > MAX_SSE_EVENT_BYTES: + raise BridgeError("stream_failed", "A StartChat SSE event exceeded the bridge limit.") + line = raw_line.rstrip("\r\n") + if not line: + if data_lines or raw_lines: + yield decode(data_lines, raw_lines) + data_lines = [] + raw_lines = [] + event_bytes = 0 + continue + if line.startswith(":"): + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + elif not data_lines: + raw_lines.append(line) + if data_lines or raw_lines: + yield decode(data_lines, raw_lines) + + +def _event_payload(result: Any) -> Any: + if not isinstance(result, dict): + return result + for key in ("statusUpdate", "artifactUpdate"): + value = result.get(key) + if isinstance(value, dict): + return value + return result + + +def _result(payload: Dict[str, Any]) -> Dict[str, Any]: + result = payload.get("result") + return result if isinstance(result, dict) else payload + + +def _normalize_state(value: str) -> str: + normalized = value.strip().lower().replace("-", "_") + if normalized.startswith("task_state_"): + normalized = normalized[len("task_state_") :] + return normalized.replace("_", "-") + + +def _state_from_result(result: Dict[str, Any]) -> Tuple[str, str]: + event = _event_payload(result) + candidates = [event] + if isinstance(event, dict) and isinstance(event.get("task"), dict): + candidates.append(event["task"]) + for candidate in candidates: + if not isinstance(candidate, dict): + continue + status = candidate.get("status") or candidate.get("Status") + if isinstance(status, dict): + state = status.get("state") or status.get("State") + if isinstance(state, str): + return _normalize_state(state), state + state = candidate.get("state") or candidate.get("State") + if isinstance(state, str) and state.upper().startswith("TASK_STATE_"): + return _normalize_state(state), state + return "", "" + + +def _find_first(value: Any, *keys: str) -> Any: + if isinstance(value, dict): + for key in keys: + if value.get(key) not in (None, ""): + return value[key] + for item in value.values(): + found = _find_first(item, *keys) + if found not in (None, ""): + return found + elif isinstance(value, list): + for item in value: + found = _find_first(item, *keys) + if found not in (None, ""): + return found + return None + + +def _metadata_from_result(result: Dict[str, Any]) -> Dict[str, Any]: + event = _event_payload(result) + candidates = [] + if isinstance(event, dict): + candidates.append(event.get("metadata")) + status = event.get("status") + if isinstance(status, dict): + candidates.append(status.get("metadata")) + task = event.get("task") + if isinstance(task, dict): + candidates.append(task.get("metadata")) + for value in candidates: + if isinstance(value, dict) and isinstance(value.get("iac_code"), dict): + return value["iac_code"] + return {} + + +def _message_text_from_result(result: Dict[str, Any]) -> str: + event = _event_payload(result) + candidates = [] + if isinstance(event, dict): + status = event.get("status") + if isinstance(status, dict): + candidates.append(status.get("message")) + candidates.append(event.get("message")) + task = event.get("task") + if isinstance(task, dict) and isinstance(task.get("status"), dict): + candidates.append(task["status"].get("message")) + for message in candidates: + if not isinstance(message, dict) or not isinstance(message.get("parts"), list): + continue + pieces = [ + part.get("text") + for part in message["parts"] + if isinstance(part, dict) and isinstance(part.get("text"), str) + ] + if pieces: + return "".join(pieces) + return "" + + +def _permission_ack_from_result(result: Dict[str, Any]) -> Optional[Dict[str, Any]]: + metadata_ack = _metadata_from_result(result).get("permissionAck") + event = _event_payload(result) + candidates = [metadata_ack, event] + if isinstance(event, dict): + candidates.append(event.get("message")) + status = event.get("status") + if isinstance(status, dict): + candidates.append(status.get("message")) + for candidate in candidates: + data_values = [candidate] + if isinstance(candidate, dict) and isinstance(candidate.get("parts"), list): + data_values.extend(part.get("data") for part in candidate["parts"] if isinstance(part, dict)) + for data in data_values: + if not isinstance(data, dict) or data.get("kind") != "permission_ack": + continue + projected = { + key: data[key] + for key in ("schemaVersion", "kind", "inputId", "toolUseId", "decision", "accepted") + if key in data + } + if projected.get("schemaVersion") != 1 or projected.get("accepted") is not True: + continue + if projected.get("decision") not in PERMISSION_DECISIONS: + continue + return projected + return None + + +def _permission_is_acknowledged(permission: Any, acknowledgement: Any) -> bool: + return ( + isinstance(permission, dict) + and isinstance(acknowledgement, dict) + and acknowledgement.get("accepted") is True + and isinstance(permission.get("inputId"), str) + and permission.get("inputId") == acknowledgement.get("inputId") + ) + + +def _permission_response_is_acknowledged(response: Any, acknowledgement: Any) -> bool: + if not isinstance(response, dict) or not isinstance(acknowledgement, dict): + return False + if acknowledgement.get("accepted") is not True or acknowledgement.get("schemaVersion") != 1: + return False + return all(response.get(key) == acknowledgement.get(key) for key in ("inputId", "toolUseId", "decision")) + + +def _safe_deployment_summary(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + return None + result = {} + fields = ( + ("candidateName", 200), + ("action", 80), + ("region", 120), + ("stackName", 200), + ("template", 300), + ("totalMonthlyCost", 300), + ) + for key, maximum in fields: + if key in value: + result[key] = sanitize_text(value.get(key), maximum) + resources = value.get("resources") + if isinstance(resources, list): + result["resources"] = [ + { + key: sanitize_text(item.get(key), maximum) + for key, maximum in (("name", 200), ("spec", 300), ("monthlyCost", 300)) + if key in item + } + for item in resources[:12] + if isinstance(item, dict) + ] + return result or None + + +def _safe_input(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + return None + kind = value.get("kind") + allowed = {"schemaVersion", "kind", "requestTaskId", "contextId", "inputId", "prompt", "options", "required"} + if kind == "permission": + allowed.update( + { + "toolUseId", + "toolName", + "title", + "purpose", + "effect", + "target", + "isReadOnly", + "safeSummary", + "deploymentSummary", + "language", + } + ) + elif kind == "ask_user_question": + allowed.update({"allowFreeText", "freeTextPrompt"}) + elif kind != "candidate_selection": + return None + result = {key: value[key] for key in allowed if key in value} + text_fields = ( + ("prompt", 1000), + ("freeTextPrompt", 600), + ("safeSummary", 1200), + ("title", 300), + ("purpose", 600), + ("effect", 120), + ("target", 600), + ("toolName", 120), + ("language", 12), + ) + for key, maximum in text_fields: + if key in result: + result[key] = sanitize_text(result[key], maximum) + if "isReadOnly" in result: + result["isReadOnly"] = result["isReadOnly"] is True + if "allowFreeText" in result: + result["allowFreeText"] = result["allowFreeText"] is True + if "deploymentSummary" in result: + result["deploymentSummary"] = _safe_deployment_summary(result["deploymentSummary"]) + options = value.get("options") + if isinstance(options, list): + safe_options = [] + for item in options[:20]: + if not isinstance(item, dict): + continue + safe_item = {} + option_fields = ( + ("id", 120), + ("label", 240), + ("summary", 800), + ("architectureDiagram", 2400), + ("totalMonthlyCost", 300), + ) + for key, maximum in option_fields: + if key in item: + safe_item[key] = sanitize_text(item.get(key), maximum, key == "architectureDiagram") + costs = item.get("costItems") + if isinstance(costs, list): + safe_item["costItems"] = [ + { + key: sanitize_text(cost.get(key), maximum) + for key, maximum in (("name", 200), ("spec", 300), ("monthlyCost", 300)) + if key in cost + } + for cost in costs[:12] + if isinstance(cost, dict) + ] + if safe_item: + safe_options.append(safe_item) + result["options"] = safe_options + return result + + +def _pipeline_events(metadata: Dict[str, Any]) -> List[Dict[str, Any]]: + batch = metadata.get("pipelineBatch") + if isinstance(batch, dict) and isinstance(batch.get("events"), list): + return [item for item in batch["events"] if isinstance(item, dict)] + event = metadata.get("pipeline") + return [event] if isinstance(event, dict) else [] + + +def _input_from_metadata(metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]: + direct = _safe_input(metadata.get("input")) + if direct is not None: + return direct + + def find(value: Any) -> Optional[Dict[str, Any]]: + projected = _safe_input(value) + if projected is not None: + return projected + if isinstance(value, dict): + for item in value.values(): + projected = find(item) + if projected is not None: + return projected + elif isinstance(value, list): + for item in value: + projected = find(item) + if projected is not None: + return projected + return None + + return find(_pipeline_events(metadata)) + + +def _pending_permissions_from_metadata(metadata: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]: + pending = metadata.get("pendingPermissions") + if not isinstance(pending, list): + return None + result = [] + for value in pending: + projected = _safe_input(value) + if projected is not None and projected.get("kind") == "permission": + result.append(projected) + return result + + +def _safe_permission_wait(metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]: + value = metadata.get("permissionWait") + if not isinstance(value, dict): + return None + status = value.get("status") + if not isinstance(status, str) or status not in {"waiting", "grace", "suspended"}: + return None + result = {"status": status} + if "resumable" in value: + result["resumable"] = value.get("resumable") is True + return result + + +def _safe_permission_recovered(metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]: + value = metadata.get("permissionRecovered") + if not isinstance(value, dict): + return None + result = {} + for key in ("inputId", "toolUseId"): + item = value.get(key) + if isinstance(item, str) and item: + result[key] = sanitize_text(item, 240) + return result or None + + +def _is_sideband_permission(metadata: Dict[str, Any], input_value: Dict[str, Any]) -> bool: + if input_value.get("kind") != "permission": + return False + return any( + event.get("eventType") == "permission_requested" and event.get("status") == "working" + for event in _pipeline_events(metadata) + ) + + +def _permission_class(value: Dict[str, Any], *, mode: str, sideband: bool) -> Dict[str, Any]: + if value.get("kind") != "permission": + return value + result = _permission_with_ref(value) + result["permissionClass"] = "sub_pipeline" if sideband else ("pipeline" if mode == "pipeline" else "normal") + return result + + +def _permission_ref(value: Any) -> Optional[str]: + if not isinstance(value, dict): + return None + input_id = value.get("inputId") + if not isinstance(input_id, str) or not input_id: + return None + digest = hashlib.sha256(input_id.encode("utf-8")).hexdigest()[:10] + return "p-{}".format(digest) + + +def _permission_with_ref(value: Dict[str, Any]) -> Dict[str, Any]: + result = dict(value) + if result.get("kind") == "permission": + permission_ref = _permission_ref(result) + if permission_ref is not None: + result["permissionRef"] = permission_ref + return result + + +def _safe_pipeline_result(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + return None + result = {} + for key, maximum in (("status", 80), ("stack_id", 240), ("error", 1000)): + item = value.get(key) + if isinstance(item, str) and item: + result[key] = sanitize_text(item, maximum) + resources = value.get("resources_created") + if isinstance(resources, list): + result["resources_created"] = [ + sanitize_text(item, 240) for item in resources[:24] if isinstance(item, str) and item + ] + outputs = value.get("outputs") + if isinstance(outputs, dict): + result["outputs"] = { + sanitize_text(str(key), 120): sanitize_text(str(item), 300) + for key, item in list(outputs.items())[:24] + if isinstance(key, str) and isinstance(item, (str, int, float, bool)) + } + return result or None + + +def _safe_intent_conclusion(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + return None + result = {} + for source, target, maximum in ( + ("user_message_summary", "requirementSummary", 360), + ("cloud_platform", "cloudPlatform", 80), + ("business_type", "businessType", 120), + ): + item = value.get(source) + if isinstance(item, str) and item: + result[target] = sanitize_text(item, maximum) + non_functional = value.get("non_functional") + if isinstance(non_functional, dict) and isinstance(non_functional.get("region_preference"), str): + result["region"] = sanitize_text(non_functional["region_preference"], 120) + resources = [] + for item in value.get("resource_intents", [])[:10] if isinstance(value.get("resource_intents"), list) else []: + if not isinstance(item, dict): + continue + projected = { + key: sanitize_text(item.get(key), maximum) + for key, maximum in (("product", 100), ("action", 40), ("role", 100)) + if isinstance(item.get(key), str) and item.get(key) + } + if projected: + resources.append(projected) + if resources: + result["resources"] = resources + while len(_json_bytes(result)) > MAX_STEP_CONCLUSION_BYTES: + if resources and len(resources) > 1: + resources.pop() + elif isinstance(result.get("requirementSummary"), str) and len(result["requirementSummary"]) > 120: + result["requirementSummary"] = _truncate_utf8(result["requirementSummary"], 120) + elif "businessType" in result: + result.pop("businessType") + elif "cloudPlatform" in result: + result.pop("cloudPlatform") + else: + break + return result or None + + +def _safe_architecture_conclusion(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict) or not isinstance(value.get("candidates"), list): + return None + raw_candidates = value["candidates"] + candidates = [] + for item in raw_candidates[:4]: + if not isinstance(item, dict): + continue + projected = {} + for source, target, maximum in ( + ("name", "name", 160), + ("topology", "topology", 300), + ("monthly_estimate", "monthlyEstimate", 160), + ): + candidate = item.get(source) + if isinstance(candidate, str) and candidate: + projected[target] = sanitize_text(candidate, maximum) + if projected: + candidates.append(projected) + result = {"candidateCount": len(raw_candidates), "candidates": candidates} + while len(_json_bytes(result)) > MAX_STEP_CONCLUSION_BYTES and candidates: + if len(candidates) > 2: + candidates.pop() + continue + changed = False + for candidate in reversed(candidates): + topology = candidate.get("topology") + if isinstance(topology, str) and len(topology) > 100: + candidate["topology"] = _truncate_utf8(topology, 100) + changed = True + break + if not changed: + candidates.pop() + return result if candidates else None + + +def _safe_step_conclusion(step_id: Any, conclusion_field: Any, value: Any) -> Optional[Dict[str, Any]]: + if step_id == "intent_parsing" or conclusion_field == "intent": + return _safe_intent_conclusion(value) + if step_id == "architecture_planning" or conclusion_field == "architecture": + return _safe_architecture_conclusion(value) + return None + + +def _safe_milestone(value: Dict[str, Any]) -> Optional[Dict[str, Any]]: + event_type = value.get("eventType") or value.get("event_type") + if event_type not in PIPELINE_EVENT_TYPES: + return None + result = {"eventType": event_type} + for key in ("status", "sequence", "scope"): + item = value.get(key) + if isinstance(item, (str, int)): + result[key] = item + for key in ("step", "parentStep", "candidate", "candidateStep"): + item = value.get(key) + if isinstance(item, dict): + result[key] = { + field: item[field] + for field in ("id", "name", "index", "total") + if isinstance(item.get(field), (str, int)) + } + data = value.get("data") + if isinstance(data, dict): + message = data.get("message") or data.get("summary") or data.get("description") + if isinstance(message, str): + result["message"] = sanitize_text(message, 500) + if event_type == "step_completed": + step = value.get("step") + step_id = step.get("id") if isinstance(step, dict) else None + conclusion = _safe_step_conclusion(step_id, data.get("conclusionField"), data.get("conclusion")) + if conclusion is not None: + result["conclusionSummary"] = conclusion + return result + + +def _normal_handoff_ready(value: Dict[str, Any]) -> bool: + if value.get("eventType") != "pipeline_handoff_ready" or value.get("visibility") not in {None, "committed"}: + return False + data = value.get("data") + return isinstance(data, dict) and data.get("action") == "switch_to_normal" and data.get("targetMode") == "normal" + + +def _safe_artifact(result: Dict[str, Any]) -> Optional[Dict[str, Any]]: + event = _event_payload(result) + artifact = event.get("artifact") if isinstance(event, dict) else None + if not isinstance(artifact, dict): + return None + parts = artifact.get("parts") + first = parts[0] if isinstance(parts, list) and parts and isinstance(parts[0], dict) else {} + uri = first.get("url") + if not isinstance(uri, str): + return None + metadata = artifact.get("metadata") if isinstance(artifact.get("metadata"), dict) else {} + projected = { + "id": sanitize_text(str(artifact.get("artifactId") or ""), 128), + "name": sanitize_text(artifact.get("name") or first.get("filename"), 240), + "uri": sanitize_text(uri, 1200, True), + } + for key in ("mediaType", "sha256", "sourcePath"): + if isinstance(metadata.get(key), str): + projected[key] = sanitize_text(metadata[key], 1000, True) + if isinstance(metadata.get("byteSize"), int): + projected["byteSize"] = metadata["byteSize"] + return projected + + +class StreamSummary: + def __init__(self, initial_session_id: Optional[str] = None, mode: str = "normal") -> None: + self.session_id = initial_session_id + self.mode = mode + self.task_id = None # type: Optional[str] + self.iac_code_session_id = None # type: Optional[str] + self.request_id = None # type: Optional[str] + self.state = "" + self.wire_state = "" + self.input_required = None # type: Optional[Dict[str, Any]] + self.input_required_from_pending = False + self.pending_permissions = [] # type: List[Dict[str, Any]] + self.permission_ack = None # type: Optional[Dict[str, Any]] + self.permission_wait = None # type: Optional[Dict[str, Any]] + self.permission_recovered = None # type: Optional[Dict[str, Any]] + self.sideband_input_ids = set() # type: set + self.resolved_sideband_input_ids = set() # type: set + self.text_parts = [] # type: List[str] + self.text_bytes = 0 + self.text_truncated = False + self.assistant_final = False + self.milestones = [] # type: List[Dict[str, Any]] + self.artifacts = [] # type: List[Dict[str, Any]] + self.pipeline_result = None # type: Optional[Dict[str, Any]] + self.normal_handoff_ready = False + self.event_count = 0 + self.heartbeat_count = 0 + self.malformed_event_count = 0 + self.error = None # type: Optional[Dict[str, Any]] + + def _append_text(self, value: str) -> None: + value = sanitize_text(value, MAX_FINAL_TEXT_BYTES, True) + if not value: + return + remaining = MAX_FINAL_TEXT_BYTES - self.text_bytes + if remaining <= 0: + self.text_truncated = True + return + bounded = _truncate_utf8(value, remaining) + self.text_parts.append(bounded) + self.text_bytes += len(bounded.encode("utf-8")) + if bounded != value: + self.text_truncated = True + + def _replace_text(self, value: str) -> None: + """Use the authoritative final snapshot instead of duplicating prior deltas.""" + raw_size = len(value.encode("utf-8")) + value = sanitize_text(value, MAX_FINAL_TEXT_BYTES, True) + self.text_parts = [value] if value else [] + self.text_bytes = len(value.encode("utf-8")) + self.text_truncated = raw_size > MAX_FINAL_TEXT_BYTES + + def apply(self, payload: Dict[str, Any]) -> None: + self.event_count += 1 + if str(payload.get("object", "")).lower() in {"heartbeat", "keepalive"}: + self.heartbeat_count += 1 + return + result = _result(payload) + session_id = _find_first(payload, "contextId", "context_id", "SessionId") + task_id = _find_first(payload, "taskId", "task_id") + iac_session_id = _find_first(payload, "iacCodeSessionId", "iac_code_session_id") + request_id = _find_first(payload, "requestId", "request_id", "RequestId") + if isinstance(session_id, str): + self.session_id = session_id + if isinstance(task_id, str): + self.task_id = task_id + if isinstance(iac_session_id, str): + self.iac_code_session_id = iac_session_id + if isinstance(request_id, str): + self.request_id = request_id + state, wire_state = _state_from_result(result) + # A few StartChat gateways emit a trailing WORKING status after the + # authoritative terminal event. Keep the terminal state monotonic so + # the managed job can expose Pipeline handoff instead of becoming an + # ownerless working job when the CLI process exits. + if state and (self.state not in TERMINAL_STATES or state in TERMINAL_STATES): + self.state = state + self.wire_state = wire_state + metadata = _metadata_from_result(result) + terminal_state_seen = self.state in TERMINAL_STATES + if terminal_state_seen: + # Terminal ownership also closes every waiting boundary. A stale + # trailing status may still contribute artifacts, Pipeline + # results, handoff metadata, text, or a real error below, but it + # cannot reopen user input or permission-wait state. + self.input_required = None + self.input_required_from_pending = False + self.pending_permissions = [] + self.permission_wait = None + permission_wait = None if terminal_state_seen else _safe_permission_wait(metadata) + if permission_wait is not None: + self.permission_wait = permission_wait + permission_recovered = None if terminal_state_seen else _safe_permission_recovered(metadata) + if permission_recovered is not None: + self.permission_recovered = permission_recovered + self.permission_wait = None + recovered_input_id = permission_recovered.get("inputId") + if isinstance(self.input_required, dict) and self.input_required.get("inputId") == recovered_input_id: + self.input_required = None + self.input_required_from_pending = False + self.pending_permissions = [ + value for value in self.pending_permissions if value.get("inputId") != recovered_input_id + ] + input_required = None if terminal_state_seen else _input_from_metadata(metadata) + pending_permissions = None if terminal_state_seen else _pending_permissions_from_metadata(metadata) + pending_input_ids = {value.get("inputId") for value in pending_permissions or [] if isinstance(value, dict)} + if pending_permissions is not None: + self.resolved_sideband_input_ids.update(self.sideband_input_ids - pending_input_ids) + previous_sideband_input_id = ( + self.input_required.get("inputId") + if isinstance(self.input_required, dict) and self.input_required.get("permissionClass") == "sub_pipeline" + else None + ) + sideband_input = input_required is not None and ( + _is_sideband_permission(metadata, input_required) + or input_required.get("inputId") in pending_input_ids + or input_required.get("inputId") in self.sideband_input_ids + or input_required.get("inputId") == previous_sideband_input_id + ) + if sideband_input and isinstance(input_required.get("inputId"), str): + self.sideband_input_ids.add(input_required["inputId"]) + stale_resolved_sideband = ( + input_required is not None and input_required.get("inputId") in self.resolved_sideband_input_ids + ) + if input_required is not None and not stale_resolved_sideband: + self.input_required = _permission_class(input_required, mode=self.mode, sideband=sideband_input) + self.input_required_from_pending = sideband_input + if pending_permissions is None and sideband_input and isinstance(self.input_required, dict): + pending_permissions = [self.input_required] + if pending_permissions is not None: + self.pending_permissions = [ + _permission_class(value, mode=self.mode, sideband=True) for value in pending_permissions + ] + direct_input_id = self.input_required.get("inputId") if isinstance(self.input_required, dict) else None + matching_pending = next( + (value for value in self.pending_permissions if value.get("inputId") == direct_input_id), + None, + ) + if matching_pending is not None: + self.input_required = matching_pending + self.input_required_from_pending = True + elif self.pending_permissions and (self.input_required is None or self.input_required_from_pending): + self.input_required = self.pending_permissions[0] + self.input_required_from_pending = True + elif not self.pending_permissions and self.input_required_from_pending: + self.input_required = None + self.input_required_from_pending = False + text = _message_text_from_result(result) + permission_ack = _permission_ack_from_result(result) + if permission_ack is not None: + self.permission_ack = permission_ack + if _permission_is_acknowledged(self.input_required, self.permission_ack): + acknowledged_input_id = self.permission_ack.get("inputId") + self.input_required = None + self.input_required_from_pending = False + self.pending_permissions = [ + value for value in self.pending_permissions if value.get("inputId") != acknowledged_input_id + ] + if self.pending_permissions: + self.input_required = self.pending_permissions[0] + self.input_required_from_pending = True + assistant_final = metadata.get("assistantFinal") + is_assistant_final = isinstance(assistant_final, dict) and assistant_final.get("complete") is True + if (is_assistant_final or self.state in TERMINAL_STATES) and self.input_required_from_pending: + self.input_required = None + self.input_required_from_pending = False + self.pending_permissions = [] + if text: + if is_assistant_final: + self._replace_text(text) + else: + self._append_text(text) + if is_assistant_final: + self.assistant_final = True + for item in _pipeline_events(metadata): + if _normal_handoff_ready(item): + self.normal_handoff_ready = True + milestone = _safe_milestone(item) + if milestone is not None and milestone not in self.milestones: + self.milestones.append(milestone) + self.milestones = self.milestones[-40:] + data = item.get("data") + if ( + item.get("eventType") == "step_completed" + and isinstance(data, dict) + and data.get("conclusionField") == "deployment" + ): + pipeline_result = _safe_pipeline_result(data.get("conclusion")) + if pipeline_result is not None: + self.pipeline_result = pipeline_result + artifact = _safe_artifact(result) + if artifact is not None and artifact not in self.artifacts: + self.artifacts.append(artifact) + self.artifacts = self.artifacts[-24:] + raw_error = payload.get("error") + if not isinstance(raw_error, dict): + raw_error = result.get("error") if isinstance(result.get("error"), dict) else None + if isinstance(raw_error, dict): + code = raw_error.get("code") or raw_error.get("Code") or "StartChatFailed" + message = raw_error.get("message") or raw_error.get("Message") or "StartChat returned an error." + self.error = {"code": sanitize_text(str(code), 160), "message": sanitize_text(str(message), 2000)} + if str(payload.get("object", "")).lower() == "response" and str(payload.get("status", "")).lower() == "failed": + self.state = "failed" + + def to_result(self, return_code: int, stderr_text: str) -> Dict[str, Any]: + failed = return_code != 0 or self.state == "failed" or self.error is not None + if failed: + state = "failed" + elif self.input_required is not None: + state = "input-required" + elif self.state in TERMINAL_STATES: + state = "turn-completed" if self.mode == "normal" and self.state == "completed" else self.state + elif self.assistant_final or (self.mode == "normal" and self.state == "input-required"): + state = "turn-completed" + elif self.permission_ack is not None: + state = "permission-responded" + else: + state = self.state or "stream-ended" + result = { + "ok": not failed, + "state": state, + "presentationRequired": True, + "eventCount": self.event_count, + "heartbeatCount": self.heartbeat_count, + "malformedEventCount": self.malformed_event_count, + } # type: Dict[str, Any] + identities = ( + ("sessionId", self.session_id), + ("taskId", self.task_id), + ("iacCodeSessionId", self.iac_code_session_id), + ("requestId", self.request_id), + ("wireState", self.wire_state), + ) + for key, value in identities: + if value: + result[key] = value + text = "".join(self.text_parts) + if state == "turn-completed": + result["finalText"] = text + result["finalTextComplete"] = not self.text_truncated + elif text: + result["latestText"] = _truncate_utf8(text, 16000) + if self.input_required is not None: + result["inputRequired"] = self.input_required + if self.pending_permissions: + result["pendingPermissions"] = self.pending_permissions + if self.permission_ack is not None: + result["permissionAck"] = self.permission_ack + if self.permission_wait is not None: + result["permissionWait"] = self.permission_wait + if self.permission_recovered is not None: + result["permissionRecovered"] = self.permission_recovered + if self.milestones: + result["milestones"] = self.milestones + if self.artifacts: + result["artifacts"] = self.artifacts + if self.pipeline_result is not None: + result["pipelineResult"] = self.pipeline_result + if self.normal_handoff_ready: + result["normalHandoffReady"] = True + result["conversationMode"] = "normal" + if self.error is not None: + result["error"] = self.error + elif return_code != 0: + result["error"] = { + "code": "aliyun_cli_failed", + "message": sanitize_text(stderr_text, 3000) + or "Alibaba Cloud CLI exited with status {}.".format(return_code), + } + elif self.event_count == 0: + result["ok"] = False + result["state"] = "failed" + result["error"] = {"code": "empty_stream", "message": "StartChat ended without an SSE event."} + return _bound_result(result) + + +def _bound_result(result: Dict[str, Any]) -> Dict[str, Any]: + def size() -> int: + return len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + + while size() > MAX_RESULT_BYTES and result.get("milestones"): + result["milestones"].pop(0) + while size() > MAX_RESULT_BYTES and result.get("artifacts"): + result["artifacts"].pop(0) + if size() > MAX_RESULT_BYTES and isinstance(result.get("finalText"), str): + result["finalText"] = _truncate_utf8(result["finalText"], MAX_RESULT_BYTES // 2) + result["finalTextComplete"] = False + return result + + +def _bound_projection(projection: Dict[str, Any]) -> Dict[str, Any]: + bounded = dict(projection) + input_value = bounded.get("inputRequired") + maximum = MAX_INPUT_PROJECTION_BYTES if isinstance(input_value, dict) else MAX_PROJECTION_BYTES + if len(_json_bytes(bounded)) <= maximum: + return bounded + if isinstance(input_value, dict): + envelope = dict(input_value) + bounded["inputRequired"] = envelope + options = envelope.get("options") + if isinstance(options, list): + envelope["options"] = [dict(item) for item in options if isinstance(item, dict)] + while len(_json_bytes(bounded)) > maximum: + changed = False + for key, minimum in ( + ("safeSummary", 160), + ("purpose", 100), + ("target", 80), + ("prompt", 120), + ("freeTextPrompt", 80), + ): + value = envelope.get(key) + if isinstance(value, str) and len(value) > minimum: + envelope[key] = value[: max(minimum, len(value) // 2)] + changed = True + for option in envelope.get("options", []): + if not isinstance(option, dict): + continue + for key, minimum in (("summary", 100), ("architectureDiagram", 240), ("totalMonthlyCost", 20)): + value = option.get(key) + if isinstance(value, str) and len(value) > minimum: + option[key] = value[: max(minimum, len(value) // 2)] + changed = True + costs = option.get("costItems") + if isinstance(costs, list) and len(costs) > 6: + del costs[6:] + changed = True + if not changed: + break + if len(_json_bytes(bounded)) > maximum: + raise BridgeError("stream_failed", "A StartChat input boundary exceeded the bounded bridge protocol.") + bounded["trimmed"] = True + return bounded + milestones = bounded.get("milestones") + while len(_json_bytes(bounded)) > maximum and isinstance(milestones, list) and len(milestones) > 1: + milestones.pop(0) + bounded.pop("latestText", None) + if len(_json_bytes(bounded)) > maximum: + bounded = { + key: bounded[key] + for key in ("type", "state", "sessionId", "taskId", "requestSeq", "error") + if key in bounded + } + bounded["trimmed"] = True + return bounded + + +def _project_stream_event( + payload: Dict[str, Any], + mode: str, + request_seq: int, + worker_role: str = "primary", + worker_token: Optional[str] = None, +) -> Dict[str, Any]: + result = _result(payload) + state, wire_state = _state_from_result(result) + metadata = _metadata_from_result(result) + projection = {"type": "status", "requestSeq": request_seq, "time": int(time.time())} # type: Dict[str, Any] + if worker_role == "sideband": + projection["workerRole"] = "sideband" + if isinstance(worker_token, str) and worker_token: + projection["workerToken"] = worker_token + if state: + projection["state"] = state + if wire_state: + projection["wireState"] = wire_state + for key, value in ( + ("sessionId", _find_first(payload, "contextId", "context_id", "SessionId")), + ("taskId", _find_first(payload, "taskId", "task_id")), + ("iacCodeSessionId", _find_first(payload, "iacCodeSessionId", "iac_code_session_id")), + ("requestId", _find_first(payload, "requestId", "request_id", "RequestId")), + ): + if isinstance(value, str) and value: + projection[key] = sanitize_text(value, 240) + + input_required = _input_from_metadata(metadata) + pending_permissions = _pending_permissions_from_metadata(metadata) + if pending_permissions is not None: + projection["pendingPermissions"] = [ + _permission_class(value, mode=mode, sideband=True) for value in pending_permissions + ] + if input_required is not None: + sideband_ids = { + value.get("inputId") for value in projection.get("pendingPermissions", []) if isinstance(value, dict) + } + sideband_input = input_required.get("inputId") in sideband_ids or _is_sideband_permission( + metadata, input_required + ) + projected_input = _permission_class( + input_required, + mode=mode, + sideband=sideband_input, + ) + projection["inputRequired"] = projected_input + if sideband_input and "pendingPermissions" not in projection: + projection["pendingPermissions"] = [projected_input] + projection["type"] = "input-required" + + permission_wait = _safe_permission_wait(metadata) + if permission_wait is not None: + projection["permissionWait"] = permission_wait + if projection["type"] == "status": + projection["type"] = "permission-wait" + + permission_recovered = _safe_permission_recovered(metadata) + if permission_recovered is not None: + projection["permissionRecovered"] = permission_recovered + if projection["type"] == "status": + projection["type"] = "permission-recovered" + + milestones = [] + for item in _pipeline_events(metadata): + if _normal_handoff_ready(item): + projection["normalHandoffReady"] = True + milestone = _safe_milestone(item) + if milestone is not None and milestone not in milestones: + milestones.append(milestone) + if milestones: + projection["milestones"] = milestones + if projection["type"] == "status": + projection["type"] = "milestone" + + permission_ack = _permission_ack_from_result(result) + if permission_ack is not None: + projection["permissionAck"] = permission_ack + if projection["type"] == "status": + projection["type"] = "permission-ack" + + artifact = _safe_artifact(result) + if artifact is not None: + projection["artifact"] = artifact + if projection["type"] == "status": + projection["type"] = "artifact" + + text = _message_text_from_result(result) + assistant_final = metadata.get("assistantFinal") + if text and isinstance(assistant_final, dict) and assistant_final.get("complete") is True: + projection["type"] = "assistant-final" + projection["finalText"] = sanitize_text(text, MAX_FINAL_TEXT_BYTES, True) + projection["finalTextComplete"] = len(text.encode("utf-8")) <= MAX_FINAL_TEXT_BYTES + elif text: + # Store only a small snapshot in job state. Token deltas are not spooled + # or returned to the outer Agent as individual events. + projection["latestText"] = sanitize_text(text, 1000, True) + + raw_error = payload.get("error") + if not isinstance(raw_error, dict): + raw_error = result.get("error") if isinstance(result.get("error"), dict) else None + if isinstance(raw_error, dict): + projection["type"] = "failed" + projection["state"] = "failed" + projection["error"] = { + "code": sanitize_text(str(raw_error.get("code") or raw_error.get("Code") or "StartChatFailed"), 160), + "message": sanitize_text( + str(raw_error.get("message") or raw_error.get("Message") or "StartChat failed."), 2000 + ), + } + elif state in TERMINAL_STATES: + projection["type"] = "terminal" + + return _bound_projection(projection) + + +def _without_wait_boundaries(projection: Dict[str, Any]) -> Dict[str, Any]: + projection = dict(projection) + for key in ("inputRequired", "pendingPermissions", "permissionWait", "permissionRecovered"): + projection.pop(key, None) + if projection.get("type") in {"input-required", "permission-wait", "permission-recovered"}: + projection["type"] = "status" + return projection + + +def _project_managed_stream_event( + payload: Dict[str, Any], + summary: StreamSummary, + mode: str, + request_seq: int, + worker_role: str, + worker_token: Optional[str], +) -> Dict[str, Any]: + projection = _project_stream_event(payload, mode, request_seq, worker_role, worker_token) + if summary.state in TERMINAL_STATES: + projection = _without_wait_boundaries(projection) + return projection + + +def _append_projection(job_id: str, projection: Dict[str, Any]) -> None: + root, job_path, spool = _job_paths(job_id) + _secure_directory(root) + projection = _bound_projection(projection) + with StateLock(root / ".job.lock"): + job = _load_state_json(job_path) + request_seq = projection.get("requestSeq") + if isinstance(request_seq, int) and request_seq != job.get("activeRequestSeq"): + return + worker_role = projection.get("workerRole") + worker_token = projection.get("workerToken") + if worker_role == "sideband" and worker_token != job.get("sidebandWorkerToken"): + return + primary_terminal = job.get("primaryStreamTerminalSeen") is True or job.get("state") in TERMINAL_STATES + if primary_terminal: + projection = _without_wait_boundaries(projection) + if projection.get("type") == "terminal" and worker_role != "sideband": + # Do not publish an incomplete final result before EOF, but close + # the primary stream's user-input ownership immediately. The + # internal marker also prevents a concurrent sideband worker from + # reopening the parent Pipeline while the primary worker exits. + projection = _without_wait_boundaries(projection) + job["primaryStreamTerminalSeen"] = True + job.pop("inputRequired", None) + job.pop("pendingPermissions", None) + job.pop("permissionWait", None) + identity_changed = False + for key in ("sessionId", "taskId", "iacCodeSessionId", "requestId", "wireState"): + value = projection.get(key) + if isinstance(value, str) and value and job.get(key) != value: + job[key] = value + identity_changed = True + latest_text = projection.get("latestText") + if isinstance(latest_text, str) and latest_text: + job["latestText"] = latest_text + projected_ack = projection.get("permissionAck") + effective_ack = projected_ack if isinstance(projected_ack, dict) else job.get("permissionAck") + in_flight_input_id = job.get("sidebandResponseInputId") + acknowledged_input_ids = {value for value in job.get("acknowledgedPermissionIds", []) if isinstance(value, str)} + if isinstance(effective_ack, dict) and isinstance(effective_ack.get("inputId"), str): + acknowledged_input_ids.add(effective_ack["inputId"]) + seen_sideband_input_ids = { + value for value in job.get("seenSidebandPermissionIds", []) if isinstance(value, str) + } + resolved_sideband_input_ids = { + value for value in job.get("resolvedSidebandPermissionIds", []) if isinstance(value, str) + } + resolved_sideband_input_ids.update(acknowledged_input_ids) + if isinstance(projection.get("pendingPermissions"), list): + projected_pending_ids = { + value.get("inputId") + for value in projection["pendingPermissions"] + if isinstance(value, dict) and isinstance(value.get("inputId"), str) + } + resolved_sideband_input_ids.update(seen_sideband_input_ids - projected_pending_ids) + seen_sideband_input_ids.update(projected_pending_ids) + job["seenSidebandPermissionIds"] = list(seen_sideband_input_ids)[-64:] + job["resolvedSidebandPermissionIds"] = list(resolved_sideband_input_ids)[-64:] + pending = [ + value + for value in projection["pendingPermissions"] + if not _permission_is_acknowledged(value, effective_ack) + and value.get("inputId") != in_flight_input_id + and value.get("inputId") not in acknowledged_input_ids + and value.get("inputId") not in resolved_sideband_input_ids + ] + projection["pendingPermissions"] = pending + if pending: + job["pendingPermissions"] = pending + current = job.get("inputRequired") + pending_ids = {value.get("inputId") for value in pending if isinstance(value, dict)} + if not isinstance(current, dict) or current.get("inputId") not in pending_ids: + job["inputRequired"] = pending[0] + else: + job.pop("pendingPermissions", None) + current = job.get("inputRequired") + if isinstance(current, dict) and current.get("permissionClass") == "sub_pipeline": + job.pop("inputRequired", None) + input_required = projection.get("inputRequired") + current_input = job.get("inputRequired") + if ( + isinstance(input_required, dict) + and isinstance(current_input, dict) + and input_required.get("inputId") == current_input.get("inputId") + and current_input.get("permissionClass") == "sub_pipeline" + ): + input_required = dict(input_required) + input_required["permissionClass"] = "sub_pipeline" + projection["inputRequired"] = input_required + if isinstance(input_required, dict) and input_required.get("inputId") in resolved_sideband_input_ids: + projection.pop("inputRequired", None) + if projection.get("type") == "input-required": + projection["type"] = "status" + elif isinstance(input_required, dict) and input_required.get("inputId") == in_flight_input_id: + projection.pop("inputRequired", None) + if projection.get("type") == "input-required": + projection["type"] = "status" + elif _permission_is_acknowledged(input_required, effective_ack) or ( + isinstance(input_required, dict) and input_required.get("inputId") in acknowledged_input_ids + ): + projection.pop("inputRequired", None) + if projection.get("type") == "input-required": + projection["type"] = "permission-ack" if isinstance(projected_ack, dict) else "status" + elif isinstance(input_required, dict): + job["inputRequired"] = input_required + job["state"] = "input-required" + permission_wait = projection.get("permissionWait") + if isinstance(permission_wait, dict): + job["permissionWait"] = permission_wait + permission_recovered = projection.get("permissionRecovered") + if isinstance(permission_recovered, dict): + job["permissionRecovered"] = permission_recovered + job.pop("permissionWait", None) + recovered_input_id = permission_recovered.get("inputId") + current = job.get("inputRequired") + if isinstance(current, dict) and current.get("inputId") == recovered_input_id: + job.pop("inputRequired", None) + remaining = [ + value + for value in job.get("pendingPermissions", []) + if isinstance(value, dict) and value.get("inputId") != recovered_input_id + ] + if remaining: + job["pendingPermissions"] = remaining + else: + job.pop("pendingPermissions", None) + if job.get("state") == "input-required" and not isinstance(job.get("inputRequired"), dict): + job["state"] = "working" + permission_ack = projection.get("permissionAck") + if isinstance(permission_ack, dict): + job["permissionAck"] = permission_ack + input_id = permission_ack.get("inputId") + if isinstance(input_id, str): + history = job.setdefault("acknowledgedPermissionIds", []) + if input_id not in history: + history.append(input_id) + del history[:-64] + remaining = [ + value + for value in job.get("pendingPermissions", []) + if isinstance(value, dict) and value.get("inputId") != input_id + ] + if remaining: + job["pendingPermissions"] = remaining + job["inputRequired"] = remaining[0] + job["state"] = "input-required" + else: + job.pop("pendingPermissions", None) + current = job.get("inputRequired") + if isinstance(current, dict) and current.get("inputId") == input_id: + job.pop("inputRequired", None) + if worker_role == "sideband" and job.get("state") not in TERMINAL_STATES: + job["state"] = "working" + artifact = projection.get("artifact") + if isinstance(artifact, dict): + artifacts = job.setdefault("artifacts", []) + if artifact not in artifacts: + artifacts.append(artifact) + del artifacts[:-24] + if projection.get("type") == "assistant-final" and isinstance(projection.get("finalText"), str): + job["assistantFinal"] = projection["finalText"] + job["assistantFinalComplete"] = projection.get("finalTextComplete") is True + if projection.get("type") == "failed" and worker_role == "sideband": + job["sidebandError"] = projection.get("error") + elif projection.get("type") == "failed": + job["state"] = "failed" + job["error"] = projection.get("error") + if projection.get("normalHandoffReady") is True and job.get("mode") == "pipeline": + job["normalHandoffReady"] = True + job["conversationMode"] = "normal" + + wire_projection = dict(projection) + wire_projection.pop("latestText", None) + wire_projection.pop("workerRole", None) + wire_projection.pop("workerToken", None) + meaningful = wire_projection.get("type") != "status" or identity_changed + if meaningful: + data = _json_bytes(wire_projection) + b"\n" + current_size = spool.stat().st_size if spool.exists() else 0 + if current_size + len(data) > MAX_SPOOL_BYTES: + raise BridgeError("stream_failed", "The bounded ROS Agent event spool is full.") + with spool.open("ab") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + os.chmod(str(spool), 0o600) + _atomic_json(job_path, job) + + +def _finish_job( + job_id: str, + request_seq: int, + result: Dict[str, Any], + worker_pid: int, + expected_worker_pid: Optional[int] = None, +) -> bool: + root, job_path, spool = _job_paths(job_id) + with StateLock(root / ".job.lock"): + job = _load_state_json(job_path) + if job.get("activeRequestSeq") != request_seq: + return False + if expected_worker_pid is not None: + current_worker_pid = job.get("workerPid") + worker_matches = ( + not isinstance(current_worker_pid, int) + if expected_worker_pid == 0 + else current_worker_pid == expected_worker_pid + ) + if ( + not worker_matches + or job.get("state") in TERMINAL_STATES | {"turn-completed", "failed"} + or isinstance(job.get("inputRequired"), dict) + ): + return False + for key in ("sessionId", "taskId", "iacCodeSessionId", "requestId", "wireState"): + value = result.get(key) + if isinstance(value, str) and value: + job[key] = value + state = result.get("state") if isinstance(result.get("state"), str) else "stream-ended" + if state == "input-required": + input_required = result.get("inputRequired") + acknowledged_input_ids = { + value for value in job.get("acknowledgedPermissionIds", []) if isinstance(value, str) + } + pending_permissions = [ + value + for value in result.get("pendingPermissions", []) + if isinstance(value, dict) and value.get("inputId") not in acknowledged_input_ids + ] + stale_sideband_input = ( + isinstance(input_required, dict) + and input_required.get("permissionClass") == "sub_pipeline" + and input_required.get("inputId") in acknowledged_input_ids + ) + if isinstance(input_required, dict) and not stale_sideband_input: + job["inputRequired"] = input_required + elif pending_permissions: + job["inputRequired"] = pending_permissions[0] + else: + job.pop("inputRequired", None) + if pending_permissions: + job["pendingPermissions"] = pending_permissions + else: + job.pop("pendingPermissions", None) + if stale_sideband_input and not pending_permissions: + state = "failed" + job["error"] = { + "code": "stream_detached", + "message": "The parent Pipeline StartChat stream ended without a terminal result.", + "retryable": True, + } + elif state == "turn-completed": + job["finalText"] = result.get("finalText", "") + job["finalTextComplete"] = result.get("finalTextComplete") is True + job.pop("inputRequired", None) + job.pop("pendingPermissions", None) + elif state in TERMINAL_STATES: + job.pop("inputRequired", None) + job.pop("pendingPermissions", None) + if isinstance(result.get("pipelineResult"), dict): + job["pipelineResult"] = result["pipelineResult"] + if result.get("normalHandoffReady") is True and job.get("mode") == "pipeline": + job["normalHandoffReady"] = True + job["conversationMode"] = "normal" + if isinstance(result.get("permissionAck"), dict): + job["permissionAck"] = result["permissionAck"] + if isinstance(result.get("permissionWait"), dict): + job["permissionWait"] = result["permissionWait"] + if isinstance(result.get("permissionRecovered"), dict): + job["permissionRecovered"] = result["permissionRecovered"] + job.pop("permissionWait", None) + if isinstance(result.get("error"), dict): + job["error"] = result["error"] + elif state == "failed" and not isinstance(job.get("error"), dict): + failure_text = result.get("latestText") or job.get("latestText") + job["error"] = { + "code": "remote_task_failed", + "message": sanitize_text(failure_text, 2000) + if isinstance(failure_text, str) and failure_text + else "The remote StartChat task failed without a structured error.", + } + if isinstance(result.get("artifacts"), list): + artifacts = job.setdefault("artifacts", []) + for artifact in result["artifacts"]: + if isinstance(artifact, dict) and artifact not in artifacts: + artifacts.append(artifact) + del artifacts[:-24] + job["state"] = state + job.pop("primaryStreamTerminalSeen", None) + if state == "completed" and job.get("mode") == "pipeline": + # Selling Pipeline publishes a normal-chat handoff before its + # terminal event. Preserve a conservative fallback for gateways + # that coalesce that event out of the final SSE projection. + job["normalHandoffReady"] = True + job["conversationMode"] = "normal" + job["workerExitedAt"] = int(time.time()) + if job.get("workerPid") == worker_pid: + job.pop("workerPid", None) + boundary = { + "type": "result-boundary", + "requestSeq": request_seq, + "state": state, + "time": int(time.time()), + } + for key in ("sessionId", "taskId", "iacCodeSessionId", "requestId", "wireState"): + if isinstance(job.get(key), str): + boundary[key] = job[key] + data = _json_bytes(boundary) + b"\n" + current_size = spool.stat().st_size if spool.exists() else 0 + if current_size + len(data) <= MAX_SPOOL_BYTES: + with spool.open("ab") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + _atomic_json(job_path, job) + _touch_manager_activity() + return True + + +def _finish_sideband_job( + job_id: str, + request_seq: int, + worker_token: str, + result: Dict[str, Any], + worker_pid: int, +) -> None: + root, job_path, _spool = _job_paths(job_id) + with StateLock(root / ".job.lock"): + job = _load_state_json(job_path) + if job.get("activeRequestSeq") != request_seq or job.get("sidebandWorkerToken") != worker_token: + return + for key in ("sessionId", "taskId", "iacCodeSessionId", "requestId", "wireState"): + value = result.get(key) + if isinstance(value, str) and value: + job[key] = value + + expected_permission = job.get("sidebandResponse") + expected_response = job.get("lastPermissionResponse") + acknowledgement = result.get("permissionAck") + acknowledged = ( + isinstance(expected_response, dict) + and isinstance(acknowledgement, dict) + and _permission_response_is_acknowledged(expected_response, acknowledgement) + ) + if acknowledged: + job["permissionAck"] = acknowledgement + acknowledged_input_id = acknowledgement.get("inputId") + if isinstance(acknowledged_input_id, str): + history = job.setdefault("acknowledgedPermissionIds", []) + if acknowledged_input_id not in history: + history.append(acknowledged_input_id) + del history[:-64] + resolved = job.setdefault("resolvedSidebandPermissionIds", []) + if acknowledged_input_id not in resolved: + resolved.append(acknowledged_input_id) + del resolved[:-64] + + parent_terminal = job.get("state") in TERMINAL_STATES or job.get("primaryStreamTerminalSeen") is True + if acknowledged and not parent_terminal: + input_id = acknowledgement.get("inputId") + remaining = [ + value + for value in job.get("pendingPermissions", []) + if isinstance(value, dict) and value.get("inputId") != input_id + ] + if remaining: + job["pendingPermissions"] = remaining + job["inputRequired"] = remaining[0] + job["state"] = "input-required" + else: + job.pop("pendingPermissions", None) + current = job.get("inputRequired") + if isinstance(current, dict) and current.get("inputId") == input_id: + job.pop("inputRequired", None) + if job.get("state") not in TERMINAL_STATES: + job["state"] = "working" + job.pop("sidebandError", None) + elif not acknowledged and not parent_terminal: + if isinstance(expected_permission, dict): + pending = [value for value in job.get("pendingPermissions", []) if isinstance(value, dict)] + expected_input_id = expected_permission.get("inputId") + if not any(value.get("inputId") == expected_input_id for value in pending): + pending.insert(0, expected_permission) + job["pendingPermissions"] = pending + job["inputRequired"] = expected_permission + job["state"] = "input-required" + raw_error = result.get("error") + job["sidebandError"] = ( + raw_error + if isinstance(raw_error, dict) + else { + "code": "permission_not_acknowledged", + "message": "The Pipeline permission response ended without an accepted acknowledgement.", + "retryable": True, + } + ) + elif acknowledged: + job.pop("sidebandError", None) + + result_state = result.get("state") if isinstance(result.get("state"), str) else None + if ( + acknowledged + and isinstance(expected_permission, dict) + and expected_permission.get("permissionClass") == "pipeline" + and not parent_terminal + and result_state in TERMINAL_STATES | {"turn-completed"} + ): + # A top-level Pipeline permission response can carry the Pipeline's + # terminal result on the sideband StartChat stream. Persist that + # result so follow does not wait forever after both workers exit. + job.pop("inputRequired", None) + job.pop("pendingPermissions", None) + job["state"] = result_state + if result_state == "turn-completed": + job["finalText"] = result.get("finalText", "") + job["finalTextComplete"] = result.get("finalTextComplete") is True + if isinstance(result.get("pipelineResult"), dict): + job["pipelineResult"] = result["pipelineResult"] + if result.get("normalHandoffReady") is True or ( + result_state == "completed" and job.get("mode") == "pipeline" + ): + job["normalHandoffReady"] = True + job["conversationMode"] = "normal" + if isinstance(result.get("error"), dict): + job["error"] = result["error"] + if isinstance(result.get("artifacts"), list): + artifacts = job.setdefault("artifacts", []) + for artifact in result["artifacts"]: + if isinstance(artifact, dict) and artifact not in artifacts: + artifacts.append(artifact) + del artifacts[:-24] + + job["sidebandWorkerExitedAt"] = int(time.time()) + if job.get("sidebandWorkerPid") == worker_pid: + job.pop("sidebandWorkerPid", None) + job.pop("sidebandWorkerToken", None) + job.pop("sidebandResponseInputId", None) + job.pop("sidebandResponse", None) + _atomic_json(job_path, job) + _touch_manager_activity() + + +def _fail_job( + job_id: str, + request_seq: int, + error: BridgeError, + worker_pid: int, + expected_worker_pid: Optional[int] = None, +) -> bool: + result = { + "ok": False, + "state": "failed", + "error": { + "code": error.code, + "message": sanitize_text(error.message, 3000), + "retryable": error.retryable, + }, + } + return _finish_job(job_id, request_seq, result, worker_pid, expected_worker_pid) + + +def _fail_sideband_job( + job_id: str, + request_seq: int, + worker_token: str, + error: BridgeError, + worker_pid: int, +) -> None: + result = { + "ok": False, + "state": "failed", + "error": { + "code": error.code, + "message": sanitize_text(error.message, 3000), + "retryable": error.retryable, + }, + } + _finish_sideband_job(job_id, request_seq, worker_token, result, worker_pid) + + +def _read_spool(spool: pathlib.Path) -> List[Dict[str, Any]]: + if not spool.exists(): + return [] + values = [] + with spool.open("r", encoding="utf-8") as handle: + for line in handle: + try: + value = json.loads(line) + except ValueError: + continue + if isinstance(value, dict): + values.append(value) + return values + + +def _follow_timeout_result(job_id: str, start_cursor: int) -> Optional[Dict[str, Any]]: + """Persist and snapshot the bounded observation returned by a timed-out follow call. + + The marker is local bridge state, not a StartChat query or a remote progress + event. Recording it gives each visible heartbeat a distinct spool cursor, + so an outer headless Agent can continue observing a long-running Pipeline + without issuing an identical tool call indefinitely. The result snapshot + stays under the same job lock so it cannot combine this cursor with a newer + terminal or input boundary while omitting intervening step events. + """ + + root, job_path, spool = _job_paths(job_id) + with StateLock(root / ".job.lock"): + job = _load_state_json(job_path) + values = _read_spool(spool) + if job.get("state") in TERMINAL_STATES | {"turn-completed", "failed"} or isinstance( + job.get("inputRequired"), dict + ): + return None + if any( + isinstance(milestone, dict) and milestone.get("eventType") in STEP_BOUNDARY_EVENT_TYPES + for item in values[max(0, int(start_cursor)) :] + for milestone in item.get("milestones", []) + ): + return None + marker = { + "type": "follow-heartbeat", + "requestSeq": job.get("activeRequestSeq"), + "time": int(time.time()), + } + data = _json_bytes(marker) + b"\n" + current_size = spool.stat().st_size if spool.exists() else 0 + if current_size + len(data) > MAX_SPOOL_BYTES: + raise BridgeError("stream_failed", "The bounded ROS Agent event spool is full.") + with spool.open("ab") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + os.chmod(str(spool), 0o600) + return _job_result( + job_id, + start_cursor, + len(values) + 1, + boundary_reached=False, + timed_out=True, + ) + + +def _coordinate_label(milestone: Dict[str, Any]) -> str: + event_type = str(milestone.get("eventType") or "") + if event_type.startswith("candidate_step_"): + candidate = milestone.get("candidate") + step = milestone.get("candidateStep") or milestone.get("step") + candidate_name = "" + if isinstance(candidate, dict): + candidate_name = sanitize_text(candidate.get("name") or candidate.get("id"), 100) + step_label = "" + if isinstance(step, dict): + step_label = sanitize_text(step.get("name") or step.get("id"), 100) + index = step.get("index") + total = step.get("total") + if isinstance(index, int) and isinstance(total, int): + step_label = "{}/{} {}".format(index, total, step_label).strip() + if candidate_name and step_label: + return "{} · {}".format(candidate_name, step_label) + if candidate_name or step_label: + return candidate_name or step_label + for key in ("candidateStep", "step", "parentStep", "candidate"): + value = milestone.get(key) + if not isinstance(value, dict): + continue + name = sanitize_text(value.get("name") or value.get("id"), 120) + index = value.get("index") + total = value.get("total") + if isinstance(index, int) and isinstance(total, int): + return "{}/{} {}".format(index, total, name).strip() + if name: + return name + return "" + + +def _format_conclusion(summary: Any, language: str) -> str: + if not isinstance(summary, dict): + return "" + parts = [] + requirement = sanitize_text(summary.get("requirementSummary"), 180) + region = sanitize_text(summary.get("region"), 80) + if requirement: + parts.append(requirement) + if region: + parts.append(("地域 " if language == "zh" else "region ") + region) + resources = summary.get("resources") + if isinstance(resources, list): + names = [] + for item in resources[:6]: + if not isinstance(item, dict): + continue + product = sanitize_text(item.get("product"), 60) + action = sanitize_text(item.get("action"), 32) + if language == "zh": + action = {"create": "新建", "use_existing": "复用", "reference": "引用", "forbid": "禁止"}.get( + action, action + ) + if product: + names.append("{} ({})".format(product, action) if action else product) + if names: + parts.append(("资源 " if language == "zh" else "resources ") + "、".join(names)) + candidates = summary.get("candidates") + if isinstance(candidates, list): + names = [] + for item in candidates[:4]: + if not isinstance(item, dict): + continue + name = sanitize_text(item.get("name"), 80) + estimate = sanitize_text(item.get("monthlyEstimate"), 80) + if name: + names.append("{} ({})".format(name, estimate) if estimate else name) + if names: + count = summary.get("candidateCount") + prefix = "{} 个候选方案 ".format(count) if language == "zh" else "{} candidates ".format(count) + parts.append(prefix + "、".join(names)) + return sanitize_text((";" if language == "zh" else "; ").join(parts), 520) + + +def _format_user_update(milestone: Dict[str, Any], language: str) -> str: + event_type = milestone.get("eventType") + detail = _coordinate_label(milestone) or sanitize_text(milestone.get("message"), 240) + labels = { + "zh": { + "step_started": "步骤开始", + "step_completed": "步骤完成", + "step_failed": "步骤失败", + "candidate_step_started": "候选步骤开始", + "candidate_step_completed": "候选步骤完成", + "candidate_step_failed": "候选步骤失败", + }, + "en": { + "step_started": "Step started", + "step_completed": "Step completed", + "step_failed": "Step failed", + "candidate_step_started": "Candidate step started", + "candidate_step_completed": "Candidate step completed", + "candidate_step_failed": "Candidate step failed", + }, + } + label = labels.get(language, labels["en"]).get(str(event_type), sanitize_text(str(event_type), 80)) + separator = ":" if language == "zh" else ": " + conclusion = _format_conclusion(milestone.get("conclusionSummary"), language) + if conclusion: + detail = "{}{}{}".format(detail, ";结论:" if language == "zh" else "; conclusion: ", conclusion) + return sanitize_text(label + (separator + detail if detail else ""), 720) + + +def _bound_follow_result(result: Dict[str, Any]) -> Dict[str, Any]: + while len(_json_bytes(result)) > MAX_FOLLOW_BYTES: + milestones = result.get("milestones") + artifacts = result.get("artifacts") + if isinstance(milestones, list) and len(milestones) > 1: + milestones.pop(0) + elif isinstance(artifacts, list) and len(artifacts) > 1: + artifacts.pop(0) + elif isinstance(result.get("latestText"), str): + result["latestText"] = _truncate_utf8(result["latestText"], 300) + elif isinstance(result.get("finalText"), str) and len(result["finalText"].encode("utf-8")) > 2000: + result["finalText"] = _truncate_utf8(result["finalText"], 2000) + result["finalTextComplete"] = False + else: + raise BridgeError("stream_failed", "The ROS Agent follow result exceeded its bounded protocol.") + return result + + +def _job_result( + job_id: str, + start_cursor: int, + end_cursor: Optional[int] = None, + boundary_reached: bool = False, + timed_out: bool = False, +) -> Dict[str, Any]: + _root, job_path, spool = _job_paths(job_id) + job = _load_state_json(job_path) + values = _read_spool(spool) + start = max(0, int(start_cursor)) + end = len(values) if end_cursor is None else min(len(values), max(start, int(end_cursor))) + unseen = values[start:end] + milestones = [] + folded = {} # type: Dict[str, int] + seen = set() + for item in unseen: + item_seq = item.get("requestSeq") + if isinstance(item_seq, int) and item_seq != job.get("activeRequestSeq"): + folded["stale_request_event"] = folded.get("stale_request_event", 0) + 1 + continue + for milestone in item.get("milestones", []): + if not isinstance(milestone, dict): + continue + signature = _json_bytes(milestone) + if signature in seen: + folded["duplicate_milestone"] = folded.get("duplicate_milestone", 0) + 1 + continue + seen.add(signature) + milestones.append(milestone) + job_state = str(job.get("state") or "unknown") + has_result_gate = job_state in TERMINAL_STATES | {"turn-completed", "failed"} or isinstance( + job.get("inputRequired"), dict + ) + state = job_state if has_result_gate else ("working" if boundary_reached else job_state) + result = { + "ok": state != "failed" and not isinstance(job.get("sidebandError"), dict), + "jobId": job_id, + "state": state, + "mode": job.get("mode"), + "preferredLanguage": job.get("preferredLanguage", "en"), + "cursor": end, + "turn": int(job.get("turn") or 1), + "milestones": milestones[-MAX_FOLLOW_EVENTS:], + "folded": folded, + } # type: Dict[str, Any] + for key in ("sessionId", "taskId", "iacCodeSessionId", "requestId", "wireState"): + if isinstance(job.get(key), str): + result[key] = job[key] + if job.get("conversationMode") in SUPPORTED_AGENT_MODES: + result["conversationMode"] = job["conversationMode"] + if job.get("normalHandoffReady") is True: + result["normalHandoffReady"] = True + if isinstance(job.get("permissionWait"), dict): + result["permissionWait"] = job["permissionWait"] + result["presentationRequired"] = True + if isinstance(job.get("permissionRecovered"), dict): + result["permissionRecovered"] = job["permissionRecovered"] + result["presentationRequired"] = True + if boundary_reached: + updates = [ + _format_user_update(value, result["preferredLanguage"]) + for value in result["milestones"] + if value.get("eventType") in STEP_BOUNDARY_EVENT_TYPES + ] + if updates: + result["boundaryReached"] = True + result["presentationRequired"] = True + result["userUpdates"] = updates + artifacts = list(job.get("artifacts") or [])[-MAX_FOLLOW_EVENTS:] + if artifacts and not timed_out and (not boundary_reached or has_result_gate): + result["artifacts"] = artifacts + if isinstance(job.get("inputRequired"), dict): + result["inputRequired"] = _permission_with_ref(job["inputRequired"]) + if isinstance(job.get("pendingPermissions"), list): + result["pendingPermissions"] = [ + _permission_with_ref(value) for value in job["pendingPermissions"] if isinstance(value, dict) + ] + result["presentationRequired"] = True + if state == "turn-completed": + result["finalText"] = job.get("finalText", "") + result["finalTextComplete"] = job.get("finalTextComplete") is True + result["presentationRequired"] = True + if state in TERMINAL_STATES and isinstance(job.get("pipelineResult"), dict): + result["pipelineResult"] = job["pipelineResult"] + result["presentationRequired"] = True + if state == "failed" and isinstance(job.get("error"), dict): + result["error"] = job["error"] + result["presentationRequired"] = True + elif isinstance(job.get("sidebandError"), dict): + result["error"] = job["sidebandError"] + result["presentationRequired"] = True + if isinstance(job.get("permissionAck"), dict): + result["permissionAck"] = job["permissionAck"] + if state == "permission-responded": + result["presentationRequired"] = True + if timed_out: + elapsed = max(0, int(time.time()) - int(job.get("turnStartedAt") or job.get("createdAt") or time.time())) + result["followTimedOut"] = True + result["heartbeat"] = ( + "ROS Agent 仍在处理中({} 秒)。".format(elapsed) + if result["preferredLanguage"] == "zh" + else "ROS Agent is still working ({}s).".format(elapsed) + ) + result["presentationRequired"] = True + if isinstance(job.get("latestText"), str): + result["latestText"] = job["latestText"] + return _bound_follow_result(result) + + +def _follow_ready_result(job_id: str, start_cursor: int) -> Tuple[Optional[Dict[str, Any]], Dict[str, Any]]: + root, job_path, spool = _job_paths(job_id) + with StateLock(root / ".job.lock"): + values = _read_spool(spool) + job = _load_state_json(job_path) + has_step_boundary = any( + isinstance(milestone, dict) and milestone.get("eventType") in STEP_BOUNDARY_EVENT_TYPES + for item in values[start_cursor:] + for milestone in item.get("milestones", []) + ) + state = job.get("state") + if ( + has_step_boundary + or state in TERMINAL_STATES | {"turn-completed", "failed"} + or isinstance(job.get("inputRequired"), dict) + or isinstance(job.get("sidebandError"), dict) + ): + return ( + _job_result( + job_id, + start_cursor, + len(values), + boundary_reached=has_step_boundary, + ), + job, + ) + if state == "permission-responded" and job.get("pendingPermissions"): + return _job_result(job_id, start_cursor, len(values)), job + return None, job + + +def _follow_job_local(job_id: str, cursor: int, wait_seconds: float) -> Dict[str, Any]: + root = _job_paths(job_id)[0] + _secure_directory(root) + wait_seconds = max(0.0, min(float(wait_seconds), MAX_FOLLOW_SECONDS)) + deadline = time.monotonic() + wait_seconds + start_cursor = max(0, int(cursor)) + while True: + ready_result, job = _follow_ready_result(job_id, start_cursor) + if ready_result is not None: + return ready_result + state = job.get("state") + sideband_worker_pid = job.get("sidebandWorkerPid") + sideband_worker_token = job.get("sidebandWorkerToken") + if ( + isinstance(sideband_worker_pid, int) + and isinstance(sideband_worker_token, str) + and not _pid_alive(sideband_worker_pid) + ): + error = BridgeError( + "worker_exited", "The Pipeline permission response worker exited before acknowledgement.", True + ) + _fail_sideband_job( + job_id, + int(job.get("activeRequestSeq") or 0), + sideband_worker_token, + error, + sideband_worker_pid, + ) + continue + worker_pid = job.get("workerPid") + if isinstance(worker_pid, int) and not _pid_alive(worker_pid): + error = BridgeError("worker_exited", "The StartChat worker exited before reaching a boundary.", True) + _fail_job( + job_id, + int(job.get("activeRequestSeq") or 0), + error, + worker_pid, + expected_worker_pid=worker_pid, + ) + continue + if state == "permission-responded" and not isinstance(worker_pid, int): + error = BridgeError( + "stream_detached", + "Permission was accepted, but the StartChat stream ended before the next Pipeline boundary.", + True, + ) + _fail_job( + job_id, + int(job.get("activeRequestSeq") or 0), + error, + 0, + expected_worker_pid=0, + ) + continue + if time.monotonic() >= deadline: + timeout_result = _follow_timeout_result(job_id, start_cursor) + if timeout_result is None: + continue + return timeout_result + time.sleep(0.1) + + +def _run_start_chat( + args: argparse.Namespace, + workspace: pathlib.Path, + prompt: str, + client_context: Optional[str], + attachments: List[Dict[str, str]], +) -> Dict[str, Any]: + return _consume_start_chat(args, workspace, prompt, client_context, attachments) + + +def _consume_start_chat( + args: argparse.Namespace, + workspace: pathlib.Path, + prompt: str, + client_context: Optional[str], + attachments: List[Dict[str, str]], + *, + summary_mode: Optional[str] = None, + on_payload: Optional[Any] = None, +) -> Dict[str, Any]: + summary = StreamSummary(args.session_id, mode=summary_mode or args.mode) + diagnostics = [] # type: List[str] + + if getattr(args, "transport", "aliyun_cli") == "code": + response = _open_code_request( + "StartChat", + build_start_chat_parameters(args, prompt, client_context, attachments), + str(args.endpoint), + args.profile, + args.region_id, + args.aliyun_path, + int(args.connect_timeout), + int(args.read_timeout), + credential_source=getattr(args, "credential_source", None), + ) + try: + content_type = str(response.headers.get("Content-Type", "")).lower() + if "text/event-stream" not in content_type: + raw = response.read(MAX_DIAGNOSTIC_BYTES + 1) + detail = sanitize_text(raw.decode("utf-8", "replace"), 2000) + raise BridgeError( + "stream_failed", + detail or "Alibaba Cloud ROS StartChat did not return an SSE stream.", + True, + ) + for payload, raw in iter_sse_payloads(_response_text_lines(response)): + if payload is None: + summary.malformed_event_count += 1 + if raw: + diagnostics.append(raw) + continue + summary.apply(payload) + if on_payload is not None: + on_payload(payload, summary) + except BridgeError: + raise + except Exception as exc: + raise BridgeError( + "stream_failed", + "Alibaba Cloud ROS StartChat stream ended unexpectedly.", + True, + ) from exc + finally: + response.close() + return summary.to_result(0, "\n".join(diagnostics)) + + command = build_command(args, prompt, client_context, attachments) + with tempfile.TemporaryFile(mode="w+b") as stderr_file: + try: + process = subprocess.Popen( + command, + cwd=str(workspace), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=stderr_file, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError as exc: + raise BridgeError("cli_start_failed", "Alibaba Cloud CLI could not be started.", True) from exc + assert process.stdout is not None + try: + for payload, raw in iter_sse_payloads(process.stdout): + if payload is None: + summary.malformed_event_count += 1 + if raw: + diagnostics.append(raw) + else: + summary.apply(payload) + if on_payload is not None: + on_payload(payload, summary) + return_code = process.wait() + except KeyboardInterrupt as exc: + _stop_process(process) + raise BridgeError( + "interrupted", + "StartChat was interrupted locally; remote cancellation is not confirmed.", + ) from exc + except BaseException: + _stop_process(process) + raise + finally: + process.stdout.close() + stderr_file.seek(0) + stderr_text = stderr_file.read(MAX_DIAGNOSTIC_BYTES).decode("utf-8", "replace") + if diagnostics and not stderr_text: + stderr_text = "\n".join(diagnostics) + return summary.to_result(return_code, stderr_text) + + +def run_chat(args: argparse.Namespace) -> Dict[str, Any]: + workspace = _workspace(args.cwd) + prompt = read_prompt(workspace, args.prompt_file) + client_context = load_client_context(workspace, args.client_context_file) + attachments = load_attachments(workspace, args.attachments_file) + return _run_start_chat(args, workspace, prompt, client_context, attachments) + + +def run_respond(args: argparse.Namespace) -> Dict[str, Any]: + workspace = _workspace(args.cwd) + query, response = load_permission_query( + workspace, + args.input_file, + args.decision, + args.session_id, + args.mode, + ) + # Keep the Query as the sole control payload. ClientContext and attachments + # would cause the ROS gateway to wrap or augment the text before A2A delivery. + result = _run_start_chat(args, workspace, query, None, []) + result["permissionResponse"] = response + return _bound_result(result) + + +def _stop_process(process: Any) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int: + root, job_path, _spool = _job_paths(job_id) + request_path = root / ("request-{}.json".format(uuid.uuid4().hex)) + _atomic_json(request_path, request) + command = [ + sys.executable, + str(pathlib.Path(__file__).resolve()), + "_worker", + "--job-id", + job_id, + "--request-file", + str(request_path), + ] + log_path = root / "worker.log" + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 + if log_path.exists() and log_path.stat().st_size > MAX_DIAGNOSTIC_BYTES: + with log_path.open("wb"): + pass + try: + with log_path.open("ab", buffering=0) as log: + if os.name != "nt": + os.chmod(str(log_path), 0o600) + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=log, + stderr=log, + start_new_session=os.name != "nt", + creationflags=creationflags, + ) + except OSError as exc: + with contextlib.suppress(OSError): + request_path.unlink() + request_seq = int(request.get("requestSeq") or 0) + error = BridgeError("worker_start_failed", "The StartChat worker could not be started.", True) + worker_token = request.get("workerToken") + if request.get("workerRole") == "sideband" and isinstance(worker_token, str): + _fail_sideband_job(job_id, request_seq, worker_token, error, 0) + else: + _fail_job(job_id, request_seq, error, 0) + raise BridgeError("worker_start_failed", "The StartChat worker could not be started.", True) from exc + with StateLock(root / ".job.lock"): + job = _load_state_json(job_path) + if request.get("workerRole") == "sideband": + job["sidebandWorkerPid"] = process.pid + job["sidebandWorkerStartedAt"] = int(time.time()) + else: + job["workerPid"] = process.pid + job["workerStartedAt"] = int(time.time()) + _atomic_json(job_path, job) + return process.pid + + +def _request_from_job(job: Dict[str, Any], prompt: str) -> Dict[str, Any]: + return { + "requestSeq": job["activeRequestSeq"], + "workspace": job["workspace"], + "prompt": prompt, + "mode": job["mode"], + "summaryMode": job.get("conversationMode") or job["mode"], + "endpoint": job["endpoint"], + # Jobs created before transport selection existed used the native CLI. + "transport": job.get("transport", "aliyun_cli"), + "sessionId": job.get("sessionId"), + "regionId": job.get("regionId"), + "profile": job.get("profile"), + "credentialSource": job.get("credentialSource"), + "noThinking": job.get("noThinking") is True, + "connectTimeout": job.get("connectTimeout", 10), + "readTimeout": job.get("readTimeout", DEFAULT_READ_TIMEOUT_SECONDS), + "aliyunPath": job.get("aliyunPath", "aliyun"), + "clientContext": None, + "attachments": [], + } + + +def _start_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: + workspace = _workspace(str(payload.get("workspace") or "")) + prompt = payload.get("prompt") + mode = payload.get("mode") + endpoint = payload.get("endpoint") + transport = payload.get("transport", DEFAULT_TRANSPORT) + if not isinstance(prompt, str) or not prompt.strip() or len(prompt.encode("utf-8")) > MAX_PROMPT_BYTES: + raise BridgeError("invalid_input", "The StartChat prompt is empty or too large.") + if mode not in SUPPORTED_AGENT_MODES: + raise BridgeError("invalid_input", "The ROS Agent mode is invalid.") + if not isinstance(endpoint, str): + raise BridgeError("invalid_input", "The ROS endpoint is invalid.") + _endpoint_kind(endpoint) + if transport not in SUPPORTED_TRANSPORTS: + raise BridgeError("invalid_input", "The ROS transport is invalid.") + aliyun_path = str(payload.get("aliyunPath") or "aliyun") + if transport == "aliyun_cli": + resolve_aliyun(aliyun_path) + else: + _load_code_sdk() + job_id = uuid.uuid4().hex + root, job_path, spool = _job_paths(job_id) + _secure_directory(root) + spool.touch() + if os.name != "nt": + os.chmod(str(spool), 0o600) + job = { + "schemaVersion": JOB_SCHEMA_VERSION, + "jobId": job_id, + "workspace": str(workspace), + "mode": mode, + "endpoint": endpoint, + "transport": transport, + "regionId": payload.get("regionId"), + "profile": payload.get("profile"), + "credentialSource": payload.get("credentialSource"), + "noThinking": payload.get("noThinking") is True, + "connectTimeout": int(payload.get("connectTimeout") or 10), + "readTimeout": int(payload.get("readTimeout") or DEFAULT_READ_TIMEOUT_SECONDS), + "aliyunPath": aliyun_path, + "preferredLanguage": _preferred_language(prompt), + "state": "submitted", + "turn": 1, + "activeRequestSeq": 1, + "createdAt": int(time.time()), + "turnStartedAt": int(time.time()), + "artifacts": [], + } # type: Dict[str, Any] + _atomic_json(job_path, job) + request = _request_from_job(job, prompt) + request["clientContext"] = payload.get("clientContext") + request["attachments"] = payload.get("attachments") if isinstance(payload.get("attachments"), list) else [] + worker_pid = _spawn_worker(job_id, request) + return { + "ok": True, + "jobId": job_id, + "state": "submitted", + "mode": mode, + "preferredLanguage": job["preferredLanguage"], + "cursor": 0, + "turn": 1, + "workerPid": worker_pid, + } + + +def _continue_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: + job_id = str(payload.get("jobId") or "") + root, job_path, spool = _job_paths(job_id) + with StateLock(root / ".job.lock"): + job = _load_state_json(job_path) + prompt = payload.get("prompt") + if not isinstance(prompt, str): + prompt_file = payload.get("promptFile") + if not isinstance(prompt_file, str): + raise BridgeError("invalid_input", "continue requires a prompt file.") + prompt = read_prompt(pathlib.Path(job["workspace"]), prompt_file) + if not prompt.strip() or len(prompt.encode("utf-8")) > MAX_PROMPT_BYTES: + raise BridgeError("invalid_input", "The continuation prompt is empty or too large.") + if isinstance(job.get("workerPid"), int) and _pid_alive(job["workerPid"]): + raise BridgeError("job_busy", "The current StartChat request is still running.", True) + session_id = job.get("sessionId") + if not isinstance(session_id, str) or not session_id: + raise BridgeError("job_not_ready", "The ROS Agent job has not received a SessionId yet.", True) + pending = job.get("inputRequired") + if isinstance(pending, dict) and pending.get("kind") == "permission": + raise BridgeError("input_response_mismatch", "A permission must be answered with respond, not continue.") + pipeline_handoff = ( + job.get("mode") == "pipeline" + and job.get("state") == "completed" + and (job.get("normalHandoffReady") is True or job.get("conversationMode") == "normal") + ) + if not isinstance(pending, dict) and job.get("state") != "turn-completed" and not pipeline_handoff: + raise BridgeError( + "input_response_mismatch", "The ROS Agent job is not waiting for a natural-language message." + ) + cursor = len(_read_spool(spool)) + if job.get("state") == "turn-completed" or pipeline_handoff: + job["turn"] = int(job.get("turn") or 1) + 1 + job["turnStartedAt"] = int(time.time()) + job.pop("finalText", None) + job.pop("finalTextComplete", None) + if pipeline_handoff: + previous_task_id = job.pop("taskId", None) + if isinstance(previous_task_id, str): + history = job.setdefault("taskHistory", []) + if previous_task_id not in history: + history.append(previous_task_id) + job["conversationMode"] = "normal" + job.pop("pipelineResult", None) + job["activeRequestSeq"] = int(job.get("activeRequestSeq") or 0) + 1 + job["state"] = "submitted" + job.pop("inputRequired", None) + job.pop("error", None) + job.pop("permissionAck", None) + _atomic_json(job_path, job) + worker_pid = _spawn_worker(job_id, _request_from_job(job, prompt)) + return { + "ok": True, + "jobId": job_id, + "state": "submitted", + "mode": job["mode"], + "conversationMode": job.get("conversationMode") or job["mode"], + "preferredLanguage": job.get("preferredLanguage", "en"), + "cursor": cursor, + "turn": int(job.get("turn") or 1), + "sessionId": job["sessionId"], + "workerPid": worker_pid, + } + + +def _managed_permission_candidates(job: Dict[str, Any]) -> List[Dict[str, Any]]: + candidates = [] # type: List[Dict[str, Any]] + seen_input_ids = set() # type: set + values = [job.get("inputRequired")] + pending_permissions = job.get("pendingPermissions") + if isinstance(pending_permissions, list): + values.extend(pending_permissions) + for value in values: + if not isinstance(value, dict) or value.get("kind") != "permission": + continue + input_id = value.get("inputId") + if not isinstance(input_id, str) or not input_id or input_id in seen_input_ids: + continue + seen_input_ids.add(input_id) + candidates.append(value) + return candidates + + +def _select_managed_permission(job: Dict[str, Any], permission_ref: Any) -> Optional[Dict[str, Any]]: + candidates = _managed_permission_candidates(job) + if permission_ref is None: + if len(candidates) > 1: + raise BridgeError( + "permission_selection_required", + "Multiple permissions are waiting; respond with the permissionRef shown for the selected action.", + ) + return candidates[0] if candidates else None + if not isinstance(permission_ref, str) or not permission_ref: + raise BridgeError("invalid_input", "permissionRef must be a non-empty string.") + matches = [value for value in candidates if _permission_ref(value) == permission_ref] + if len(matches) != 1: + raise BridgeError("input_response_mismatch", "permissionRef does not match a pending permission.") + return matches[0] + + +def _respond_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: + job_id = str(payload.get("jobId") or "") + root, job_path, spool = _job_paths(job_id) + with StateLock(root / ".job.lock"): + job = _load_state_json(job_path) + session_id = job.get("sessionId") + if not isinstance(session_id, str) or not session_id: + raise BridgeError("job_not_ready", "The ROS Agent job has no SessionId.") + decision = payload.get("decision") + if not isinstance(decision, str) or decision not in PERMISSION_DECISIONS: + raise BridgeError("invalid_input", "respond requires allow_once or deny.") + response_mode = job.get("conversationMode") or job["mode"] + pending = job.get("inputRequired") + input_file = payload.get("inputFile") + if isinstance(input_file, str): + workspace = pathlib.Path(job["workspace"]) + query, response = load_permission_query(workspace, input_file, decision, session_id, response_mode) + else: + pending = _select_managed_permission(job, payload.get("permissionRef")) + if pending is None: + last_response = job.get("lastPermissionResponse") + acknowledgement = job.get("permissionAck") + if isinstance(last_response, dict) and decision == last_response.get("decision"): + if _permission_response_is_acknowledged(last_response, acknowledgement): + return { + "ok": True, + "jobId": job_id, + "state": "permission-responded", + "mode": job["mode"], + "preferredLanguage": job.get("preferredLanguage", "en"), + "cursor": len(_read_spool(spool)), + "turn": int(job.get("turn") or 1), + "sessionId": session_id, + "permissionResponse": last_response, + "permissionAck": acknowledgement, + "duplicate": True, + } + raise BridgeError("job_busy", "The permission response is already running.", True) + if isinstance(last_response, dict): + raise BridgeError( + "input_response_mismatch", + "The permission response conflicts with the stored decision.", + ) + raise BridgeError("input_response_mismatch", "The ROS Agent job is not waiting for permission.") + query, response = build_permission_query(pending, decision, session_id, response_mode) + if not isinstance(pending, dict) or pending.get("kind") != "permission": + last_response = job.get("lastPermissionResponse") + acknowledgement = job.get("permissionAck") + if response == last_response and _permission_response_is_acknowledged(response, acknowledgement): + return { + "ok": True, + "jobId": job_id, + "state": "permission-responded", + "mode": job["mode"], + "preferredLanguage": job.get("preferredLanguage", "en"), + "cursor": len(_read_spool(spool)), + "turn": int(job.get("turn") or 1), + "sessionId": session_id, + "permissionResponse": response, + "permissionAck": acknowledgement, + "duplicate": True, + } + if isinstance(last_response, dict) and all( + response.get(key) == last_response.get(key) + for key in ("requestTaskId", "contextId", "inputId", "toolUseId") + ): + raise BridgeError( + "input_response_mismatch", + "The permission response conflicts with the stored decision.", + ) + raise BridgeError("input_response_mismatch", "The ROS Agent job is not waiting for permission.") + for key in ("requestTaskId", "contextId", "inputId", "toolUseId"): + if response.get(key) != pending.get(key): + raise BridgeError( + "input_response_mismatch", "The permission response does not match the pending input." + ) + cursor = len(_read_spool(spool)) + primary_worker_alive = isinstance(job.get("workerPid"), int) and _pid_alive(job["workerPid"]) + sub_pipeline = pending.get("permissionClass") == "sub_pipeline" + sideband = sub_pipeline + worker_token = None # type: Optional[str] + if sideband: + if job.get("mode") != "pipeline": + raise BridgeError("input_response_mismatch", "A Sub Pipeline permission requires Pipeline mode.") + if sub_pipeline and not primary_worker_alive: + raise BridgeError( + "stream_detached", + "The parent Pipeline StartChat stream ended before its Sub Pipeline permission was answered.", + True, + ) + if isinstance(job.get("sidebandWorkerToken"), str): + raise BridgeError("job_busy", "A Pipeline permission response is already running.", True) + worker_token = uuid.uuid4().hex + job["sidebandWorkerToken"] = worker_token + job["sidebandResponseInputId"] = response.get("inputId") + job["sidebandResponse"] = pending + job["state"] = "working" + else: + if primary_worker_alive: + raise BridgeError("job_busy", "The current StartChat request is still running.", True) + job["activeRequestSeq"] = int(job.get("activeRequestSeq") or 0) + 1 + job["state"] = "submitted" + job["lastPermissionResponse"] = response + remaining = [ + value + for value in job.get("pendingPermissions", []) + if isinstance(value, dict) and value.get("inputId") != response.get("inputId") + ] + job.pop("inputRequired", None) + if remaining: + job["pendingPermissions"] = remaining + job["inputRequired"] = remaining[0] + else: + job.pop("pendingPermissions", None) + job.pop("permissionAck", None) + job.pop("sidebandError", None) + job.pop("error", None) + _atomic_json(job_path, job) + request = _request_from_job(job, query) + request["permissionResponse"] = response + if sideband: + request["workerRole"] = "sideband" + request["workerToken"] = worker_token + worker_pid = _spawn_worker(job_id, request) + return { + "ok": True, + "jobId": job_id, + "state": "submitted", + "mode": job["mode"], + "preferredLanguage": job.get("preferredLanguage", "en"), + "cursor": cursor, + "turn": int(job.get("turn") or 1), + "sessionId": session_id, + "workerPid": worker_pid, + "permissionResponse": response, + } + + +def _run_stop_chat(job: Dict[str, Any], session_id: str) -> Dict[str, Any]: + if job.get("transport", "aliyun_cli") == "code": + response = _open_code_request( + "StopChat", + {"AgentVersion": "V2", "SessionId": session_id}, + str(job.get("endpoint") or ""), + job.get("profile") if isinstance(job.get("profile"), str) else None, + job.get("regionId") if isinstance(job.get("regionId"), str) else None, + str(job.get("aliyunPath") or "aliyun"), + max(1, min(int(job.get("connectTimeout") or 10), 30)), + int(STOP_REQUEST_TIMEOUT_SECONDS), + credential_source=( + job.get("credentialSource") if job.get("credentialSource") in {"environment", "profile"} else None + ), + error_code="stop_chat_failed", + ) + try: + raw = response.read(MAX_DIAGNOSTIC_BYTES + 1) + finally: + response.close() + if len(raw) > MAX_DIAGNOSTIC_BYTES: + raise BridgeError("stop_chat_failed", "Alibaba Cloud ROS StopChat response was too large.", True) + stdout = raw.decode("utf-8", "replace") + else: + command = build_stop_command(job, session_id) + try: + completed = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=STOP_REQUEST_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise BridgeError("stop_chat_failed", "Alibaba Cloud CLI could not complete StopChat.", True) from exc + stdout = (completed.stdout or b"").decode("utf-8", "replace") + stderr = (completed.stderr or b"").decode("utf-8", "replace") + if completed.returncode != 0: + raise BridgeError( + "stop_chat_failed", + sanitize_text(stderr, 2000) or "Alibaba Cloud ROS StopChat failed.", + True, + ) + try: + value = json.loads(stdout) + except ValueError as exc: + raise BridgeError("stop_chat_failed", "Alibaba Cloud ROS StopChat returned invalid JSON.", True) from exc + if not isinstance(value, dict): + raise BridgeError("stop_chat_failed", "Alibaba Cloud ROS StopChat returned invalid JSON.", True) + status = value.get("Status", value.get("status")) + returned_session_id = value.get("SessionId", value.get("sessionId", value.get("session_id"))) + if status not in {"Stopped", "Stopping", "NoActiveStream", "Failed"}: + raise BridgeError("stop_chat_failed", "Alibaba Cloud ROS StopChat returned an unknown status.", True) + if returned_session_id not in (None, session_id): + raise BridgeError("stop_chat_failed", "Alibaba Cloud ROS StopChat returned a different SessionId.") + result = {"status": status, "sessionId": session_id} + request_id = value.get("RequestId", value.get("requestId", value.get("request_id"))) + if isinstance(request_id, str) and request_id: + result["requestId"] = request_id + return result + + +def _cancel_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: + job_id = str(payload.get("jobId") or "") + root, job_path, spool = _job_paths(job_id) + deadline = time.monotonic() + STOP_SESSION_WAIT_SECONDS + while True: + job = _load_state_json(job_path) + session_id = job.get("sessionId") + if isinstance(session_id, str) and session_id: + break + if time.monotonic() >= deadline: + raise BridgeError("job_not_ready", "The ROS Agent job has not received a SessionId yet.", True) + time.sleep(0.1) + + stopped = _run_stop_chat(job, session_id) + stop_status = stopped["status"] + with StateLock(root / ".job.lock"): + latest = _load_state_json(job_path) + latest["stopStatus"] = stop_status + latest["stopRequestedAt"] = int(time.time()) + if stop_status == "Stopped": + latest["state"] = "canceled" + latest.pop("inputRequired", None) + latest.pop("pendingPermissions", None) + _atomic_json(job_path, latest) + state_by_status = { + "Stopped": "canceled", + "Stopping": "canceling", + "NoActiveStream": "not-active", + "Failed": "cancel-failed", + } + result = { + "ok": stop_status != "Failed", + "jobId": job_id, + "state": state_by_status[stop_status], + "stopStatus": stop_status, + "mode": latest.get("mode"), + "preferredLanguage": latest.get("preferredLanguage", "en"), + "cursor": len(_read_spool(spool)), + "turn": int(latest.get("turn") or 1), + "sessionId": session_id, + "presentationRequired": True, + } # type: Dict[str, Any] + if latest.get("conversationMode") in SUPPORTED_AGENT_MODES: + result["conversationMode"] = latest["conversationMode"] + if isinstance(stopped.get("requestId"), str): + result["requestId"] = stopped["requestId"] + if stop_status == "Failed": + result["error"] = { + "code": "stop_chat_failed", + "message": "Alibaba Cloud ROS could not stop the active chat.", + "retryable": True, + } + return result + + +def run_worker(job_id: str, request_file: str) -> int: + request_path = pathlib.Path(request_file).resolve() + request = _load_state_json(request_path, "invalid_input") + with contextlib.suppress(OSError): + request_path.unlink() + request_seq = int(request.get("requestSeq") or 0) + worker_pid = os.getpid() + worker_role = request.get("workerRole") + worker_token = request.get("workerToken") + + def fail_worker(error: BridgeError) -> None: + if worker_role == "sideband" and isinstance(worker_token, str): + _fail_sideband_job(job_id, request_seq, worker_token, error, worker_pid) + else: + _fail_job(job_id, request_seq, error, worker_pid) + + args = argparse.Namespace( + aliyun_path=request.get("aliyunPath", "aliyun"), + transport=request.get("transport", "aliyun_cli"), + endpoint=request.get("endpoint"), + connect_timeout=int(request.get("connectTimeout") or 10), + read_timeout=int(request.get("readTimeout") or DEFAULT_READ_TIMEOUT_SECONDS), + profile=request.get("profile"), + credential_source=request.get("credentialSource"), + region_id=request.get("regionId"), + no_thinking=request.get("noThinking") is True, + mode=request.get("mode"), + session_id=request.get("sessionId"), + ) + prompt = request.get("prompt") + if not isinstance(prompt, str): + fail_worker(BridgeError("invalid_input", "The worker prompt is invalid.")) + return 1 + workspace = _workspace(str(request.get("workspace") or "")) + client_context = request.get("clientContext") if isinstance(request.get("clientContext"), str) else None + attachments = request.get("attachments") if isinstance(request.get("attachments"), list) else [] + summary_mode = request.get("summaryMode") if request.get("summaryMode") in SUPPORTED_AGENT_MODES else args.mode + + def project(payload: Dict[str, Any], summary: StreamSummary) -> None: + _append_projection( + job_id, + _project_managed_stream_event( + payload, + summary, + summary_mode, + request_seq, + str(worker_role or "primary"), + worker_token, + ), + ) + + try: + result = _consume_start_chat( + args, + workspace, + prompt, + client_context, + attachments, + summary_mode=summary_mode, + on_payload=project, + ) + except BaseException as exc: + error = exc if isinstance(exc, BridgeError) else BridgeError("stream_failed", str(exc), True) + fail_worker(error) + return 1 + permission_response = request.get("permissionResponse") + if isinstance(permission_response, dict): + result["permissionResponse"] = permission_response + if worker_role == "sideband" and isinstance(worker_token, str): + _finish_sideband_job(job_id, request_seq, worker_token, result, worker_pid) + else: + _finish_job(job_id, request_seq, result, worker_pid) + return 0 if result.get("ok") is True else 1 + + +def _manager_record_path() -> pathlib.Path: + return _state_root() / "manager" / "manager.json" + + +def _manager_activity_path() -> pathlib.Path: + return _state_root() / "manager" / "activity" + + +def _touch_manager_activity() -> None: + path = _manager_activity_path() + with contextlib.suppress(OSError): + _secure_directory(path.parent) + path.touch() + + +def _manager_request( + record: Dict[str, Any], + path: str, + payload: Optional[Dict[str, Any]] = None, + timeout: float = 10.0, +) -> Dict[str, Any]: + url = "http://127.0.0.1:{}{}".format(record.get("port"), path) + data = _json_bytes(payload) if payload is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + str(record.get("token") or "")} + if data is not None: + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers, method="POST" if data is not None else "GET") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read(MAX_MANAGER_REQUEST_BYTES + 1) + except urllib.error.HTTPError as exc: + raw = exc.read(MAX_MANAGER_REQUEST_BYTES + 1) + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeError, ValueError): + value = {} + error = value.get("error") if isinstance(value, dict) else None + if isinstance(error, dict): + raise BridgeError( + str(error.get("code") or "manager_failed"), + sanitize_text(str(error.get("message") or "The local ROS Agent manager rejected the request."), 3000), + error.get("retryable") is True, + ) from exc + raise BridgeError("manager_failed", "The local ROS Agent manager rejected the request.", True) from exc + except (OSError, urllib.error.URLError) as exc: + raise BridgeError("manager_unavailable", "The local ROS Agent manager did not respond.", True) from exc + if len(raw) > MAX_MANAGER_REQUEST_BYTES: + raise BridgeError("manager_failed", "The local ROS Agent manager response exceeded its limit.") + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeError, ValueError) as exc: + raise BridgeError("manager_failed", "The local ROS Agent manager returned invalid JSON.") from exc + if not isinstance(value, dict): + raise BridgeError("manager_failed", "The local ROS Agent manager returned invalid JSON.") + return value + + +def _manager_matches(record: Dict[str, Any]) -> bool: + if ( + record.get("schemaVersion") != MANAGER_SCHEMA_VERSION + or record.get("scriptPath") != str(pathlib.Path(__file__).resolve()) + or not _pid_alive(record.get("pid")) + or not isinstance(record.get("token"), str) + or not isinstance(record.get("generation"), str) + or not isinstance(record.get("port"), int) + ): + return False + try: + health = _manager_request(record, "/health", timeout=2) + except BridgeError: + return False + return ( + health.get("ok") is True + and health.get("generation") == record.get("generation") + and health.get("schemaVersion") == MANAGER_SCHEMA_VERSION + ) + + +def _stop_spawned_process(process: Any) -> None: + if process.poll() is not None: + return + with contextlib.suppress(OSError): + process.terminate() + try: + process.wait(timeout=5) + return + except (OSError, subprocess.TimeoutExpired): + pass + with contextlib.suppress(OSError): + process.kill() + + +def _normalized_manager_idle_seconds(value: Optional[float]) -> float: + idle_seconds = MANAGER_IDLE_SECONDS if value is None else value + if ( + isinstance(idle_seconds, bool) + or not isinstance(idle_seconds, (int, float)) + or idle_seconds != idle_seconds + or idle_seconds <= 0 + or idle_seconds > MAX_MANAGER_IDLE_SECONDS + ): + raise BridgeError("invalid_config", "The manager idle timeout is invalid.") + return float(idle_seconds) + + +def ensure_manager(idle_seconds: Optional[float] = None) -> Dict[str, Any]: + desired_idle_seconds = _normalized_manager_idle_seconds(idle_seconds) + record_path = _manager_record_path() + root = record_path.parent + _secure_directory(root) + with StateLock(root / ".manager.lock"): + if record_path.is_file(): + with contextlib.suppress(BridgeError): + current = _load_state_json(record_path, "manager_unavailable") + if _manager_matches(current): + if current.get("idleSeconds") != desired_idle_seconds: + current["idleSeconds"] = desired_idle_seconds + _atomic_json(record_path, current) + return current + record = { + "schemaVersion": MANAGER_SCHEMA_VERSION, + "scriptPath": str(pathlib.Path(__file__).resolve()), + "generation": uuid.uuid4().hex, + "port": _free_port(), + "token": secrets.token_urlsafe(32), + "pid": 0, + "startedAt": int(time.time()), + "idleSeconds": desired_idle_seconds, + } # type: Dict[str, Any] + _atomic_json(record_path, record) + command = [ + sys.executable, + str(pathlib.Path(__file__).resolve()), + "_server", + "--record-file", + str(record_path), + ] + log_path = root / "manager.log" + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 + process = None + ready = False + try: + with log_path.open("ab", buffering=0) as log: + process = subprocess.Popen( + command, + cwd=str(root), + stdin=subprocess.DEVNULL, + stdout=log, + stderr=log, + start_new_session=os.name != "nt", + creationflags=creationflags, + ) + record["pid"] = process.pid + record["logPath"] = str(log_path) + _atomic_json(record_path, record) + deadline = time.monotonic() + MANAGER_START_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if process.poll() is not None: + break + if _manager_matches(record): + ready = True + return record + time.sleep(0.1) + raise BridgeError("manager_start_failed", "The local ROS Agent manager failed its health check.", True) + finally: + if process is not None and not ready: + _stop_spawned_process(process) + with contextlib.suppress(OSError): + record_path.unlink() + + +def _active_worker_exists() -> bool: + jobs_root = _state_root() / "jobs" + if not jobs_root.is_dir(): + return False + for path in jobs_root.glob("*/job.json"): + with contextlib.suppress(BridgeError): + job = _load_state_json(path) + if _pid_alive(job.get("workerPid")) or _pid_alive(job.get("sidebandWorkerPid")): + return True + return False + + +class _ManagerServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, address: Tuple[str, int], record: Dict[str, Any]) -> None: + super().__init__(address, _ManagerHandler) + self.record = record + self.last_activity = time.monotonic() + self.activity_mtime_ns = 0 + + +class _ManagerHandler(BaseHTTPRequestHandler): + server: _ManagerServer + protocol_version = "HTTP/1.1" + + def log_message(self, _format: str, *args: Any) -> None: + return + + def _authorized(self) -> bool: + expected = "Bearer " + str(self.server.record.get("token") or "") + supplied = self.headers.get("Authorization", "") + return bool(expected) and secrets.compare_digest(supplied, expected) + + def _write(self, status: int, value: Dict[str, Any]) -> None: + data = _json_bytes(value) + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) + self.wfile.flush() + self.server.last_activity = time.monotonic() + self.close_connection = True + + def do_GET(self) -> None: + if not self._authorized(): + self._write(401, {"ok": False, "error": {"code": "unauthorized", "message": "Unauthorized."}}) + return + self.server.last_activity = time.monotonic() + if self.path != "/health": + self._write(404, {"ok": False, "error": {"code": "not_found", "message": "Not found."}}) + return + self._write( + 200, + { + "ok": True, + "schemaVersion": MANAGER_SCHEMA_VERSION, + "generation": self.server.record.get("generation"), + "pid": os.getpid(), + }, + ) + + def do_POST(self) -> None: + if not self._authorized(): + self._write(401, {"ok": False, "error": {"code": "unauthorized", "message": "Unauthorized."}}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + if length <= 0 or length > MAX_MANAGER_REQUEST_BYTES: + raise BridgeError("invalid_input", "The manager request size is invalid.") + value = json.loads(self.rfile.read(length).decode("utf-8")) + if not isinstance(value, dict): + raise BridgeError("invalid_input", "The manager request must be a JSON object.") + self.server.last_activity = time.monotonic() + if self.path == "/start": + result = _start_job_local(value) + elif self.path == "/continue": + result = _continue_job_local(value) + elif self.path == "/respond": + result = _respond_job_local(value) + elif self.path == "/cancel": + result = _cancel_job_local(value) + elif self.path == "/follow": + result = _follow_job_local( + str(value.get("jobId") or ""), + int(value.get("cursor") or 0), + float(value.get("waitSeconds") or 0), + ) + else: + self._write(404, {"ok": False, "error": {"code": "not_found", "message": "Not found."}}) + return + except BridgeError as exc: + self._write( + 400, + { + "ok": False, + "state": "failed", + "error": { + "code": exc.code, + "message": sanitize_text(exc.message, 3000), + "retryable": exc.retryable, + }, + }, + ) + return + except (TypeError, ValueError, UnicodeError) as exc: + self._write( + 400, + { + "ok": False, + "state": "failed", + "error": {"code": "invalid_input", "message": sanitize_text(str(exc), 1000)}, + }, + ) + return + self._write(200, result) + + +def run_manager_server(record_file: str) -> int: + record_path = pathlib.Path(record_file).resolve() + record = _load_state_json(record_path, "manager_start_failed") + if record.get("scriptPath") != str(pathlib.Path(__file__).resolve()): + raise BridgeError("manager_start_failed", "The manager script identity does not match.") + server = _ManagerServer(("127.0.0.1", int(record["port"])), record) + activity_path = _manager_activity_path() + _touch_manager_activity() + with contextlib.suppress(OSError): + server.activity_mtime_ns = activity_path.stat().st_mtime_ns + server.timeout = 0.5 + try: + while True: + server.handle_request() + with contextlib.suppress(OSError): + activity_mtime_ns = activity_path.stat().st_mtime_ns + if activity_mtime_ns > server.activity_mtime_ns: + server.activity_mtime_ns = activity_mtime_ns + server.last_activity = time.monotonic() + if _active_worker_exists(): + server.last_activity = time.monotonic() + continue + idle_seconds = float(record.get("idleSeconds") or MANAGER_IDLE_SECONDS) + with contextlib.suppress(BridgeError): + latest_record = _load_state_json(record_path, "manager_unavailable") + if latest_record.get("generation") == record.get("generation"): + idle_seconds = _normalized_manager_idle_seconds(latest_record.get("idleSeconds")) + if time.monotonic() - server.last_activity >= idle_seconds: + break + finally: + server.server_close() + return 0 + + +def _run_check_command(command: List[str], required: bool = False) -> Optional[subprocess.CompletedProcess]: + try: + result = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=15, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + if required: + raise BridgeError("cli_check_failed", "Alibaba Cloud CLI could not be checked.", True) from exc + return None + if result.returncode != 0: + if required: + error = (result.stderr or b"").decode("utf-8", "replace") + raise BridgeError("cli_check_failed", sanitize_text(error, 1000) or "Alibaba Cloud CLI check failed.", True) + return None + return result + + +def _parse_profile_fields(output: bytes) -> Dict[str, str]: + values = {} # type: Dict[str, str] + for raw_line in output.decode("utf-8", "replace").splitlines(): + key, separator, raw_value = raw_line.partition("=") + if not separator or key not in {"profile", "mode", "language"}: + continue + value = sanitize_text(raw_value, 200) + if value: + values[key] = value + return values + + +def run_check(args: argparse.Namespace) -> Dict[str, Any]: + sdk = None # type: Optional[Dict[str, Any]] + environment_credentials = None # type: Optional[Tuple[str, str, Optional[str]]] + if args.transport == "code": + sdk = _load_code_sdk() + if not args.profile_pinned: + environment_credentials = _environment_credentials() + + if environment_credentials is not None: + current_profile = {"configured": True, "mode": "Environment"} # type: Dict[str, Any] + current_profile["regionId"] = _environment_region() or "cn-hangzhou" + cli = None + version = None + else: + selected = _selected_cli_profile_record(args.profile) + region_id = _environment_region() or selected.get("regionId") or "cn-hangzhou" + current_profile = {"configured": True, "name": selected["name"], "mode": selected["mode"]} + if selected.get("language"): + current_profile["language"] = selected["language"] + current_profile["regionId"] = region_id + if args.transport == "code": + assert sdk is not None + try: + _code_credentials(sdk, args.aliyun_path, selected["name"], region_id, "profile") + except BridgeError: + raise + except Exception as exc: + raise BridgeError( + "credential_failed", + "Alibaba Cloud SDK could not load or refresh the selected CLI Profile.", + True, + ) from exc + cli = None + version = None + else: + aliyun = resolve_aliyun(args.aliyun_path) + version_result = _run_check_command([aliyun, "version"], required=True) + assert version_result is not None + cli = "aliyun" + version = sanitize_text((version_result.stdout or b"").decode("utf-8", "replace"), 200) + + return { + "ok": True, + "cli": cli, + "version": version, + "transport": args.transport, + "endpoint": args.endpoint, + "allowedAgentModes": args.allowed_agent_modes, + "managerIdleSeconds": args.manager_idle_seconds, + "enableThinking": args.enable_thinking, + "aliyunCLIProfile": args.aliyun_cli_profile, + "currentProfile": current_profile, + } + + +def _follow_after_command(args: argparse.Namespace, result: Dict[str, Any]) -> Dict[str, Any]: + if not getattr(args, "follow", False): + return result + followed = run_follow_job( + argparse.Namespace( + job_id=result["jobId"], + cursor=result["cursor"], + wait_seconds=getattr(args, "follow_seconds", DEFAULT_FOLLOW_SECONDS), + manager_idle_seconds=getattr(args, "manager_idle_seconds", MANAGER_IDLE_SECONDS), + ) + ) + followed["workerPid"] = result.get("workerPid") + return _bound_follow_result(followed) + + +def run_start_job(args: argparse.Namespace) -> Dict[str, Any]: + workspace = _workspace() + prompt = read_prompt(workspace, args.prompt_file) + client_context = load_client_context(workspace, args.client_context_file) + attachments = load_attachments(workspace, args.attachments_file) + _resolve_start_identity(args) + record = ensure_manager(args.manager_idle_seconds) + result = _manager_request( + record, + "/start", + { + "workspace": str(workspace), + "prompt": prompt, + "mode": args.mode, + "transport": args.transport, + "endpoint": args.endpoint, + "regionId": args.region_id, + "profile": args.profile, + "credentialSource": args.credential_source, + "noThinking": args.no_thinking, + "connectTimeout": args.connect_timeout, + "readTimeout": args.read_timeout, + "aliyunPath": args.aliyun_path, + "clientContext": client_context, + "attachments": attachments, + }, + timeout=15, + ) + return _follow_after_command(args, result) + + +def run_follow_job(args: argparse.Namespace) -> Dict[str, Any]: + wait_seconds = max(0.0, min(float(args.wait_seconds), MAX_FOLLOW_SECONDS)) + record = ensure_manager(args.manager_idle_seconds) + return _manager_request( + record, + "/follow", + {"jobId": args.job_id, "cursor": int(args.cursor), "waitSeconds": wait_seconds}, + timeout=wait_seconds + 15, + ) + + +def run_continue_job(args: argparse.Namespace) -> Dict[str, Any]: + record = ensure_manager(args.manager_idle_seconds) + result = _manager_request( + record, + "/continue", + {"jobId": args.job_id, "promptFile": str(pathlib.Path(args.prompt_file).expanduser().resolve())}, + timeout=15, + ) + return _follow_after_command(args, result) + + +def run_respond_job(args: argparse.Namespace) -> Dict[str, Any]: + record = ensure_manager(args.manager_idle_seconds) + result = _manager_request( + record, + "/respond", + { + "jobId": args.job_id, + "inputFile": ( + str(pathlib.Path(args.input_file).expanduser().resolve()) if args.input_file is not None else None + ), + "permissionRef": args.permission_ref, + "decision": args.decision, + }, + timeout=15, + ) + return _follow_after_command(args, result) + + +def run_cancel_job(args: argparse.Namespace) -> Dict[str, Any]: + record = ensure_manager(args.manager_idle_seconds) + return _manager_request( + record, + "/cancel", + {"jobId": args.job_id}, + timeout=STOP_REQUEST_TIMEOUT_SECONDS + 15, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Use Alibaba Cloud ROS Agent through Alibaba Cloud CLI.") + subparsers = parser.add_subparsers(dest="command", required=True) + check = subparsers.add_parser("check", help="Check Alibaba Cloud CLI without calling StartChat.") + check.add_argument("--aliyun-path", default="aliyun") + + start = subparsers.add_parser("start", help="Start a managed StartChat job.") + start.add_argument("--prompt-file", required=True) + start.add_argument("--mode", choices=("normal", "pipeline"), default="normal") + start.add_argument("--region-id") + start.add_argument("--endpoint") + start.add_argument("--profile") + start.add_argument("--client-context-file") + start.add_argument("--attachments-file") + start.add_argument("--no-thinking", action="store_true") + start.add_argument("--connect-timeout", type=int, default=10) + start.add_argument("--read-timeout", type=int, default=DEFAULT_READ_TIMEOUT_SECONDS) + start.add_argument("--aliyun-path", default="aliyun") + start.add_argument("--follow", action="store_true") + start.add_argument("--follow-seconds", type=float, default=DEFAULT_FOLLOW_SECONDS) + + follow = subparsers.add_parser("follow", help="Wait for the next managed StartChat boundary.") + follow.add_argument("--job-id", required=True) + follow.add_argument("--cursor", type=int, default=0) + follow.add_argument("--wait-seconds", type=float, default=DEFAULT_FOLLOW_SECONDS) + + continued = subparsers.add_parser("continue", help="Send a natural-language continuation for a managed job.") + continued.add_argument("--job-id", required=True) + continued.add_argument("--prompt-file", required=True) + continued.add_argument("--follow", action="store_true") + continued.add_argument("--follow-seconds", type=float, default=DEFAULT_FOLLOW_SECONDS) + + respond = subparsers.add_parser("respond", help="Approve or deny a managed StartChat permission.") + respond.add_argument("--job-id", required=True) + respond.add_argument("--permission-ref") + respond.add_argument("--input-file", help=argparse.SUPPRESS) + respond.add_argument("--decision", choices=("allow_once", "deny"), required=True) + respond.add_argument("--follow", action="store_true") + respond.add_argument("--follow-seconds", type=float, default=DEFAULT_FOLLOW_SECONDS) + + cancel = subparsers.add_parser("cancel", help="Stop the remote chat for a managed job.") + cancel.add_argument("--job-id", required=True) + + server = subparsers.add_parser("_server", help=argparse.SUPPRESS) + server.add_argument("--record-file", required=True) + worker = subparsers.add_parser("_worker", help=argparse.SUPPRESS) + worker.add_argument("--job-id", required=True) + worker.add_argument("--request-file", required=True) + return parser + + +def _print_json(value: Dict[str, Any]) -> None: + print(json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)) + + +def main(argv: Optional[List[str]] = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "_server": + return run_manager_server(args.record_file) + if args.command == "_worker": + return run_worker(args.job_id, args.request_file) + apply_skill_config(args, load_skill_config()) + if args.command == "check": + result = run_check(args) + elif args.command == "start": + if args.connect_timeout <= 0 or args.read_timeout <= 0: + raise BridgeError("invalid_input", "Timeout values must be positive integers.") + result = run_start_job(args) + elif args.command == "follow": + result = run_follow_job(args) + elif args.command == "continue": + result = run_continue_job(args) + elif args.command == "cancel": + result = run_cancel_job(args) + else: + result = run_respond_job(args) + except BridgeError as exc: + failure = { + "ok": False, + "state": "failed", + "error": { + "code": exc.code, + "message": sanitize_text(exc.message, 3000), + "retryable": exc.retryable, + }, + } + if args.command != "check": + failure["presentationRequired"] = True + _print_json(failure) + return 1 + _print_json(result) + return 0 if result.get("ok") is True else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/iac-code/SKILL.md b/skills/iac-code/SKILL.md index c18d7b32..c01ca6d0 100644 --- a/skills/iac-code/SKILL.md +++ b/skills/iac-code/SKILL.md @@ -18,7 +18,20 @@ Use the single standard-library entry point at `scripts/iac_code.py`. Never inst Set `` to the user's language code (`en`, `zh`, `es`, `fr`, `de`, `ja`, or `pt`). If it is unknown, use `auto`. Every job result repeats `preferredLanguage`; treat it as durable control state across all turns. Present progress, questions, permissions, candidate plans, and final results in that language; protocol field names, enums, IDs, and commands remain unchanged. When authoritative text already uses `preferredLanguage`, present it directly or summarize it in the same language—never translate Chinese user-visible content into English. - The installer or Skill distributor may place an optional `config.json` beside this `SKILL.md` with `{"channel":""}`. Store only the channel identifier (for example `codex`); the bridge adds the `skill/` prefix before sending `skill/codex` to iac-code. It reads the config automatically when a job starts, binds the normalized channel to the new A2A context, and carries it across normal turns, Pipeline execution and handoff, cleanup, permissions, and user responses. If the file or `channel` field is absent, the bridge sends no channel override. Never derive a channel from the user's request, ask the user for it, or create, edit, or reveal this install-local configuration during an infrastructure task. + The installer or Skill distributor may place an optional `config.json` beside this `SKILL.md`: + + ```json + { + "channel": "codex", + "permissionWaitPolicy": { + "residentTimeoutSeconds": null, + "subPipelineTimeoutSeconds": null, + "timeoutGraceSeconds": 30 + } + } + ``` + + `channel` stores only the channel identifier; the bridge adds the `skill/` prefix before sending it to iac-code. `permissionWaitPolicy` applies only to the temporary A2A server owned by this Skill: `null` timeouts mean unlimited waits, positive finite values set resident/Sub Pipeline limits, and grace is a non-negative finite value. Finite values cannot exceed 10 years; use `null` instead of an arbitrarily large number for an unlimited resident or Sub Pipeline wait. The bridge validates and converts this object into server configuration; it never sends the policy through A2A message metadata. Missing fields use the defaults shown above. The bridge rejects unknown configuration fields. If the file or a field is absent, no corresponding override is applied. Never derive these values from the user's request, ask the user for them, or create, edit, or reveal this install-local configuration during an infrastructure task. Normal is the default, including concrete resource queries/changes, template work, troubleshooting, and deployment of a clear target. Use `--mode pipeline --pipeline-name selling` only when the user explicitly requests it or the request genuinely needs the fixed candidate-architecture, cost-comparison, plan-confirmation, and deployment flow. Questions, permissions, tool use, or deployment alone do not select Pipeline. When uncertain, use normal. Start performs a non-secret configuration preflight through the Runtime. An incomplete LLM provider/API Key returns `llm_not_configured` and stops before creating a job. Selling Pipeline also requires complete Alibaba Cloud credentials and otherwise returns `cloud_credentials_not_configured`. Normal mode may continue without cloud credentials for work that does not call cloud APIs; report its preflight warning rather than claiming cloud operations are available. diff --git a/skills/iac-code/scripts/iac_code.py b/skills/iac-code/scripts/iac_code.py index 3b40889a..9d4dcf1a 100644 --- a/skills/iac-code/scripts/iac_code.py +++ b/skills/iac-code/scripts/iac_code.py @@ -11,6 +11,7 @@ import errno import hashlib import json +import math import os import pathlib import platform @@ -56,6 +57,7 @@ MAX_AUTO_CLEANUP_TASKS_PER_FOLLOW = 4 MAX_CHANNEL_LENGTH = 128 MAX_SKILL_CONFIG_BYTES = 16 * 1024 +MAX_PERMISSION_WAIT_SECONDS = 10 * 365 * 24 * 60 * 60 SKILL_CHANNEL_PREFIX = "skill/" FOLLOW_HEARTBEAT_SECONDS = 12.0 DEFAULT_FOLLOW_SECONDS = 60.0 @@ -791,13 +793,18 @@ def clean_runtime_cache(args): } -def _runtime_key(mode, pipeline_name, target): - identity = "\0".join([RUNTIME_TAG, target, mode, pipeline_name or ""]) +def _runtime_key(mode, pipeline_name, target, permission_wait_policy=None): + identity_parts = [RUNTIME_TAG, target, mode, pipeline_name or ""] + if permission_wait_policy is not None: + identity_parts.append(json.dumps(permission_wait_policy, sort_keys=True, separators=(",", ":"))) + identity = "\0".join(identity_parts) return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24] -def _runtime_record_path(mode, pipeline_name, target): - return _bridge_root() / "servers" / _runtime_key(mode, pipeline_name, target) / "runtime.json" +def _runtime_record_path(mode, pipeline_name, target, permission_wait_policy=None): + return ( + _bridge_root() / "servers" / _runtime_key(mode, pipeline_name, target, permission_wait_policy) / "runtime.json" + ) def _pid_alive(pid): @@ -914,13 +921,14 @@ def _runtime_configuration_readiness(record, require_cloud): return readiness -def _runtime_matches(record, mode, pipeline_name, target): +def _runtime_matches(record, mode, pipeline_name, target, permission_wait_policy=None): expected = { "runtimeTag": RUNTIME_TAG, "iacCodeVersion": IAC_CODE_VERSION, "target": target, "mode": mode, "pipelineName": pipeline_name or "", + "permissionWaitPolicy": permission_wait_policy, } if any(record.get(key) != value for key, value in expected.items()) or not _pid_alive(record.get("pid")): return False @@ -953,7 +961,7 @@ def _runtime_record_for_job(job): record = _load_json(pathlib.Path(record_path), "runtime_identity_mismatch") if record.get("generation") != job.get("runtimeGeneration"): raise BridgeError("runtime_identity_mismatch", "The Skill job runtime generation is no longer active.") - if not _runtime_matches(record, mode, pipeline_name, target): + if not _runtime_matches(record, mode, pipeline_name, target, job.get("permissionWaitPolicy")): raise BridgeError("runtime_identity_mismatch", "The Skill job runtime identity no longer matches.") return record @@ -992,10 +1000,10 @@ def _remove_runtime_record(record_path, generation): record_path.unlink() -def ensure_server(executable, artifact, mode, pipeline_name): +def ensure_server(executable, artifact, mode, pipeline_name, permission_wait_policy=None): runtimes = _bridge_root() / "servers" _secure_directory(runtimes) - key = _runtime_key(mode, pipeline_name, artifact["target"]) + key = _runtime_key(mode, pipeline_name, artifact["target"], permission_wait_policy) root = runtimes / key _secure_directory(root) record_path = root / "runtime.json" @@ -1003,7 +1011,7 @@ def ensure_server(executable, artifact, mode, pipeline_name): if record_path.is_file(): with contextlib.suppress(BridgeError): record = _load_json(record_path, "runtime_identity_mismatch") - if _runtime_matches(record, mode, pipeline_name, artifact["target"]): + if _runtime_matches(record, mode, pipeline_name, artifact["target"], permission_wait_policy): return record token = secrets.token_urlsafe(32) port = _free_port() @@ -1021,6 +1029,12 @@ def ensure_server(executable, artifact, mode, pipeline_name): "log_to_stdout": False, "idle_shutdown_seconds": RUNTIME_IDLE_TIMEOUT_SECONDS, } + if permission_wait_policy is not None: + config["permission_wait"] = { + "resident_timeout_seconds": permission_wait_policy["residentTimeoutSeconds"], + "sub_pipeline_timeout_seconds": permission_wait_policy["subPipelineTimeoutSeconds"], + "timeout_grace_seconds": permission_wait_policy["timeoutGraceSeconds"], + } config_path = root / "a2a.json" _atomic_json(config_path, config) log_path = root / "runtime.log" @@ -1067,6 +1081,7 @@ def ensure_server(executable, artifact, mode, pipeline_name): "generation": generation, "mode": mode, "pipelineName": pipeline_name or "", + "permissionWaitPolicy": permission_wait_policy, "pid": process.pid, "port": port, "token": token, @@ -1078,7 +1093,7 @@ def ensure_server(executable, artifact, mode, pipeline_name): while time.monotonic() < deadline: if process.poll() is not None: break - if _runtime_matches(record, mode, pipeline_name, artifact["target"]): + if _runtime_matches(record, mode, pipeline_name, artifact["target"], permission_wait_policy): ready = True return record time.sleep(0.15) @@ -2063,12 +2078,8 @@ def _append_projection(job_id, projection, turn_text=""): job["cleanupOnlyActive"] = True _append_turn_text(root, job, turn_text) if isinstance(original.get("artifacts"), list): - public_artifacts = [ - _public_workspace_artifact(value, job["workspace"]) for value in original["artifacts"] - ] - job["turnArtifacts"] = _deduplicated_artifacts( - list(job.get("turnArtifacts") or []) + public_artifacts - ) + public_artifacts = [_public_workspace_artifact(value, job["workspace"]) for value in original["artifacts"]] + job["turnArtifacts"] = _deduplicated_artifacts(list(job.get("turnArtifacts") or []) + public_artifacts) if original.get("type") == "input-required": job["inputRequired"] = original.get("inputRequired") job["state"] = "input-required" @@ -2308,9 +2319,13 @@ def _worker_payload(job, prompt=None, response=None, cleanup_only=False): "parts": [{"text": answer if isinstance(answer, str) else json.dumps(answer, ensure_ascii=False)}], } ) - sideband_permission = response is not None and response.get("kind") == "permission" and any( - isinstance(value, dict) and value.get("inputId") == response.get("inputId") - for value in job.get("pendingPermissions", []) + sideband_permission = ( + response is not None + and response.get("kind") == "permission" + and any( + isinstance(value, dict) and value.get("inputId") == response.get("inputId") + for value in job.get("pendingPermissions", []) + ) ) return _jsonrpc_payload( "SendMessage" if sideband_permission else "SendStreamingMessage", @@ -2481,12 +2496,83 @@ def _normalize_telemetry_channel(value): raise BridgeError("skill_configuration_invalid", "The Skill telemetry channel must be a non-empty string.") -def _skill_telemetry_channel(): +def _permission_wait_number(value, name, allow_zero): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise BridgeError( + "skill_configuration_invalid", + "{} must be a finite {} number no greater than {} seconds or null.".format( + name, + "non-negative" if allow_zero else "positive", + MAX_PERMISSION_WAIT_SECONDS, + ), + ) + try: + number = float(value) + except (OverflowError, ValueError) as exc: + raise BridgeError( + "skill_configuration_invalid", + "{} must be a finite number no greater than {} seconds or null.".format( + name, + MAX_PERMISSION_WAIT_SECONDS, + ), + ) from exc + if ( + not math.isfinite(number) + or number < 0 + or (number == 0 and not allow_zero) + or number > MAX_PERMISSION_WAIT_SECONDS + ): + raise BridgeError( + "skill_configuration_invalid", + "{} must be a finite {} number no greater than {} seconds or null.".format( + name, + "non-negative" if allow_zero else "positive", + MAX_PERMISSION_WAIT_SECONDS, + ), + ) + return number + + +def _normalize_permission_wait_policy(value): + if value is None: + return None + if not isinstance(value, dict): + raise BridgeError("skill_configuration_invalid", "permissionWaitPolicy must be a JSON object.") + allowed = {"residentTimeoutSeconds", "subPipelineTimeoutSeconds", "timeoutGraceSeconds"} + unknown = sorted(str(key) for key in value if key not in allowed) + if unknown: + raise BridgeError( + "skill_configuration_invalid", + "Unknown permissionWaitPolicy fields: {}.".format(", ".join(unknown)), + ) + + def optional_timeout(name): + raw = value.get(name) + return None if raw is None else _permission_wait_number(raw, "permissionWaitPolicy." + name, False) + + grace = value.get("timeoutGraceSeconds", 30) + if grace is None: + raise BridgeError( + "skill_configuration_invalid", + "permissionWaitPolicy.timeoutGraceSeconds must be a finite non-negative number.", + ) + return { + "residentTimeoutSeconds": optional_timeout("residentTimeoutSeconds"), + "subPipelineTimeoutSeconds": optional_timeout("subPipelineTimeoutSeconds"), + "timeoutGraceSeconds": _permission_wait_number( + grace, + "permissionWaitPolicy.timeoutGraceSeconds", + True, + ), + } + + +def _skill_config(): config_path = SKILL_ROOT / "config.json" try: encoded = config_path.read_bytes() except FileNotFoundError: - return None + return {"channel": None, "permissionWaitPolicy": None} except OSError as exc: raise BridgeError("skill_configuration_invalid", "The installed Skill config could not be read.") from exc if len(encoded) > MAX_SKILL_CONFIG_BYTES: @@ -2497,9 +2583,20 @@ def _skill_telemetry_channel(): raise BridgeError("skill_configuration_invalid", "The installed Skill config is not valid UTF-8 JSON.") from exc if not isinstance(config, dict): raise BridgeError("skill_configuration_invalid", "The installed Skill config must be a JSON object.") - if config.get("channel") is None: - return None - return _normalize_telemetry_channel(config.get("channel")) + unknown = sorted(str(key) for key in config if key not in {"channel", "permissionWaitPolicy"}) + if unknown: + raise BridgeError( + "skill_configuration_invalid", + "Unknown installed Skill config fields: {}.".format(", ".join(unknown)), + ) + return { + "channel": _normalize_telemetry_channel(config.get("channel")) if config.get("channel") is not None else None, + "permissionWaitPolicy": _normalize_permission_wait_policy(config.get("permissionWaitPolicy")), + } + + +def _skill_telemetry_channel(): + return _skill_config()["channel"] def _identity_result(job_id, job, cursor, worker_pid): @@ -2562,10 +2659,18 @@ def start_job(args): prompt = _read_workspace_prompt(workspace, args.prompt_file) preferred_language = _preferred_language(prompt, args.language) _set_output_language(preferred_language) - channel = _skill_telemetry_channel() + skill_config = _skill_config() + channel = skill_config["channel"] + permission_wait_policy = skill_config["permissionWaitPolicy"] artifact, executable, cache_hit = ensure_runtime() _progress("start", "Starting or reusing the local A2A runtime") - record = ensure_server(executable, artifact, args.mode, args.pipeline_name) + record = ensure_server( + executable, + artifact, + args.mode, + args.pipeline_name, + permission_wait_policy, + ) readiness = _runtime_configuration_readiness( record, require_cloud=args.mode == "pipeline" and args.pipeline_name == "selling", @@ -2576,7 +2681,12 @@ def start_job(args): spool.touch() if os.name != "nt": os.chmod(str(spool), 0o600) - runtime_record = _runtime_record_path(args.mode, args.pipeline_name, artifact["target"]) + runtime_record = _runtime_record_path( + args.mode, + args.pipeline_name, + artifact["target"], + permission_wait_policy, + ) job = { "schemaVersion": 1, "jobId": job_id, @@ -2600,6 +2710,8 @@ def start_job(args): } if channel is not None: job["channel"] = channel + if permission_wait_policy is not None: + job["permissionWaitPolicy"] = permission_wait_policy _atomic_json(job_path, job) payload = _worker_payload(job, prompt=prompt) worker_pid = _spawn_worker(job_id, payload) @@ -2642,11 +2754,20 @@ def _ensure_job_runtime(job_id): if artifact.get("target") != target: raise BridgeError("runtime_identity_mismatch", "The Skill job Runtime target is no longer available.") _progress("start", "Starting or reusing the local A2A runtime") - record = ensure_server(executable, artifact, mode, pipeline_name) - runtime_record = _runtime_record_path(mode, pipeline_name, target) + permission_wait_policy = job.get("permissionWaitPolicy") + record = ensure_server(executable, artifact, mode, pipeline_name, permission_wait_policy) + runtime_record = _runtime_record_path(mode, pipeline_name, target, permission_wait_policy) with InstallLock(root / ".job.lock", timeout=10): current = _load_json(job_path) - immutable = ("runtimeIdentityVersion", "runtimeTag", "target", "mode", "pipelineName", "workspace") + immutable = ( + "runtimeIdentityVersion", + "runtimeTag", + "target", + "mode", + "pipelineName", + "workspace", + "permissionWaitPolicy", + ) if any(current.get(key) != job.get(key) for key in immutable): raise BridgeError("runtime_identity_mismatch", "The Skill job runtime identity changed during recovery.") current["runtimeGeneration"] = record["generation"] @@ -2722,11 +2843,7 @@ def _job_result( current_task_id = job.get("taskId") for item in unseen: item_task_id = item.get("taskId") - if ( - isinstance(current_task_id, str) - and isinstance(item_task_id, str) - and item_task_id != current_task_id - ): + if isinstance(current_task_id, str) and isinstance(item_task_id, str) and item_task_id != current_task_id: folded["stale_task_event"] = folded.get("stale_task_event", 0) + 1 continue if isinstance(item.get("text"), str): @@ -2780,16 +2897,16 @@ def _job_result( not boundary_reached and isinstance(job.get("pipelineResult"), dict) and cleanup_status not in CLEANUP_PENDING_STATES - and ( - (job.get("mode") == "pipeline" and state in TERMINAL_STATES) - or cleanup_status in CLEANUP_TERMINAL_STATES - ) + and ((job.get("mode") == "pipeline" and state in TERMINAL_STATES) or cleanup_status in CLEANUP_TERMINAL_STATES) ): result["pipelineResult"] = job["pipelineResult"] result.pop("latestText", None) - if include_heartbeat and not unseen and state not in TERMINAL_STATES and state not in INPUT_STATES | { - TURN_COMPLETED_STATE - }: + if ( + include_heartbeat + and not unseen + and state not in TERMINAL_STATES + and state not in INPUT_STATES | {TURN_COMPLETED_STATE} + ): elapsed = max(0, int(time.time()) - int(job.get("turnStartedAt", job.get("createdAt", time.time())))) result["heartbeat"] = ( "iac-code 仍在处理中({} 秒)。".format(elapsed) @@ -3325,10 +3442,7 @@ def respond_job(args): input_file = getattr(args, "input_file", None) inline_decision = getattr(args, "decision", None) if input_file: - if any( - getattr(args, key, None) - for key in ("input_id", "tool_use_id", "decision") - ): + if any(getattr(args, key, None) for key in ("input_id", "tool_use_id", "decision")): raise BridgeError( "input_response_mismatch", "Use either an input file or an inline permission decision, not both.", @@ -3415,9 +3529,13 @@ def continue_job(args): "input_response_mismatch", "Only a normal conversation or a completed Pipeline handoff can continue with a new turn.", ) - pipeline_handoff = job.get("mode") == "pipeline" and job.get("state") in TERMINAL_STATES and ( - job.get("normalHandoffReady") is True - or (job.get("state") == "completed" and job.get("pipelineName") in PIPELINE_NORMAL_HANDOFFS) + pipeline_handoff = ( + job.get("mode") == "pipeline" + and job.get("state") in TERMINAL_STATES + and ( + job.get("normalHandoffReady") is True + or (job.get("state") == "completed" and job.get("pipelineName") in PIPELINE_NORMAL_HANDOFFS) + ) ) expected_state = job.get("state") if (job.get("state") != TURN_COMPLETED_STATE and not pipeline_handoff) or isinstance( diff --git a/src/iac_code/a2a/app.py b/src/iac_code/a2a/app.py index 82219881..4b12045f 100644 --- a/src/iac_code/a2a/app.py +++ b/src/iac_code/a2a/app.py @@ -392,6 +392,7 @@ def create_app( supported_interfaces: list[dict[str, str]] | None = None, agent_extensions: object | None = None, auto_approve_permissions: bool = False, + permission_wait: object | None = None, thinking_exposure: object | None = None, idle_shutdown_seconds: float = 0, idle_shutdown_callback: Callable[[], None] | None = None, @@ -423,6 +424,7 @@ def create_app( supported_interfaces=supported_interfaces, agent_extensions=agent_extensions, auto_approve_permissions=auto_approve_permissions, + permission_wait=permission_wait, thinking_exposure=thinking_exposure, ) from iac_code.a2a.pipeline_recovery import A2APipelineRecoveryService @@ -607,6 +609,7 @@ def run_server( push_consumer_name: str | None = None, push_lease_timeout_ms: int = 300_000, auto_approve_permissions: bool = False, + permission_wait: object | None = None, thinking_exposure: object | None = None, idle_shutdown_seconds: float = 0, ) -> None: @@ -678,6 +681,7 @@ def run_server( "push_lease_timeout_ms": push_lease_timeout_ms, "supported_interfaces": supported_interfaces, "auto_approve_permissions": auto_approve_permissions, + "permission_wait": permission_wait, "thinking_exposure": thinking_exposure, } @@ -783,6 +787,7 @@ def request_idle_shutdown() -> None: push_lease_timeout_ms=push_lease_timeout_ms, supported_interfaces=supported_interfaces, auto_approve_permissions=auto_approve_permissions, + permission_wait=permission_wait, thinking_exposure=thinking_exposure, idle_shutdown_seconds=idle_shutdown_seconds, idle_shutdown_callback=request_idle_shutdown, diff --git a/src/iac_code/a2a/backup.py b/src/iac_code/a2a/backup.py index b4917d26..c08a4735 100644 --- a/src/iac_code/a2a/backup.py +++ b/src/iac_code/a2a/backup.py @@ -41,13 +41,14 @@ async def backup_session_async( critical: bool, metrics: Any | None = None, publication_proofs: dict[str, BackupPublicationProof] | None = None, + backup_call: Callable[..., Any] | None = None, ) -> Any | None: failed_recorded = False try: kwargs: dict[str, Any] = {"reason": reason, "critical": critical} if publication_proofs is not None: kwargs["publication_proofs"] = publication_proofs - result = await run_sync_fenced(backup_service.backup_session, cwd, session_id, **kwargs) + result = await run_sync_fenced(backup_call or backup_service.backup_session, cwd, session_id, **kwargs) retry_count = _retry_count(result) if getattr(result, "enabled", False) and not getattr(result, "succeeded", True): message = str( diff --git a/src/iac_code/a2a/events.py b/src/iac_code/a2a/events.py index 82992a0b..fa418155 100644 --- a/src/iac_code/a2a/events.py +++ b/src/iac_code/a2a/events.py @@ -288,6 +288,9 @@ async def publish_stream_event( auto_approve_permissions: bool = False, exposure_types: Any = None, iac_code_session_id: str | None = None, + permission_wait_cwd: str | None = None, + permission_wait_backup_service: Any | None = None, + permission_wait_metrics: Any | None = None, ) -> str | None: enabled_exposure_types = normalize_a2a_exposure_types(exposure_types) @@ -437,56 +440,18 @@ async def publish_stream_event( permission_event = _permission_request_event(event) if permission_event is not None: if permission_input_registry is not None and permission_resolver is None and not auto_approve_permissions: - pending = await permission_input_registry.register( - permission_event, + await publish_interactive_permission_boundary( + event_queue, + permission_event=permission_event, + permission_input_registry=permission_input_registry, task_id=task_id, context_id=context_id, + iac_code_session_id=iac_code_session_id, + permission_wait_cwd=permission_wait_cwd, + permission_wait_backup_service=permission_wait_backup_service, + permission_wait_metrics=permission_wait_metrics, + wait_for_response=True, ) - try: - await _enqueue_status( - event_queue, - task_id=task_id, - context_id=context_id, - state=TaskState.TASK_STATE_INPUT_REQUIRED, - metadata={ - "iac_code": { - "input": pending.envelope(), - "permission": { - "autoApproved": False, - "pending": True, - "toolName": permission_event.tool_name, - "toolUseId": permission_event.tool_use_id, - }, - } - }, - iac_code_session_id=iac_code_session_id, - ) - future = permission_event.response_future - if future is None: - await permission_input_registry.fail(pending) - return None - await asyncio.shield(future) - await _enqueue_status( - event_queue, - task_id=task_id, - context_id=context_id, - state=TaskState.TASK_STATE_WORKING, - metadata={ - "iac_code": { - "inputReceived": { - "kind": "permission", - "inputId": pending.input_id, - "toolUseId": permission_event.tool_use_id, - } - } - }, - iac_code_session_id=iac_code_session_id, - ) - except BaseException: - await permission_input_registry.fail(pending) - raise - finally: - await permission_input_registry.complete(pending) return None approved = auto_approve_permissions if permission_resolver is not None: @@ -571,6 +536,107 @@ async def publish_stream_event( return None +async def publish_interactive_permission_boundary( + event_queue: Any, + *, + permission_event: PermissionRequestEvent, + permission_input_registry: Any, + task_id: str, + context_id: str, + iac_code_session_id: str | None, + permission_wait_cwd: str | None, + permission_wait_backup_service: Any | None, + permission_wait_metrics: Any | None = None, + wait_for_response: bool, +) -> Any: + """Publish one real external permission wait, optionally detaching Normal SSE.""" + + pending = await permission_input_registry.register( + permission_event, + task_id=task_id, + context_id=context_id, + ) + try: + if ( + iac_code_session_id is not None + and permission_wait_cwd is not None + and permission_wait_backup_service is not None + ): + await permission_input_registry.open_durable_boundary( + pending, + cwd=permission_wait_cwd, + session_id=iac_code_session_id, + permission_class="normal", + backup_service=permission_wait_backup_service, + metrics=permission_wait_metrics, + ) + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + metadata={ + "iac_code": { + "input": pending.envelope(), + "permission": { + "autoApproved": False, + "pending": True, + "toolName": permission_event.tool_name, + "toolUseId": permission_event.tool_use_id, + }, + } + }, + iac_code_session_id=iac_code_session_id, + ) + if not wait_for_response: + return pending + future = permission_event.response_future + if future is None: + await permission_input_registry.fail(pending) + return pending + outcome = await asyncio.shield(future) + from iac_code.types.stream_events import PermissionWaitOutcome + + if outcome is PermissionWaitOutcome.SUSPEND: + return pending + await publish_permission_input_received( + event_queue, + pending=pending, + iac_code_session_id=iac_code_session_id, + ) + return pending + except BaseException: + await permission_input_registry.fail(pending) + raise + finally: + if wait_for_response: + await permission_input_registry.complete(pending) + + +async def publish_permission_input_received( + event_queue: Any, + *, + pending: Any, + iac_code_session_id: str | None, +) -> None: + await _enqueue_status( + event_queue, + task_id=pending.task_id, + context_id=pending.context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "inputReceived": { + "kind": "permission", + "inputId": pending.input_id, + "toolUseId": pending.request.tool_use_id, + } + } + }, + iac_code_session_id=iac_code_session_id, + ) + + def _emit_auto_permission_audit( request: PermissionRequestEvent, approved: bool, @@ -601,6 +667,8 @@ def _emit_resolver_permission_audit( *, persistence_failure: bool = False, ) -> bool: + if request.permission_decision_audited and not persistence_failure: + return True source = "a2a_resolver" reason_type = "a2a_resolver" reason_detail = "allow" if approved else "deny" diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 226a2d44..02cf6175 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -21,7 +21,9 @@ from iac_code.a2a.events import ( iac_code_session_metadata, make_text_part, + publish_interactive_permission_boundary, publish_mcp_warnings, + publish_permission_input_received, publish_stream_event, with_iac_code_session_metadata, ) @@ -29,6 +31,7 @@ from iac_code.a2a.input_required import ( PermissionInputRegistry, PermissionResponse, + backup_permission_wait_checkpoint, parse_permission_response, permission_ack_message, ) @@ -98,6 +101,14 @@ from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime from iac_code.services.capabilities.multimodal import is_model_multimodal +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + RecoveredPermissionAuditBoundary, + canonical_digest, + permission_execution_identity, + recover_permission_audit_boundary, +) +from iac_code.services.permissions.audit import emit_permission_boundary_audit from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential from iac_code.services.session_backup import ( BackupReason, @@ -112,7 +123,13 @@ ) from iac_code.services.session_storage import SessionStorage from iac_code.services.telemetry.attributes import normalize_telemetry_channel -from iac_code.types.stream_events import MessageEndEvent, MessageStartEvent, TextDeltaEvent +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + PermissionRequestEvent, + PermissionWaitSuspended, + TextDeltaEvent, +) from iac_code.utils.file_security import atomic_write_text, ensure_private_dir, ensure_private_file from iac_code.utils.public_errors import sanitize_strict_text from iac_code.utils.public_paths import build_public_path_roots @@ -1007,6 +1024,7 @@ def __init__( permission_resolver: A2APermissionResolver | None = None, permission_input_registry: PermissionInputRegistry | None = None, auto_approve_permissions: bool = False, + permission_wait_policy: Any | None = None, thinking_exposure_types: Any = None, backup_service: Any | None = None, ) -> None: @@ -1018,6 +1036,12 @@ def __init__( self._permission_resolver = permission_resolver self._permission_input_registry = permission_input_registry or PermissionInputRegistry() self._auto_approve_permissions = auto_approve_permissions + from iac_code.services.permission_wait import PermissionWaitCoordinator, PermissionWaitPolicy + + self._permission_wait_policy = permission_wait_policy or PermissionWaitPolicy() + self._permission_wait_coordinator = PermissionWaitCoordinator(self._permission_wait_policy) + self._permission_input_registry.set_permission_wait_coordinator(self._permission_wait_coordinator) + self._task_store.set_permission_wait_active_probe(self._permission_wait_coordinator.has_live_owners) self._thinking_exposure_types = normalize_a2a_exposure_types(thinking_exposure_types) self._metadata_echo_redactor = A2AMetadataEchoRedactor() self._backup_service = backup_service or SessionBackupService() @@ -1048,7 +1072,67 @@ async def _execute(self, context: RequestContext, event_queue: EventQueue, *, co task_id = requested_task_id or "task-" + uuid.uuid4().hex[:12] permission_response = parse_permission_response(getattr(context, "message", None)) if permission_response is not None: - approved = await self._permission_input_registry.answer(permission_response) + try: + pending = await self._permission_input_registry.pending_for_response(permission_response) + approved = await self._permission_input_registry.answer(permission_response) + except InvalidParamsError: + if await self._resume_persisted_permission( + context, + event_queue, + response=permission_response, + ): + return + raise + if pending.state == "suspended_decision_claimed": + if pending.boundary_id is not None: + owner_released = await self._permission_wait_coordinator.wait_for_suspended_owner( + pending.boundary_id + ) + if not owner_released: + await self._publish_status( + event_queue, + task_id=permission_response.task_id, + context_id=permission_response.context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "permissionAck": { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": permission_response.input_id, + "toolUseId": permission_response.tool_use_id, + "decision": "allow_once" if approved else "deny", + "accepted": True, + "recoveryPending": True, + }, + "permissionWait": {"status": "suspending", "resumable": True}, + } + }, + ) + # Keep this single correlated response alive while the + # old owner finishes cleanup. Each wait is bounded and + # holds no resolution/file lock; once owner completion + # arrives this same request performs the one recovery, + # so the user never has to repeat an accepted decision. + while not await self._permission_wait_coordinator.wait_for_suspended_owner(pending.boundary_id): + pass + await self._permission_input_registry.complete(pending) + if await self._resume_persisted_permission( + context, + event_queue, + response=permission_response, + ): + return + raise InvalidParamsError("permission_resume_invalid: suspended permission is unavailable.") + continuation = await self._permission_input_registry.claim_continuation(pending) + if continuation is not None: + await publish_permission_input_received( + event_queue, + pending=pending, + iac_code_session_id=None, + ) + await continuation(event_queue, pending) + return await self._publish_status( event_queue, task_id=permission_response.task_id, @@ -1061,10 +1145,23 @@ async def _execute(self, context: RequestContext, event_queue: EventQueue, *, co "inputId": permission_response.input_id, "toolUseId": permission_response.tool_use_id, "decision": "allow_once" if approved else "deny", - } + }, + "permissionAck": { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": permission_response.input_id, + "toolUseId": permission_response.tool_use_id, + "decision": "allow_once" if approved else "deny", + "accepted": True, + }, } }, ) + # A live top-level Pipeline reply is delivered on a separate + # StartChat/A2A reentry stream while progress remains owned by the + # parent stream. Return the same compact acknowledgement used by + # sideband permissions so that the reentry caller can prove its + # correlated decision was accepted without taking over progress. return task = None initial_task_published = False @@ -1295,7 +1392,20 @@ def runtime_factory(session_id: str) -> Any: resume_messages = None if session_storage.exists(cwd, session_id): loaded = session_storage.load(cwd, session_id) - resume_messages = SessionStorage.repair_interrupted(loaded) if loaded else None + has_permission_checkpoint = False + try: + has_permission_checkpoint = bool( + PermissionWaitCheckpointStore(cwd, session_id, storage=session_storage).list_active() + ) + except ValueError: + has_permission_checkpoint = False + resume_messages = ( + loaded + if loaded and has_permission_checkpoint + else SessionStorage.repair_interrupted(loaded) + if loaded + else None + ) return create_agent_runtime( AgentFactoryOptions( model=model, @@ -1486,100 +1596,216 @@ def runtime_factory(session_id: str) -> Any: ) current_assistant_text: list[str] = [] final_assistant_text = "" - async for event in stream: - if isinstance(event, MessageStartEvent): - current_assistant_text = [] - elif isinstance(event, TextDeltaEvent): - current_assistant_text.append(event.text) - elif isinstance(event, MessageEndEvent): - if event.stop_reason not in {"tool_use", "tool_calls"}: - final_assistant_text = "".join(current_assistant_text) - current_assistant_text = [] + detached_permission = None + + async def finalize_normal_turn(target_queue: EventQueue) -> None: + nonlocal final_assistant_text + if current_assistant_text: + final_assistant_text = "".join(current_assistant_text) await publish_mcp_warnings( - event_queue, + target_queue, task_id=task_id, context_id=context_id, runtime=runtime, iac_code_session_id=ctx.session_id, ) await self._publish_mcp_status( - event_queue, + target_queue, task_id=task_id, context_id=context_id, runtime=runtime, session_id=ctx.session_id, ) - text_chunk = await publish_stream_event( - event_queue, + final_metadata: dict[str, Any] = {"assistantFinal": {"complete": True}} + if cleanup_only: + final_metadata[_CLEANUP_ONLY_METADATA_KEY] = _cleanup_only_summary(cleanup_ledger) + await self._publish_status( + target_queue, task_id=task_id, context_id=context_id, - event=event, - artifact_store=self._artifact_store, - permission_resolver=self._permission_resolver, - permission_input_registry=self._permission_input_registry, - auto_approve_permissions=self._auto_approve_permissions, - exposure_types=self._thinking_exposure_types, - iac_code_session_id=ctx.session_id, + state=TaskState.TASK_STATE_WORKING, + text=final_assistant_text or None, + metadata={"iac_code": final_metadata}, + session_id=ctx.session_id, ) - if text_chunk: - task.output_text.append(text_chunk) - if current_assistant_text: - final_assistant_text = "".join(current_assistant_text) - await publish_mcp_warnings( - event_queue, - task_id=task_id, - context_id=context_id, - runtime=runtime, - iac_code_session_id=ctx.session_id, - ) - await self._publish_mcp_status( - event_queue, - task_id=task_id, - context_id=context_id, - runtime=runtime, - session_id=ctx.session_id, - ) - final_metadata: dict[str, Any] = {"assistantFinal": {"complete": True}} - if cleanup_only: - final_metadata[_CLEANUP_ONLY_METADATA_KEY] = _cleanup_only_summary(cleanup_ledger) - await self._publish_status( - event_queue, - task_id=task_id, - context_id=context_id, - state=TaskState.TASK_STATE_WORKING, - text=final_assistant_text or None, - metadata={"iac_code": final_metadata}, - session_id=ctx.session_id, - ) - task.state = TASK_STATE_INPUT_REQUIRED - ctx.active_task_id = None - task.touch() - ctx.touch() - self._task_store.mirror_task(task) - self._task_store.mirror_context(ctx) - await backup_session_async( - self._backup_service, - cwd, - ctx.session_id, - reason=BackupReason.NORMAL_TURN_END, - critical=False, - metrics=self._metrics, - ) - terminal_metadata = None - if cleanup_only: - terminal_metadata = { - "iac_code": {_CLEANUP_ONLY_METADATA_KEY: _cleanup_only_summary(cleanup_ledger)} - } - await self._publish_status( - event_queue, - task_id=task_id, - context_id=context_id, - state=TaskState.TASK_STATE_INPUT_REQUIRED, - metadata=terminal_metadata, - session_id=ctx.session_id, - ) - await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) - self._metrics.record_turn_completed() + task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await backup_session_async( + self._backup_service, + cwd, + ctx.session_id, + reason=BackupReason.NORMAL_TURN_END, + critical=False, + metrics=self._metrics, + ) + terminal_metadata = None + if cleanup_only: + terminal_metadata = { + "iac_code": {_CLEANUP_ONLY_METADATA_KEY: _cleanup_only_summary(cleanup_ledger)} + } + await self._publish_status( + target_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + metadata=terminal_metadata, + session_id=ctx.session_id, + ) + await self._notify_terminal_task( + task_id=task.task_id, + context_id=task.context_id, + state=task.state, + ) + self._metrics.record_turn_completed() + + async def mark_detached_input_required() -> None: + task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await self._notify_terminal_task( + task_id=task.task_id, + context_id=task.context_id, + state=task.state, + ) + + async def resolve_consumed_boundary(pending: Any) -> None: + checkpoint_store = pending.checkpoint_store + if checkpoint_store is not None and pending.boundary_id is not None: + persisted = SessionStorage().load(cwd, ctx.session_id) + digest = canonical_digest(persisted[-1].to_dict()) if persisted else "" + decision_record = checkpoint_store.load(pending.boundary_id) + decision = decision_record.get("decision") if isinstance(decision_record, dict) else {} + checkpoint_store.resolve( + pending.boundary_id, + result_digest=digest, + ack={ + "decision": decision.get("value"), + "accepted": True, + }, + ) + await self._permission_input_registry.complete(pending) + + async def resume_detached_normal(target_queue: EventQueue, pending: Any) -> None: + if ctx.lock is None: + ctx.lock = asyncio.Lock() + await ctx.lock.acquire() + try: + ctx.active_task_id = task.task_id + task.active_task = asyncio.current_task() + task.state = TASK_STATE_WORKING + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + with a2a_request_context( + session_id=ctx.session_id, + user_id=user_id, + aliyun_credential=aliyun_credential, + preferred_language=preferred_language, + ): + completed = await consume_normal_stream(target_queue) + await resolve_consumed_boundary(pending) + if completed: + await finalize_normal_turn(target_queue) + else: + await mark_detached_input_required() + finally: + task.active_task = None + ctx.active_task_id = None + ctx.touch() + task.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + ctx.lock.release() + + async def consume_normal_stream(target_queue: EventQueue) -> bool: + nonlocal current_assistant_text, final_assistant_text, detached_permission + async for event in stream: + if isinstance(event, MessageStartEvent): + current_assistant_text = [] + elif isinstance(event, TextDeltaEvent): + current_assistant_text.append(event.text) + elif isinstance(event, MessageEndEvent): + if event.stop_reason not in {"tool_use", "tool_calls"}: + final_assistant_text = "".join(current_assistant_text) + current_assistant_text = [] + await publish_mcp_warnings( + target_queue, + task_id=task_id, + context_id=context_id, + runtime=runtime, + iac_code_session_id=ctx.session_id, + ) + await self._publish_mcp_status( + target_queue, + task_id=task_id, + context_id=context_id, + runtime=runtime, + session_id=ctx.session_id, + ) + interactive_permission = ( + isinstance(event, PermissionRequestEvent) + and self._permission_resolver is None + and not self._auto_approve_permissions + ) + if interactive_permission: + pending = await publish_interactive_permission_boundary( + target_queue, + permission_event=event, + permission_input_registry=self._permission_input_registry, + task_id=task_id, + context_id=context_id, + iac_code_session_id=ctx.session_id, + permission_wait_cwd=cwd, + permission_wait_backup_service=self._backup_service, + permission_wait_metrics=self._metrics, + wait_for_response=False, + ) + detached_permission = pending + pending.continuation = resume_detached_normal + + async def suspend_detached(pending_permission: Any = pending) -> None: + pending_permission.continuation = None + close_stream = getattr(stream, "aclose", None) + if callable(close_stream): + with contextlib.suppress(RuntimeError): + await close_stream() + try: + await self._task_store.discard_context_runtime(context_id) + finally: + await self._permission_input_registry.complete(pending_permission) + + pending.suspend_callback = suspend_detached + return False + text_chunk = await publish_stream_event( + target_queue, + task_id=task_id, + context_id=context_id, + event=event, + artifact_store=self._artifact_store, + permission_resolver=self._permission_resolver, + permission_input_registry=self._permission_input_registry, + auto_approve_permissions=self._auto_approve_permissions, + exposure_types=self._thinking_exposure_types, + iac_code_session_id=ctx.session_id, + permission_wait_cwd=cwd, + permission_wait_backup_service=self._backup_service, + permission_wait_metrics=self._metrics, + ) + if text_chunk: + task.output_text.append(text_chunk) + return True + + completed = await consume_normal_stream(event_queue) + if completed: + await finalize_normal_turn(event_queue) + else: + await mark_detached_input_required() except asyncio.CancelledError: task.state = TASK_STATE_CANCELED ctx.active_task_id = None @@ -1688,6 +1914,468 @@ def runtime_factory(session_id: str) -> Any: finally: lock.release() + async def _resume_persisted_permission( + self, + context: RequestContext, + event_queue: EventQueue, + *, + response: PermissionResponse, + ) -> bool: + """Claim and resume a permission whose process-local registry was lost.""" + + try: + task_record = await self._task_store.get_task_record(response.task_id) + context_record = await self._task_store.get_context_record(response.context_id) + except ValueError: + return False + if task_record.context_id != response.context_id: + raise InvalidParamsError("input_response_mismatch: permission task context changed.") + store = PermissionWaitCheckpointStore(context_record.cwd, context_record.session_id) + record = store.find( + task_id=response.task_id, + context_id=response.context_id, + input_id=response.input_id, + tool_use_id=response.tool_use_id, + ) + if record is None: + return False + expected_value = "allow_once" if response.decision == "allow_once" else "deny" + boundary_id = str(record["boundaryId"]) + if record.get("phase") == "RESOLVED": + decision = record.get("decision") + if not isinstance(decision, dict) or decision.get("value") != expected_value: + raise InvalidParamsError("permission_resume_invalid: permission decision conflicts with receipt.") + await self._publish_permission_recovery_ack( + event_queue, + response=response, + decision=expected_value, + duplicate=True, + ) + return True + + if record.get("phase") == "RESTORING": + decision = record.get("decision") + if not isinstance(decision, dict) or decision.get("value") != expected_value: + raise InvalidParamsError( + "permission_resume_invalid: permission decision conflicts with active recovery." + ) + await self._publish_permission_recovery_ack( + event_queue, + response=response, + decision=str(decision["value"]), + duplicate=True, + session_id=context_record.session_id, + ) + return True + + metadata = getattr(context, "metadata", None) or getattr(getattr(context, "message", None), "metadata", None) + model = self._resolve_model(metadata) or self._model + metadata_api_key = self._resolve_api_key(metadata) + request_policy_override = self._resolve_request_policy(metadata) + user_id = self._resolve_user_id(metadata) + preferred_language = self._resolve_preferred_language(metadata) + aliyun_credential = self._resolve_aliyun_credential(metadata) + + def make_pipeline_executor() -> IacCodeA2APipelineExecutor: + return IacCodeA2APipelineExecutor( + task_store=self._task_store, + model=model, + metrics=self._metrics, + artifact_store=self._artifact_store, + push_notifier=self._push_notifier, + permission_resolver=self._permission_resolver, + permission_input_registry=self._permission_input_registry, + auto_approve_permissions=self._auto_approve_permissions, + thinking_exposure_types=self._thinking_exposure_types, + user_id=user_id, + aliyun_credential=aliyun_credential, + preferred_language=preferred_language, + candidate_presentation=self._resolve_candidate_presentation(metadata), + model_from_metadata=self._resolve_model(metadata) is not None, + metadata_api_key=metadata_api_key, + request_policy_override=request_policy_override, + backup_service=self._backup_service, + ) + + persisted_decision = record.get("decision") + audit_already_final = False + if isinstance(persisted_decision, Mapping) and persisted_decision.get("status") in {"claimed", "applied"}: + if persisted_decision.get("value") != expected_value: + raise InvalidParamsError("permission_resume_invalid: permission response conflicts with checkpoint.") + audit_already_final = persisted_decision.get("auditStatus") in {"recorded", "failed"} + pipeline_executor: IacCodeA2APipelineExecutor | None = None + audit_event: PermissionRequestEvent | None = None + if not audit_already_final: + recovered = recover_permission_audit_boundary( + record, + cwd=context_record.cwd, + session_id=context_record.session_id, + ) + if recovered is None: + raise InvalidParamsError("permission_resume_invalid: canonical permission request changed.") + try: + if record.get("permissionClass") == "pipeline": + pipeline_executor = make_pipeline_executor() + audit_event = await pipeline_executor.rebuild_permission_audit_event( + cwd=context_record.cwd, + session_id=context_record.session_id, + checkpoint=record, + recovered=recovered, + ) + else: + audit_event = await self._rebuild_normal_permission_audit_event( + recovered=recovered, + cwd=context_record.cwd, + session_id=context_record.session_id, + model=model, + model_from_metadata=self._resolve_model(metadata) is not None, + metadata_api_key=metadata_api_key, + request_policy_override=request_policy_override, + user_id=user_id, + aliyun_credential=aliyun_credential, + preferred_language=preferred_language, + ) + except InvalidParamsError: + raise + except (OSError, RuntimeError, TypeError, ValueError) as exc: + raise InvalidParamsError(f"permission_resume_invalid: {exc}") from exc + + permission_audit = getattr(audit_event.permission_result, "audit", None) + principal_ref, region = permission_execution_identity( + tool_name=audit_event.tool_name, + tool_input=audit_event.tool_input, + permission_audit=permission_audit, + ) + if principal_ref != record.get("principalRef") or region != record.get("region"): + raise InvalidParamsError("permission_resume_invalid: cloud execution identity changed.") + + record = store.reconcile_deadline( + boundary_id, + grace_seconds=self._permission_wait_policy.timeout_grace_seconds, + live_owner=False, + ) + try: + record, _created = store.claim_decision( + boundary_id, + value=expected_value, + source="user", + ) + except ValueError as exc: + raise InvalidParamsError(f"permission_resume_invalid: {exc}") from exc + decision = record.get("decision") + if isinstance(decision, dict): + claim_id = str(decision.get("claimId") or "") + + def audit_claim(value: str) -> bool: + if audit_event is None: + return audit_already_final + return emit_permission_boundary_audit( + audit_event, + session_id=context_record.session_id, + decision="allow" if value == "allow_once" else "deny", + scope="a2a_input_required", + source="a2a_user_permission", + reason_type="user_decision", + reason_detail=value, + ) + + record, _audit_created = store.run_claim_audit_once( + boundary_id, + claim_id=claim_id, + audit=audit_claim, + ) + expected_value = str(record["decision"]["value"]) + decision = record.get("decision") + if isinstance(decision, dict) and decision.get("backupStatus") != "committed": + claim_id = str(decision.get("claimId") or "") + await backup_permission_wait_checkpoint( + store=store, + boundary_id=boundary_id, + cwd=context_record.cwd, + session_id=context_record.session_id, + backup_service=self._backup_service, + metrics=self._metrics, + ) + record = store.mark_claim_backed_up(boundary_id, claim_id=claim_id) + if not await self._permission_wait_coordinator.acquire_restore(boundary_id): + await self._publish_permission_recovery_ack( + event_queue, + response=response, + decision=expected_value, + duplicate=True, + session_id=context_record.session_id, + ) + return True + try: + record = store.begin_restore(boundary_id) + except ValueError as exc: + await self._permission_wait_coordinator.release_restore(boundary_id) + raise InvalidParamsError(f"permission_resume_invalid: {exc}") from exc + + await self._publish_status( + event_queue, + task_id=response.task_id, + context_id=response.context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "permissionRecovered": { + "inputId": response.input_id, + "toolUseId": response.tool_use_id, + } + } + }, + session_id=context_record.session_id, + ) + normal_final_assistant_text: str | None = None + try: + if record.get("permissionClass") == "pipeline": + task = await self._task_store.get_or_create_task( + task_id=response.task_id, + context_id=response.context_id, + restore_interrupted=False, + ) + if pipeline_executor is None: + pipeline_executor = make_pipeline_executor() + await pipeline_executor.execute( + context=context, + event_queue=event_queue, + task=task, + task_id=response.task_id, + context_id=response.context_id, + cwd=context_record.cwd, + pipeline_input="", + permission_checkpoint=record, + ) + else: + storage = SessionStorage() + messages = storage.load(context_record.cwd, context_record.session_id) + if not messages: + raise InvalidParamsError("permission_resume_invalid: session transcript is unavailable.") + runtime = create_agent_runtime( + AgentFactoryOptions( + model=model, + session_id=context_record.session_id, + cwd=context_record.cwd, + resume_messages=messages, + a2a_safe_mode=_a2a_safe_mode_enabled(), + source="a2a", + ) + ) + configure_runtime_model( + runtime, + model, + from_metadata=self._resolve_model(metadata) is not None, + metadata_api_key=metadata_api_key, + request_policy_override=request_policy_override, + ) + refresh_runtime_cloud_tools(runtime) + task = await self._task_store.get_or_create_task( + task_id=response.task_id, + context_id=response.context_id, + ) + current_assistant_text: list[str] = [] + normal_final_assistant_text = "" + try: + with a2a_request_context( + session_id=context_record.session_id, + user_id=user_id, + aliyun_credential=aliyun_credential, + preferred_language=preferred_language, + ): + async for event in runtime.agent_loop.resume_permission_boundary(record): + if isinstance(event, MessageStartEvent): + current_assistant_text = [] + elif isinstance(event, TextDeltaEvent): + current_assistant_text.append(event.text) + elif isinstance(event, MessageEndEvent): + if event.stop_reason not in {"tool_use", "tool_calls"}: + normal_final_assistant_text = "".join(current_assistant_text) + current_assistant_text = [] + text_chunk = await publish_stream_event( + event_queue, + task_id=response.task_id, + context_id=response.context_id, + event=event, + artifact_store=self._artifact_store, + permission_resolver=self._permission_resolver, + permission_input_registry=self._permission_input_registry, + auto_approve_permissions=self._auto_approve_permissions, + exposure_types=self._thinking_exposure_types, + iac_code_session_id=context_record.session_id, + permission_wait_cwd=context_record.cwd, + permission_wait_backup_service=self._backup_service, + permission_wait_metrics=self._metrics, + ) + if text_chunk: + task.output_text.append(text_chunk) + if current_assistant_text: + normal_final_assistant_text = "".join(current_assistant_text) + finally: + await _close_runtime(runtime) + except PermissionWaitSuspended: + store.mark_suspended(boundary_id) + await self._publish_status( + event_queue, + task_id=response.task_id, + context_id=response.context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + metadata={ + "iac_code": { + "permissionWait": {"status": "suspended", "resumable": True}, + } + }, + session_id=context_record.session_id, + ) + return True + except BaseException: + with contextlib.suppress(ValueError): + store.reconcile_deadline( + boundary_id, + grace_seconds=self._permission_wait_policy.timeout_grace_seconds, + live_owner=False, + ) + raise + finally: + await self._permission_wait_coordinator.release_restore(boundary_id) + + storage = SessionStorage() + persisted_messages = storage.load(context_record.cwd, context_record.session_id) + result_digest = canonical_digest(persisted_messages[-1].to_dict()) if persisted_messages else "" + store.resolve( + boundary_id, + result_digest=result_digest, + ack={"decision": expected_value, "accepted": True}, + ) + task = await self._task_store.get_or_create_task( + task_id=response.task_id, + context_id=response.context_id, + ) + task.state = TASK_STATE_INPUT_REQUIRED + self._task_store.mirror_task(task) + await self._publish_permission_recovery_ack( + event_queue, + response=response, + decision=expected_value, + duplicate=False, + session_id=context_record.session_id, + ) + if normal_final_assistant_text is not None: + await self._publish_status( + event_queue, + task_id=response.task_id, + context_id=response.context_id, + state=TaskState.TASK_STATE_WORKING, + text=normal_final_assistant_text or None, + metadata={"iac_code": {"assistantFinal": {"complete": True}}}, + session_id=context_record.session_id, + ) + await backup_session_async( + self._backup_service, + context_record.cwd, + context_record.session_id, + reason=BackupReason.NORMAL_TURN_END, + critical=False, + metrics=self._metrics, + ) + await self._publish_status( + event_queue, + task_id=response.task_id, + context_id=response.context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + session_id=context_record.session_id, + ) + await self._notify_terminal_task( + task_id=task.task_id, + context_id=task.context_id, + state=task.state, + ) + self._metrics.record_turn_completed() + return True + + async def _rebuild_normal_permission_audit_event( + self, + *, + recovered: RecoveredPermissionAuditBoundary, + cwd: str, + session_id: str, + model: str, + model_from_metadata: bool, + metadata_api_key: str | None, + request_policy_override: ProviderRequestPolicy | None, + user_id: str | None, + aliyun_credential: AliyunCredential | None, + preferred_language: str | None, + ) -> PermissionRequestEvent: + """Use a current Normal runtime to rebuild restart audit metadata/settings.""" + + storage = SessionStorage() + messages = storage.load(cwd, session_id) + if not messages: + raise ValueError("permission_resume_invalid: session transcript is unavailable") + runtime = create_agent_runtime( + AgentFactoryOptions( + model=model, + session_id=session_id, + cwd=cwd, + resume_messages=messages, + a2a_safe_mode=_a2a_safe_mode_enabled(), + source="a2a", + ) + ) + try: + configure_runtime_model( + runtime, + model, + from_metadata=model_from_metadata, + metadata_api_key=metadata_api_key, + request_policy_override=request_policy_override, + ) + refresh_runtime_cloud_tools(runtime) + with a2a_request_context( + session_id=session_id, + user_id=user_id, + aliyun_credential=aliyun_credential, + preferred_language=preferred_language, + ): + return await runtime.agent_loop.rebuild_permission_audit_event( + tool_name=recovered.tool_name, + tool_input=recovered.tool_input, + tool_use_id=recovered.tool_use_id, + audit_context=recovered.audit_context, + ) + finally: + await _close_runtime(runtime) + + async def _publish_permission_recovery_ack( + self, + event_queue: EventQueue, + *, + response: PermissionResponse, + decision: str, + duplicate: bool, + session_id: str | None = None, + ) -> None: + await self._publish_status( + event_queue, + task_id=response.task_id, + context_id=response.context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "inputReceived": { + "kind": "permission", + "inputId": response.input_id, + "toolUseId": response.tool_use_id, + "decision": decision, + "recovered": True, + "duplicate": duplicate, + } + } + }, + session_id=session_id, + ) + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: task_id = context.task_id context_id = context.context_id or "unknown" @@ -1731,9 +2419,7 @@ def _resolve_cwd(self, metadata: Any | None) -> str: raise ValueError("Invalid A2A workspace metadata.") logical_cwd = os.path.normpath(cwd) resolved_cwd = resolve_workspace_path(Path(logical_cwd)) - if not trust_request_cwd() and not any( - _is_relative_to(resolved_cwd, root) for root in _allowed_cwd_roots() - ): + if not trust_request_cwd() and not any(_is_relative_to(resolved_cwd, root) for root in _allowed_cwd_roots()): raise ValueError("Invalid A2A workspace metadata.") if resolved_cwd.exists(): if not resolved_cwd.is_dir(): diff --git a/src/iac_code/a2a/input_required.py b/src/iac_code/a2a/input_required.py index 4418de08..979d1b7c 100644 --- a/src/iac_code/a2a/input_required.py +++ b/src/iac_code/a2a/input_required.py @@ -14,6 +14,14 @@ from iac_code.a2a.runtime_overrides import get_a2a_preferred_language from iac_code.i18n import translate_message +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitCoordinator, + PermissionWaitPolicy, + build_permission_checkpoint, + canonicalize_permission_continuation_frame, + permission_execution_identity, +) from iac_code.services.permissions.audit import ( build_prompt_tool_input, emit_permission_boundary_audit, @@ -23,6 +31,7 @@ PERMISSION_SCHEMA_VERSION = 1 PERMISSION_DECISIONS = frozenset({"allow_once", "deny"}) +PERMISSION_QUERY_PREFIX = "IAC_CODE_PERMISSION:" _SAFE_SUMMARY_MAX_CHARS = 1200 _DISPLAY_FIELD_MAX_CHARS = 500 @@ -48,6 +57,17 @@ class PendingPermission: scope: str = "pipeline" coordinates: dict[str, Any] | None = None state: str = "pending" + boundary_id: str | None = None + checkpoint_store: PermissionWaitCheckpointStore | None = field(default=None, repr=False) + claim_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) + timeout_task: asyncio.Task[None] | None = field(default=None, repr=False) + continuation: Any | None = field(default=None, repr=False) + continuation_claimed: bool = field(default=False, repr=False) + suspend_callback: Any | None = field(default=None, repr=False) + backup_cwd: str | None = field(default=None, repr=False) + backup_session_id: str | None = field(default=None, repr=False) + backup_service: Any | None = field(default=None, repr=False) + backup_metrics: Any | None = field(default=None, repr=False) def envelope(self) -> dict[str, Any]: return permission_input_envelope( @@ -178,35 +198,29 @@ def permission_display_fields(request: PermissionRequestEvent, *, language: str command = safe_input.get("command") or safe_input.get("cmd") if isinstance(command, str) and command.strip(): command_fallback = translate_message("shell command", language=language) - target = translate_message( - "the current local workspace; command: {command}", language=language - ).format(command=_display_text(command, fallback=command_fallback, maximum=240)) + target = translate_message("the current local workspace; command: {command}", language=language).format( + command=_display_text(command, fallback=command_fallback, maximum=240) + ) else: target = translate_message("the current local workspace", language=language) effect = "read" if is_read_only else ("local_execution" if read_only_known else "unknown") elif tool_name in {"write_file", "edit_file"}: title = translate_message("Change a workspace file", language=language) - purpose = translate_message( - "Write a file needed for the requested infrastructure task.", language=language - ) + purpose = translate_message("Write a file needed for the requested infrastructure task.", language=language) target = _safe_input_target(safe_input, language=language) or translate_message( "a file in the current workspace", language=language ) effect = "file_change" elif tool_name in {"read_file", "glob", "grep"} or is_read_only: title = translate_message("Read workspace data with {tool}", language=language).format(tool=public_tool) - purpose = translate_message( - "Read local data needed for the requested infrastructure task.", language=language - ) + purpose = translate_message("Read local data needed for the requested infrastructure task.", language=language) target = _safe_input_target(safe_input, language=language) or translate_message( "the current local workspace", language=language ) effect = "read" else: title = translate_message("Run {tool}", language=language).format(tool=public_tool) - purpose = translate_message( - "Run this operation for the requested infrastructure task.", language=language - ) + purpose = translate_message("Run this operation for the requested infrastructure task.", language=language) target = _safe_input_target(safe_input, language=language) or translate_message( "the current task workspace or cloud account", language=language ) @@ -319,9 +333,7 @@ def _cloud_operation_title(product: str, action: str, *, is_read_only: bool, lan if action == "CreateStack": return translate_message("Create {product} stack", language=language).format(product=product_label) if action == "ContinueCreateStack": - return translate_message("Continue creating {product} stack", language=language).format( - product=product_label - ) + return translate_message("Continue creating {product} stack", language=language).format(product=product_label) if action == "UpdateStack": return translate_message("Update {product} stack", language=language).format(product=product_label) if action == "DeleteStack": @@ -344,27 +356,43 @@ def _safe_input_target(value: Any, *, language: str) -> str: for key in ("file_path", "filePath", "path", "region_id", "regionId", "resource_id", "resourceId"): candidate = value.get(key) if isinstance(candidate, str) and candidate.strip(): - return _display_text( - candidate, fallback=translate_message("the current task scope", language=language) - ) + return _display_text(candidate, fallback=translate_message("the current task scope", language=language)) return "" def parse_permission_response(message: Message | None) -> PermissionResponse | None: if not isinstance(message, Message): return None - decoded_parts = [_json_data_part(part) for part in message.parts] - permission_parts = [ - value for value in decoded_parts if isinstance(value, dict) and value.get("kind") == "permission" + data_permission_parts = [ + value + for part in message.parts + if (value := _json_data_part(part)) is not None and value.get("kind") == "permission" ] - if not permission_parts: + text_permission_parts = [ + value + for part in message.parts + if (value := _json_text_part(part)) is not None and value.get("kind") == "permission" + ] + if not data_permission_parts and not text_permission_parts: return None if message.role != Role.ROLE_USER: raise InvalidParamsError("Permission responses must use ROLE_USER.") - if len(message.parts) != 1 or len(permission_parts) != 1: + + text_transport = bool(text_permission_parts) + if data_permission_parts and text_permission_parts: + raise InvalidParamsError("Permission responses must use exactly one supported JSON transport.") + if data_permission_parts and (len(message.parts) != 1 or len(data_permission_parts) != 1): raise InvalidParamsError("Permission responses must contain exactly one application/json DataPart.") - payload = permission_parts[0] + if text_permission_parts and (len(message.parts) != 1 or len(text_permission_parts) != 1): + raise InvalidParamsError("Permission responses must contain exactly one JSON TextPart.") + + payload = text_permission_parts[0] if text_transport else data_permission_parts[0] expected_keys = {"schemaVersion", "kind", "requestTaskId", "inputId", "toolUseId", "decision"} + if text_transport: + # Some gateways, including ROS StartChat, can only forward user input as + # an A2A TextPart. A fixed prefix keeps ordinary chat on the fast path; + # the exact JSON object after that prefix carries the full correlation. + expected_keys.add("contextId") if set(payload) != expected_keys: raise InvalidParamsError("Permission response payload fields do not match schemaVersion 1.") if payload.get("schemaVersion") != PERMISSION_SCHEMA_VERSION: @@ -377,6 +405,11 @@ def parse_permission_response(message: Message | None) -> PermissionResponse | N request_task_id = payload.get("requestTaskId") input_id = payload.get("inputId") tool_use_id = payload.get("toolUseId") + if text_transport and not outer_task_id and isinstance(request_task_id, str): + # Text-only gateways may preserve the A2A context while omitting taskId + # on follow-up messages. The registry still validates every opaque + # correlation field against an active pending permission. + outer_task_id = request_task_id values = (outer_task_id, outer_context_id, request_task_id, input_id, tool_use_id) if not all(isinstance(value, str) and value for value in values): raise InvalidParamsError("Permission response correlation fields are required.") @@ -387,6 +420,8 @@ def parse_permission_response(message: Message | None) -> PermissionResponse | N assert isinstance(tool_use_id, str) if outer_task_id != request_task_id: raise InvalidParamsError("Permission response taskId does not match requestTaskId.") + if text_transport and payload.get("contextId") != outer_context_id: + raise InvalidParamsError("Permission response contextId does not match message contextId.") return PermissionResponse( task_id=outer_task_id, context_id=outer_context_id, @@ -419,6 +454,49 @@ def permission_ack_message(response: PermissionResponse, *, approved: bool) -> M ) +async def backup_permission_wait_checkpoint( + *, + store: PermissionWaitCheckpointStore, + boundary_id: str, + cwd: str, + session_id: str, + backup_service: Any, + metrics: Any | None = None, +) -> Any | None: + """Commit one permission checkpoint under the permission/backup lock order.""" + + from iac_code.a2a.backup import backup_session_async + from iac_code.services.session_backup import BackupReason, SessionBackupBlocked + + record = store.load(boundary_id) + if record is None: + raise RuntimeError("permission checkpoint is unavailable for critical backup") + generation = int(record["generation"]) + + def fenced_backup(_cwd: str, _session_id: str, **kwargs: Any) -> Any: + return store.run_generation_fenced( + boundary_id, + expected_generation=generation, + operation=lambda: backup_service.backup_session(_cwd, _session_id, **kwargs), + ) + + try: + result = await backup_session_async( + backup_service, + cwd, + session_id, + reason=BackupReason.INPUT_REQUIRED, + critical=True, + metrics=metrics, + backup_call=fenced_backup, + ) + except ValueError as exc: + raise SessionBackupBlocked("Permission checkpoint changed during critical backup.") from exc + if getattr(result, "enabled", False) and not getattr(result, "shared_committed", False): + raise SessionBackupBlocked("Critical permission backup did not reach the shared target.") + return result + + class PermissionInputRegistry: """Coordinate legacy input-required and concurrent Sub Pipeline permissions.""" @@ -426,6 +504,166 @@ def __init__(self) -> None: self._condition = asyncio.Condition() self._pending: dict[tuple[str, str], PendingPermission] = {} self._closing_tasks: dict[str, _PermissionTaskClosingState] = {} + self._permission_wait_coordinator: PermissionWaitCoordinator | None = None + + def set_permission_wait_coordinator(self, coordinator: PermissionWaitCoordinator | None) -> None: + self._permission_wait_coordinator = coordinator + + @property + def durable_permission_wait_enabled(self) -> bool: + return self._permission_wait_coordinator is not None + + @property + def permission_wait_policy(self) -> PermissionWaitPolicy: + coordinator = self._permission_wait_coordinator + return coordinator.policy if coordinator is not None else PermissionWaitPolicy() + + async def open_durable_boundary( + self, + pending: PendingPermission, + *, + cwd: str, + session_id: str, + permission_class: str, + backup_service: Any, + metrics: Any | None = None, + pipeline_coordinates: dict[str, Any] | None = None, + perform_backup: bool = True, + ) -> dict[str, Any]: + """Persist and critically back up a real external wait before publication.""" + + coordinator = self._permission_wait_coordinator + if coordinator is None: + raise RuntimeError("permission wait coordinator is unavailable") + source_frame = pending.request.continuation_frame + if not isinstance(source_frame, dict): + raise RuntimeError("permission_resume_invalid: continuation frame is unavailable") + store = PermissionWaitCheckpointStore(cwd, session_id) + audit_context = pending.request.audit_context if isinstance(pending.request.audit_context, dict) else {} + try: + frame = canonicalize_permission_continuation_frame(source_frame, audit_context=audit_context) + except ValueError as exc: + raise RuntimeError(f"permission_resume_invalid: {exc}") from exc + principal_ref = audit_context.get("principal_ref") + region = audit_context.get("region") + record = build_permission_checkpoint( + session_id=session_id, + task_id=pending.task_id, + context_id=pending.context_id, + input_id=pending.input_id, + tool_use_id=pending.request.tool_use_id, + tool_name=pending.request.tool_name, + tool_input=pending.request.tool_input, + permission_class="pipeline" if permission_class == "pipeline" else "normal", + continuation_frame=frame, + policy=coordinator.policy, + principal_ref=principal_ref if isinstance(principal_ref, str) else None, + region=region if isinstance(region, str) else None, + pipeline_coordinates=pipeline_coordinates, + ) + previous_boundary_id = frame.get("previousBoundaryId") + if isinstance(previous_boundary_id, str) and previous_boundary_id: + store.create_successor(record, previous_boundary_id=previous_boundary_id) + await self._release_replaced_durable_boundary(previous_boundary_id) + else: + store.create(record) + pending.boundary_id = record["boundaryId"] + pending.checkpoint_store = store + pending.request.boundary_id = pending.boundary_id + pending.backup_cwd = cwd + pending.backup_session_id = session_id + pending.backup_service = backup_service + pending.backup_metrics = metrics + + if perform_backup: + await self.backup_durable_boundary( + pending, + cwd, + session_id, + backup_service=backup_service, + metrics=metrics, + ) + current = store.load(pending.boundary_id) + if current is None: + raise RuntimeError("permission checkpoint is unavailable after critical backup") + self.activate_durable_boundary(pending, current) + return record + + async def _release_replaced_durable_boundary(self, boundary_id: str) -> None: + """Mirror a successor checkpoint swap in registry/coordinator state.""" + + replaced: list[PendingPermission] = [] + async with self._condition: + for key, pending in list(self._pending.items()): + if pending.boundary_id != boundary_id: + continue + self._pending.pop(key, None) + pending.state = "completed" + replaced.append(pending) + if replaced: + self._condition.notify_all() + for pending in replaced: + if pending.timeout_task is not None and pending.timeout_task is not asyncio.current_task(): + pending.timeout_task.cancel() + if self._permission_wait_coordinator is not None: + self._permission_wait_coordinator.unregister_live(boundary_id) + + async def backup_durable_boundary( + self, + pending: PendingPermission, + cwd: str, + session_id: str, + *, + backup_service: Any, + metrics: Any | None = None, + ) -> Any | None: + """Commit the critical shared copy under the documented lock order.""" + + store = pending.checkpoint_store + boundary_id = pending.boundary_id + if store is None or boundary_id is None: + raise RuntimeError("permission checkpoint is unavailable for critical backup") + pending.backup_cwd = cwd + pending.backup_session_id = session_id + pending.backup_service = backup_service + pending.backup_metrics = metrics + return await backup_permission_wait_checkpoint( + store=store, + boundary_id=boundary_id, + cwd=cwd, + session_id=session_id, + backup_service=backup_service, + metrics=metrics, + ) + + def activate_durable_boundary( + self, + pending: PendingPermission, + record: dict[str, Any] | None = None, + ) -> dict[str, Any]: + coordinator = self._permission_wait_coordinator + store = pending.checkpoint_store + if coordinator is None or store is None or pending.boundary_id is None: + raise RuntimeError("permission checkpoint is unavailable") + current = record or store.load(pending.boundary_id) + if current is None: + raise RuntimeError("permission checkpoint is unavailable") + future = pending.request.response_future + if future is None or future.done(): + raise RuntimeError("permission wait point is unavailable after critical backup") + + async def on_suspend() -> None: + callback = pending.suspend_callback + if callback is not None: + result = callback() + if asyncio.iscoroutine(result): + await result + + coordinator.register_live(record=current, store=store, future=future, on_suspend=on_suspend) + return current + + async def pending_for_response(self, response: PermissionResponse) -> PendingPermission: + return await self._lookup(response) async def register( self, @@ -452,7 +690,11 @@ async def register( request.response_future.set_result(False) raise InvalidParamsError("permission_resume_invalid: task cancellation is already in progress.") while resolution_owner is None and any( - pending.task_id == task_id and pending.resolution_owner is None for pending in self._pending.values() + pending.task_id == task_id + and pending.resolution_owner is None + and pending.request.response_future is not None + and not pending.request.response_future.done() + for pending in self._pending.values() ): await self._condition.wait() input_id = _permission_input_id() @@ -477,6 +719,36 @@ async def answer(self, response: PermissionResponse) -> bool: if pending.resolution_owner is not None: return await pending.resolution_owner.resolve_permission(pending, response) + coordinator = self._permission_wait_coordinator + if coordinator is not None and pending.boundary_id is not None: + self._validate_live_execution_identity(pending) + + def audit_new_claim(value: str) -> bool: + return emit_permission_boundary_audit( + pending.request, + decision="allow" if value == "allow_once" else "deny", + scope="a2a_input_required", + source="a2a_user_permission", + reason_type="user_decision", + reason_detail=value, + ) + + try: + record, _created = await coordinator.claim_live( + boundary_id=pending.boundary_id, + value="allow_once" if response.decision == "allow_once" else "deny", + source="user", + on_new_claim=audit_new_claim, + before_delivery=lambda _record: self._backup_claim_before_delivery(pending), + ) + except (LookupError, ValueError) as exc: + raise InvalidParamsError(f"permission_resume_invalid: {exc}") from exc + decision = record.get("decision") + approved = isinstance(decision, dict) and decision.get("value") == "allow_once" + if record.get("phase") in {"SUSPENDING", "SUSPENDED", "RESTORING"}: + pending.state = "suspended_decision_claimed" + return approved + async with self._condition: self._validate_response(pending, response) future = pending.request.response_future @@ -496,6 +768,41 @@ async def answer(self, response: PermissionResponse) -> bool: future.set_result(approved) return approved + @staticmethod + def _validate_live_execution_identity(pending: PendingPermission) -> None: + store = pending.checkpoint_store + boundary_id = pending.boundary_id + if store is None or boundary_id is None: + return + record = store.load(boundary_id) + if record is None: + raise InvalidParamsError("permission_resume_invalid: permission checkpoint is unavailable.") + permission_audit = getattr(pending.request.permission_result, "audit", None) + principal_ref, region = permission_execution_identity( + tool_name=pending.request.tool_name, + tool_input=pending.request.tool_input, + permission_audit=permission_audit, + ) + if principal_ref != record.get("principalRef") or region != record.get("region"): + raise InvalidParamsError("permission_resume_invalid: cloud execution identity changed.") + + async def _backup_claim_before_delivery(self, pending: PendingPermission) -> None: + store = pending.checkpoint_store + boundary_id = pending.boundary_id + cwd = pending.backup_cwd + session_id = pending.backup_session_id + backup_service = pending.backup_service + if store is None or boundary_id is None or cwd is None or session_id is None or backup_service is None: + return + await backup_permission_wait_checkpoint( + store=store, + boundary_id=boundary_id, + cwd=cwd, + session_id=session_id, + backup_service=backup_service, + metrics=pending.backup_metrics, + ) + async def is_sideband_response(self, response: PermissionResponse) -> bool: try: pending = await self._lookup(response) @@ -521,9 +828,7 @@ async def claim_for_cancel(self, task_id: str, owner: PermissionResolutionOwner) claimed = [ pending for pending in self._pending.values() - if pending.task_id == task_id - and pending.resolution_owner is owner - and pending.state in {"pending", "resolving"} + if pending.task_id == task_id and pending.resolution_owner is owner and pending.state == "pending" ] for pending in claimed: pending.state = "canceling" @@ -575,7 +880,14 @@ async def cancel_task( for owner in owners: await owner.cancel_permissions(task_id) for pending in legacy: - await self.fail(pending) + coordinator = self._permission_wait_coordinator + canceled = bool( + coordinator is not None + and pending.boundary_id is not None + and await coordinator.cancel_live(pending.boundary_id) + ) + if not canceled: + await self.fail(pending) await self.complete(pending) return token @@ -597,6 +909,20 @@ async def fail(self, pending: PendingPermission) -> None: if pending.resolution_owner is not None: await pending.resolution_owner.fail_permission(pending) return + # This cleanup is serialized by the same per-boundary resolution lock + # used for answers and cancellation. It prevents a failed critical + # backup/publication from leaving a recoverable checkpoint for an + # INPUT_REQUIRED boundary that was never externally visible. + coordinator = self._permission_wait_coordinator + if pending.boundary_id is not None and pending.checkpoint_store is not None: + canceled = bool(coordinator is not None and await coordinator.cancel_live(pending.boundary_id)) + if not canceled: + try: + pending.checkpoint_store.cancel(pending.boundary_id) + except ValueError: + # A concurrently claimed decision remains authoritative; + # recovery will finish applying that decision. + pass future = pending.request.response_future if future is not None and not future.done(): emit_permission_boundary_audit( @@ -616,6 +942,28 @@ async def complete(self, pending: PendingPermission) -> None: self._pending.pop(key, None) pending.state = "completed" self._condition.notify_all() + if pending.timeout_task is not None and pending.timeout_task is not asyncio.current_task(): + pending.timeout_task.cancel() + if pending.boundary_id is not None and self._permission_wait_coordinator is not None: + self._permission_wait_coordinator.unregister_live(pending.boundary_id) + + async def claim_continuation(self, pending: PendingPermission) -> Any | None: + """Claim a detached serial permission continuation exactly once. + + Durable decision claiming is idempotent, but invoking the live + continuation is not. Keep this one-shot ownership under the registry + lock so concurrent/retried permission answers can only return the + existing acknowledgement. + """ + + async with self._condition: + key = (pending.task_id, pending.input_id) + if self._pending.get(key) is not pending: + return None + if pending.continuation is None or pending.continuation_claimed: + return None + pending.continuation_claimed = True + return pending.continuation async def _lookup(self, response: PermissionResponse) -> PendingPermission: async with self._condition: @@ -647,11 +995,30 @@ def _json_data_part(part: Any) -> dict[str, Any] | None: return value if isinstance(value, dict) else None +def _json_text_part(part: Any) -> dict[str, Any] | None: + try: + has_text = part.HasField("text") + except (AttributeError, ValueError): + has_text = False + text = getattr(part, "text", None) + if not has_text or not isinstance(text, str) or not text.startswith(PERMISSION_QUERY_PREFIX): + return None + payload_text = text[len(PERMISSION_QUERY_PREFIX) :].lstrip() + if not payload_text: + return None + try: + value = json.loads(payload_text) + except (json.JSONDecodeError, TypeError): + return None + return value if isinstance(value, dict) else None + + def _permission_input_id() -> str: return "permission-{}".format(uuid.uuid4().hex) __all__ = [ + "PERMISSION_QUERY_PREFIX", "PendingPermission", "PermissionInputRegistry", "PermissionResolutionOwner", diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index cf073fec..80d58535 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -16,10 +16,12 @@ import yaml from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent from a2a.utils.errors import InvalidParamsError +from google.protobuf.json_format import ParseDict from iac_code.a2a.artifacts import artifact_store_for_session from iac_code.a2a.backup import backup_session_async from iac_code.a2a.events import make_text_part, publish_mcp_warnings +from iac_code.a2a.input_required import PendingPermission from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator from iac_code.a2a.pipeline_flow_monitor import ( PipelineA2AFlowIdentity, @@ -45,6 +47,7 @@ configure_runtime_model, refresh_runtime_cloud_tools, ) +from iac_code.a2a.task_store import _close_runtime from iac_code.a2a.types import ( TASK_STATE_CANCELED, TASK_STATE_COMPLETED, @@ -66,6 +69,7 @@ from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime +from iac_code.services.permission_wait import RecoveredPermissionAuditBoundary, canonical_digest from iac_code.services.providers.aliyun import AliyunCredential from iac_code.services.session_backup import BackupReason, SessionBackupBlocked, SessionBackupService from iac_code.services.session_backup_state import NORMAL_HANDOFF_PROOF_KEY, BackupPublicationProof @@ -74,6 +78,7 @@ from iac_code.types.stream_events import ( AskUserQuestionEvent, PermissionRequestEvent, + PermissionWaitSuspended, SubPipelineStreamEvent, TextDeltaEvent, ) @@ -114,6 +119,10 @@ def _new_set_asyncio_event() -> asyncio.Event: return event +async def _async_noop() -> None: + return None + + class WaitingInputCancelResult(str, Enum): CANCELED = "canceled" NOT_OWNER = "not_owner" @@ -173,6 +182,63 @@ class _StreamConsumeResult: had_events: bool restart_requested: bool terminal_handoff_unavailable: bool = False + detached_permission: "_DetachedPipelinePermission | None" = None + + +@dataclass +class _DetachedPipelinePermission: + """One top-level permission whose continuation will move to a new SSE.""" + + stream: Any + registry: Any + on_suspend: Callable[[], Awaitable[None]] + resume_agent_loops: Callable[[], Any] | None = None + pending: PendingPermission | None = None + _continuation: Callable[[Any, PendingPermission], Awaitable[None]] | None = None + _ready: asyncio.Event = field(default_factory=asyncio.Event) + _close_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + _closed: bool = False + _loops_resumed: bool = False + + def prepare(self, pending: PendingPermission) -> None: + self.pending = pending + pending.continuation = self._run + pending.suspend_callback = self._suspend + + def install(self, continuation: Callable[[Any, PendingPermission], Awaitable[None]]) -> None: + self._continuation = continuation + self._ready.set() + + async def close_stream(self) -> None: + async with self._close_lock: + if self._closed: + return + self._closed = True + if not self._loops_resumed and callable(self.resume_agent_loops): + self._loops_resumed = True + await _maybe_await(self.resume_agent_loops()) + close = getattr(self.stream, "aclose", None) + if callable(close): + with contextlib.suppress(RuntimeError): + await close() + + async def _run(self, event_queue: Any, pending: PendingPermission) -> None: + await self._ready.wait() + continuation = self._continuation + if continuation is None: + raise RuntimeError("detached Pipeline permission continuation is unavailable") + await continuation(event_queue, pending) + + async def _suspend(self) -> None: + pending = self.pending + if pending is None: + return + pending.continuation = None + await self.close_stream() + try: + await self.on_suspend() + finally: + await self.registry.complete(pending) @dataclass(frozen=True) @@ -302,6 +368,52 @@ def __init__( self._backup_service = backup_service or SessionBackupService() self._aliyun_delegated_executor_factory = aliyun_delegated_executor_factory + async def rebuild_permission_audit_event( + self, + *, + cwd: str, + session_id: str, + checkpoint: dict[str, Any], + recovered: RecoveredPermissionAuditBoundary, + ) -> PermissionRequestEvent: + """Use the exact restored Pipeline step runtime for restart audit data.""" + + session_storage = SessionStorage() + restore_session = getattr(self._backup_service, "restore_session", None) + if restore_session is None: + SessionBackupService(session_storage=session_storage).restore_session(cwd, session_id) + else: + restore_session(cwd, session_id) + runtime = create_agent_runtime( + AgentFactoryOptions( + model=self._model, + session_id=session_id, + cwd=cwd, + provider_key_override=self._provider_key_override, + provider_api_key_override=self._provider_api_key_override, + provider_base_url_override=self._provider_base_url_override, + provider_config_frozen=self._provider_config_frozen, + provider_config_override=self._provider_config_override, + effort_override=self._effort_override, + source="a2a-pipeline", + ) + ) + try: + with self._request_context(session_id=session_id): + self._configure_agent_runtime_for_request(runtime) + pipeline = self._create_pipeline( + session_id=session_id, + cwd=cwd, + runtime=runtime, + session_storage=session_storage, + ) + rebuild = getattr(pipeline, "rebuild_permission_audit_event", None) + if not callable(rebuild): + raise ValueError("permission_resume_invalid: Pipeline cannot rebuild permission audit") + return await rebuild(checkpoint, recovered) + finally: + await _close_runtime(runtime) + async def execute( self, *, @@ -314,6 +426,7 @@ async def execute( pipeline_input: PipelineUserInput | str | None = None, prompt: str | None = None, active_followup_only: bool = False, + permission_checkpoint: dict[str, Any] | None = None, ) -> bool | None: if pipeline_input is None: pipeline_input = prompt or "" @@ -518,15 +631,24 @@ async def fresh_pipeline_factory() -> Any: else: fresh_pipeline_factory = create_fresh_pipeline - selected = await self._select_stream( - pipeline, - prompt, - pipeline_input=pipeline_input, - publisher=publisher, - task_id=task_id, - context_id=context_id, - fresh_pipeline_factory=fresh_pipeline_factory, - ) + if permission_checkpoint is not None: + resume_permission = getattr(pipeline, "resume_permission_boundary", None) + if not callable(resume_permission): + raise RuntimeError("permission_resume_invalid: Pipeline cannot resume permissions") + selected = _SelectedPipelineStream( + pipeline=pipeline, + stream=resume_permission(permission_checkpoint), + ) + else: + selected = await self._select_stream( + pipeline, + prompt, + pipeline_input=pipeline_input, + publisher=publisher, + task_id=task_id, + context_id=context_id, + fresh_pipeline_factory=fresh_pipeline_factory, + ) if selected.pipeline is not pipeline: pipeline = selected.pipeline publisher = self._publisher( @@ -550,6 +672,11 @@ async def fresh_pipeline_factory() -> Any: self._task_store.mirror_context(ctx) stream_had_events = False terminal_handoff_unavailable = False + detached_permission: _DetachedPipelinePermission | None = None + + async def release_detached_runtime() -> None: + await self._task_store.discard_context_runtime(context_id) + with self._request_context(session_id=ctx.session_id): while True: stream_result = await self._consume_stream_until_restart( @@ -557,17 +684,74 @@ async def fresh_pipeline_factory() -> Any: runtime=pipeline_runtime, publisher=publisher, task=task, + on_detached_permission_suspend=release_detached_runtime, ) stream_had_events = stream_had_events or stream_result.had_events terminal_handoff_unavailable = ( terminal_handoff_unavailable or stream_result.terminal_handoff_unavailable ) + if stream_result.detached_permission is not None: + detached_permission = stream_result.detached_permission + break + if not stream_result.restart_requested: break stream = self._continue_after_interrupt_stream(pipeline, pipeline_input) + if detached_permission is not None: + pending = detached_permission.pending + permission_input_registry = self._permission_input_registry + if pending is None or permission_input_registry is None: + raise RuntimeError("detached Pipeline permission is unavailable") + + async def resume_detached_pipeline(target_queue: Any, resumed: PendingPermission) -> None: + store = resumed.checkpoint_store + boundary_id = resumed.boundary_id + if store is None or boundary_id is None: + raise RuntimeError("permission checkpoint is unavailable") + await detached_permission.close_stream() + record = store.load(boundary_id) + if record is None: + raise RuntimeError("permission checkpoint is unavailable") + try: + await self.execute( + context=context, + event_queue=target_queue, + task=task, + task_id=task_id, + context_id=context_id, + cwd=cwd, + pipeline_input="", + permission_checkpoint=record, + ) + persisted = SessionStorage().load(cwd, ctx.session_id) + digest = canonical_digest(persisted[-1].to_dict()) if persisted else "" + decision = record.get("decision") + value = decision.get("value") if isinstance(decision, dict) else None + store.resolve( + boundary_id, + result_digest=digest, + ack={"decision": value, "accepted": True}, + ) + finally: + await permission_input_registry.complete(resumed) + + detached_permission.install(resume_detached_pipeline) + task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await self._notify_terminal_task( + task_id=task.task_id, + context_id=task.context_id, + state=task.state, + ) + return + terminal_status_published = False terminal_sidecar = _is_terminal_sidecar_status(getattr(pipeline, "sidecar_status", None)) terminal_sidecar_recovery_allowed = not terminal_handoff_unavailable @@ -687,6 +871,30 @@ async def publish_cancel_terminal() -> bool: except _PipelineBackupBlockedTransitionError: task_persistence_started = True await self._complete_backup_blocked_transition(task=task, ctx=ctx) + except PermissionWaitSuspended: + task_persistence_started = True + task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + metadata={ + "iac_code": { + "permissionWait": {"status": "suspended", "resumable": True}, + } + }, + ) + await self._notify_terminal_task( + task_id=task.task_id, + context_id=task.context_id, + state=task.state, + ) except Exception as exc: task_persistence_started = True try: @@ -1175,6 +1383,25 @@ async def _continue_active_pause_confirmation( self._record_state(task.state) except _PipelineBackupBlockedTransitionError: await self._complete_backup_blocked_transition(task=task, ctx=ctx) + except PermissionWaitSuspended: + task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + metadata={ + "iac_code": { + "permissionWait": {"status": "suspended", "resumable": True}, + } + }, + ) + await self._notify_terminal_task(task_id=task_id, context_id=context_id, state=task.state) except Exception as exc: try: await self._publish_exception_status( @@ -1235,11 +1462,7 @@ def _create_pipeline( def permission_context_getter() -> Any: return getattr(agent_loop, "_permission_context", None) - surface = ( - A2A_RICH_CANDIDATE_SURFACE - if self._candidate_presentation == RICH_CANDIDATE_PRESENTATION - else "a2a" - ) + surface = A2A_RICH_CANDIDATE_SURFACE if self._candidate_presentation == RICH_CANDIDATE_PRESENTATION else "a2a" return create_pipeline( pipeline_name, provider_manager=runtime.provider_manager, @@ -1476,6 +1699,7 @@ async def _consume_stream_until_restart( runtime: A2APipelineRuntime, publisher: PipelineA2AEventPublisher, task: Any, + on_detached_permission_suspend: Callable[[], Awaitable[None]] | None = None, ) -> "_StreamConsumeResult": had_events = False outbound = PipelineA2AOutboundQueue(publisher) if publisher.extreme_performance else None @@ -1621,17 +1845,33 @@ async def _consume_stream_until_restart( await outbound.flush() pause_agent_loops = getattr(runtime.pipeline, "pause_agent_loops", None) resume_agent_loops = getattr(runtime.pipeline, "resume_agent_loops", None) + detached_permission = _DetachedPipelinePermission( + stream=stream_iter, + registry=self._permission_input_registry, + on_suspend=on_detached_permission_suspend or _async_noop, + resume_agent_loops=(resume_agent_loops if callable(resume_agent_loops) else None), + ) if callable(pause_agent_loops): await _maybe_await(pause_agent_loops()) try: - text = await publisher.publish( + published = await publisher.publish( event, permission_resolver=self._permission_resolver, auto_approve_permissions=self._auto_approve_permissions, + prepare_detached_permission=detached_permission.prepare, ) - finally: + except BaseException: if callable(resume_agent_loops): await _maybe_await(resume_agent_loops()) + raise + if published is not detached_permission.pending: + raise RuntimeError("Pipeline permission could not detach from its current stream") + return _StreamConsumeResult( + had_events=had_events, + restart_requested=False, + terminal_handoff_unavailable=terminal_handoff_unavailable, + detached_permission=detached_permission, + ) elif outbound is not None: delivery_text = _text_delta_output(event) await outbound.submit( @@ -1746,6 +1986,8 @@ async def publish() -> tuple[_TerminalHandoffPublishResult, str | None]: permission_resolver=self._permission_resolver, auto_approve_permissions=self._auto_approve_permissions, ) + if isinstance(text, PendingPermission): + raise RuntimeError("terminal Pipeline event produced a permission boundary") self._track_pending_question(runtime, publisher, event) await self._maybe_publish_normal_handoff_ready(runtime.pipeline, publisher, event) return handoff, text @@ -1873,6 +2115,8 @@ def _publisher( task_store=self._task_store, backup_commit_gate=_requires_backup_committed_publication, flow_monitor=flow_monitor, + permission_wait_cwd=cwd, + permission_wait_session_id=session_id, ) def _install_backup_hook( @@ -1928,8 +2172,6 @@ async def _backup_before_pipeline_publication( reason = _backup_reason_for_pipeline_envelope(envelope) if reason is None: return True - if reason in {BackupReason.INPUT_REQUIRED, BackupReason.WAITING_INPUT}: - return True if reason in {BackupReason.TERMINAL, BackupReason.HANDOFF_READY}: if _is_pending_backup_publication_event(envelope): return True @@ -1945,6 +2187,10 @@ async def _backup_before_pipeline_publication( ctx=ctx, reason=reason, ) + if reason == BackupReason.INPUT_REQUIRED: + pending = publisher.pending_durable_permission + if pending is not None and self._permission_input_registry is not None: + self._permission_input_registry.activate_durable_boundary(pending) return True async def _backup_after_pipeline_publication( @@ -1961,17 +2207,11 @@ async def _backup_after_pipeline_publication( reason = _backup_reason_for_pipeline_envelope(envelope) if reason not in {BackupReason.INPUT_REQUIRED, BackupReason.WAITING_INPUT}: return - self._mirror_a2a_snapshots_for_pipeline_publication(envelope, task=task, ctx=ctx) - await self._backup_pipeline_publication( - envelope, - publisher=publisher, - pipeline=pipeline, - cwd=cwd, - session_id=session_id, - task=task, - ctx=ctx, - reason=reason, - ) + # INPUT_REQUIRED/WAITING_INPUT are backup-gated now. Their critical + # commit happened in ``before_enqueue`` after journal persistence and + # before external visibility; repeating it here would create a second + # generation after publication. + return async def _backup_pipeline_publication( self, @@ -1991,15 +2231,25 @@ async def _backup_pipeline_publication( NORMAL_HANDOFF_PROOF_KEY: BackupPublicationProof.from_envelope(envelope), } try: - await backup_session_async( - self._backup_service, - cwd, - session_id, - reason=reason, - critical=True, - metrics=self._metrics, - publication_proofs=publication_proofs, - ) + pending = publisher.pending_durable_permission if reason == BackupReason.INPUT_REQUIRED else None + if pending is not None and self._permission_input_registry is not None: + await self._permission_input_registry.backup_durable_boundary( + pending, + cwd, + session_id, + backup_service=self._backup_service, + metrics=self._metrics, + ) + else: + await backup_session_async( + self._backup_service, + cwd, + session_id, + reason=reason, + critical=True, + metrics=self._metrics, + publication_proofs=publication_proofs, + ) except SessionBackupBlocked as exc: sidecar_synced = await _sync_pipeline_backup_blocked_sidecar( pipeline, @@ -2991,6 +3241,7 @@ async def _publish_status( context_id: str, state: int, text: str | None = None, + metadata: dict[str, Any] | None = None, ) -> None: message = None if text: @@ -3003,7 +3254,10 @@ async def _publish_status( ) status = TaskStatus(state=TaskState.Name(state), message=message) status.timestamp.GetCurrentTime() - await event_queue.enqueue_event(TaskStatusUpdateEvent(task_id=task_id, context_id=context_id, status=status)) + update = TaskStatusUpdateEvent(task_id=task_id, context_id=context_id, status=status) + if metadata is not None: + ParseDict(metadata, update.metadata) + await event_queue.enqueue_event(update) async def _notify_terminal_task(self, *, task_id: str, context_id: str, state: str) -> None: if self._push_notifier is None: @@ -3158,7 +3412,12 @@ def _backup_retry_count_from_exception(exc: BaseException) -> int: def _requires_backup_committed_publication(envelope: dict[str, Any]) -> bool: if _publication_visibility_from_event(envelope) in {_PENDING_BACKUP_VISIBILITY, _COMMITTED_BACKUP_VISIBILITY}: return False - return _backup_reason_for_pipeline_envelope(envelope) in {BackupReason.TERMINAL, BackupReason.HANDOFF_READY} + return _backup_reason_for_pipeline_envelope(envelope) in { + BackupReason.INPUT_REQUIRED, + BackupReason.WAITING_INPUT, + BackupReason.TERMINAL, + BackupReason.HANDOFF_READY, + } def _pending_backup_publication_envelope(envelope: dict[str, Any]) -> dict[str, Any]: diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index 028f0fd0..c3a3c60d 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -47,11 +47,18 @@ PIPELINE_EVENT_CLEANUP_PROGRESS, PIPELINE_EVENT_CLEANUP_STARTED, ) +from iac_code.services.permission_wait import canonical_digest from iac_code.services.permissions.audit import ( emit_permission_boundary_audit, is_aliyun_api_non_read_only_permission_event, ) -from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent, ToolResultEvent +from iac_code.types.stream_events import ( + MessageStartEvent, + PermissionRequestEvent, + PermissionWaitOutcome, + SubPipelineStreamEvent, + ToolResultEvent, +) from iac_code.utils.public_errors import sanitize_strict_text PipelinePermissionResolver = Callable[[PermissionRequestEvent], bool | Awaitable[bool]] @@ -162,6 +169,8 @@ def __init__( backup_commit_gate: PipelineBackupCommitGate | None = None, extreme_performance: bool | None = None, flow_monitor: Any | None = None, + permission_wait_cwd: str | None = None, + permission_wait_session_id: str | None = None, ) -> None: self.event_queue = event_queue self.translator = translator @@ -178,6 +187,8 @@ def __init__( self.after_backup_commit = after_backup_commit self.backup_commit_gate = backup_commit_gate self.flow_monitor = flow_monitor + self.permission_wait_cwd = permission_wait_cwd + self.permission_wait_session_id = permission_wait_session_id self._sequence_lock = asyncio.Lock() self._delivery_lock = asyncio.Lock() self._delivery_lock_owner: asyncio.Task[Any] | None = None @@ -192,6 +203,42 @@ def __init__( self._extreme_pending_journal_events: list[dict[str, Any]] = [] self._extreme_pending_snapshot_events: list[dict[str, Any]] = [] self.permission_resolution_owner = _PipelinePermissionResolutionOwner(self) + self.pending_durable_permission: PendingPermission | None = None + self._consumed_durable_permissions: list[PendingPermission] = [] + + async def _finish_consumed_durable_permissions(self, *, results_persisted: bool) -> None: + """Compact live top-level receipts only after their tool-result batch is durable.""" + + registry = self.permission_input_registry + if registry is None or not self._consumed_durable_permissions: + return + remaining: list[PendingPermission] = [] + for pending in self._consumed_durable_permissions: + store = pending.checkpoint_store + boundary_id = pending.boundary_id + if store is None or boundary_id is None: + await registry.complete(pending) + continue + record = store.load(boundary_id) + if record is None: + await registry.complete(pending) + continue + if results_persisted and record.get("phase") != "RESOLVED": + decision = record.get("decision") + snapshot = self.snapshot_store.load() or {} + record = store.resolve( + boundary_id, + result_digest=canonical_digest(snapshot), + ack={ + "decision": decision.get("value") if isinstance(decision, dict) else None, + "accepted": True, + }, + ) + if record.get("phase") == "RESOLVED": + await registry.complete(pending) + else: + remaining.append(pending) + self._consumed_durable_permissions = remaining async def publish_sub_pipeline_permission(self, event: Any) -> str | None: """Publish only a wrapped Sub Pipeline permission without waiting for its Future.""" @@ -234,6 +281,11 @@ async def publish_sub_pipeline_permission(self, event: Any) -> str | None: except BaseException: await self.permission_resolution_owner.fail_permission(pending) raise + timeout = self.permission_input_registry.permission_wait_policy.sub_pipeline_timeout_seconds + if timeout is not None: + pending.timeout_task = asyncio.create_task( + self.permission_resolution_owner.timeout_permission(pending, timeout) + ) return None async def _commit_permission_control_event( @@ -268,6 +320,8 @@ async def publish_permission_resolution( *, decision: str, canceled: bool = False, + automatic: bool = False, + timed_out: bool = False, ) -> bool: permission = { "permissionId": pending.input_id, @@ -279,6 +333,10 @@ async def publish_permission_resolution( } if canceled: permission["canceled"] = True + if automatic: + permission["automatic"] = True + if timed_out: + permission["timedOut"] = True envelope = self.translator.manual_event( "permission_resolved", pending.scope, @@ -297,7 +355,13 @@ async def publish( *, permission_resolver: PipelinePermissionResolver | None = None, auto_approve_permissions: bool = False, - ) -> str | None: + prepare_detached_permission: Callable[[PendingPermission], None] | None = None, + ) -> str | PendingPermission | None: + if isinstance(_unwrap_stream_event(event), MessageStartEvent): + # AgentLoop appends the ordered ToolResult batch immediately before + # it starts the next model message, making this the first safe + # compaction boundary for a live top-level permission. + await self._finish_consumed_durable_permissions(results_persisted=True) if ( _sub_pipeline_permission_request_from(event) is not None and self.permission_input_registry is not None @@ -306,9 +370,29 @@ async def publish( ): return await self.publish_sub_pipeline_permission(event) envelopes = self.translator.translate(event) + if any( + envelope.get("eventType") + in {"step_completed", "step_failed", "pipeline_completed", "pipeline_failed", "pipeline_canceled"} + for envelope in envelopes + ): + # Custom Pipeline steps need not expose the AgentLoop message + # boundary. A committed step/terminal transition is also proof + # that the permission-controlled work has left its waiting frame. + await self._finish_consumed_durable_permissions(results_persisted=True) permission_request = _permission_request_from(event) tool_result = _tool_result_from(event) text_parts: list[str] = [] + if permission_request is not None and permission_resolver is not None: + permission_request.permission_wait_class = ( + "sub_pipeline" if _sub_pipeline_permission_request_from(event) is not None else "pipeline" + ) + coordinates: dict[str, Any] = {} + for candidate_envelope in envelopes: + for key in ("step", "candidate", "candidateStep"): + value = candidate_envelope.get(key) + if isinstance(value, dict): + coordinates[key] = dict(value) + permission_request.permission_wait_coordinates = coordinates interactive_permission = ( permission_request is not None and self.permission_input_registry is not None @@ -322,7 +406,33 @@ async def publish( task_id=self.translator.context.task_id, context_id=self.translator.context.context_id, ) + if prepare_detached_permission is not None: + prepare_detached_permission(pending_permission) + coordinates: dict[str, Any] = {} + for candidate_envelope in envelopes: + for key in ("step", "candidate", "candidateStep"): + value = candidate_envelope.get(key) + if isinstance(value, dict): + coordinates[key] = dict(value) + if bool(getattr(self.permission_input_registry, "durable_permission_wait_enabled", False)): + if self.permission_wait_cwd is None or self.permission_wait_session_id is None: + raise PipelineA2APersistenceError("Pipeline permission checkpoint session is unavailable") + await self.permission_input_registry.open_durable_boundary( + pending_permission, + cwd=self.permission_wait_cwd, + session_id=self.permission_wait_session_id, + permission_class="pipeline", + backup_service=None, + pipeline_coordinates=coordinates, + perform_backup=False, + ) + self.pending_durable_permission = pending_permission + # A successor creation atomically turns the previous boundary + # into a receipt. Its process-local owner can now be released. + await self._finish_consumed_durable_permissions(results_persisted=False) + retain_consumed_permission = False + retain_detached_permission = False try: for envelope in envelopes: if _should_skip_envelope(envelope, exposure_types=self.exposure_types): @@ -413,8 +523,18 @@ async def publish( if future is None: assert pending_permission is not None await self.permission_input_registry.fail(pending_permission) + elif prepare_detached_permission is not None: + assert pending_permission is not None + retain_detached_permission = True else: - await asyncio.shield(future) + outcome = await asyncio.shield(future) + retain_consumed_permission = ( + outcome is not PermissionWaitOutcome.SUSPEND + and pending_permission is not None + and pending_permission.boundary_id is not None + ) + if retain_consumed_permission: + self._consumed_durable_permissions.append(pending_permission) except BaseException: if pending_permission is not None: assert self.permission_input_registry is not None @@ -423,8 +543,13 @@ async def publish( finally: if pending_permission is not None: assert self.permission_input_registry is not None - await self.permission_input_registry.complete(pending_permission) + if not retain_consumed_permission and not retain_detached_permission: + await self.permission_input_registry.complete(pending_permission) + if self.pending_durable_permission is pending_permission: + self.pending_durable_permission = None + if retain_detached_permission: + return pending_permission return "".join(text_parts) if text_parts else None async def publish_batch(self, events: list[Any]) -> None: @@ -1331,57 +1456,92 @@ def _delivery_context_id(self, envelope: dict[str, Any]) -> str: class _PipelinePermissionResolutionOwner: - """Serialize replies, cancellation, journal order, and Future completion for one Task.""" + """Resolve each Sub Pipeline permission independently and at most once.""" def __init__(self, publisher: PipelineA2AEventPublisher) -> None: self.publisher = publisher - self._lock = asyncio.Lock() async def resolve_permission(self, pending: PendingPermission, response: PermissionResponse) -> bool: registry = self.publisher.permission_input_registry if registry is None: raise PipelineA2APersistenceError("Sub Pipeline permission registry is unavailable") - async with self._lock: + async with pending.claim_lock: await registry.claim(pending, response) - approved = response.decision == "allow_once" - audit_ok = emit_permission_boundary_audit( + approved = response.decision == "allow_once" + audit_ok = emit_permission_boundary_audit( + pending.request, + decision="allow" if approved else "deny", + scope="a2a_sub_pipeline_permission", + source="a2a_user_permission", + reason_type="user_decision", + reason_detail=response.decision, + ) + if approved and not audit_ok: + approved = False + decision = "allow_once" if approved else "deny" + try: + committed = await self.publisher.publish_permission_resolution(pending, decision=decision) + except BaseException: + await self._finish_failed(pending) + raise + if not committed: + await self._finish_failed(pending) + raise PipelineA2APersistenceError("Failed to publish Sub Pipeline permission resolution") + future = pending.request.response_future + if future is None or future.done(): + await registry.complete(pending) + raise PipelineA2APersistenceError("Sub Pipeline permission wait point is unavailable") + future.set_result(approved) + await registry.complete(pending) + return approved + + async def timeout_permission(self, pending: PendingPermission, timeout_seconds: float) -> None: + registry = self.publisher.permission_input_registry + if registry is None: + return + try: + await asyncio.sleep(timeout_seconds) + async with pending.claim_lock: + if pending.state != "pending": + return + pending.state = "resolving" + emit_permission_boundary_audit( pending.request, - decision="allow" if approved else "deny", + decision="deny", scope="a2a_sub_pipeline_permission", - source="a2a_user_permission", - reason_type="user_decision", - reason_detail=response.decision, + source="timeout", + reason_type="permission_wait_timeout", + reason_detail="Sub Pipeline permission wait timed out", + ) + committed = await self.publisher.publish_permission_resolution( + pending, + decision="deny", + automatic=True, + timed_out=True, ) - if approved and not audit_ok: - approved = False - decision = "allow_once" if approved else "deny" - try: - committed = await self.publisher.publish_permission_resolution(pending, decision=decision) - except BaseException: - await self._finish_failed(pending) - raise if not committed: - await self._finish_failed(pending) - raise PipelineA2APersistenceError("Failed to publish Sub Pipeline permission resolution") + raise PipelineA2APersistenceError("Failed to publish Sub Pipeline permission timeout") future = pending.request.response_future - if future is None or future.done(): - await registry.complete(pending) - raise PipelineA2APersistenceError("Sub Pipeline permission wait point is unavailable") - future.set_result(approved) + if future is not None and not future.done(): + future.set_result(False) await registry.complete(pending) - return approved + except asyncio.CancelledError: + return + except Exception: + logger.warning("Failed to resolve timed-out Sub Pipeline permission", exc_info=True) + await self._finish_failed(pending) async def fail_permission(self, pending: PendingPermission) -> None: - async with self._lock: + async with pending.claim_lock: await self._finish_failed(pending) async def cancel_permissions(self, task_id: str) -> None: registry = self.publisher.permission_input_registry if registry is None: return - async with self._lock: - pending_permissions = await registry.claim_for_cancel(task_id, self) - for pending in pending_permissions: + pending_permissions = await registry.claim_for_cancel(task_id, self) + for pending in pending_permissions: + async with pending.claim_lock: emit_permission_boundary_audit( pending.request, decision="deny", @@ -1399,9 +1559,9 @@ async def cancel_permissions(self, task_id: str) -> None: if future is not None and not future.done(): future.set_result(False) await registry.complete(pending) - if self.publisher.task_store is not None: - remaining = await registry.pending_envelopes(task_id) - await self.publisher.task_store.set_pending_permissions(task_id, remaining) + if self.publisher.task_store is not None: + remaining = await registry.pending_envelopes(task_id) + await self.publisher.task_store.set_pending_permissions(task_id, remaining) async def _finish_failed(self, pending: PendingPermission) -> None: registry = self.publisher.permission_input_registry @@ -1552,6 +1712,10 @@ def committed_backup_publication_envelope( value = pending_envelope.get(key) if isinstance(value, dict): envelope[key] = dict(value) + for key in ("permission", "input"): + value = pending_envelope.get(key) + if isinstance(value, dict): + envelope[key] = dict(value) return envelope diff --git a/src/iac_code/a2a/task_store.py b/src/iac_code/a2a/task_store.py index 0f1ae69d..6482a7b0 100644 --- a/src/iac_code/a2a/task_store.py +++ b/src/iac_code/a2a/task_store.py @@ -79,6 +79,10 @@ def __init__( self._context_execution_starts: dict[str, dict[str, asyncio.Task[Any]]] = {} self._owner_resolver = owner_resolver self._backup_service = backup_service or SessionBackupService() + self._permission_wait_active_probe: Callable[[], bool] | None = None + + def set_permission_wait_active_probe(self, probe: Callable[[], bool] | None) -> None: + self._permission_wait_active_probe = probe async def get(self, task_id: str, context: ServerCallContext | None = None) -> Task | None: owner = self._owner(context) @@ -864,14 +868,13 @@ async def has_active_work(self) -> bool: any(record.active_task is not None and not record.active_task.done() for record in self._tasks.values()) or any(not task.done() for task in self._context_runtime_tasks.values()) or any( - not task.done() - for starts in self._context_execution_starts.values() - for task in starts.values() + not task.done() for starts in self._context_execution_starts.values() for task in starts.values() ) or any(self._context_reconciliation_waiters.values()) or any(lock.locked() for lock in self._reconciliation_locks.values()) or any(count > 0 for count in self._context_runtime_waiters.values()) or any(not task.done() for task in self._discarded_context_runtime_tasks) + or bool(self._permission_wait_active_probe and self._permission_wait_active_probe()) ) def mirror_task(self, record: A2ATaskRecord) -> None: diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index 0fb2451f..a5524a32 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -11,6 +11,7 @@ import httpx from a2a.server.agent_execution.active_task import INTERRUPTED_TASK_STATES, TERMINAL_TASK_STATES +from a2a.server.events.event_queue import EventQueue from a2a.server.events.event_queue_v2 import QueueShutDown from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.routes import create_jsonrpc_routes @@ -90,6 +91,7 @@ from iac_code.a2a.task_store import A2ATaskStore from iac_code.i18n import _ from iac_code.pipeline.config import RunMode, get_run_mode +from iac_code.services.permission_wait import PermissionWaitCheckpointStore from iac_code.services.session_backup import SessionBackupService from iac_code.services.session_backup_staging import ( SessionBackupStagingProcess, @@ -110,6 +112,16 @@ class _ASGIResponseChunk: consumed: asyncio.Future[None] +class _DetachedPermissionEventQueue(EventQueue): + """Minimal producer queue for resuming an already-persisted A2A task.""" + + def __init__(self, queue: asyncio.Queue[Any]) -> None: + self._queue = queue + + async def enqueue_event(self, event: Any) -> None: + await self._queue.put(event) + + class _StreamingASGIResponseStream(httpx.AsyncByteStream): def __init__( self, @@ -339,10 +351,14 @@ def create_runtime_components( supported_interfaces: list[dict[str, str]] | None = None, agent_extensions: object | None = None, auto_approve_permissions: bool = False, + permission_wait: object | None = None, thinking_exposure: object | None = None, backup_service: Any | None = None, ) -> A2ARuntimeComponents: + from iac_code.services.permission_wait import PermissionWaitPolicy + metrics = NoOpA2AMetrics() + permission_wait_policy = PermissionWaitPolicy.from_config(permission_wait) thinking_exposure_types = normalize_a2a_exposure_types(thinking_exposure) backup_staging_process = None if backup_service is None: @@ -401,6 +417,7 @@ def create_runtime_components( metrics=metrics, artifact_store=artifact_store, auto_approve_permissions=auto_approve_permissions, + permission_wait_policy=permission_wait_policy, thinking_exposure_types=thinking_exposure_types, backup_service=backup_service, ) @@ -478,11 +495,26 @@ async def on_message_send(self, params: SendMessageRequest, context): self._validate_pipeline_message_request(params) permission_response = parse_permission_response(params.message) if permission_response is not None: + if not params.message.task_id: + params.message.task_id = permission_response.task_id resolve = getattr(getattr(self, "agent_executor", None), "resolve_sideband_permission", None) if callable(resolve): ack = await resolve(permission_response) if ack is not None: return ack + if isinstance(self.task_store, A2ATaskStore) and not await self.task_store.is_task_active( + permission_response.task_id + ): + task = await self.task_store.get(permission_response.task_id, context) + if task is not None: + async for _event in self._on_inactive_permission_send_stream( + params, + context, + task=task, + ): + pass + refreshed = await self.task_store.get(permission_response.task_id, context) + return refreshed or task await self._hydrate_recoverable_pipeline_task_id(params) await self._reconcile_recoverable_pipeline_task(params, context) return await super().on_message_send(params, context) @@ -492,6 +524,8 @@ async def on_message_send_stream(self, params: SendMessageRequest, context): self._validate_pipeline_message_request(params) permission_response = parse_permission_response(params.message) if permission_response is not None: + if not params.message.task_id: + params.message.task_id = permission_response.task_id resolve = getattr(getattr(self, "agent_executor", None), "resolve_sideband_permission", None) if callable(resolve): ack = await resolve(permission_response) @@ -523,6 +557,16 @@ async def on_message_send_stream(self, params: SendMessageRequest, context): finally: await active_stream.aclose() return + if permission_response is not None and isinstance(self.task_store, A2ATaskStore): + task = await self.task_store.get(permission_response.task_id, context) + if task is not None: + direct_stream = self._on_inactive_permission_send_stream(params, context, task=task) + try: + async for event in direct_stream: + yield event + finally: + await direct_stream.aclose() + return base_stream = super().on_message_send_stream(params, context) tracked_stream = ( base_stream @@ -541,6 +585,69 @@ async def on_message_send_stream(self, params: SendMessageRequest, context): finally: await tracked_stream.aclose() + async def _on_inactive_permission_send_stream(self, params: SendMessageRequest, context, *, task: Task): + """Resume an existing input boundary without asking the SDK to recreate its task.""" + + request_context = await self._request_context_builder.build( + params=params, + task_id=task.id, + context_id=params.message.context_id, + task=task, + context=context, + ) + completed = object() + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1024) + + async def run_permission_response() -> None: + try: + await self.agent_executor.execute(request_context, _DetachedPermissionEventQueue(queue)) + except BaseException as exc: + await queue.put(exc) + finally: + await queue.put(completed) + + producer = asyncio.create_task(run_permission_response()) + handed_off = False + try: + while True: + event = await queue.get() + if event is completed: + break + if isinstance(event, BaseException): + raise event + if isinstance(event, Task): + self._validate_task_id_match(task.id, event.id) + yield apply_history_length(event, params.configuration) + else: + yield event + await producer + except (asyncio.CancelledError, GeneratorExit): + # The decision may already be committed. Let the same continuation + # finish exactly once even if the response transport disappears. + handed_off = True + asyncio.create_task(self._drain_inactive_permission_response(queue, producer, completed)) + raise + finally: + if not handed_off and not producer.done(): + producer.cancel() + with suppress(asyncio.CancelledError): + await producer + + @staticmethod + async def _drain_inactive_permission_response( + queue: asyncio.Queue[Any], + producer: asyncio.Task[None], + completed: object, + ) -> None: + try: + while True: + value = await queue.get() + if value is completed: + break + await producer + except BaseException: + logger.debug("Detached permission response continuation failed", exc_info=True) + async def _on_active_message_send_stream(self, params: SendMessageRequest, context, *, task: Task, active_task): request_context = await self._request_context_builder.build( params=params, @@ -708,6 +815,13 @@ async def on_cancel_task(self, params: CancelTaskRequest, context) -> Task | Non if task is None: raise TaskNotFoundError(f"Task {params.id} not found") if isinstance(self.task_store, A2ATaskStore) and not await self.task_store.is_task_active(params.id): + durable_cancel = await self._claim_inactive_durable_permission_cancel(task) + if durable_cancel == "lost": + return await self._reconcile_inactive_pipeline_input_required_task(task, context) + if durable_cancel == "normal": + canceled = await self._reconcile_inactive_terminal_task(task, context, "canceled") + await self.task_store.discard_context_runtime(task.context_id) + return canceled canceled_task = await self._cancel_inactive_pipeline_waiting_input_task(task, context) if canceled_task is not None: return canceled_task @@ -717,6 +831,44 @@ async def on_cancel_task(self, params: CancelTaskRequest, context) -> Task | Non raise TaskNotCancelableError return await super().on_cancel_task(params, context) + async def _claim_inactive_durable_permission_cancel(self, task: Task) -> str | None: + """Let the checkpoint lock decide a restart-time answer/cancel race.""" + + if not isinstance(self.task_store, A2ATaskStore) or not _task_is_input_required(task): + return None + try: + context_record = await self.task_store.get_context_record(task.context_id) + store = PermissionWaitCheckpointStore(context_record.cwd, context_record.session_id) + record = next( + ( + value + for value in store.list_active() + if value.get("taskId") == task.id and value.get("contextId") == task.context_id + ), + None, + ) + except Exception: + logger.debug("Failed to inspect durable permission wait during cancellation", exc_info=True) + return None + if record is None: + return None + boundary_id = str(record.get("boundaryId") or "") + try: + canceled = await asyncio.to_thread(store.cancel, boundary_id) + except ValueError: + # The same checkpoint file lock serializes this with a permission + # decision. A claimed decision wins and cancellation must not + # terminalize the task underneath its recovery. + try: + current = await asyncio.to_thread(store.load, boundary_id) + except ValueError: + current = None + if isinstance(current, dict) and current.get("phase") == "CANCELED": + canceled = current + else: + return "lost" + return "normal" if canceled.get("permissionClass") == "normal" else "pipeline" + async def _cancel_inactive_pipeline_waiting_input_task(self, task: Task, context) -> Task | None: if not isinstance(self.task_store, A2ATaskStore) or not _task_is_input_required(task): return None diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index f045d960..d3f00cab 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -8,7 +8,7 @@ import time import uuid from collections import deque -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Mapping from contextlib import suppress from dataclasses import dataclass, replace from pathlib import Path @@ -26,6 +26,7 @@ ) from iac_code.i18n import _ from iac_code.services.context_manager import ContextManager +from iac_code.services.permission_wait import canonical_digest, permission_execution_identity from iac_code.services.permissions.audit import ( PermissionAuditRecord, build_input_summary, @@ -61,6 +62,8 @@ CompactionEvent, MessageEndEvent, PermissionRequestEvent, + PermissionWaitOutcome, + PermissionWaitSuspended, QueuedInputSubmittedEvent, StreamEvent, SubAgentToolEvent, @@ -1084,6 +1087,519 @@ async def continue_streaming(self) -> AsyncGenerator[StreamEvent, None]: serialize_output_messages("".join(final_text_chunks), final_stop_reason), ) + async def resume_permission_boundary( + self, + checkpoint: dict[str, Any], + ) -> AsyncGenerator[StreamEvent, None]: + """Resume the exact trailing assistant tool batch from a durable wait. + + This intentionally bypasses ``SessionStorage.repair_interrupted``. The + trailing assistant message is not an interrupted execution: it is the + canonical source for a permission continuation frame. + """ + + from iac_code.agent.message import Message + + frame = checkpoint.get("continuationFrame") + decision = checkpoint.get("decision") + if not isinstance(frame, dict) or not isinstance(decision, dict): + raise ValueError("permission_resume_invalid: continuation frame is missing") + if decision.get("status") not in {"claimed", "applied"} or decision.get("value") not in { + "allow_once", + "deny", + }: + raise ValueError("permission_resume_invalid: permission decision is missing") + + messages = self.context_manager.get_messages() + if not messages or messages[-1].role != "assistant": + raise ValueError("permission_resume_invalid: assistant tool message is missing") + message_index = len(messages) - 1 + expected_message_ref = f"session.jsonl:{message_index}" + if self._transcript_id is not None: + expected_message_ref = f"pipeline/transcripts/{self._transcript_id}/session.jsonl:{message_index}" + if frame.get("assistantMessageRef") != expected_message_ref: + raise ValueError("permission_resume_invalid: assistant message reference changed") + assistant_message = messages[-1] + tool_uses = assistant_message.get_tool_use_blocks() + ordered_ids = [tool_use.id for tool_use in tool_uses] + if ordered_ids != frame.get("orderedToolUseIds"): + raise ValueError("permission_resume_invalid: tool ordering changed") + assistant_digest = canonical_digest( + [block.model_dump(mode="json") for block in assistant_message.content] + if isinstance(assistant_message.content, list) + else assistant_message.content + ) + if assistant_digest != frame.get("assistantMessageDigest"): + raise ValueError("permission_resume_invalid: assistant message changed") + current_index = frame.get("currentIndex") + if isinstance(current_index, bool) or not isinstance(current_index, int): + raise ValueError("permission_resume_invalid: current tool index is invalid") + if current_index < 0 or current_index >= len(tool_uses): + raise ValueError("permission_resume_invalid: current tool index is invalid") + if tool_uses[current_index].id != checkpoint.get("toolUseId"): + raise ValueError("permission_resume_invalid: current tool correlation changed") + if canonical_digest( + {"name": tool_uses[current_index].name, "input": tool_uses[current_index].input} + ) != checkpoint.get("payloadDigest"): + raise ValueError("permission_resume_invalid: tool payload changed") + frame_payload_digest = frame.get("currentPayloadDigest") + if frame_payload_digest is not None and frame_payload_digest != checkpoint.get("payloadDigest"): + raise ValueError("permission_resume_invalid: continuation payload changed") + + requests: list[ToolCallRequest] = [] + event_queues: dict[str, asyncio.Queue[Any]] = {} + tools_with_progress = {"agent", "ros_stack", "ros_stack_instances"} + for tool_use in tool_uses: + tool = self.tool_registry.get(tool_use.name) + invocation_input = tool_use.input + prepare_invocation_input = getattr(tool, "prepare_invocation_input", None) + if callable(prepare_invocation_input): + invocation_input = prepare_invocation_input(invocation_input) + queue = None + if tool_use.name in tools_with_progress or (tool is not None and tool.needs_event_queue()): + queue = asyncio.Queue() + event_queues[tool_use.id] = queue + requests.append( + ToolCallRequest( + id=tool_use.id, + name=tool_use.name, + input=invocation_input, + event_queue=queue, + invocation_binding=InvocationBinding( + runtime_nonce=self._runtime_nonce, + session_id=self._session_id, + tool_use_id=tool_use.id, + tool_name=tool_use.name, + canonical_input_sha256=canonical_input_sha256(invocation_input), + ), + ) + ) + + context = ToolContext( + cwd=self._cwd, + trusted_read_directories=list(self._tool_context_trusted_read_directories), + relative_read_directories=list(self._tool_context_relative_read_directories), + pipeline_mode=self._pipeline_mode, + env_overrides=dict(self._tool_context_env_overrides), + telemetry_attributes=dict(self._telemetry_attributes), + ) + recorded_decisions = frame.get("decisions") + if not isinstance(recorded_decisions, list) or len(recorded_decisions) != len(requests): + raise ValueError("permission_resume_invalid: tool decisions changed") + + allowed_requests: list[ToolCallRequest] = [] + denied_by_id: dict[str, ToolResult] = {} + continuation_decisions = [dict(item) for item in recorded_decisions if isinstance(item, dict)] + if len(continuation_decisions) != len(requests): + raise ValueError("permission_resume_invalid: tool decisions changed") + + for request_index, request in enumerate(requests): + permission, audit_context = await self._permission_for_recovered_request(request, context) + recorded = continuation_decisions[request_index] + state = recorded.get("state") + source = recorded.get("source") + if request_index == current_index: + if permission is None: + raise ValueError("permission_resume_invalid: current tool is unavailable") + principal_ref, region = permission_execution_identity( + tool_name=request.name, + tool_input=request.input, + permission_audit=getattr(permission, "audit", None), + ) + if principal_ref != checkpoint.get("principalRef") or region != checkpoint.get("region"): + raise ValueError("permission_resume_invalid: cloud execution identity changed") + state = "allow" if decision["value"] == "allow_once" else "deny" + source = "user" + if permission.behavior != "deny": + additional_decision: Literal["allow", "deny"] = "allow" if state == "allow" else "deny" + additional_audit_ok = _emit_permission_audit_items( + session_id=self._session_id, + cwd=context.cwd, + request=request, + audits=_permission_audits(permission, include_primary=False), + decision=additional_decision, + settings=audit_context.get("settings"), + audit_log_path=audit_context.get("audit_log_path"), + ) + if state == "allow" and not additional_audit_ok: + state = "deny" + source = "audit_failure" + recorded["deniedResult"] = _("Permission denied.") + if state == "allow": + recorded.update(principalRef=principal_ref, region=region) + elif request_index < current_index and state == "allow": + if permission is None: + state = "deny" + source = "missing_tool" + recorded["deniedResult"] = _("Permission denied.") + elif source == "user": + principal_ref, region = permission_execution_identity( + tool_name=request.name, + tool_input=request.input, + permission_audit=getattr(permission, "audit", None), + ) + if ( + "principalRef" not in recorded + or "region" not in recorded + or principal_ref != recorded.get("principalRef") + or region != recorded.get("region") + ): + state = "deny" + source = "identity_changed" + recorded["deniedResult"] = _("Permission denied.") + elif source != "policy": + state = "deny" + source = "permission_changed" + recorded["deniedResult"] = _("Permission denied.") + + if ( + state == "allow" + and permission is not None + and permission.behavior not in {"allow", "deny"} + and source == "policy" + ): + state = "deny" + source = "permission_changed" + recorded["deniedResult"] = _("Permission denied.") + elif request_index > current_index: + if permission is None: + state = "allow" + source = "missing_tool" + elif permission.behavior == "allow": + audit_ok = _emit_no_prompt_permission_audit( + session_id=self._session_id, + cwd=context.cwd, + request=request, + permission=permission, + decision="allow", + settings=audit_context.get("settings"), + audit_log_path=audit_context.get("audit_log_path"), + ) + if audit_ok: + state = "allow" + source = "policy" + else: + state = "deny" + source = "audit_failure" + recorded["deniedResult"] = _("Permission denied.") + elif permission.behavior == "deny": + _emit_no_prompt_permission_audit( + session_id=self._session_id, + cwd=context.cwd, + request=request, + permission=permission, + decision="deny", + settings=audit_context.get("settings"), + audit_log_path=audit_context.get("audit_log_path"), + ) + state = "deny" + source = "policy" + recorded["deniedResult"] = permission.message or _("Permission denied.") + else: + state = "pending" + source = None + recorded.update(state=state, source=source) + response_future: asyncio.Future[bool | PermissionWaitOutcome] = ( + asyncio.get_running_loop().create_future() + ) + permission_event = PermissionRequestEvent( + tool_name=request.name, + tool_input=request.input, + tool_use_id=request.id, + response_future=response_future, + permission_result=permission, + audit_context=audit_context, + continuation_frame={ + **frame, + "currentIndex": request_index, + "currentPayloadDigest": canonical_digest( + {"name": tool_uses[request_index].name, "input": tool_uses[request_index].input} + ), + "decisions": [dict(item) for item in continuation_decisions], + "previousBoundaryId": checkpoint.get("boundaryId"), + }, + ) + yield permission_event + outcome = await asyncio.shield(response_future) + if outcome is PermissionWaitOutcome.SUSPEND: + raise PermissionWaitSuspended(permission_event.boundary_id) + state = "allow" if bool(outcome) else "deny" + source = "user" + additional_audit_ok = _emit_permission_audit_items( + session_id=self._session_id, + cwd=context.cwd, + request=request, + audits=_permission_audits(permission, include_primary=False), + decision="allow" if state == "allow" else "deny", + settings=audit_context.get("settings"), + audit_log_path=audit_context.get("audit_log_path"), + ) + if state == "allow" and not additional_audit_ok: + state = "deny" + source = "audit_failure" + recorded["deniedResult"] = _("Permission denied.") + if state == "allow": + principal_ref, region = permission_execution_identity( + tool_name=request.name, + tool_input=request.input, + permission_audit=getattr(permission, "audit", None), + ) + recorded.update(principalRef=principal_ref, region=region) + + # A recovered user decision never overrides a policy that has since + # become a hard deny. Persist that transition in the continuation + # frame before a later permission can create a successor boundary. + if state == "allow" and permission is not None and permission.behavior == "deny": + _emit_no_prompt_permission_audit( + session_id=self._session_id, + cwd=context.cwd, + request=request, + permission=permission, + decision="deny", + settings=audit_context.get("settings"), + audit_log_path=audit_context.get("audit_log_path"), + ) + state = "deny" + source = "policy" + recorded["deniedResult"] = permission.message or _("Permission denied.") + if state == "deny": + recorded.pop("principalRef", None) + recorded.pop("region", None) + recorded.update(state=state, source=source) + if state == "allow": + allowed_requests.append(request) + elif state == "deny": + denied_by_id[request.id] = ToolResult.error( + str(recorded.get("deniedResult") or _("Permission denied.")) + ) + self._reject_owned_contract_snapshot(request.snapshot_id) + else: + raise ValueError("permission_resume_invalid: prior tool decision is incomplete") + + public_path_roots = build_public_path_roots( + cwd=context.cwd, + additional_directories=context.additional_directories, + trusted_read_directories=context.trusted_read_directories, + relative_read_directories=context.relative_read_directories, + ) + for request in requests: + denied = denied_by_id.get(request.id) + if denied is not None: + yield ToolResultEvent( + tool_use_id=request.id, + tool_name=request.name, + result=denied.content, + is_error=True, + public_path_roots=public_path_roots, + ) + + executed_by_id: dict[str, ToolResult] = {} + if allowed_requests: + results = await self._tool_executor.execute_batch(allowed_requests, context) + for request, result in zip(allowed_requests, results): + executed_by_id[request.id] = result + processed = self._result_storage.process(request.id, result.content) + self._mark_read_memory_tool_result(request, result) + result_metadata = self._tool_result_event_metadata(result.metadata, processed) + result_metadata = self._tool_result_render_metadata( + result_metadata, + self.tool_registry.get(request.name), + processed.content, + is_error=result.is_error, + tool_name=request.name, + tool_input=request.input, + ) + yield ToolResultEvent( + tool_use_id=request.id, + tool_name=request.name, + result=processed.content, + is_error=result.is_error, + public_path_roots=public_path_roots, + metadata=result_metadata, + ) + result.content = processed.content + result.metadata = result_metadata + + result_blocks: list[ToolResultBlock] = [] + for request in requests: + denied = denied_by_id.get(request.id) + if denied is not None: + result_blocks.append(ToolResultBlock(tool_use_id=request.id, content=denied.content, is_error=True)) + continue + result = executed_by_id.get(request.id) + if result is None: + raise ValueError("permission_resume_invalid: tool result is missing") + result_blocks.append( + ToolResultBlock( + tool_use_id=request.id, + content=result.content, + is_error=result.is_error, + metadata=result.metadata or {}, + ) + ) + self.context_manager.add_tool_results(result_blocks) + if self._session_storage: + result_content: list[ContentBlock] = list(result_blocks) + self._session_storage.append( + self._cwd, + self._session_id, + Message(role="user", content=result_content), + git_branch=self._current_git_branch, + ) + + for request in requests: + result = executed_by_id.get(request.id) + if result is None: + continue + for raw_message in result.new_messages: + injected = self.context_manager.add_raw_message(raw_message) + if self._session_storage: + self._session_storage.append( + self._cwd, + self._session_id, + injected, + git_branch=self._current_git_branch, + ) + if result.context_modifier is not None: + self._apply_context_modifier(result.context_modifier) + + async for event in self.continue_streaming(): + yield event + + async def _permission_for_recovered_request( + self, + request: ToolCallRequest, + context: ToolContext, + ) -> tuple[PermissionResult | None, dict[str, Any]]: + tool = self.tool_registry.get(request.name) + if tool is None: + return None, {} + perm_ctx = self._permission_context_getter() if self._permission_context_getter is not None else None + if perm_ctx is None: + perm_ctx = self._permission_context + if perm_ctx is not None: + from iac_code.services.permissions.pipeline import check_tool_permission + + effective_perm_ctx = _with_tool_read_directories( + perm_ctx, + trusted_directories=self._tool_context_trusted_read_directories, + relative_directories=self._tool_context_relative_read_directories, + ) + if isinstance(effective_perm_ctx, ToolPermissionContext): + effective_perm_ctx = replace( + effective_perm_ctx, + invocation_binding=request.invocation_binding, + pipeline_mode=self._pipeline_mode, + ) + _extend_unique(context.additional_directories, list(effective_perm_ctx.additional_directories)) + _extend_unique(context.trusted_read_directories, list(effective_perm_ctx.trusted_read_directories)) + _extend_unique(context.relative_read_directories, list(effective_perm_ctx.relative_read_directories)) + _extend_unique( + context.strict_read_directories, + list(getattr(effective_perm_ctx, "strict_read_directories", [])), + ) + context.read_path_violation_behavior = getattr( + effective_perm_ctx, + "read_path_violation_behavior", + context.read_path_violation_behavior, + ) + context.set_permission_context(effective_perm_ctx) + permission = await check_tool_permission(tool, request.input, effective_perm_ctx) + else: + permission = await tool.check_permissions( + request.input, + ToolPermissionContext( + cwd=context.cwd, + invocation_binding=request.invocation_binding, + pipeline_mode=self._pipeline_mode, + ), + ) + permission = _with_prompt_permission_metadata(tool, request.input, permission) + request.snapshot_id = permission.snapshot_id + request.security_digest = permission.security_digest + request.execution_class = permission.execution_class + if permission.invocation_binding is not None: + request.invocation_binding = permission.invocation_binding + if request.snapshot_id is not None: + self._owned_contract_snapshot_ids.add(request.snapshot_id) + audit_context = { + "session_id": self._session_id, + "cwd": context.cwd, + "settings": perm_ctx.audit_settings if perm_ctx is not None else None, + "metadata": permission.audit, + } + principal_ref, region = permission_execution_identity( + tool_name=request.name, + tool_input=request.input, + permission_audit=permission.audit, + ) + audit_context.update(principal_ref=principal_ref, region=region) + if self._has_session_hierarchy: + audit_context["root_session_id"] = self._root_session_id + audit_context["transcript_id"] = self._transcript_id + if self._audit_log_path is not None: + audit_context["audit_log_path"] = self._audit_log_path + return permission, audit_context + + async def rebuild_permission_audit_event( + self, + *, + tool_name: str, + tool_input: dict[str, Any], + tool_use_id: str, + audit_context: Mapping[str, Any], + ) -> PermissionRequestEvent: + """Recheck one canonical request only to rebuild restart audit data. + + The returned event is not an execution authorization. Any process-local + execution-contract snapshot created by the permission check is rejected; + the real continuation must rebuild its own contract again. + """ + + tool = self.tool_registry.get(tool_name) + if tool is None: + raise ValueError("permission_resume_invalid: current tool is unavailable") + invocation_input = dict(tool_input) + prepare_invocation_input = getattr(tool, "prepare_invocation_input", None) + if callable(prepare_invocation_input): + invocation_input = prepare_invocation_input(invocation_input) + request = ToolCallRequest( + id=tool_use_id, + name=tool_name, + input=invocation_input, + invocation_binding=InvocationBinding( + runtime_nonce=self._runtime_nonce, + session_id=self._session_id, + tool_use_id=tool_use_id, + tool_name=tool_name, + canonical_input_sha256=canonical_input_sha256(invocation_input), + ), + ) + context = ToolContext( + cwd=self._cwd, + trusted_read_directories=list(self._tool_context_trusted_read_directories), + relative_read_directories=list(self._tool_context_relative_read_directories), + pipeline_mode=self._pipeline_mode, + env_overrides=dict(self._tool_context_env_overrides), + telemetry_attributes=dict(self._telemetry_attributes), + ) + try: + permission, rebuilt_context = await self._permission_for_recovered_request( + request, + context, + ) + if permission is None: + raise ValueError("permission_resume_invalid: current tool is unavailable") + return PermissionRequestEvent( + tool_name=tool_name, + tool_input=invocation_input, + tool_use_id=tool_use_id, + permission_result=permission, + audit_context={**rebuilt_context, **dict(audit_context)}, + ) + finally: + self._reject_owned_contract_snapshot(request.snapshot_id) + async def _stream_provider( self, *, @@ -1354,7 +1870,20 @@ async def _run_streaming_inner( allowed_requests: list[ToolCallRequest] = [] denied_results: list[tuple[ToolCallRequest, ToolResult]] = [] - for request in requests: + assistant_message_digest = canonical_digest( + [block.model_dump(mode="json") for block in assistant_blocks] + ) + continuation_decisions: list[dict[str, Any]] = [ + { + "toolUseId": request.id, + "state": "not_evaluated", + "source": None, + "deniedResult": None, + } + for request in requests + ] + previous_permission_boundary_id: str | None = None + for request_index, request in enumerate(requests): tool = self.tool_registry.get(request.name) if tool is None: allowed_requests.append(request) @@ -1424,6 +1953,12 @@ async def _run_streaming_inner( "settings": perm_ctx.audit_settings if perm_ctx is not None else None, "metadata": permission.audit, } + principal_ref, region = permission_execution_identity( + tool_name=request.name, + tool_input=request.input, + permission_audit=permission.audit, + ) + audit_context.update(principal_ref=principal_ref, region=region) if self._has_session_hierarchy: audit_context["root_session_id"] = self._root_session_id audit_context["transcript_id"] = self._transcript_id @@ -1443,8 +1978,14 @@ async def _run_streaming_inner( if not audit_ok: self._reject_owned_contract_snapshot(request.snapshot_id) denied_results.append((request, ToolResult.error(_("Permission denied.")))) + continuation_decisions[request_index].update( + state="deny", + source="audit_failure", + deniedResult=_("Permission denied."), + ) continue allowed_requests.append(request) + continuation_decisions[request_index].update(state="allow", source="policy") continue if permission.behavior == "deny": _emit_no_prompt_permission_audit( @@ -1459,9 +2000,17 @@ async def _run_streaming_inner( self._reject_owned_contract_snapshot(request.snapshot_id) msg = permission.message or _("Permission denied.") denied_results.append((request, ToolResult.error(msg))) + continuation_decisions[request_index].update( + state="deny", + source="policy", + deniedResult=msg, + ) continue - response_future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + continuation_decisions[request_index].update(state="pending", source=None) + response_future: asyncio.Future[bool | PermissionWaitOutcome] = ( + asyncio.get_running_loop().create_future() + ) permission_event = PermissionRequestEvent( tool_name=request.name, tool_input=request.input, @@ -1469,14 +2018,38 @@ async def _run_streaming_inner( response_future=response_future, permission_result=permission, audit_context=audit_context, + continuation_frame={ + "assistantMessageRef": "session.jsonl:{}".format( + len(self.context_manager.get_messages()) - 1 + ), + "assistantMessageDigest": assistant_message_digest, + "orderedToolUseIds": [item.id for item in requests], + "currentIndex": request_index, + "currentPayloadDigest": canonical_digest( + { + "name": completed_tools[request_index]["name"], + "input": completed_tools[request_index].get("input", {}), + } + ), + "decisions": [dict(item) for item in continuation_decisions], + **( + {"previousBoundaryId": previous_permission_boundary_id} + if previous_permission_boundary_id is not None + else {} + ), + }, ) yield permission_event try: - approved = await asyncio.shield(response_future) + outcome = await asyncio.shield(response_future) except asyncio.CancelledError: if not permission_event.resolution_owner_managed and not response_future.done(): response_future.set_result(False) raise + if outcome is PermissionWaitOutcome.SUSPEND: + raise PermissionWaitSuspended(permission_event.boundary_id) + previous_permission_boundary_id = permission_event.boundary_id + approved = bool(outcome) additional_audit_ok = _emit_permission_audit_items( session_id=self._session_id, cwd=context.cwd, @@ -1490,9 +2063,20 @@ async def _run_streaming_inner( approved = False if approved: allowed_requests.append(request) + continuation_decisions[request_index].update( + state="allow", + source="user", + principalRef=principal_ref, + region=region, + ) else: self._reject_owned_contract_snapshot(request.snapshot_id) denied_results.append((request, ToolResult.error(_("Permission denied.")))) + continuation_decisions[request_index].update( + state="deny", + source="user", + deniedResult=_("Permission denied."), + ) public_path_roots = build_public_path_roots( cwd=context.cwd, @@ -1655,9 +2239,15 @@ async def poll_event_queues(): ) for req, result in zip(requests, results): - if result.new_messages: - for msg in result.new_messages: - self.context_manager.add_raw_message(msg) + for msg in result.new_messages: + injected = self.context_manager.add_raw_message(msg) + if self._session_storage: + self._session_storage.append( + self._cwd, + self._session_id, + injected, + git_branch=self._current_git_branch, + ) if result.context_modifier is not None: self._apply_context_modifier(result.context_modifier) diff --git a/src/iac_code/cli/main.py b/src/iac_code/cli/main.py index 80f46763..742bf006 100644 --- a/src/iac_code/cli/main.py +++ b/src/iac_code/cli/main.py @@ -795,6 +795,7 @@ def a2a( push_consumer_name = config.get("push_consumer_name", "") push_lease_timeout_ms = config.get("push_lease_timeout_ms", 300000) auto_approve_permissions = config.get("auto_approve_permissions", False) + permission_wait = config.get("permission_wait") idle_shutdown_seconds = config.get("idle_shutdown_seconds", 0) log_to_stdout = _a2a_config_value(ctx, config, "log_to_stdout", log_to_stdout) thinking_exposure = _a2a_config_value(ctx, config, "thinking_exposure", thinking_exposure) @@ -938,6 +939,7 @@ def _telemetry_signal_handler(signum, frame): response_stream=response_stream, consumer_group=consumer_group, auto_approve_permissions=auto_approve_permissions, + permission_wait=permission_wait, thinking_exposure=thinking_exposure, idle_shutdown_seconds=idle_shutdown_seconds, ) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index e00a5449..75352e41 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -3442,6 +3442,7 @@ msgstr "MCP-Tool {tool!r} vom Server {server!r}." #: src/iac_code/tools/bash/bash_tool.py #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #: src/iac_code/web/permissions.py src/iac_code/web/runtime.py +#: src/iac_code/web/session_manager.py #, python-brace-format msgid "Allow {}?" msgstr "{} erlauben?" @@ -12338,6 +12339,10 @@ msgstr "unbekanntes Modell" msgid "unknown effort" msgstr "unbekannter Aufwand" +#: src/iac_code/web/session_manager.py +msgid "Permission wait point is no longer active." +msgstr "Die Berechtigungsanforderung ist nicht mehr aktiv." + #: src/iac_code/web/session_manager.py #, python-brace-format msgid "Pipeline · {}" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index be131234..274d90a8 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -3430,6 +3430,7 @@ msgstr "Herramienta MCP {tool!r} del servidor {server!r}." #: src/iac_code/tools/bash/bash_tool.py #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #: src/iac_code/web/permissions.py src/iac_code/web/runtime.py +#: src/iac_code/web/session_manager.py #, python-brace-format msgid "Allow {}?" msgstr "¿Permitir {}?" @@ -12263,6 +12264,10 @@ msgstr "modelo desconocido" msgid "unknown effort" msgstr "esfuerzo desconocido" +#: src/iac_code/web/session_manager.py +msgid "Permission wait point is no longer active." +msgstr "El punto de espera de permisos ya no está activo." + #: src/iac_code/web/session_manager.py #, python-brace-format msgid "Pipeline · {}" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 67ef1a4c..d19c6f41 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -3433,6 +3433,7 @@ msgstr "Outil MCP {tool!r} du serveur {server!r}." #: src/iac_code/tools/bash/bash_tool.py #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #: src/iac_code/web/permissions.py src/iac_code/web/runtime.py +#: src/iac_code/web/session_manager.py #, python-brace-format msgid "Allow {}?" msgstr "Autoriser {} ?" @@ -12320,6 +12321,10 @@ msgstr "modèle inconnu" msgid "unknown effort" msgstr "effort inconnu" +#: src/iac_code/web/session_manager.py +msgid "Permission wait point is no longer active." +msgstr "Le point d’attente d’autorisation n’est plus actif." + #: src/iac_code/web/session_manager.py #, python-brace-format msgid "Pipeline · {}" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 83869fb5..15f4f0c8 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -3301,6 +3301,7 @@ msgstr "server {server!r} の MCP tool {tool!r}。" #: src/iac_code/tools/bash/bash_tool.py #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #: src/iac_code/web/permissions.py src/iac_code/web/runtime.py +#: src/iac_code/web/session_manager.py #, python-brace-format msgid "Allow {}?" msgstr "{} を許可しますか?" @@ -11455,6 +11456,10 @@ msgstr "不明なモデル" msgid "unknown effort" msgstr "不明な推論強度" +#: src/iac_code/web/session_manager.py +msgid "Permission wait point is no longer active." +msgstr "権限の待機ポイントはすでに無効です。" + #: src/iac_code/web/session_manager.py #, python-brace-format msgid "Pipeline · {}" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 953c6a8e..951a8143 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -3416,6 +3416,7 @@ msgstr "Ferramenta MCP {tool!r} do servidor {server!r}." #: src/iac_code/tools/bash/bash_tool.py #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #: src/iac_code/web/permissions.py src/iac_code/web/runtime.py +#: src/iac_code/web/session_manager.py #, python-brace-format msgid "Allow {}?" msgstr "Permitir {}?" @@ -12171,6 +12172,10 @@ msgstr "modelo desconhecido" msgid "unknown effort" msgstr "esforço desconhecido" +#: src/iac_code/web/session_manager.py +msgid "Permission wait point is no longer active." +msgstr "O ponto de espera de permissão já não está ativo." + #: src/iac_code/web/session_manager.py #, python-brace-format msgid "Pipeline · {}" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index a1f9264f..152cd47f 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -3281,6 +3281,7 @@ msgstr "来自服务器 {server!r} 的 MCP 工具 {tool!r}。" #: src/iac_code/tools/bash/bash_tool.py #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #: src/iac_code/web/permissions.py src/iac_code/web/runtime.py +#: src/iac_code/web/session_manager.py #, python-brace-format msgid "Allow {}?" msgstr "允许 {}?" @@ -11262,6 +11263,10 @@ msgstr "未知的模型" msgid "unknown effort" msgstr "未知的推理强度" +#: src/iac_code/web/session_manager.py +msgid "Permission wait point is no longer active." +msgstr "权限等待点已不再处于活动状态。" + #: src/iac_code/web/session_manager.py #, python-brace-format msgid "Pipeline · {}" diff --git a/src/iac_code/mcp/manager.py b/src/iac_code/mcp/manager.py index dc1296d9..69c2da26 100644 --- a/src/iac_code/mcp/manager.py +++ b/src/iac_code/mcp/manager.py @@ -169,6 +169,7 @@ def __init__( self._needs_auth_cache = needs_auth_cache or MCPNeedsAuthCache() self._session_id = session_id self._change_listeners: list[ChangeListener] = [] + self._status_revision = 0 self._elicitation_handler: ElicitationHandler = _default_elicitation_handler self._reconnect_tasks: dict[str, asyncio.Task[None]] = {} self._connections = { @@ -201,10 +202,10 @@ async def _notify_connect_state_transition( record: MCPConnectionRecord, previous_state: MCPConnectionState, ) -> None: - if ( - previous_state in {MCPConnectionState.DISABLED, MCPConnectionState.PENDING} - or previous_state is record.state - ): + if previous_state is record.state: + return + self._mark_status_changed() + if previous_state in {MCPConnectionState.DISABLED, MCPConnectionState.PENDING}: return if record.state is MCPConnectionState.NEEDS_AUTH: await self._notify_changed(record.name, "auth") @@ -212,6 +213,7 @@ async def _notify_connect_state_transition( await self._notify_changed(record.name, "connection") async def disconnect_all(self) -> None: + status_changed = False for task in list(self._reconnect_tasks.values()): task.cancel() for task in list(self._reconnect_tasks.values()): @@ -219,6 +221,7 @@ async def disconnect_all(self) -> None: await task self._reconnect_tasks.clear() for record in self._connections.values(): + status_changed = status_changed or record.state is not MCPConnectionState.DISABLED try: if record.client is not None: await record.client.close() @@ -243,6 +246,8 @@ async def disconnect_all(self) -> None: record.prompts = [] record.capability_errors = {} record.metadata = _metadata_for_record(record) + if status_changed: + self._mark_status_changed() async def reconnect_failed(self, server_name: str) -> None: record = self.connection(server_name) @@ -283,6 +288,11 @@ def server_instructions_text(self) -> str: def status_metadata(self, warnings: list[Any] | None = None) -> dict[str, Any] | None: return mcp_status_metadata(self, warnings=warnings) + @property + def status_revision(self) -> int: + """Monotonic revision for public MCP status-affecting changes.""" + return self._status_revision + def list_tools(self) -> list[MCPToolRecord]: return [tool for record in self._connections.values() for tool in record.tools] @@ -438,11 +448,15 @@ async def handle_list_changed(self, server_name: str, *, capability: str) -> Non await self._notify_changed(server_name, capability) async def _notify_changed(self, server_name: str, capability: str) -> None: + self._mark_status_changed() for listener in list(self._change_listeners): result = listener(server_name, capability) if inspect.isawaitable(result): await result + def _mark_status_changed(self) -> None: + self._status_revision += 1 + async def list_roots(self) -> list[str]: return [root.resolve().as_uri() for root in self._roots] diff --git a/src/iac_code/mcp/oauth.py b/src/iac_code/mcp/oauth.py index 6ae5e94b..8e0ab8f4 100644 --- a/src/iac_code/mcp/oauth.py +++ b/src/iac_code/mcp/oauth.py @@ -1921,8 +1921,7 @@ def _oauth_auth_flow_marker_is_current( def oauth_storage_key(config: MCPServerConfig, *, scope: MCPConfigScope | str | None = None) -> str: - # 一个 MCP 的完整 OAuth 状态存进单个钥匙串条目(JSON blob),而不是按字段拆成多条。 - # 这样 macOS「始终允许」只需授权一次即可覆盖 access_token / refresh_token / client_* 等全部字段。 + # 一个 MCP 的完整 OAuth 状态存进单个加密 JSON blob,而不是按字段拆成多条。 return _oauth_storage_key_for_signature(config.name, config.content_signature(), scope=scope) @@ -1933,7 +1932,7 @@ def get_oauth_storage_secret( *, scope: MCPConfigScope | str | None = None, ) -> str | None: - # 读路径无锁:keyring 单次读取返回的是完整 blob,要么是旧的完整值要么是新的完整值,不会读到半写状态。 + # 存储层以文件锁保证单次读取返回完整 blob,不会读到半写状态。 blob = _read_oauth_blob(storage, oauth_storage_key(config, scope=scope)) return blob.get(kind) diff --git a/src/iac_code/mcp/storage.py b/src/iac_code/mcp/storage.py index 97d8e47c..67200001 100644 --- a/src/iac_code/mcp/storage.py +++ b/src/iac_code/mcp/storage.py @@ -24,13 +24,9 @@ class MCPSecretStorage: def __init__(self, *, keyring_backend: Any | None = None, service_name: str = "iac-code:mcp") -> None: - if keyring_backend is None and os.environ.get("IAC_CODE_MCP_DISABLE_KEYRING") != "1": - try: - import keyring - - keyring_backend = keyring - except Exception: - keyring_backend = None + # Production storage is always the encrypted local file below. An explicit + # backend remains as a test seam only; never auto-discover the OS keyring, + # because even reads can trigger disruptive macOS Keychain prompts. self._keyring = keyring_backend self._service_name = service_name diff --git a/src/iac_code/mcp/types.py b/src/iac_code/mcp/types.py index e9619fd0..1ce5ad61 100644 --- a/src/iac_code/mcp/types.py +++ b/src/iac_code/mcp/types.py @@ -1,9 +1,12 @@ from __future__ import annotations +import hashlib +import json import re import shlex from dataclasses import dataclass, field from enum import Enum +from functools import lru_cache from typing import Any, Mapping, Sequence, cast from urllib.parse import urlparse @@ -13,6 +16,8 @@ MCP_INITIALIZE_INSTRUCTIONS_MAX_CHARS = 4000 MCP_INSTRUCTIONS_TRUNCATION_MARKER = "[truncated]" +_MCP_CONFIG_SIGNATURE_SALT = b"iac-code-mcp-config-signature-v1" +_MCP_CONFIG_SIGNATURE_ITERATIONS = 100_000 class MCPConfigError(ValueError): @@ -234,14 +239,22 @@ def content_signature(self) -> str: prefix = "stdio" else: prefix = "url" - import hashlib - import json - data = json.dumps(material, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") - digest = hashlib.pbkdf2_hmac("sha256", data, b"iac-code-mcp-config-signature-v1", 100_000).hex() + digest = _content_signature_digest(data) return "{}:{}".format(prefix, digest) +@lru_cache(maxsize=8192) +def _content_signature_digest(data: bytes) -> str: + """Cache the expensive derivation for immutable normalized config content.""" + return hashlib.pbkdf2_hmac( + "sha256", + data, + _MCP_CONFIG_SIGNATURE_SALT, + _MCP_CONFIG_SIGNATURE_ITERATIONS, + ).hex() + + @dataclass(frozen=True) class ScopedMCPServerConfig: config: MCPServerConfig diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index 59b22a03..c6f1b264 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -11,7 +11,7 @@ import stat import time from collections import deque -from collections.abc import AsyncGenerator, Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any, cast @@ -46,6 +46,7 @@ PipelineUserInput, normalize_pipeline_user_input, ) +from iac_code.services.permission_wait import RecoveredPermissionAuditBoundary from iac_code.services.session_backup import BackupReason, SessionBackupBlocked, SessionBackupService from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME from iac_code.types.stream_events import ( @@ -93,6 +94,8 @@ def _is_a2a_surface(surface: str) -> bool: return surface == "a2a" or surface.startswith("a2a_") + + _SIDECAR_ROOT_DIRS = {"a2a", "image-cache", "pipeline", "tool-results"} _SIDECAR_ROOT_FILES = { ".backup-state.json", @@ -570,6 +573,7 @@ def __init__( self._mcp_manager = mcp_manager self._mcp_config_warnings = mcp_config_warnings if mcp_config_warnings is not None else [] self._mcp_status_event_signature: str | None = None + self._mcp_status_revision: int | None = None self._aliyun_delegated_executor_factory = aliyun_delegated_executor_factory self._pipeline_dir = pipeline_dir @@ -1334,7 +1338,12 @@ async def _continue_after_sidecar_hard_interrupt( def _mcp_status_event(self, *, force: bool = False) -> PipelineEvent | None: from iac_code.mcp.manager import mcp_status_metadata + status_revision = getattr(self._mcp_manager, "status_revision", None) + if not force and isinstance(status_revision, int) and self._mcp_status_revision == status_revision: + return None status_metadata = mcp_status_metadata(self._mcp_manager, warnings=self._mcp_config_warnings) + if isinstance(status_revision, int): + self._mcp_status_revision = status_revision if status_metadata is None: return None status_signature = repr(status_metadata) @@ -1788,6 +1797,106 @@ def _load_repaired_resume_messages(self, transcript_id: str | None) -> list | No loaded = self._session_storage.load(self._cwd, transcript_id) return self._session_storage.repair_interrupted(loaded) if isinstance(loaded, list) and loaded else None + def _load_unrepaired_resume_messages(self, transcript_id: str | None) -> list | None: + if not transcript_id: + return None + if self._transcript_storage is not None: + loaded = self._transcript_storage.load(self._cwd, transcript_id) + if isinstance(loaded, list) and loaded: + return loaded + if self._session_storage is None: + return None + loaded = self._session_storage.load(self._cwd, transcript_id) + return loaded if isinstance(loaded, list) and loaded else None + + async def resume_permission_boundary( + self, + checkpoint: dict[str, Any], + ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: + """Resume the current top-level step from its unrepaired transcript.""" + + transcript_id = self._execution.get("transcript_id") if isinstance(self._execution, dict) else None + transcript_id = transcript_id if isinstance(transcript_id, str) else None + messages = self._load_unrepaired_resume_messages(transcript_id) + if not messages: + raise ValueError("permission_resume_invalid: pipeline transcript is unavailable") + async for event in self._continue_from_current( + resume_messages=messages, + resume_running_step=True, + permission_checkpoint=checkpoint, + ): + yield event + + async def rebuild_permission_audit_event( + self, + checkpoint: Mapping[str, Any], + recovered: RecoveredPermissionAuditBoundary, + ) -> PermissionRequestEvent: + """Rebuild restart audit data from the exact active parent step runtime.""" + + execution = self._execution if isinstance(self._execution, dict) else {} + current_step = self.state_machine.current_step + step_id = current_step.step_id + attempt_id = execution.get("active_attempt_id") + transcript_id = execution.get("transcript_id") + if ( + execution.get("kind") != "step" + or execution.get("step_id") != step_id + or not isinstance(attempt_id, str) + or not isinstance(transcript_id, str) + or recovered.audit_context.get("transcript_id") != transcript_id + ): + raise ValueError("permission_resume_invalid: pipeline execution changed") + + attempt = self._attempts.get("items", {}).get(attempt_id) + if ( + not isinstance(attempt, dict) + or attempt.get("attempt_id") != attempt_id + or attempt.get("scope") != "parent" + or attempt.get("step_id") != step_id + or attempt.get("status") != "running" + or attempt.get("transcript_id") != transcript_id + ): + raise ValueError("permission_resume_invalid: pipeline attempt changed") + + coordinates = checkpoint.get("pipelineCoordinates") + if coordinates is not None: + if not isinstance(coordinates, Mapping): + raise ValueError("permission_resume_invalid: pipeline coordinates changed") + if "candidate" in coordinates or "candidateStep" in coordinates: + raise ValueError("permission_resume_invalid: pipeline coordinates changed") + step_coordinate = coordinates.get("step") + if step_coordinate is not None: + if not isinstance(step_coordinate, Mapping) or step_coordinate.get("id") != step_id: + raise ValueError("permission_resume_invalid: pipeline coordinates changed") + step_attempt = self._current_step_attempt(step_id) + coordinate_attempt = step_coordinate.get("attempt") + if coordinate_attempt is not None and coordinate_attempt != step_attempt: + raise ValueError("permission_resume_invalid: pipeline coordinates changed") + coordinate_run_id = step_coordinate.get("runId") + if coordinate_run_id is not None and coordinate_run_id != f"step-{step_id}-{step_attempt}": + raise ValueError("permission_resume_invalid: pipeline coordinates changed") + + messages = self._load_unrepaired_resume_messages(transcript_id) + if not messages: + raise ValueError("permission_resume_invalid: pipeline transcript is unavailable") + agent_context = self._step_executor.build_agent_loop_context( + current_step, + self.context, + self._session_id, + attempt_id=attempt_id, + transcript_id=transcript_id, + resume_messages=messages, + ) + if agent_context.agent_loop is None: + raise ValueError("permission_resume_invalid: pipeline step already completed") + return await agent_context.agent_loop.rebuild_permission_audit_event( + tool_name=recovered.tool_name, + tool_input=recovered.tool_input, + tool_use_id=recovered.tool_use_id, + audit_context=recovered.audit_context, + ) + def _attempt_has_resume_transcript(self, attempt: dict[str, Any] | None) -> bool: if not attempt: return False @@ -3704,6 +3813,7 @@ async def _continue_from_current( precompleted_tools: dict[str, dict[str, Any]] | None = None, resume_waiting_step: bool = False, resume_running_step: bool = False, + permission_checkpoint: dict[str, Any] | None = None, ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: is_first_step = True terminal_pipeline_telemetry_emitted = False @@ -3962,7 +4072,11 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: is_first_step = False step_resume_messages = first_step_resume_messages if first_step else None step_precompleted_tools = first_step_precompleted_tools if first_step else None - if self._transcript_storage is not None and attempt.get("status") == "running": + if ( + self._transcript_storage is not None + and attempt.get("status") == "running" + and not (first_step and permission_checkpoint is not None) + ): loaded = self._transcript_storage.load(self._cwd, attempt["transcript_id"]) repaired_resume_messages = self._transcript_storage.repair_interrupted(loaded) step_resume_messages = reconcile_resume_messages( @@ -3993,6 +4107,7 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: "rollback_targets": self.state_machine.completed_non_future_rollback_targets(), "rollback_count": self.state_machine.rollback_count, "max_rollbacks": self.state_machine.max_rollbacks, + "permission_checkpoint": permission_checkpoint if first_step else None, } if step_precompleted_tools is not None: execute_kwargs["precompleted_tools"] = step_precompleted_tools diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index f6a7c31e..ecf05b89 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -207,6 +207,7 @@ async def execute( rollback_targets: list[str] | None = None, rollback_count: int = 0, max_rollbacks: int = 5, + permission_checkpoint: dict[str, Any] | None = None, ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: """Execute a step, yielding AgentLoop events and a final StepResult.""" preserved_selection = self._preserved_candidate_selection( @@ -337,7 +338,9 @@ async def consume_complete_step_events( try: first_stream_had_event = False - if agent_context.resume_messages and user_message is None: + if permission_checkpoint is not None: + first_stream = agent_loop.resume_permission_boundary(permission_checkpoint) + elif agent_context.resume_messages and user_message is None: first_stream = agent_loop.continue_streaming() else: first_stream = await mcp_prompt_command_stream( diff --git a/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py b/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py index e9e4845f..d70c703e 100644 --- a/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +++ b/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py @@ -165,6 +165,10 @@ def input_schema(self) -> dict[str, Any]: def supports_blanket_allow(self) -> bool: return False + @property + def uses_operation_scoped_permissions(self) -> bool: + return True + def is_read_only(self, input: dict | None = None) -> bool: return isinstance(input, dict) and input.get("action") == "wait" diff --git a/src/iac_code/services/agent_factory.py b/src/iac_code/services/agent_factory.py index c1187c99..2f8bfb1c 100644 --- a/src/iac_code/services/agent_factory.py +++ b/src/iac_code/services/agent_factory.py @@ -27,7 +27,7 @@ class AgentFactoryOptions: mcp_interactive_project_approval: bool = False a2a_safe_mode: bool = False # 离线上下文核算契约:仅为算系统提示 + 本地工具定义开销构造 runtime 时置真。 - # 显式禁止连接 MCP / 读取 MCP 钥匙串等外部副作用,但保留完整本地工具注册以保证 token 口径准确。 + # 显式禁止连接 MCP / 读取 MCP 凭证文件等外部副作用,但保留完整本地工具注册以保证 token 口径准确。 disable_external_services: bool = False mcp_elicitation_handler: Any = None provider_key_override: str | None = None diff --git a/src/iac_code/services/permission_wait.py b/src/iac_code/services/permission_wait.py new file mode 100644 index 00000000..6b119b6c --- /dev/null +++ b/src/iac_code/services/permission_wait.py @@ -0,0 +1,1280 @@ +"""Bounded, session-owned persistence for externally answerable permissions.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import math +import re +import uuid +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Literal, cast + +from iac_code.services.session_layout import SessionPaths, ensure_session_owned_dir +from iac_code.services.session_storage import SessionStorage +from iac_code.types.stream_events import PermissionWaitOutcome +from iac_code.utils.file_security import ensure_private_file +from iac_code.utils.state_io import atomic_write_json, cross_process_file_lock + +PermissionClass = Literal["normal", "pipeline"] +PermissionPhase = Literal[ + "WAITING", + "TIMEOUT_GRACE", + "SUSPENDING", + "SUSPENDED", + "RESTORING", + "RESOLVED", + "CANCELED", +] + +_BOUNDARY_ID = re.compile(r"^[A-Za-z0-9_-]{8,128}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_ROOT_MESSAGE_REF = re.compile(r"^session\.jsonl:(0|[1-9][0-9]*)$") +_PIPELINE_MESSAGE_REF = re.compile(r"^pipeline/transcripts/([A-Za-z0-9_.-]+)/session\.jsonl:(0|[1-9][0-9]*)$") +_ACTIVE_PHASES = {"WAITING", "TIMEOUT_GRACE", "SUSPENDING", "SUSPENDED", "RESTORING"} +logger = logging.getLogger(__name__) + + +def _parse_timeout(value: object, *, name: str, allow_zero: bool) -> float | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be null or a finite number.") + result = float(value) + if not math.isfinite(result) or result < 0 or (result == 0 and not allow_zero): + qualifier = "non-negative" if allow_zero else "positive" + raise ValueError(f"{name} must be null or a finite {qualifier} number.") + return result + + +@dataclass(frozen=True) +class PermissionWaitPolicy: + resident_timeout_seconds: float | None = None + sub_pipeline_timeout_seconds: float | None = None + timeout_grace_seconds: float = 30.0 + + @classmethod + def from_config(cls, raw: object | None) -> PermissionWaitPolicy: + if raw is None: + return cls() + if not isinstance(raw, Mapping): + raise ValueError("permission_wait must be an object.") + config = dict(raw) + allowed = { + "resident_timeout_seconds", + "sub_pipeline_timeout_seconds", + "timeout_grace_seconds", + } + unknown = sorted(str(key) for key in config if key not in allowed) + if unknown: + raise ValueError("Unknown permission_wait fields: {}.".format(", ".join(unknown))) + resident = _parse_timeout( + config.get("resident_timeout_seconds"), + name="permission_wait.resident_timeout_seconds", + allow_zero=False, + ) + sub_pipeline = _parse_timeout( + config.get("sub_pipeline_timeout_seconds"), + name="permission_wait.sub_pipeline_timeout_seconds", + allow_zero=False, + ) + grace_value = config.get("timeout_grace_seconds", 30) + grace = _parse_timeout( + grace_value, + name="permission_wait.timeout_grace_seconds", + allow_zero=True, + ) + assert grace is not None + return cls( + resident_timeout_seconds=resident, + sub_pipeline_timeout_seconds=sub_pipeline, + timeout_grace_seconds=grace, + ) + + def to_config(self) -> dict[str, float | None]: + return { + "resident_timeout_seconds": self.resident_timeout_seconds, + "sub_pipeline_timeout_seconds": self.sub_pipeline_timeout_seconds, + "timeout_grace_seconds": self.timeout_grace_seconds, + } + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def format_utc(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def parse_utc(value: object) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def canonical_digest(value: object) -> str: + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class RecoveredPermissionAuditBoundary: + """Canonical tool/audit data reconstructed from a persisted transcript.""" + + tool_name: str + tool_input: dict[str, Any] + tool_use_id: str + audit_context: dict[str, Any] + + +def _parse_permission_message_ref(value: object) -> tuple[str | None, int]: + if not isinstance(value, str): + raise ValueError("invalid permission continuation message reference") + root_match = _ROOT_MESSAGE_REF.fullmatch(value) + if root_match is not None: + return None, int(root_match.group(1)) + pipeline_match = _PIPELINE_MESSAGE_REF.fullmatch(value) + if pipeline_match is None: + raise ValueError("invalid permission continuation message reference") + transcript_id = pipeline_match.group(1) + # Reuse the session layout's cross-platform component validation without + # resolving or touching a caller-controlled path. + SessionPaths.from_session_dir(Path(".")).transcript_dir(transcript_id) + return transcript_id, int(pipeline_match.group(2)) + + +def canonicalize_permission_continuation_frame( + frame: Mapping[str, Any], + *, + audit_context: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Bind an AgentLoop-local message index to its canonical transcript.""" + + result = dict(frame) + referenced_transcript, message_index = _parse_permission_message_ref(result.get("assistantMessageRef")) + transcript_value = audit_context.get("transcript_id") if audit_context is not None else None + if transcript_value is None: + if referenced_transcript is not None: + raise ValueError("invalid permission continuation transcript context") + return result + if not isinstance(transcript_value, str) or not transcript_value: + raise ValueError("invalid permission continuation transcript context") + SessionPaths.from_session_dir(Path(".")).transcript_dir(transcript_value) + if referenced_transcript is not None and referenced_transcript != transcript_value: + raise ValueError("invalid permission continuation transcript context") + result["assistantMessageRef"] = f"pipeline/transcripts/{transcript_value}/session.jsonl:{message_index}" + return result + + +def recover_permission_audit_boundary( + record: Mapping[str, Any], + *, + cwd: str, + session_id: str, + storage: SessionStorage | None = None, +) -> RecoveredPermissionAuditBoundary | None: + """Re-read and verify the exact permission tool call from canonical storage.""" + + frame = record.get("continuationFrame") + if not isinstance(frame, Mapping) or record.get("sessionId") != session_id: + return None + try: + transcript_id, message_index = _parse_permission_message_ref(frame.get("assistantMessageRef")) + root_storage = storage or SessionStorage() + root_session_dir = root_storage.session_dir(cwd, session_id) + if transcript_id is None: + messages = root_storage.load(cwd, session_id) + audit_context: dict[str, Any] = { + "session_id": session_id, + "cwd": cwd, + "audit_log_path": str(SessionPaths.from_session_dir(root_session_dir).permission_audit_path), + } + else: + root_session_dir = root_storage.v2_session_dir(cwd, session_id) + if root_session_dir is None: + return None + session_paths = SessionPaths.require_supported(root_session_dir) + ensure_session_owned_dir( + root_session_dir, + session_paths.transcript_dir(transcript_id), + ) + from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage + + transcript_storage = PipelineTranscriptStorage(session_paths.session_dir / "pipeline") + messages = transcript_storage.load(cwd, transcript_id) + audit_context = { + "session_id": transcript_id, + "cwd": cwd, + "root_session_id": session_id, + "transcript_id": transcript_id, + "audit_log_path": str(session_paths.transcript_permission_audit_path(transcript_id)), + } + if not messages or message_index != len(messages) - 1: + return None + message = messages[message_index] + if message.role != "assistant": + return None + message_content = ( + [block.model_dump(mode="json") for block in message.content] + if isinstance(message.content, list) + else message.content + ) + if canonical_digest(message_content) != frame.get("assistantMessageDigest"): + return None + tool_uses = message.get_tool_use_blocks() + ordered_ids = [tool_use.id for tool_use in tool_uses] + if ordered_ids != frame.get("orderedToolUseIds"): + return None + current_index = frame.get("currentIndex") + if isinstance(current_index, bool) or not isinstance(current_index, int): + return None + if current_index < 0 or current_index >= len(tool_uses): + return None + tool_use = tool_uses[current_index] + if tool_use.id != record.get("toolUseId") or tool_use.name != record.get("toolName"): + return None + if canonical_digest({"name": tool_use.name, "input": tool_use.input}) != record.get("payloadDigest"): + return None + return RecoveredPermissionAuditBoundary( + tool_name=tool_use.name, + tool_input=dict(tool_use.input), + tool_use_id=tool_use.id, + audit_context=audit_context, + ) + except (OSError, RuntimeError, TypeError, ValueError): + return None + + +def permission_execution_identity( + *, + tool_name: str, + tool_input: Mapping[str, Any], + permission_audit: object | None = None, +) -> tuple[str | None, str | None]: + """Return a non-secret Alibaba Cloud principal fingerprint and effective Region. + + Local permissions are deliberately not coupled to Alibaba Cloud credentials. + For a cloud operation, an unavailable stable credential anchor remains + ``None`` so durable recovery can fail closed instead of treating an + unknown principal as an approval for the current process identity. + """ + + operation = getattr(permission_audit, "operation", None) + operation = operation if isinstance(operation, Mapping) else {} + cloud_operation = bool(operation.get("product")) or tool_name == "aliyun_api" or tool_name.startswith("ros_") + if not cloud_operation: + return None, None + + from iac_code.services.providers.aliyun import AliyunCredentials + + credential = AliyunCredentials.load() + region = tool_input.get("region_id") + params = tool_input.get("params") + if not isinstance(region, str) or not region: + if isinstance(params, Mapping): + region = params.get("RegionId") + if not isinstance(region, str) or not region: + region = operation.get("region") + if (not isinstance(region, str) or not region) and credential is not None: + region = credential.region_id + effective_region = region if isinstance(region, str) and region else None + + if credential is None: + return None, effective_region + anchor = credential.ram_role_arn or credential.ram_role_name or credential.access_key_id + if not anchor: + return None, effective_region + principal_ref = "aliyun:" + canonical_digest({"mode": credential.mode, "anchor": anchor}) + return principal_ref, effective_region + + +def new_boundary_id() -> str: + return "pwb_" + uuid.uuid4().hex + + +class PermissionWaitCheckpointStore: + """Atomic JSON records scoped to one existing conversation session.""" + + def __init__(self, cwd: str, session_id: str, *, storage: SessionStorage | None = None) -> None: + self.cwd = cwd + self.session_id = session_id + self._storage = storage or SessionStorage() + session_dir = self._storage.v2_session_dir(cwd, session_id) + if session_dir is None: + session_dir = self._storage.ensure_v2_session_dir_for_new_session(cwd, session_id) + if session_dir is None: + raise ValueError("permission waits require a version 2 session directory") + self.paths = SessionPaths.require_supported(session_dir) + ensure_session_owned_dir(self.paths.session_dir, self.paths.permission_waits_dir) + + def create(self, record: Mapping[str, Any]) -> dict[str, Any]: + candidate = dict(record) + boundary_id = self._validate_record(candidate) + path = self._record_path(boundary_id) + with cross_process_file_lock(self.paths.permission_waits_lock_path): + if path.exists(): + raise ValueError("permission boundary already exists") + atomic_write_json(path, candidate, durable=True) + ensure_private_file(path) + return candidate + + def create_successor(self, record: Mapping[str, Any], *, previous_boundary_id: str) -> dict[str, Any]: + """Atomically move an ordered tool batch from one wait boundary to the next.""" + + candidate = dict(record) + boundary_id = self._validate_record(candidate) + new_path = self._record_path(boundary_id) + previous_path = self._record_path(previous_boundary_id) + with cross_process_file_lock(self.paths.permission_waits_lock_path): + if new_path.exists(): + raise ValueError("permission boundary already exists") + previous = self._read(previous_path) + if previous is None or previous.get("phase") in {"RESOLVED", "CANCELED"}: + raise ValueError("previous permission boundary is not active") + decision = previous.get("decision") + if not isinstance(decision, dict) or decision.get("status") not in {"claimed", "applied"}: + raise ValueError("previous permission decision is not available") + receipt = { + "schemaVersion": 1, + "boundaryId": previous["boundaryId"], + "inputId": previous["inputId"], + "taskId": previous.get("taskId"), + "contextId": previous.get("contextId"), + "sessionId": previous["sessionId"], + "toolUseId": previous["toolUseId"], + "payloadDigest": previous["payloadDigest"], + "phase": "RESOLVED", + "generation": int(previous["generation"]) + 1, + "decision": decision, + "resultDigest": "", + "nextBoundaryId": boundary_id, + "ack": { + "decision": decision.get("value"), + "accepted": True, + "nextBoundaryId": boundary_id, + }, + "resolvedAt": format_utc(utc_now()), + } + atomic_write_json(new_path, candidate, durable=True) + ensure_private_file(new_path) + atomic_write_json(previous_path, receipt, durable=True) + ensure_private_file(previous_path) + return candidate + + def load(self, boundary_id: str) -> dict[str, Any] | None: + path = self._record_path(boundary_id) + with cross_process_file_lock(self.paths.permission_waits_lock_path): + return self._read(path) + + def find(self, *, task_id: str, context_id: str, input_id: str, tool_use_id: str) -> dict[str, Any] | None: + with cross_process_file_lock(self.paths.permission_waits_lock_path): + for path in sorted(self.paths.permission_waits_dir.glob("pwb_*.json")): + record = self._read(path) + if record is None: + continue + if ( + record.get("taskId") == task_id + and record.get("contextId") == context_id + and record.get("inputId") == input_id + and record.get("toolUseId") == tool_use_id + ): + return record + return None + + def find_by_input_id(self, input_id: str) -> dict[str, Any] | None: + """Find one session-scoped browser correlation, including its compact receipt.""" + + with cross_process_file_lock(self.paths.permission_waits_lock_path): + for path in sorted(self.paths.permission_waits_dir.glob("pwb_*.json")): + record = self._read(path) + if record is not None and record.get("inputId") == input_id: + return record + return None + + def list_active(self) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + with cross_process_file_lock(self.paths.permission_waits_lock_path): + for path in sorted(self.paths.permission_waits_dir.glob("pwb_*.json")): + record = self._read(path) + if record is not None and record.get("phase") in _ACTIVE_PHASES: + records.append(record) + return records + + def transaction( + self, + boundary_id: str, + mutate: Callable[[dict[str, Any]], dict[str, Any] | None], + ) -> dict[str, Any]: + path = self._record_path(boundary_id) + with cross_process_file_lock(self.paths.permission_waits_lock_path): + current = self._read(path) + if current is None: + raise ValueError("permission boundary not found") + updated = mutate(dict(current)) + if updated is None: + return current + self._validate_record(updated) + atomic_write_json(path, updated, durable=True) + ensure_private_file(path) + return updated + + def run_generation_fenced( + self, + boundary_id: str, + *, + expected_generation: int, + operation: Callable[[], Any], + ) -> Any: + """Run a backup while holding permission lock before its backup lock.""" + + path = self._record_path(boundary_id) + with cross_process_file_lock(self.paths.permission_waits_lock_path): + current = self._read(path) + if current is None or int(current.get("generation", 0)) != expected_generation: + raise ValueError("permission generation changed") + result = operation() + verified = self._read(path) + if verified is None or int(verified.get("generation", 0)) != expected_generation: + raise ValueError("permission generation changed") + return result + + def reconcile_deadline( + self, + boundary_id: str, + *, + now: datetime | None = None, + grace_seconds: float, + live_owner: bool, + expected_generation: int | None = None, + ) -> dict[str, Any]: + observed_at = now or utc_now() + + def mutate(record: dict[str, Any]) -> dict[str, Any] | None: + if expected_generation is not None and int(record.get("generation", 0)) != expected_generation: + raise ValueError("permission generation changed") + phase = record.get("phase") + decision = record.get("decision") + decision_status = decision.get("status") if isinstance(decision, dict) else None + if not live_owner and phase in {"WAITING", "TIMEOUT_GRACE", "SUSPENDING"}: + record["phase"] = "SUSPENDED" + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(observed_at) + return record + resident_deadline = parse_utc(record.get("residentDeadlineAt")) + if phase == "WAITING" and resident_deadline is not None and observed_at >= resident_deadline: + # A paused sandbox cannot run this callback at the resident + # deadline. Persist the grace window when expiry is first + # observed so a resumed permission reply still gets the + # configured request-versus-timeout race window. + grace_deadline = observed_at + timedelta(seconds=grace_seconds) + record["graceDeadlineAt"] = format_utc(grace_deadline) + if decision_status == "none" and grace_seconds == 0: + record["phase"] = "SUSPENDING" if live_owner else "SUSPENDED" + else: + record["phase"] = "TIMEOUT_GRACE" + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(observed_at) + return record + grace_deadline = parse_utc(record.get("graceDeadlineAt")) + if ( + phase == "TIMEOUT_GRACE" + and decision_status == "none" + and grace_deadline is not None + and observed_at >= grace_deadline + ): + record["phase"] = "SUSPENDING" if live_owner else "SUSPENDED" + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(observed_at) + return record + return None + + return self.transaction(boundary_id, mutate) + + def claim_decision( + self, + boundary_id: str, + *, + value: Literal["allow_once", "deny"], + source: str, + claim_id: str | None = None, + expected_generation: int | None = None, + ) -> tuple[dict[str, Any], bool]: + claim = claim_id or uuid.uuid4().hex + created = False + + def mutate(record: dict[str, Any]) -> dict[str, Any] | None: + nonlocal created + if expected_generation is not None and int(record.get("generation", 0)) != expected_generation: + raise ValueError("permission generation changed") + if record.get("phase") in {"CANCELED"}: + raise ValueError("permission boundary is canceled") + decision = record.get("decision") + if not isinstance(decision, dict): + raise ValueError("invalid permission decision state") + status = decision.get("status") + if status in {"claimed", "applied"}: + if decision.get("value") != value: + raise ValueError("permission response conflicts with the recorded decision") + return None + if status != "none": + raise ValueError("invalid permission decision state") + record["decision"] = { + "status": "claimed", + "value": value, + "source": source, + "claimId": claim, + "auditStatus": "pending", + "backupStatus": "pending", + } + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(utc_now()) + created = True + return record + + return self.transaction(boundary_id, mutate), created + + def mark_applied(self, boundary_id: str, *, claim_id: str) -> dict[str, Any]: + def mutate(record: dict[str, Any]) -> dict[str, Any] | None: + decision = record.get("decision") + if not isinstance(decision, dict) or decision.get("claimId") != claim_id: + raise ValueError("permission claim changed") + if decision.get("status") == "applied": + return None + if decision.get("status") != "claimed": + raise ValueError("permission claim is not pending delivery") + decision = dict(decision) + decision["status"] = "applied" + record["decision"] = decision + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(utc_now()) + return record + + return self.transaction(boundary_id, mutate) + + def mark_claim_backed_up(self, boundary_id: str, *, claim_id: str) -> dict[str, Any]: + """Record that the accepted decision reached every required backup target.""" + + def mutate(record: dict[str, Any]) -> dict[str, Any] | None: + decision = record.get("decision") + if not isinstance(decision, dict) or decision.get("claimId") != claim_id: + raise ValueError("permission claim changed") + if decision.get("backupStatus") == "committed": + return None + if decision.get("status") not in {"claimed", "applied"}: + raise ValueError("permission claim is not available for backup") + decision = dict(decision) + decision["backupStatus"] = "committed" + record["decision"] = decision + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(utc_now()) + return record + + return self.transaction(boundary_id, mutate) + + def run_claim_audit_once( + self, + boundary_id: str, + *, + claim_id: str, + audit: Callable[[str], bool], + ) -> tuple[dict[str, Any], bool]: + """Run one authoritative decision audit under the checkpoint file lock. + + This lock only serializes the short audit-and-checkpoint commit. It is + never held while waiting for a user, backing up a session, restoring a + runtime, or executing a tool. + """ + + path = self._record_path(boundary_id) + with cross_process_file_lock(self.paths.permission_waits_lock_path): + record = self._read(path) + if record is None: + raise ValueError("permission boundary not found") + decision = record.get("decision") + if not isinstance(decision, dict) or decision.get("claimId") != claim_id: + raise ValueError("permission claim changed") + if decision.get("status") not in {"claimed", "applied"}: + raise ValueError("permission claim is not available for audit") + audit_status = decision.get("auditStatus", "pending") + if audit_status in {"recorded", "failed"}: + return record, False + if audit_status != "pending": + raise ValueError("invalid permission claim audit state") + delivered_value = str(decision.get("value") or "") + try: + succeeded = bool(audit(delivered_value)) + except Exception: + logger.exception("Permission decision audit failed boundary_id=%s", boundary_id) + succeeded = False + decision = dict(decision) + if not succeeded and delivered_value == "allow_once": + decision["value"] = "deny" + decision["auditStatus"] = "recorded" if succeeded else "failed" + record["decision"] = decision + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(utc_now()) + self._validate_record(record) + atomic_write_json(path, record, durable=True) + ensure_private_file(path) + return record, True + + def mark_suspended(self, boundary_id: str, *, expected_generation: int | None = None) -> dict[str, Any]: + def mutate(record: dict[str, Any]) -> dict[str, Any] | None: + if expected_generation is not None and int(record.get("generation", 0)) != expected_generation: + raise ValueError("permission generation changed") + if record.get("phase") == "SUSPENDED": + return None + if record.get("phase") not in {"SUSPENDING", "WAITING", "TIMEOUT_GRACE"}: + raise ValueError("permission boundary cannot be suspended") + record["phase"] = "SUSPENDED" + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(utc_now()) + return record + + return self.transaction(boundary_id, mutate) + + def begin_restore(self, boundary_id: str) -> dict[str, Any]: + def mutate(record: dict[str, Any]) -> dict[str, Any]: + if record.get("phase") not in {"SUSPENDED", "SUSPENDING"}: + raise ValueError("permission boundary is not recoverable") + decision = record.get("decision") + if not isinstance(decision, dict) or decision.get("status") not in {"claimed", "applied"}: + raise ValueError("permission boundary has no decision to recover") + record["phase"] = "RESTORING" + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(utc_now()) + return record + + return self.transaction(boundary_id, mutate) + + def resolve(self, boundary_id: str, *, result_digest: str, ack: Mapping[str, Any]) -> dict[str, Any]: + def mutate(record: dict[str, Any]) -> dict[str, Any] | None: + if record.get("phase") == "RESOLVED": + return None + decision = record.get("decision") + receipt = { + "schemaVersion": 1, + "boundaryId": record["boundaryId"], + "inputId": record["inputId"], + "taskId": record.get("taskId"), + "contextId": record.get("contextId"), + "sessionId": record["sessionId"], + "toolUseId": record["toolUseId"], + "payloadDigest": record["payloadDigest"], + "phase": "RESOLVED", + "generation": int(record["generation"]) + 1, + "decision": decision, + "resultDigest": result_digest, + "ack": dict(ack), + "resolvedAt": format_utc(utc_now()), + } + return receipt + + return self.transaction(boundary_id, mutate) + + def cancel(self, boundary_id: str, *, expected_generation: int | None = None) -> dict[str, Any]: + def mutate(record: dict[str, Any]) -> dict[str, Any] | None: + if expected_generation is not None and int(record.get("generation", 0)) != expected_generation: + raise ValueError("permission generation changed") + if record.get("phase") == "CANCELED": + return None + if record.get("phase") == "RESOLVED": + raise ValueError("resolved permission cannot be canceled") + decision = record.get("decision") + if isinstance(decision, dict) and decision.get("status") != "none": + raise ValueError("permission decision already claimed") + record["phase"] = "CANCELED" + record["generation"] = int(record["generation"]) + 1 + record["updatedAt"] = format_utc(utc_now()) + return record + + return self.transaction(boundary_id, mutate) + + def _record_path(self, boundary_id: str) -> Path: + if not _BOUNDARY_ID.fullmatch(boundary_id): + raise ValueError("invalid permission boundary id") + return self.paths.permission_waits_dir / f"{boundary_id}.json" + + @staticmethod + def _read(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("invalid permission checkpoint") from exc + if not isinstance(value, dict): + raise ValueError("invalid permission checkpoint") + return value + + def _validate_record(self, record: Mapping[str, Any]) -> str: + boundary_id = record.get("boundaryId") + if not isinstance(boundary_id, str) or not _BOUNDARY_ID.fullmatch(boundary_id): + raise ValueError("invalid permission boundary id") + if record.get("schemaVersion") != 1 or record.get("sessionId") != self.session_id: + raise ValueError("invalid permission checkpoint identity") + if record.get("phase") not in { + "WAITING", + "TIMEOUT_GRACE", + "SUSPENDING", + "SUSPENDED", + "RESTORING", + "RESOLVED", + "CANCELED", + }: + raise ValueError("invalid permission checkpoint phase") + generation = record.get("generation") + if isinstance(generation, bool) or not isinstance(generation, int) or generation < 1: + raise ValueError("invalid permission checkpoint generation") + if not isinstance(record.get("payloadDigest"), str) or not _SHA256.fullmatch(record["payloadDigest"]): + raise ValueError("invalid permission payload digest") + if record.get("phase") in _ACTIVE_PHASES: + permission_class = record.get("permissionClass") + mode = record.get("mode") + if permission_class not in {"normal", "pipeline"} or mode != permission_class: + raise ValueError("invalid permission checkpoint class") + self._validate_continuation_frame(record) + return boundary_id + + @staticmethod + def _validate_continuation_frame(record: Mapping[str, Any]) -> None: + frame = record.get("continuationFrame") + if not isinstance(frame, Mapping): + raise ValueError("invalid permission continuation frame") + message_ref = frame.get("assistantMessageRef") + message_digest = frame.get("assistantMessageDigest") + ordered_ids = frame.get("orderedToolUseIds") + current_index = frame.get("currentIndex") + current_payload_digest = frame.get("currentPayloadDigest") + decisions = frame.get("decisions") + if not isinstance(message_ref, str) or not message_ref: + raise ValueError("invalid permission continuation message reference") + transcript_id, _message_index = _parse_permission_message_ref(message_ref) + permission_class = record.get("permissionClass") + if (permission_class == "normal" and transcript_id is not None) or ( + permission_class == "pipeline" and transcript_id is None + ): + raise ValueError("invalid permission continuation transcript class") + if not isinstance(message_digest, str) or not _SHA256.fullmatch(message_digest): + raise ValueError("invalid permission continuation message digest") + if ( + not isinstance(ordered_ids, list) + or not ordered_ids + or not all(isinstance(value, str) and value for value in ordered_ids) + or len(set(ordered_ids)) != len(ordered_ids) + ): + raise ValueError("invalid permission continuation tool ordering") + if ( + isinstance(current_index, bool) + or not isinstance(current_index, int) + or current_index < 0 + or current_index >= len(ordered_ids) + ): + raise ValueError("invalid permission continuation current index") + if ( + not isinstance(current_payload_digest, str) + or not _SHA256.fullmatch(current_payload_digest) + or current_payload_digest != record.get("payloadDigest") + ): + raise ValueError("invalid permission continuation payload digest") + if not isinstance(decisions, list) or len(decisions) != len(ordered_ids): + raise ValueError("invalid permission continuation decisions") + pending_indexes: list[int] = [] + for index, (tool_use_id, decision) in enumerate(zip(ordered_ids, decisions)): + if not isinstance(decision, dict): + raise ValueError("invalid permission continuation decision correlation") + decision_record = cast(dict[str, Any], decision) + if decision_record.get("toolUseId") != tool_use_id: + raise ValueError("invalid permission continuation decision correlation") + state = decision_record.get("state") + if state not in {"not_evaluated", "pending", "allow", "deny"}: + raise ValueError("invalid permission continuation decision state") + if state == "allow" and decision_record.get("source") == "user": + if "principalRef" not in decision_record or "region" not in decision_record: + raise ValueError("invalid permission continuation user identity") + if not all( + value is None or isinstance(value, str) + for value in (decision_record["principalRef"], decision_record["region"]) + ): + raise ValueError("invalid permission continuation user identity") + if state == "pending": + pending_indexes.append(index) + if pending_indexes != [current_index] or record.get("toolUseId") != ordered_ids[current_index]: + raise ValueError("invalid permission continuation pending boundary") + + +@dataclass +class _LiveOwner: + boundary_id: str + generation: int + future: asyncio.Future[bool | PermissionWaitOutcome] + store: PermissionWaitCheckpointStore + permission_resolution_lock: asyncio.Lock + timer: asyncio.Task[None] | None = None + timer_retry: asyncio.TimerHandle | None = None + on_suspend: Callable[[], Awaitable[None] | None] | None = None + owner_completed: asyncio.Event = field(default_factory=asyncio.Event) + + +def _consume_background_exception(task: asyncio.Task[Any]) -> None: + """Retrieve failures from a shielded delivery task if its caller went away.""" + + if task.cancelled(): + return + task.exception() + + +class PermissionWaitCoordinator: + """Own process-local Futures while the checkpoint remains authoritative.""" + + def __init__(self, policy: PermissionWaitPolicy | None = None) -> None: + self.policy = policy or PermissionWaitPolicy() + self._owners: dict[str, _LiveOwner] = {} + self._restoring: set[str] = set() + self._restore_lock = asyncio.Lock() + + def has_live_owners(self) -> bool: + # A completed decision Future does not mean the continuation has + # finished. Keep the owner authoritative until its caller explicitly + # unregisters it after persisting the successor ToolResult/receipt. + return bool(self._restoring) or bool(self._owners) + + def is_restoring(self, boundary_id: str) -> bool: + return boundary_id in self._restoring + + def has_live_boundary(self, boundary_id: str) -> bool: + return boundary_id in self._owners + + async def acquire_restore(self, boundary_id: str) -> bool: + async with self._restore_lock: + if boundary_id in self._restoring: + return False + self._restoring.add(boundary_id) + return True + + async def release_restore(self, boundary_id: str) -> None: + async with self._restore_lock: + self._restoring.discard(boundary_id) + + def register_live( + self, + *, + record: Mapping[str, Any], + store: PermissionWaitCheckpointStore, + future: asyncio.Future[bool | PermissionWaitOutcome], + on_suspend: Callable[[], Awaitable[None] | None] | None = None, + ) -> None: + boundary_id = str(record["boundaryId"]) + existing = self._owners.get(boundary_id) + if existing is not None: + if existing.future is future: + # Pipeline publication may expose the same durable boundary + # more than once. Keep the original owner and timer: replacing + # it with a stale checkpoint generation can strand the Future + # after the resident deadline. + logger.info( + "Permission wait live owner registration reused boundary_id=%s generation=%s", + boundary_id, + existing.generation, + ) + return + raise RuntimeError("permission boundary already has a different live owner") + owner = _LiveOwner( + boundary_id=boundary_id, + generation=int(record["generation"]), + future=future, + store=store, + permission_resolution_lock=asyncio.Lock(), + on_suspend=on_suspend, + ) + self._owners[boundary_id] = owner + logger.info( + "Permission wait live owner registered boundary_id=%s generation=%s resident_timeout_seconds=%s", + boundary_id, + owner.generation, + self.policy.resident_timeout_seconds, + ) + if self.policy.resident_timeout_seconds is not None: + self._start_resident_timer(owner) + + def _start_resident_timer(self, owner: _LiveOwner) -> None: + """Start the request-independent deadline task for one live owner.""" + + if self._owners.get(owner.boundary_id) is not owner or owner.future.done(): + return + owner.timer_retry = None + timer = asyncio.create_task( + self._run_resident_timer(owner), + name=f"permission-wait-{owner.boundary_id}", + ) + owner.timer = timer + logger.info( + "Permission wait resident timer scheduled boundary_id=%s generation=%s loop=%s", + owner.boundary_id, + owner.generation, + id(timer.get_loop()), + ) + timer.add_done_callback(lambda completed: self._resident_timer_finished(owner, completed)) + + def _resident_timer_finished(self, owner: _LiveOwner, timer: asyncio.Task[None]) -> None: + """Re-arm only an unexpectedly canceled live deadline task. + + The timer is created while an A2A request is active, but its lifetime is + owned by the durable permission boundary. A transport/request cancel + must therefore not silently strand an authoritative Future in WAITING. + The short TimerHandle indirection also avoids creating a new Task while + an event loop is completing its own shutdown cancellation pass. + """ + + if owner.timer is not timer: + return + owner.timer = None + if timer.cancelled(): + if self._owners.get(owner.boundary_id) is not owner or owner.future.done(): + return + logger.warning( + "Permission wait resident timer was canceled while its owner remained live; " + "re-arming from the persisted deadline boundary_id=%s generation=%s", + owner.boundary_id, + owner.generation, + ) + loop = timer.get_loop() + if not loop.is_closed(): + owner.timer_retry = loop.call_later(0.1, self._start_resident_timer, owner) + return + error = timer.exception() + if error is not None: + logger.error( + "Permission wait resident timer failed boundary_id=%s generation=%s", + owner.boundary_id, + owner.generation, + exc_info=(type(error), error, error.__traceback__), + ) + + def unregister_live(self, boundary_id: str) -> None: + owner = self._owners.pop(boundary_id, None) + if owner is None: + return + try: + record = owner.store.load(boundary_id) + if record is not None: + logger.info( + "Permission wait live owner released boundary_id=%s generation=%s phase=%s future_done=%s", + boundary_id, + record.get("generation"), + record.get("phase"), + owner.future.done(), + ) + if record is not None and record.get("phase") == "SUSPENDING": + owner.store.mark_suspended(boundary_id, expected_generation=owner.generation) + except ValueError: + # A newer cross-process generation is authoritative. Its next + # request reconciles any orphaned SUSPENDING phase. + pass + finally: + owner.owner_completed.set() + if owner.timer_retry is not None: + owner.timer_retry.cancel() + owner.timer_retry = None + if owner.timer is not None and owner.timer is not asyncio.current_task(): + owner.timer.cancel() + + async def wait_for_suspended_owner(self, boundary_id: str, *, timeout_seconds: float = 5.0) -> bool: + """Wait outside all resolution locks for a SUSPENDING owner to unwind. + + ``False`` means the same process-local owner is still alive after the + bounded wait. It must remain authoritative: treating it as a crashed + owner would allow a recovery continuation to overlap its cleanup. + """ + + owner = self._owners.get(boundary_id) + if owner is not None: + try: + await asyncio.wait_for(owner.owner_completed.wait(), timeout=max(0.0, timeout_seconds)) + except asyncio.TimeoutError: + pass + current_owner = self._owners.get(boundary_id) + if current_owner is not None: + return False + return True + + async def claim_live( + self, + *, + boundary_id: str, + value: Literal["allow_once", "deny"], + source: str = "user", + on_new_claim: Callable[[str], bool] | None = None, + before_delivery: Callable[[dict[str, Any]], Awaitable[None] | None] | None = None, + ) -> tuple[dict[str, Any], bool]: + # Keep delivery independent of the transport request that carried the + # answer. Once the decision is durable, canceling that request must not + # strand the resident continuation before its Future is completed. + delivery = asyncio.create_task( + self._claim_live_and_deliver( + boundary_id=boundary_id, + value=value, + source=source, + on_new_claim=on_new_claim, + before_delivery=before_delivery, + ) + ) + delivery.add_done_callback(_consume_background_exception) + return await asyncio.shield(delivery) + + async def _claim_live_and_deliver( + self, + *, + boundary_id: str, + value: Literal["allow_once", "deny"], + source: str, + on_new_claim: Callable[[str], bool] | None, + before_delivery: Callable[[dict[str, Any]], Awaitable[None] | None] | None, + ) -> tuple[dict[str, Any], bool]: + owner = self._owners.get(boundary_id) + if owner is None: + raise LookupError("permission boundary has no live owner") + async with owner.permission_resolution_lock: + reconciled = owner.store.reconcile_deadline( + boundary_id, + grace_seconds=self.policy.timeout_grace_seconds, + live_owner=True, + expected_generation=owner.generation, + ) + owner.generation = int(reconciled["generation"]) + record, created = owner.store.claim_decision( + boundary_id, + value=value, + source=source, + expected_generation=owner.generation, + ) + owner.generation = int(record["generation"]) + decision = record["decision"] + claim_id = str(decision["claimId"]) + delivered_value = str(decision["value"]) + if on_new_claim is not None: + record, _audit_created = owner.store.run_claim_audit_once( + boundary_id, + claim_id=claim_id, + audit=on_new_claim, + ) + owner.generation = int(record["generation"]) + delivered_value = str(record["decision"]["value"]) + decision = record["decision"] + if decision.get("backupStatus") != "committed": + if before_delivery is not None: + result = before_delivery(record) + if asyncio.iscoroutine(result): + await result + record = owner.store.mark_claim_backed_up(boundary_id, claim_id=claim_id) + owner.generation = int(record["generation"]) + phase = record.get("phase") + if phase in {"SUSPENDING", "SUSPENDED", "RESTORING"}: + return record, created + if not owner.future.done(): + owner.future.set_result(delivered_value == "allow_once") + record = owner.store.mark_applied(boundary_id, claim_id=claim_id) + owner.generation = int(record["generation"]) + if owner.timer is not None: + owner.timer.cancel() + return record, created + + async def cancel_live(self, boundary_id: str) -> bool: + owner = self._owners.get(boundary_id) + if owner is None: + return False + async with owner.permission_resolution_lock: + try: + record = owner.store.cancel(boundary_id, expected_generation=owner.generation) + owner.generation = int(record["generation"]) + except ValueError: + return False + if not owner.future.done(): + owner.future.cancel() + if owner.timer is not None: + owner.timer.cancel() + return True + + async def suspend_now(self, boundary_id: str) -> bool: + owner = self._owners.get(boundary_id) + if owner is None: + return False + callback = None + async with owner.permission_resolution_lock: + try: + record = owner.store.reconcile_deadline( + boundary_id, + grace_seconds=self.policy.timeout_grace_seconds, + live_owner=True, + expected_generation=owner.generation, + ) + except ValueError: + current = owner.store.load(boundary_id) + logger.warning( + "Permission wait suspension lost generation fence boundary_id=%s owner_generation=%s " + "record_generation=%s phase=%s", + boundary_id, + owner.generation, + current.get("generation") if current is not None else None, + current.get("phase") if current is not None else None, + ) + return False + owner.generation = int(record["generation"]) + if record.get("phase") != "SUSPENDING": + return False + if not owner.future.done(): + owner.future.set_result(PermissionWaitOutcome.SUSPEND) + callback = owner.on_suspend + if callback is not None: + result = callback() + if asyncio.iscoroutine(result): + await result + return True + + async def _run_resident_timer(self, owner: _LiveOwner) -> None: + try: + record = owner.store.load(owner.boundary_id) + if record is None: + return + resident_deadline = parse_utc(record.get("residentDeadlineAt")) + if resident_deadline is None: + return + delay = max(0.0, (resident_deadline - utc_now()).total_seconds()) + logger.info( + "Permission wait resident timer started boundary_id=%s generation=%s delay_seconds=%.3f", + owner.boundary_id, + owner.generation, + delay, + ) + await asyncio.sleep(delay) + logger.info( + "Permission wait resident timer woke boundary_id=%s generation=%s", + owner.boundary_id, + owner.generation, + ) + async with owner.permission_resolution_lock: + record = owner.store.reconcile_deadline( + owner.boundary_id, + grace_seconds=self.policy.timeout_grace_seconds, + live_owner=True, + expected_generation=owner.generation, + ) + owner.generation = int(record["generation"]) + if record.get("phase") == "SUSPENDING": + grace_deadline = None + elif record.get("phase") != "TIMEOUT_GRACE": + return + else: + grace_deadline = parse_utc(record.get("graceDeadlineAt")) + logger.info( + "Permission wait resident deadline reconciled boundary_id=%s generation=%s phase=%s", + owner.boundary_id, + owner.generation, + record.get("phase"), + ) + if grace_deadline is not None: + await asyncio.sleep(max(0.0, (grace_deadline - utc_now()).total_seconds())) + suspended = await self.suspend_now(owner.boundary_id) + while not suspended: + current = owner.store.load(owner.boundary_id) + decision = current.get("decision") if current is not None else None + if ( + current is None + or current.get("phase") != "TIMEOUT_GRACE" + or not isinstance(decision, Mapping) + or decision.get("status") != "none" + ): + break + grace_deadline = parse_utc(current.get("graceDeadlineAt")) + if grace_deadline is None: + break + await asyncio.sleep(max(0.001, (grace_deadline - utc_now()).total_seconds())) + suspended = await self.suspend_now(owner.boundary_id) + logger.info( + "Permission wait grace completion handled boundary_id=%s suspended=%s", + owner.boundary_id, + suspended, + ) + except ValueError: + return + + +def build_permission_checkpoint( + *, + session_id: str, + task_id: str | None, + context_id: str, + input_id: str, + tool_use_id: str, + tool_name: str, + tool_input: Mapping[str, Any], + permission_class: PermissionClass, + continuation_frame: Mapping[str, Any], + policy: PermissionWaitPolicy, + principal_ref: str | None = None, + region: str | None = None, + pipeline_coordinates: Mapping[str, Any] | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + created = now or utc_now() + boundary_id = new_boundary_id() + frame = dict(continuation_frame) + prepared_payload_digest = canonical_digest({"name": tool_name, "input": tool_input}) + canonical_payload_digest = frame.get("currentPayloadDigest", prepared_payload_digest) + if not isinstance(canonical_payload_digest, str) or not _SHA256.fullmatch(canonical_payload_digest): + raise ValueError("invalid permission continuation payload digest") + frame["currentPayloadDigest"] = canonical_payload_digest + resident_deadline = None + if policy.resident_timeout_seconds is not None: + resident_deadline = format_utc(created + timedelta(seconds=policy.resident_timeout_seconds)) + return { + "schemaVersion": 1, + "boundaryId": boundary_id, + "inputId": input_id, + "taskId": task_id, + "contextId": context_id, + "sessionId": session_id, + "principalRef": principal_ref, + "region": region, + "mode": "normal" if permission_class == "normal" else "pipeline", + "permissionClass": permission_class, + "toolUseId": tool_use_id, + "toolName": tool_name, + "payloadDigest": canonical_payload_digest, + "pipelineCoordinates": dict(pipeline_coordinates) if pipeline_coordinates is not None else None, + "continuationFrame": frame, + "phase": "WAITING", + "generation": 1, + "createdAt": format_utc(created), + "updatedAt": format_utc(created), + "residentDeadlineAt": resident_deadline, + "graceDeadlineAt": None, + "decision": {"status": "none", "value": None, "source": None, "claimId": None}, + } + + +__all__ = [ + "PermissionWaitCheckpointStore", + "PermissionWaitCoordinator", + "PermissionWaitPolicy", + "RecoveredPermissionAuditBoundary", + "build_permission_checkpoint", + "canonical_digest", + "canonicalize_permission_continuation_frame", + "format_utc", + "parse_utc", + "permission_execution_identity", + "recover_permission_audit_boundary", +] diff --git a/src/iac_code/services/permissions/pipeline.py b/src/iac_code/services/permissions/pipeline.py index 5fb4dd74..09d64eba 100644 --- a/src/iac_code/services/permissions/pipeline.py +++ b/src/iac_code/services/permissions/pipeline.py @@ -40,12 +40,34 @@ def _is_explicit_operation_write_allow(result: PermissionResult, tool: Tool) -> ) +def _is_explicit_operation_ask(result: PermissionResult, tool: Tool) -> bool: + """Keep an operation-scoped ask rule authoritative in bypass mode.""" + + return ( + _uses_operation_scoped_permissions(tool) + and result.behavior == "ask" + and result.audit is not None + and result.audit.reason_type == "rule" + ) + + def _uses_operation_scoped_permissions(tool: Tool) -> bool: """Read the optional capability without breaking legacy duck-typed tools.""" return bool(getattr(tool, "uses_operation_scoped_permissions", False)) +def _is_read_only_operation_allow(result: PermissionResult, tool: Tool) -> bool: + """Keep metadata-confirmed operation reads automatic despite a bare ask rule.""" + + return ( + _uses_operation_scoped_permissions(tool) + and result.behavior == "allow" + and result.audit is not None + and result.audit.is_read_only is True + ) + + def _get_tool_rule(tool_name: str, rules_by_source: dict[str, list[str]]) -> tuple[str, str] | None: """Check if there's a bare tool-name rule (e.g. 'write_file' without parens).""" for source, rules in rules_by_source.items(): @@ -139,7 +161,7 @@ async def check_tool_permission( if result.behavior == "deny": return result - if _is_safety_check_ask(result): + if _is_safety_check_ask(result) and context.mode != PermissionMode.BYPASS_PERMISSIONS: return _with_prompt_audit(tool, input, result) if result.behavior == "ask" and ask_rule is not None: @@ -160,7 +182,7 @@ async def check_tool_permission( ), ) - if result.behavior == "allow" and ask_rule is not None: + if result.behavior == "allow" and ask_rule is not None and not _is_read_only_operation_allow(result, tool): source, rule = ask_rule detail = _("matched ask rule(s): {}").format(tool.name) return replace( @@ -180,14 +202,14 @@ async def check_tool_permission( ), ) - if _uses_operation_scoped_permissions(tool) and _is_sticky_ask(result): + if _is_explicit_operation_ask(result, tool) or ( + context.mode != PermissionMode.BYPASS_PERMISSIONS + and _uses_operation_scoped_permissions(tool) + and _is_sticky_ask(result) + ): return _with_prompt_audit(tool, input, result) - if ( - context.mode == PermissionMode.BYPASS_PERMISSIONS - and not _is_safety_check_ask(result) - and not _is_explicit_operation_write_allow(result, tool) - ): + if context.mode == PermissionMode.BYPASS_PERMISSIONS and not _is_explicit_operation_write_allow(result, tool): return replace( result, behavior="allow", diff --git a/src/iac_code/services/session_layout.py b/src/iac_code/services/session_layout.py index bd44d184..69220c1f 100644 --- a/src/iac_code/services/session_layout.py +++ b/src/iac_code/services/session_layout.py @@ -171,6 +171,14 @@ def image_cache_dir(self) -> Path: def tool_results_dir(self) -> Path: return self.session_dir / "tool-results" + @property + def permission_waits_dir(self) -> Path: + return self.session_dir / "permission-waits" + + @property + def permission_waits_lock_path(self) -> Path: + return self.permission_waits_dir / ".lock" + @property def a2a_dir(self) -> Path: return self.session_dir / "a2a" diff --git a/src/iac_code/services/session_storage.py b/src/iac_code/services/session_storage.py index 242e4763..0ce2a9c0 100644 --- a/src/iac_code/services/session_storage.py +++ b/src/iac_code/services/session_storage.py @@ -468,6 +468,7 @@ def _is_allowed_sidecar_child(child: Path) -> bool: allowed_dirs = { "a2a", "image-cache", + "permission-waits", "pipeline", "tool-results", } diff --git a/src/iac_code/tools/cloud/aliyun/aliyun_api.py b/src/iac_code/tools/cloud/aliyun/aliyun_api.py index dde73922..e7ce53a7 100644 --- a/src/iac_code/tools/cloud/aliyun/aliyun_api.py +++ b/src/iac_code/tools/cloud/aliyun/aliyun_api.py @@ -1332,6 +1332,8 @@ async def check_permissions(self, input: dict, context=None) -> PermissionResult ("ask", context.ask_rules), ("allow", context.allow_rules), ): + if behavior == "ask" and is_read_only: + continue if behavior == "allow" and not supports_persistent_allow: continue match = self._matching_rule(input, rules_by_source, require_exact=behavior == "allow" and not is_read_only) @@ -1505,6 +1507,7 @@ def observe(stage: str) -> None: ) ask_source: str | None = None ask_rule: str | None = None + ask_rule_reasons: list[PermissionDecisionReason] = [] ask_match = self._matching_rule( normalized, context.ask_rules, @@ -1512,7 +1515,7 @@ def observe(stage: str) -> None: ) if ask_match is not None: ask_source, ask_rule = ask_match.source, ask_match.rule_content - pending_reasons.append( + ask_rule_reasons.append( PermissionDecisionReason( type="rule", detail=_("matched ask rule(s): {}").format(ask_rule), @@ -1600,7 +1603,7 @@ def observe(stage: str) -> None: ) if canonical_ask is not None: ask_source, ask_rule = canonical_ask.source, canonical_ask.rule_content - pending_reasons.append( + ask_rule_reasons.append( PermissionDecisionReason( type="rule", detail=_("matched ask rule(s): {}").format(ask_rule), @@ -1628,6 +1631,12 @@ def observe(stage: str) -> None: metadata_contract, ) execution_class: ExecutionClass = "concurrent" if is_read_only else "serial" + if is_read_only: + ask_source = None + ask_rule = None + ask_match = None + else: + pending_reasons.extend(ask_rule_reasons) allow_match = self._matching_rule( normalized, context.allow_rules, diff --git a/src/iac_code/types/stream_events.py b/src/iac_code/types/stream_events.py index f91fabca..f161d611 100644 --- a/src/iac_code/types/stream_events.py +++ b/src/iac_code/types/stream_events.py @@ -8,6 +8,7 @@ import asyncio from dataclasses import dataclass, field +from enum import Enum from typing import Any, Literal, Union TOOL_RENDER_METADATA_KEY = "_iac_code_tool_render" @@ -189,6 +190,20 @@ class ToolResultEvent: type: Literal["tool_result"] = "tool_result" +class PermissionWaitOutcome(str, Enum): + """Internal outcomes that must not be projected as a user decision.""" + + SUSPEND = "suspend" + + +class PermissionWaitSuspended(RuntimeError): # noqa: N818 - domain event, not an error outcome + """The live permission owner was durably suspended without a decision.""" + + def __init__(self, boundary_id: str | None = None) -> None: + self.boundary_id = boundary_id + super().__init__("permission wait suspended") + + @dataclass class PermissionRequestEvent: """Tool execution requires user permission.""" @@ -196,10 +211,19 @@ class PermissionRequestEvent: tool_name: str tool_input: dict[str, Any] tool_use_id: str - response_future: asyncio.Future[bool] | None = field(default=None) + response_future: asyncio.Future[bool | PermissionWaitOutcome] | None = field(default=None) permission_result: Any | None = field(default=None) audit_context: Any | None = field(default=None, repr=False, compare=False) resolution_owner_managed: bool = field(default=False, repr=False, compare=False) + continuation_frame: dict[str, Any] | None = field(default=None, repr=False, compare=False) + boundary_id: str | None = field(default=None, repr=False, compare=False) + permission_wait_class: Literal["normal", "pipeline", "sub_pipeline"] | None = field( + default=None, + repr=False, + compare=False, + ) + permission_wait_coordinates: dict[str, Any] | None = field(default=None, repr=False, compare=False) + permission_decision_audited: bool = field(default=False, repr=False, compare=False) type: Literal["permission_request"] = "permission_request" diff --git a/src/iac_code/utils/state_io.py b/src/iac_code/utils/state_io.py index 3e557999..612c7d55 100644 --- a/src/iac_code/utils/state_io.py +++ b/src/iac_code/utils/state_io.py @@ -372,6 +372,43 @@ def cross_process_append_lock(path: Path) -> Iterator[None]: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) +@contextmanager +def cross_process_file_lock(lock_path: Path) -> Iterator[None]: + """Acquire the exact cross-process lock file supplied by the caller. + + Unlike :func:`cross_process_append_lock`, this helper does not derive a + sibling name. Recovery protocols that define a stable lock path can use + it without accidentally creating ``..lock.lock`` files. + """ + + lock_path.parent.mkdir(parents=True, exist_ok=True) + with _open_lock_binary(lock_path) as lock_file: + if sys.platform == "win32": + import msvcrt + + try: + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + except OSError as exc: + raise RuntimeError(f"could not acquire file lock {lock_path}") from exc + try: + yield + finally: + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + except OSError as exc: + raise RuntimeError(f"could not acquire file lock {lock_path}") from exc + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + _cross_process_append_lock = cross_process_append_lock diff --git a/src/iac_code/web/app.py b/src/iac_code/web/app.py index fe12c703..e644f5bb 100644 --- a/src/iac_code/web/app.py +++ b/src/iac_code/web/app.py @@ -12,7 +12,7 @@ import threading import time import uuid -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import asynccontextmanager from dataclasses import replace from datetime import datetime, timezone @@ -210,6 +210,7 @@ def create_app( WebModelSelection, WebSessionRuntime, WebTurnRequest, + attach_session_permission_context, close_agent_runtime, create_session_agent_runtime_in_thread, flush_web_telemetry, @@ -440,7 +441,7 @@ async def consume_waiters( try: for session in sessions: try: - manager.cancel_pending_requests_for_session(session) + manager.cancel_pending_requests_for_shutdown(session) except BaseException as error: record_cleanup_error(error) @@ -1573,6 +1574,7 @@ def make_pipeline_permission_resolver(session: WebSession): 多个 sub-pipeline 并发触发时各自拿到独立 request_id/future,前端逐个排队审批。回合被 取消时 future 被 cancel:清理该 pending 并向上抛,让执行器把该工具当作拒绝处理。 """ + from iac_code.types.stream_events import PermissionWaitOutcome, PermissionWaitSuspended from iac_code.web.runtime import _permission_request_payload async def resolver(event: Any) -> bool: @@ -1581,13 +1583,37 @@ async def resolver(event: Any) -> bool: turn_id=session.active_turn_id or "", allow_always=False, ) - future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - request_id = manager.add_permission_request(session, payload, future=future) + permission_class = getattr(event, "permission_wait_class", None) + legacy_permission = permission_class == "sub_pipeline" or event.continuation_frame is None + if legacy_permission: + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + request_id = manager.add_permission_request( + session, + payload, + future=future, + audit_event=event, + ) + else: + future = event.response_future + if future is None: + raise ValueError("Permission wait point is no longer active.") + request_id = await manager.open_permission_request( + session, + payload, + permission_event=event, + permission_class="pipeline", + pipeline_coordinates=getattr(event, "permission_wait_coordinates", None), + ) try: result = await asyncio.shield(future) except asyncio.CancelledError: - manager.cancel_permission_request(request_id, session_id=session.session_id) + if legacy_permission: + manager.cancel_permission_request(request_id, session_id=session.session_id) raise + if result is PermissionWaitOutcome.SUSPEND: + if event.boundary_id is not None: + manager.permission_wait_coordinator.unregister_live(event.boundary_id) + raise PermissionWaitSuspended(event.boundary_id) return bool(result) return resolver @@ -4726,6 +4752,100 @@ def record_shell_event(event: dict[str, Any]) -> None: if shell_task is not None: session.active_local_tasks.discard(shell_task) + async def rebuild_permission_audit_event( + session: WebSession, + checkpoint: Mapping[str, Any], + recovered: Any, + ) -> Any: + if session.mode == "pipeline": + rebuild = getattr(pipeline_action_runner, "rebuild_permission_audit_event", None) + if not callable(rebuild): + raise ValueError("permission_resume_invalid: Pipeline audit runtime is unavailable") + return await rebuild( + session, + checkpoint, + recovered, + model_selection=active_model_selection(session), + ) + runtime = None + try: + runtime = await create_session_agent_runtime_in_thread( + session, + manager, + lifecycle_owner=desktop_runtime_lifecycle, + ) + attach_session_permission_context(runtime, session) + return await runtime.agent_loop.rebuild_permission_audit_event( + tool_name=recovered.tool_name, + tool_input=recovered.tool_input, + tool_use_id=recovered.tool_use_id, + audit_context=recovered.audit_context, + ) + finally: + await close_agent_runtime(runtime, lifecycle_owner=desktop_runtime_lifecycle) + + async def recover_pipeline_permission(session: WebSession, checkpoint: dict[str, Any]) -> None: + boundary_id = str(checkpoint.get("boundaryId") or "") + coordinator = manager.permission_wait_coordinator + if not boundary_id or not await coordinator.acquire_restore(boundary_id): + return + store = manager.permission_checkpoint_store(session) + try: + current = store.load(boundary_id) + if current is None: + raise ValueError("permission_resume_invalid: checkpoint is unavailable") + if current.get("phase") != "RESTORING": + current = store.begin_restore(boundary_id) + async with session.turn_lock: + session.active_turn_task = asyncio.current_task() + session.status = "running" + result = await pipeline_action_runner.resume_permission( + session, + current, + model_selection=active_model_selection(session), + event_sink=lambda evs: publish_pipeline_live_events(session, evs), + permission_resolver=make_pipeline_permission_resolver(session), + ) + await publish_pipeline_action_events( + session, + list(result.events), + base_payload={ + "contextId": session.context_id, + "taskId": session.task_id, + "mode": "pipeline", + "permissionRecovered": True, + }, + ) + if not result.accepted: + raise ValueError(result.response.get("error") or "permission_resume_invalid") + from iac_code.services.permission_wait import canonical_digest + + snapshot = await load_pipeline_snapshot(context_id=session.context_id, task_id=session.task_id) + store.resolve( + boundary_id, + result_digest=canonical_digest(snapshot or {}), + ack={"decision": current["decision"]["value"], "accepted": True}, + ) + except Exception as exc: + try: + store.reconcile_deadline( + boundary_id, + grace_seconds=manager.permission_wait_policy.timeout_grace_seconds, + live_owner=False, + ) + except ValueError: + pass + manager.restore_permission_requests(session) + await session.events.publish( + "error", + {"message": public_exception_message(exc), "retryable": False}, + ) + finally: + await coordinator.release_restore(boundary_id) + session.status = "idle" + if session.active_turn_task is asyncio.current_task(): + session.active_turn_task = None + async def answer_permission(request): request_id = request.path_params["request_id"] try: @@ -4734,12 +4854,43 @@ async def answer_permission(request): except ValueError as exc: return json_error(str(exc), 400) pending = manager.get_pending_permission(request_id, session_id=answer["sessionId"]) - if pending is None: - return JSONResponse({"requestId": request_id, "resolved": False}, status_code=404) - if answer["choice"] not in offered_permission_choice_ids(pending.payload): + if pending is not None and answer["choice"] not in offered_permission_choice_ids(pending.payload): return json_error(_("choice was not offered"), 400) - result = manager.resolve_permission(request_id, {"choice": answer["choice"]}, session_id=answer["sessionId"]) - status_code = 200 if result["resolved"] else 404 + if pending is not None and pending.boundary_id is None: + result = manager.resolve_permission( + request_id, + {"choice": answer["choice"]}, + session_id=answer["sessionId"], + ) + status_code = 200 if result["resolved"] else 404 + return JSONResponse(result, status_code=status_code) + try: + result = await manager.resolve_durable_permission( + request_id, + {"choice": answer["choice"]}, + session_id=answer["sessionId"], + audit_event_rebuilder=rebuild_permission_audit_event, + ) + except ValueError as exc: + return json_error(str(exc), 409) + checkpoint = result.pop("checkpoint", None) + web_session_id = result.pop("webSessionId", None) + if result.get("needsRecovery") and isinstance(checkpoint, dict) and isinstance(web_session_id, str): + session = manager.get_session(web_session_id) + if session is None: + return JSONResponse({"requestId": request_id, "resolved": False}, status_code=404) + if session.mode == "pipeline": + task = asyncio.create_task(recover_pipeline_permission(session, checkpoint)) + else: + runtime = make_runtime(session) + resume_permission = getattr(runtime, "resume_permission", None) + if not callable(resume_permission): + return json_error("permission_resume_invalid", 409) + task = asyncio.create_task(resume_permission(checkpoint)) + session.active_local_tasks.add(task) + task.add_done_callback(session.active_local_tasks.discard) + result["recoveryStarted"] = True + status_code = 202 if result.get("recoveryStarted") else (200 if result["resolved"] else 404) return JSONResponse(result, status_code=status_code) async def answer_question(request): diff --git a/src/iac_code/web/permissions.py b/src/iac_code/web/permissions.py index b59daf51..ad52de05 100644 --- a/src/iac_code/web/permissions.py +++ b/src/iac_code/web/permissions.py @@ -42,6 +42,8 @@ class WebPendingPermission: future: asyncio.Future[Any] created_at: str audit_event: Any | None = None + boundary_id: str | None = None + checkpoint_store: Any | None = None def to_dict(self) -> dict[str, Any]: return { diff --git a/src/iac_code/web/pipeline_actions.py b/src/iac_code/web/pipeline_actions.py index db4240e1..da112543 100644 --- a/src/iac_code/web/pipeline_actions.py +++ b/src/iac_code/web/pipeline_actions.py @@ -61,6 +61,25 @@ async def interrupt( permission_resolver: PipelinePermissionResolver | None = None, ) -> "PipelineActionResult": ... + async def resume_permission( + self, + session: Any, + checkpoint: dict[str, Any], + *, + model_selection: WebModelSelection | None = None, + event_sink: PipelineEventSink | None = None, + permission_resolver: PipelinePermissionResolver | None = None, + ) -> "PipelineActionResult": ... + + async def rebuild_permission_audit_event( + self, + session: Any, + checkpoint: dict[str, Any], + recovered: Any, + *, + model_selection: WebModelSelection | None = None, + ) -> Any: ... + @dataclass(frozen=True) class PipelineActionResult: @@ -215,6 +234,29 @@ async def interrupt( permission_resolver=permission_resolver, ) + async def resume_permission( + self, + session: Any, + checkpoint: dict[str, Any], + *, + model_selection: WebModelSelection | None = None, + event_sink: PipelineEventSink | None = None, + permission_resolver: PipelinePermissionResolver | None = None, + ) -> PipelineActionResult: + unavailable = await self._unavailable_result(session) + if unavailable is not None: + return unavailable + return await self._execute( + session, + "", + action="permission_recovered", + events=[{"kind": "permission.recovered"}], + model_selection=model_selection, + event_sink=event_sink, + permission_resolver=permission_resolver, + permission_checkpoint=checkpoint, + ) + async def _unavailable_result( self, session: Any, @@ -261,18 +303,13 @@ def _resolve_auto_approve(self, session: Any) -> bool: return mode in (PermissionMode.BYPASS_PERMISSIONS, PermissionMode.DONT_ASK) return str(mode or "").strip().lower() in ("bypass_permissions", "dont_ask") - async def _execute( + def _executor_for_session( self, session: Any, - pipeline_input: str | PipelineInputContent, *, - action: str, - events: list[dict[str, Any]], - model_selection: WebModelSelection | None = None, - event_sink: PipelineEventSink | None = None, - permission_resolver: PipelinePermissionResolver | None = None, - envelope_observer: Callable[[Mapping[str, Any]], None] | None = None, - ) -> PipelineActionResult: + model_selection: WebModelSelection | None, + permission_resolver: PipelinePermissionResolver | None, + ) -> Any: from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor if model_selection is not None: @@ -303,14 +340,9 @@ async def _execute( provider_config_override = None effort_override = getattr(session, "effort", None) - # Issue 6: the pipeline executor denies every tool when it has no resolver and - # auto-approve is off. When the session opts into a non-interactive mode we keep - # the silent auto-approve path (resolver=None + auto_approve=True); otherwise we - # thread the session-bound web resolver so tool permission prompts surface in the - # browser and block on the user's answer instead of auto-denying. auto_approve = self._resolve_auto_approve(session) resolver = None if auto_approve else (permission_resolver or self._owner.permission_resolver) - executor = IacCodeA2APipelineExecutor( + return IacCodeA2APipelineExecutor( task_store=self._task_store, model=session_model, provider_key_override=provider_key_override, @@ -326,6 +358,50 @@ async def _execute( auto_approve_permissions=auto_approve, thinking_exposure_types=self._owner.thinking_exposure_types, ) + + async def rebuild_permission_audit_event( + self, + session: Any, + checkpoint: dict[str, Any], + recovered: Any, + *, + model_selection: WebModelSelection | None = None, + ) -> Any: + executor = self._executor_for_session( + session, + model_selection=model_selection, + permission_resolver=None, + ) + return await executor.rebuild_permission_audit_event( + cwd=session.cwd, + session_id=session.session_id, + checkpoint=checkpoint, + recovered=recovered, + ) + + async def _execute( + self, + session: Any, + pipeline_input: str | PipelineInputContent, + *, + action: str, + events: list[dict[str, Any]], + model_selection: WebModelSelection | None = None, + event_sink: PipelineEventSink | None = None, + permission_resolver: PipelinePermissionResolver | None = None, + envelope_observer: Callable[[Mapping[str, Any]], None] | None = None, + permission_checkpoint: dict[str, Any] | None = None, + ) -> PipelineActionResult: + # Issue 6: the pipeline executor denies every tool when it has no resolver and + # auto-approve is off. When the session opts into a non-interactive mode we keep + # the silent auto-approve path (resolver=None + auto_approve=True); otherwise we + # thread the session-bound web resolver so tool permission prompts surface in the + # browser and block on the user's answer instead of auto-denying. + executor = self._executor_for_session( + session, + model_selection=model_selection, + permission_resolver=permission_resolver, + ) task = await self._task_store.get_or_create_task(task_id=session.task_id, context_id=session.context_id) event_queue = ( _ForwardingEventQueue(event_sink, envelope_observer=envelope_observer) @@ -341,6 +417,7 @@ async def _execute( context_id=session.context_id, cwd=session.cwd, pipeline_input=normalize_pipeline_user_input(pipeline_input), + permission_checkpoint=permission_checkpoint, ) except Exception as exc: return _action_error(str(exc)[:500], status_code=500) diff --git a/src/iac_code/web/runtime.py b/src/iac_code/web/runtime.py index 4f708ed7..f077fbef 100644 --- a/src/iac_code/web/runtime.py +++ b/src/iac_code/web/runtime.py @@ -21,6 +21,8 @@ AskUserQuestionEvent, MessageEndEvent, PermissionRequestEvent, + PermissionWaitOutcome, + PermissionWaitSuspended, QueuedInputSubmittedEvent, SubPipelineStreamEvent, Usage, @@ -239,7 +241,7 @@ def agent_factory_options_for_session( ) -> AgentFactoryOptions: """Build the same AgentFactory options for every Web operation. - disable_external_services=True 用于会话切换时的离线上下文核算:不连接 MCP、不读钥匙串, + disable_external_services=True 用于会话切换时的离线上下文核算:不连接 MCP、不读 MCP 凭证文件, 只算系统提示 + 本地工具定义开销(见 prime_session_context_overhead)。 """ selection = model_selection or model_selection_for_session(session) @@ -292,6 +294,28 @@ def create_session_agent_runtime( ) +def attach_session_permission_context(runtime: Any, session: WebSession) -> None: + """Attach the session's live permission policy to a newly built Web runtime.""" + + agent_loop = getattr(runtime, "agent_loop", None) + if agent_loop is None: + return + runtime_context = getattr(agent_loop, "_permission_context", None) + if session.permission_context is None and runtime_context is not None: + if session.permission_mode is not None: + runtime_context.mode = session.permission_mode + session.permission_context = runtime_context + if session.permission_context is not None: + setattr(agent_loop, "_permission_context", session.permission_context) + setattr(agent_loop, "_permission_context_getter", lambda: session.permission_context) + tool_registry = getattr(runtime, "tool_registry", None) + agent_tool = tool_registry.get("agent") if hasattr(tool_registry, "get") else None + if agent_tool is not None and hasattr(agent_tool, "_permission_context"): + setattr(agent_tool, "_permission_context", session.permission_context) + if agent_tool is not None and hasattr(agent_tool, "_permission_context_getter"): + setattr(agent_tool, "_permission_context_getter", lambda: session.permission_context) + + async def create_session_agent_runtime_in_thread( session: WebSession, manager: WebSessionManager, @@ -481,6 +505,7 @@ async def start_turn(self, request: WebTurnRequest) -> dict[str, Any]: usage = Usage() agent_runtime: Any | None = None input_consumed = False + completed_permission_boundaries: list[str] = [] async with self.session.turn_lock: self.session.active_turn_task = asyncio.current_task() # 记录本轮为「上一次操作」,让侧边栏相对时间反映真实活动(否则一直显示距创建多久)。 @@ -549,13 +574,27 @@ async def start_turn(self, request: WebTurnRequest) -> dict[str, Any]: allow_always=self._tool_supports_blanket_allow(agent_runtime, inner_event.tool_name), ) payload.update(sub_pipeline_payload) - request_id = self.manager.add_permission_request( - self.session, - payload, - future=inner_event.response_future, - audit_event=inner_event, - ) - await self._await_permission_request(request_id, inner_event) + if ( + sub_pipeline_payload + or inner_event.response_future is None + or inner_event.continuation_frame is None + ): + request_id = self.manager.add_permission_request( + self.session, + payload, + future=inner_event.response_future, + audit_event=inner_event, + ) + else: + request_id = await self.manager.open_permission_request( + self.session, + payload, + permission_event=inner_event, + permission_class="normal", + ) + completed_boundary = await self._await_permission_request(request_id, inner_event) + if completed_boundary is not None: + completed_permission_boundaries.append(completed_boundary) continue if isinstance(inner_event, AskUserQuestionEvent): payload = _question_request_payload(inner_event, turn_id=turn_id) @@ -604,11 +643,26 @@ async def start_turn(self, request: WebTurnRequest) -> dict[str, Any]: if final_context_usage is not None: done_payload["contextUsage"] = final_context_usage _cache_session_context_overhead(self.session, final_context_usage) + self.manager.resolve_permission_boundaries( + self.session, + completed_permission_boundaries, + ) await self.session.events.publish("turn.done", done_payload) # 正常结束:若此刻无人在看(用户已切走、无活跃 SSE 订阅),标记为未读。 self.manager.mark_session_completed(self.session) + except PermissionWaitSuspended: + suspended_payload = _turn_done_payload(turn_id=turn_id) + suspended_payload["permissionWait"] = {"status": "suspended", "resumable": True} + suspended_payload["usage"] = usage_payload(usage) + await self.session.events.publish("turn.done", suspended_payload) + return { + "accepted": True, + "reason": "permission wait suspended", + "turnId": turn_id, + "inputConsumed": input_consumed, + } except asyncio.CancelledError: - self.manager.cancel_pending_requests_for_session(self.session) + self.manager.cancel_pending_requests_for_session(self.session, preserve_durable=True) canceled_payload = _turn_done_payload(turn_id=turn_id) canceled_payload["interrupted"] = True canceled_payload["canceled"] = True @@ -621,7 +675,7 @@ async def start_turn(self, request: WebTurnRequest) -> dict[str, Any]: "inputConsumed": input_consumed, } except Exception as exc: - self.manager.cancel_pending_requests_for_session(self.session) + self.manager.cancel_pending_requests_for_session(self.session, preserve_durable=True) await self.session.events.publish( "error", { @@ -654,6 +708,137 @@ async def start_turn(self, request: WebTurnRequest) -> dict[str, Any]: self.session.active_turn_floor_sequence = None return {"accepted": True, "turnId": turn_id, "inputConsumed": True} + async def resume_permission(self, checkpoint: dict[str, Any]) -> dict[str, Any]: + """Resume one persisted normal-Web permission without appending user input.""" + + boundary_id = str(checkpoint.get("boundaryId") or "") + if not boundary_id: + return {"accepted": False, "reason": "permission_resume_invalid"} + coordinator = self.manager.permission_wait_coordinator + if not await coordinator.acquire_restore(boundary_id): + return {"accepted": True, "duplicate": True} + turn_id = _turn_id() + usage = Usage() + agent_runtime: Any | None = None + store: Any | None = None + completed_permission_boundaries = [boundary_id] + try: + store = self.manager.permission_checkpoint_store(self.session) + record = store.load(boundary_id) + if record is None: + raise ValueError("permission_resume_invalid: checkpoint is unavailable") + if record.get("phase") != "RESTORING": + record = store.begin_restore(boundary_id) + async with self.session.turn_lock: + self.session.active_turn_task = asyncio.current_task() + self.manager.mark_session_running(self.session) + agent_runtime = await create_session_agent_runtime_in_thread( + self.session, + self.manager, + lifecycle_owner=self.lifecycle_owner, + ) + self._attach_session_permission_context(agent_runtime) + await self._attach_mcp_status_updates(agent_runtime) + self.session.active_agent_loop = agent_runtime.agent_loop + self.session.active_turn_id = turn_id + translator = WebEventTranslator(self.session.session_id) + try: + async for stream_event in agent_runtime.agent_loop.resume_permission_boundary(record): + inner_event, sub_pipeline_payload = _unwrap_sub_pipeline_event(stream_event) + if isinstance(inner_event, MessageEndEvent): + _accumulate_usage(usage, inner_event.usage) + if isinstance(inner_event, PermissionRequestEvent): + payload = _permission_request_payload( + inner_event, + turn_id=turn_id, + allow_always=self._tool_supports_blanket_allow( + agent_runtime, + inner_event.tool_name, + ), + ) + payload.update(sub_pipeline_payload) + if ( + sub_pipeline_payload + or inner_event.response_future is None + or inner_event.continuation_frame is None + ): + request_id = self.manager.add_permission_request( + self.session, + payload, + future=inner_event.response_future, + audit_event=inner_event, + ) + else: + request_id = await self.manager.open_permission_request( + self.session, + payload, + permission_event=inner_event, + permission_class="normal", + ) + completed_boundary = await self._await_permission_request(request_id, inner_event) + if completed_boundary is not None: + completed_permission_boundaries.append(completed_boundary) + continue + if isinstance(inner_event, AskUserQuestionEvent): + request_id = self.manager.add_question_request( + self.session, + _question_request_payload(inner_event, turn_id=turn_id), + future=inner_event.response_future, + ) + await self._await_question_request(request_id, inner_event) + continue + translated = translator.translate_stream_event(stream_event, turn_id=turn_id) + await self.session.events.publish(translated["type"], translated["payload"]) + self.manager.resolve_permission_boundaries( + self.session, + completed_permission_boundaries, + ) + await self.session.events.publish( + "turn.done", + { + **_turn_done_payload(turn_id=turn_id), + "permissionRecovered": True, + "usage": usage_payload(usage), + }, + ) + self.manager.mark_session_completed(self.session) + return {"accepted": True, "turnId": turn_id, "permissionRecovered": True} + except PermissionWaitSuspended as exc: + if exc.boundary_id == boundary_id: + store.mark_suspended(boundary_id) + await self.session.events.publish( + "turn.done", + { + **_turn_done_payload(turn_id=turn_id), + "permissionWait": {"status": "suspended", "resumable": True}, + "usage": usage_payload(usage), + }, + ) + return {"accepted": True, "turnId": turn_id, "permissionWaitSuspended": True} + except Exception as exc: + if store is not None: + try: + store.reconcile_deadline( + boundary_id, + grace_seconds=self.manager.permission_wait_policy.timeout_grace_seconds, + live_owner=False, + ) + except ValueError: + pass + self.manager.restore_permission_requests(self.session) + await self.session.events.publish( + "error", + {"turnId": turn_id, "message": str(exc)[:500], "retryable": False}, + ) + return {"accepted": False, "turnId": turn_id, "reason": "permission_resume_invalid"} + finally: + await coordinator.release_restore(boundary_id) + await close_agent_runtime(agent_runtime, lifecycle_owner=self.lifecycle_owner) + if self.session.active_turn_task is asyncio.current_task(): + self.session.active_turn_task = None + self.session.active_agent_loop = None + self.session.active_turn_id = None + async def _attach_mcp_status_updates(self, runtime: Any) -> None: async def publish_status(_server_name: str = "", _capability: str = "") -> None: status = runtime_mcp_status(runtime) @@ -676,41 +861,38 @@ def _stamp_turn_elapsed(self, runtime: Any, elapsed: float) -> None: pass def _attach_session_permission_context(self, runtime: Any) -> None: - agent_loop = getattr(runtime, "agent_loop", None) - if agent_loop is None: - return - runtime_context = getattr(agent_loop, "_permission_context", None) - if self.session.permission_context is None and runtime_context is not None: - # 首次运行本会话:采用运行时新建的 context,但要把会话已选定的权限模式 - # (如新会话草稿里选的「完全访问」)应用上去,否则会退回默认模式。 - if self.session.permission_mode is not None: - runtime_context.mode = self.session.permission_mode - self.session.permission_context = runtime_context - if self.session.permission_context is not None: - setattr(agent_loop, "_permission_context", self.session.permission_context) - setattr(agent_loop, "_permission_context_getter", lambda: self.session.permission_context) - tool_registry = getattr(runtime, "tool_registry", None) - agent_tool = tool_registry.get("agent") if hasattr(tool_registry, "get") else None - if agent_tool is not None and hasattr(agent_tool, "_permission_context"): - setattr(agent_tool, "_permission_context", self.session.permission_context) - if agent_tool is not None and hasattr(agent_tool, "_permission_context_getter"): - setattr(agent_tool, "_permission_context_getter", lambda: self.session.permission_context) + attach_session_permission_context(runtime, self.session) def _tool_supports_blanket_allow(self, runtime: Any, tool_name: str) -> bool: tool_registry = getattr(runtime, "tool_registry", None) tool = tool_registry.get(tool_name) if hasattr(tool_registry, "get") else None return bool(getattr(tool, "supports_blanket_allow", False)) - async def _await_permission_request(self, request_id: str, event: PermissionRequestEvent) -> None: + async def _await_permission_request(self, request_id: str, event: PermissionRequestEvent) -> str | None: if event.response_future is None: self.manager.cancel_permission_request(request_id, session_id=self.session.session_id) - return + return None try: - await asyncio.shield(event.response_future) + outcome = await asyncio.shield(event.response_future) except asyncio.CancelledError: - self.manager.cancel_permission_request(request_id, session_id=self.session.session_id) + if ( + event.boundary_id is not None + and event.boundary_id in self.session.shutdown_preserved_permission_boundaries + ): + self.manager.orphan_durable_permission_request( + request_id, + session_id=self.session.session_id, + ) + else: + self.manager.cancel_permission_request(request_id, session_id=self.session.session_id) raise - self.manager.discard_permission_request(request_id, session_id=self.session.session_id) + if outcome is PermissionWaitOutcome.SUSPEND: + if event.boundary_id is not None: + self.manager.permission_wait_coordinator.unregister_live(event.boundary_id) + raise PermissionWaitSuspended(event.boundary_id) + if event.boundary_id is None: + self.manager.discard_permission_request(request_id, session_id=self.session.session_id) + return event.boundary_id async def _await_question_request(self, request_id: str, event: AskUserQuestionEvent) -> None: if event.response_future is None: @@ -824,7 +1006,7 @@ async def prime_session_context_overhead( 维持既有行为,绝不阻断会话切换。 会话切换只读展示历史会话,不应产生外部副作用,因此这里用 disable_external_services 的离线核算 - runtime:不连接 MCP、不读取 MCP 钥匙串(避免 macOS 反复弹出 iac-code:mcp 授权窗)、不发起 Provider/ + runtime:不连接 MCP、不读取 MCP 凭证文件、不发起 Provider/ 云请求。代价是动态 MCP 工具定义暂不计入本地基线,待首个真实回合启动正常 runtime 后用精确值自动纠正。 """ if session is None: diff --git a/src/iac_code/web/session_manager.py b/src/iac_code/web/session_manager.py index 1b98db34..c4ad7468 100644 --- a/src/iac_code/web/session_manager.py +++ b/src/iac_code/web/session_manager.py @@ -10,7 +10,7 @@ import os import re import uuid -from collections.abc import Iterator +from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager from dataclasses import asdict, dataclass, field from datetime import datetime, timezone @@ -36,6 +36,15 @@ from iac_code.pipeline.engine.step_spec import AllowUserEscapes from iac_code.providers.base import ContentBlock from iac_code.providers.registry import PROVIDER_REGISTRY +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitCoordinator, + PermissionWaitPolicy, + build_permission_checkpoint, + canonicalize_permission_continuation_frame, + permission_execution_identity, + recover_permission_audit_boundary, +) from iac_code.services.permissions.storage import apply_session_rule from iac_code.services.permissions.trusted_roots import build_session_trusted_read_directories from iac_code.services.session_index import SessionEntry, SessionIndex, _trim_title @@ -722,6 +731,9 @@ class WebSession: # 打开会话(建立 SSE 订阅)时清除。持久化到 sidecar,跨设备共享。 unread: bool = False pending_permissions: dict[str, WebPendingPermission] = field(default_factory=dict) + # Lifecycle shutdown is not a protocol/user cancellation. Keep these + # durable boundaries orphan-recoverable until their turn tasks unwind. + shutdown_preserved_permission_boundaries: set[str] = field(default_factory=set, repr=False) pending_questions: dict[str, WebPendingQuestion] = field(default_factory=dict) pending_elicitations: dict[str, WebPendingElicitation] = field(default_factory=dict) queued_inputs: list[str] = field(default_factory=list) @@ -888,12 +900,20 @@ def __init__(self, message: str, status: int) -> None: class WebSessionManager: """Create and list Web sessions while preserving CLI/REPL session storage.""" - def __init__(self, *, projects_dir: Path | str | None = None, cwd: Path | str | None = None) -> None: + def __init__( + self, + *, + projects_dir: Path | str | None = None, + cwd: Path | str | None = None, + permission_wait: object | None = None, + ) -> None: self.cwd = Path(cwd or os.environ.get("IAC_CODE_CWD", os.getcwd())).expanduser().resolve() resolved_projects_dir = Path(projects_dir) if projects_dir is not None else None self.storage = SessionStorage(projects_dir=resolved_projects_dir) self.index = SessionIndex(projects_dir=resolved_projects_dir) self._sessions: dict[tuple[str, str], WebSession] = {} + self.permission_wait_policy = PermissionWaitPolicy.from_config(permission_wait) + self.permission_wait_coordinator = PermissionWaitCoordinator(self.permission_wait_policy) self._session_lifecycle_epoch = 0 self._session_mutation_epochs: dict[tuple[str, str], int] = {} # 请求级缓存(仅在 batch_reads() 窗口内生效):外来会话可见性开关本是一次请求内 @@ -1031,6 +1051,7 @@ def create_session( ) session.pending_llm_title = not storage_existed self._sessions[session_key] = session + self._restore_permission_requests(session) if not storage_existed: self._record_session_lifecycle_mutation(actual_cwd, actual_session_id) self.persist_web_metadata(session) @@ -2366,6 +2387,36 @@ def _resolve_session_arg(self, session: WebSession | str) -> WebSession: raise ValueError(_("session not found")) return resolved + def permission_checkpoint_store(self, session: WebSession | str) -> PermissionWaitCheckpointStore: + session = self._resolve_session_arg(session) + return PermissionWaitCheckpointStore(session.cwd, session.session_id, storage=self.storage) + + def resolve_permission_boundaries(self, session: WebSession | str, boundary_ids: list[str]) -> None: + session = self._resolve_session_arg(session) + if not boundary_ids: + return + messages = self.storage.load(session.cwd, session.session_id) + from iac_code.services.permission_wait import canonical_digest + + result_digest = canonical_digest(messages[-1].to_dict()) if messages else "" + store = self.permission_checkpoint_store(session) + for boundary_id in dict.fromkeys(boundary_ids): + record = store.load(boundary_id) + if record is None: + continue + decision = record.get("decision") + value = decision.get("value") if isinstance(decision, Mapping) else None + store.resolve( + boundary_id, + result_digest=result_digest, + ack={"decision": value, "accepted": True}, + ) + self.permission_wait_coordinator.unregister_live(boundary_id) + for request_id, pending in list(session.pending_permissions.items()): + if pending.boundary_id == boundary_id: + session.pending_permissions.pop(request_id, None) + session.shutdown_preserved_permission_boundaries.discard(boundary_id) + def status(self, session: WebSession | str) -> dict[str, Any]: """Return a redacted JSON-safe status snapshot for a session.""" session = self._resolve_session_arg(session) @@ -2868,6 +2919,284 @@ def add_permission_request( session.events.append("permission.request", pending.to_dict()) return request_id + async def open_permission_request( + self, + session: WebSession | str, + payload: dict[str, Any], + *, + permission_event: Any, + permission_class: Literal["normal", "pipeline"], + pipeline_coordinates: Mapping[str, Any] | None = None, + ) -> str: + """Persist a real browser permission wait before making it visible.""" + + session = self._resolve_session_arg(session) + request_id = uuid.uuid4().hex + future = getattr(permission_event, "response_future", None) + if future is None or future.done(): + raise ValueError(_("Permission wait point is no longer active.")) + payload = normalize_permission_payload(payload, request_id=request_id, session_id=session.session_id) + pending = WebPendingPermission( + request_id=request_id, + session_id=session.session_id, + payload=payload, + future=future, + created_at=_utc_now(), + audit_event=permission_event, + ) + source_frame = getattr(permission_event, "continuation_frame", None) + if not isinstance(source_frame, Mapping): + raise ValueError("permission_resume_invalid: continuation frame is missing") + store = PermissionWaitCheckpointStore(session.cwd, session.session_id, storage=self.storage) + audit_context = permission_event.audit_context if isinstance(permission_event.audit_context, Mapping) else {} + frame = canonicalize_permission_continuation_frame(source_frame, audit_context=audit_context) + principal_ref = audit_context.get("principal_ref") + region = audit_context.get("region") + record = build_permission_checkpoint( + session_id=session.session_id, + task_id=session.task_id if permission_class == "pipeline" else None, + context_id=session.context_id or session.web_session_id, + input_id=request_id, + tool_use_id=str(permission_event.tool_use_id), + tool_name=str(permission_event.tool_name), + tool_input=permission_event.tool_input, + permission_class=permission_class, + continuation_frame=frame, + policy=self.permission_wait_policy, + principal_ref=principal_ref if isinstance(principal_ref, str) else None, + region=region if isinstance(region, str) else None, + pipeline_coordinates=pipeline_coordinates, + ) + previous_boundary_id = frame.get("previousBoundaryId") + if isinstance(previous_boundary_id, str) and previous_boundary_id: + store.create_successor(record, previous_boundary_id=previous_boundary_id) + self._release_replaced_permission_boundary(session, previous_boundary_id) + else: + store.create(record) + pending.boundary_id = str(record["boundaryId"]) + pending.checkpoint_store = store + permission_event.boundary_id = pending.boundary_id + self.permission_wait_coordinator.register_live(record=record, store=store, future=future) + session.pending_permissions[request_id] = pending + session.events.append("permission.request", pending.to_dict()) + return request_id + + def _release_replaced_permission_boundary(self, session: WebSession, boundary_id: str) -> None: + """Mirror an atomic successor checkpoint swap in process-local Web state.""" + + self.permission_wait_coordinator.unregister_live(boundary_id) + for request_id, pending in list(session.pending_permissions.items()): + if pending.boundary_id == boundary_id: + session.pending_permissions.pop(request_id, None) + session.shutdown_preserved_permission_boundaries.discard(boundary_id) + + def _restore_permission_requests(self, session: WebSession) -> None: + """Rehydrate safe browser prompts from orphaned local checkpoints.""" + + try: + store = PermissionWaitCheckpointStore(session.cwd, session.session_id, storage=self.storage) + records = store.list_active() + except ValueError: + return + for record in records: + if record.get("permissionClass") not in {"normal", "pipeline"}: + continue + if record.get("permissionClass") == "pipeline" and session.mode != "pipeline": + continue + boundary_id = str(record.get("boundaryId") or "") + input_id = str(record.get("inputId") or "") + if not boundary_id or not input_id or input_id in session.pending_permissions: + continue + try: + record = store.reconcile_deadline( + boundary_id, + grace_seconds=self.permission_wait_policy.timeout_grace_seconds, + live_owner=False, + ) + except ValueError: + continue + payload = normalize_permission_payload( + { + "turnId": "", + "toolName": str(record.get("toolName") or ""), + "toolUseId": str(record.get("toolUseId") or ""), + "toolInput": {}, + "message": _("Allow {}?").format(str(record.get("toolName") or "tool")), + "suggestions": [], + "allowAlways": False, + "resumable": True, + "permissionWaitStatus": str(record.get("phase") or "").lower(), + }, + request_id=input_id, + session_id=session.session_id, + ) + session.pending_permissions[input_id] = WebPendingPermission( + request_id=input_id, + session_id=session.session_id, + payload=payload, + future=_new_future(), + created_at=str(record.get("createdAt") or _utc_now()), + boundary_id=boundary_id, + checkpoint_store=store, + ) + + def restore_permission_requests(self, session: WebSession | str) -> None: + self._restore_permission_requests(self._resolve_session_arg(session)) + + async def resolve_durable_permission( + self, + request_id: str, + answer: dict[str, Any], + *, + session_id: str, + audit_event_rebuilder: Callable[[WebSession, Mapping[str, Any], Any], Awaitable[Any]] | None = None, + ) -> dict[str, Any]: + """Claim a durable browser decision and report whether runtime recovery is needed.""" + + session = next( + ( + candidate + for candidate in self._sessions.values() + if candidate.session_id == session_id and request_id in candidate.pending_permissions + ), + None, + ) + if session is None: + session = next( + (candidate for candidate in self._sessions.values() if candidate.session_id == session_id), + None, + ) + if session is None: + return {"requestId": request_id, "resolved": False} + store = self.permission_checkpoint_store(session) + receipt = store.find_by_input_id(request_id) + if receipt is None: + return {"requestId": request_id, "resolved": False} + choice = str(answer["choice"]) + requested_value = "allow_once" if permission_choice_to_allowed(choice) else "deny" + decision = receipt.get("decision") + if not isinstance(decision, Mapping) or decision.get("value") != requested_value: + raise ValueError("permission_resume_invalid: permission response conflicts with receipt") + return { + "requestId": request_id, + "resolved": True, + "duplicate": True, + "decision": decision["value"], + "needsRecovery": receipt.get("phase") != "RESOLVED", + "checkpoint": receipt if receipt.get("phase") != "RESOLVED" else None, + "webSessionId": session.web_session_id, + } + pending = session.pending_permissions.get(request_id) + if pending is None or pending.boundary_id is None or pending.checkpoint_store is None: + return self.resolve_permission(request_id, answer, session_id=session_id) + choice = str(answer["choice"]) + requested_value = "allow_once" if permission_choice_to_allowed(choice) else "deny" + coordinator = self.permission_wait_coordinator + store = pending.checkpoint_store + boundary_id = pending.boundary_id + checkpoint = store.load(boundary_id) + if checkpoint is None: + raise ValueError("permission_resume_invalid: permission checkpoint is unavailable") + persisted_decision = checkpoint.get("decision") + audit_already_final = False + if isinstance(persisted_decision, Mapping) and persisted_decision.get("status") in {"claimed", "applied"}: + if persisted_decision.get("value") != requested_value: + raise ValueError("permission_resume_invalid: permission response conflicts with checkpoint") + audit_already_final = persisted_decision.get("auditStatus") in {"recorded", "failed"} + recovered_audit_event = pending.audit_event + if recovered_audit_event is None and not audit_already_final: + recovered = recover_permission_audit_boundary( + checkpoint, + cwd=session.cwd, + session_id=session.session_id, + storage=self.storage, + ) + if recovered is None: + raise ValueError("permission_resume_invalid: canonical permission request changed") + if audit_event_rebuilder is None: + raise ValueError("permission_resume_invalid: permission audit runtime is unavailable") + recovered_audit_event = await audit_event_rebuilder(session, checkpoint, recovered) + if recovered_audit_event is not None: + permission_audit = getattr(recovered_audit_event.permission_result, "audit", None) + principal_ref, region = permission_execution_identity( + tool_name=recovered_audit_event.tool_name, + tool_input=recovered_audit_event.tool_input, + permission_audit=permission_audit, + ) + if principal_ref != checkpoint.get("principalRef") or region != checkpoint.get("region"): + raise ValueError("permission_resume_invalid: cloud execution identity changed") + + def audit_new_claim(value: str) -> bool: + if recovered_audit_event is None: + return audit_already_final + from iac_code.services.permissions.audit import emit_permission_boundary_audit + + emitted = emit_permission_boundary_audit( + recovered_audit_event, + session_id=session.session_id, + decision="allow" if value == "allow_once" else "deny", + scope="session_rule" if choice in {PERMISSION_ALWAYS_ALLOW, PERMISSION_ALWAYS_DENY} else "once", + source="web_prompt", + reason_type="prompt_selection", + reason_detail=choice, + rule=_permission_audit_rule(pending.payload), + ) + if emitted: + recovered_audit_event.permission_decision_audited = True + return emitted + + needs_recovery = not coordinator.has_live_boundary(boundary_id) + decision_created = False + if needs_recovery: + record = store.reconcile_deadline( + boundary_id, + grace_seconds=self.permission_wait_policy.timeout_grace_seconds, + live_owner=False, + ) + record, decision_created = store.claim_decision(boundary_id, value=requested_value, source="user") + decision = record.get("decision") + if isinstance(decision, Mapping): + claim_id = str(record["decision"]["claimId"]) + record, _audit_created = store.run_claim_audit_once( + boundary_id, + claim_id=claim_id, + audit=audit_new_claim, + ) + else: + record, decision_created = await coordinator.claim_live( + boundary_id=boundary_id, + value=requested_value, + source="user", + on_new_claim=audit_new_claim, + ) + needs_recovery = record.get("phase") in {"SUSPENDING", "SUSPENDED", "RESTORING"} + decision = record.get("decision") + accepted_value = decision.get("value") if isinstance(decision, Mapping) else requested_value + audit_status = decision.get("auditStatus") if isinstance(decision, Mapping) else None + if ( + decision_created + and choice in {PERMISSION_ALWAYS_ALLOW, PERMISSION_ALWAYS_DENY} + and accepted_value == requested_value + and (accepted_value != "allow_once" or audit_status == "recorded") + ): + self._apply_permission_choice(session, pending, choice) + if needs_recovery: + session.pending_permissions.pop(request_id, None) + if decision_created: + session.events.append( + "permission.resolved", + {"requestId": request_id, "answer": {"choice": choice}}, + ) + return { + "requestId": request_id, + "resolved": True, + "duplicate": not decision_created, + "needsRecovery": needs_recovery, + "decision": accepted_value, + "checkpoint": record if needs_recovery else None, + "webSessionId": session.web_session_id, + } + def get_pending_permission( self, request_id: str, @@ -2982,6 +3311,30 @@ def discard_permission_request(self, request_id: str, *, session_id: str | None session.pending_permissions.pop(request_id, None) return + def orphan_durable_permission_request(self, request_id: str, *, session_id: str | None = None) -> None: + """Release only process-local ownership, preserving restart recovery.""" + + for session in self._sessions.values(): + pending = session.pending_permissions.get(request_id) + if pending is None: + continue + if session_id is not None and pending.session_id != session_id: + return + if pending.boundary_id is None or pending.checkpoint_store is None: + return + self.permission_wait_coordinator.unregister_live(pending.boundary_id) + try: + pending.checkpoint_store.reconcile_deadline( + pending.boundary_id, + grace_seconds=self.permission_wait_policy.timeout_grace_seconds, + live_owner=False, + ) + except ValueError: + # A concurrent answer/receipt is authoritative; shutdown must + # never overwrite it with cancellation state. + pass + return + def cancel_permission_request(self, request_id: str, *, session_id: str | None = None) -> None: """Resolve a pending permission as canceled so browser state can clear it.""" for session in self._sessions.values(): @@ -2990,8 +3343,19 @@ def cancel_permission_request(self, request_id: str, *, session_id: str | None = continue if session_id is not None and pending.session_id != session_id: return + if pending.boundary_id is not None and pending.checkpoint_store is not None: + try: + pending.checkpoint_store.cancel(pending.boundary_id) + except ValueError: + # A decision already claimed under the same checkpoint lock + # wins; do not hide or cancel its continuation. + return + self.permission_wait_coordinator.unregister_live(pending.boundary_id) + if not pending.future.done(): + pending.future.cancel() + else: + _set_future_result(pending.future, False) session.pending_permissions.pop(request_id, None) - _set_future_result(pending.future, False) session.events.append( "permission.resolved", { @@ -3229,9 +3593,11 @@ def cancel_pending_requests_for_session( *, permission_result: bool = False, question_result: dict[str, str] | None = None, + preserve_durable: bool = False, ) -> None: """Resolve and clear pending futures when the owning turn cannot continue.""" session = self._resolve_session_arg(session) + preserve_durable = preserve_durable or bool(session.shutdown_preserved_permission_boundaries) for pending in list(session.pending_elicitations.values()): session.pending_elicitations.pop(pending.request_id, None) _set_future_result(pending.future, {"action": "cancel"}) @@ -3243,8 +3609,23 @@ def cancel_pending_requests_for_session( }, ) for pending in list(session.pending_permissions.values()): + if pending.boundary_id is not None and preserve_durable: + self.orphan_durable_permission_request( + pending.request_id, + session_id=session.session_id, + ) + continue + if pending.boundary_id is not None and pending.checkpoint_store is not None: + try: + pending.checkpoint_store.cancel(pending.boundary_id) + except ValueError: + continue + self.permission_wait_coordinator.unregister_live(pending.boundary_id) + if not pending.future.done(): + pending.future.cancel() + else: + _set_future_result(pending.future, permission_result) session.pending_permissions.pop(pending.request_id, None) - _set_future_result(pending.future, permission_result) session.events.append( "permission.resolved", { @@ -3264,6 +3645,15 @@ def cancel_pending_requests_for_session( }, ) + def cancel_pending_requests_for_shutdown(self, session: WebSession | str) -> None: + """Cancel ephemeral inputs while leaving durable permissions orphan-recoverable.""" + + session = self._resolve_session_arg(session) + session.shutdown_preserved_permission_boundaries.update( + pending.boundary_id for pending in session.pending_permissions.values() if pending.boundary_id is not None + ) + self.cancel_pending_requests_for_session(session) + def classify_queued_input(self, session: WebSession | str, text: str) -> dict[str, Any]: """Classify mid-turn user input as a queued message or composer draft.""" session = self._resolve_session_arg(session) diff --git a/tests/a2a/test_app.py b/tests/a2a/test_app.py index e04b8c81..31179f67 100644 --- a/tests/a2a/test_app.py +++ b/tests/a2a/test_app.py @@ -46,6 +46,11 @@ from iac_code.a2a.transports.dispatcher import create_runtime_components from iac_code.mcp.errors import MCPNeedsAuthError from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitPolicy, + build_permission_checkpoint, +) from iac_code.services.session_backup import BackupReason, SessionBackupBlocked from iac_code.services.session_backup_state import NORMAL_HANDOFF_PROOF_KEY, BackupPublicationProof from iac_code.services.session_storage import SessionStorage @@ -2175,6 +2180,27 @@ async def test_cancel_input_required_pipeline_task_after_restart_marks_canceled( persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) persistence.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="input-required")) SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), session_id) + permission_store = PermissionWaitCheckpointStore(str(tmp_path), session_id) + permission = permission_store.create( + build_permission_checkpoint( + session_id=session_id, + task_id="task-1", + context_id="ctx-1", + input_id="permission-1", + tool_use_id="tool-1", + tool_name="aliyun_api", + tool_input={"action": "CreateStack"}, + permission_class="pipeline", + continuation_frame={ + "assistantMessageRef": "pipeline/transcripts/transcript-step-1/session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}], + }, + policy=PermissionWaitPolicy(), + ) + ) pipeline_dir = SessionStorage().session_dir(str(tmp_path), session_id) / "a2a" / "pipeline" pending = _pipeline_pending_ask_event() @@ -2195,6 +2221,7 @@ async def test_cancel_input_required_pipeline_task_after_restart_marks_canceled( assert isinstance(task, Task) assert task.status.state == TaskState.TASK_STATE_CANCELED assert persistence.load_task("task-1").state == "canceled" + assert permission_store.load(permission["boundaryId"])["phase"] == "CANCELED" snapshot = A2APipelineSnapshotStore(pipeline_dir).load() assert snapshot["status"] == "canceled" assert snapshot["normalHandoff"]["action"] == "switch_to_normal" @@ -2216,6 +2243,54 @@ async def test_cancel_input_required_pipeline_task_after_restart_marks_canceled( await components.aclose() +@pytest.mark.asyncio +async def test_cancel_inactive_permission_wait_loses_to_claimed_decision(tmp_path: Path) -> None: + persistence_dir = tmp_path / "a2a" + session_id = "session-ctx-1" + persistence = A2APersistenceStore(persistence_dir) + persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) + persistence.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="input-required")) + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), session_id) + permission_store = PermissionWaitCheckpointStore(str(tmp_path), session_id) + permission = permission_store.create( + build_permission_checkpoint( + session_id=session_id, + task_id="task-1", + context_id="ctx-1", + input_id="permission-1", + tool_use_id="tool-1", + tool_name="aliyun_api", + tool_input={"action": "CreateStack"}, + permission_class="normal", + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}], + }, + policy=PermissionWaitPolicy(), + ) + ) + permission_store.claim_decision(permission["boundaryId"], value="allow_once", source="user") + components = create_runtime_components( + model="qwen3.6-plus", + host="127.0.0.1", + port=41242, + persistence_dir=persistence_dir, + ) + + try: + task = await components.handler.on_cancel_task(CancelTaskRequest(id="task-1"), ServerCallContext()) + + assert isinstance(task, Task) + assert task.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert persistence.load_task("task-1").state == "input-required" + assert permission_store.load(permission["boundaryId"])["decision"]["status"] == "claimed" + finally: + await components.aclose() + + @pytest.mark.asyncio async def test_cancel_input_required_normal_task_marks_canceled_and_allows_same_context_retry( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/a2a/test_events.py b/tests/a2a/test_events.py index 77d5b236..3b92e2b2 100644 --- a/tests/a2a/test_events.py +++ b/tests/a2a/test_events.py @@ -1,7 +1,9 @@ import json +from types import SimpleNamespace import pytest from a2a.types import TaskArtifactUpdateEvent +from a2a.utils.errors import InvalidParamsError from google.protobuf.json_format import MessageToDict from iac_code.a2a.events import ( @@ -9,10 +11,21 @@ _METADATA_MAX_CHARS, _tool_result_metadata, _truncate, + publish_interactive_permission_boundary, publish_stream_event, ) from iac_code.a2a.exposure import A2AExposureType +from iac_code.a2a.input_required import PermissionInputRegistry, PermissionResponse +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitCoordinator, + PermissionWaitPolicy, + permission_execution_identity, +) from iac_code.services.permissions.audit import fingerprint_text +from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked +from iac_code.services.session_storage import SessionStorage from iac_code.tools.cloud.aliyun.result_contract import ALIYUN_HTTP_METADATA_KEY from iac_code.types.stream_events import ( ErrorEvent, @@ -32,6 +45,62 @@ from .fakes import FakeEventQueue, UnknownEvent, pending_future +class _ObservedBoundaryBackup: + def __init__( + self, + queue: FakeEventQueue, + store: PermissionWaitCheckpointStore, + *, + fail: bool = False, + shared_committed: bool = True, + ) -> None: + self.queue = queue + self.store = store + self.fail = fail + self.shared_committed = shared_committed + self.calls: list[tuple[BackupReason, bool]] = [] + + def backup_session(self, _cwd, _session_id, *, reason, critical): + self.calls.append((reason, critical)) + if len(self.calls) == 1: + assert self.queue.events == [] + paths = list(self.store.paths.permission_waits_dir.glob("pwb_*.json")) + assert len(paths) == 1 + assert json.loads(paths[0].read_text(encoding="utf-8"))["phase"] == "WAITING" + if self.fail: + raise RuntimeError("shared backup failed") + return SimpleNamespace( + enabled=True, + succeeded=True, + retry_count=0, + shared_committed=self.shared_committed, + ) + + +def _durable_permission_fixture(tmp_path, monkeypatch): + config_dir = tmp_path / "config" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + monkeypatch.setattr(AliyunCredentials, "load", staticmethod(lambda: None)) + session_id = "session-1" + SessionStorage().ensure_v2_session_dir_for_new_session(str(workspace), session_id) + store = PermissionWaitCheckpointStore(str(workspace), session_id) + registry = PermissionInputRegistry() + registry.set_permission_wait_coordinator(PermissionWaitCoordinator(PermissionWaitPolicy())) + return workspace, session_id, store, registry + + +def _permission_frame(tool_use_id: str) -> dict: + return { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": [tool_use_id], + "currentIndex": 0, + "decisions": [{"toolUseId": tool_use_id, "state": "pending", "source": None, "deniedResult": None}], + } + + def dump(event): return MessageToDict(event, preserving_proto_field_name=False) @@ -338,6 +407,312 @@ async def approve(request: PermissionRequestEvent) -> bool: assert dumped["metadata"]["iac_code"]["permission"]["autoApproved"] is True +@pytest.mark.asyncio +async def test_external_permission_is_backed_up_before_input_required_is_visible(tmp_path, monkeypatch) -> None: + workspace, session_id, store, registry = _durable_permission_fixture(tmp_path, monkeypatch) + queue = FakeEventQueue() + backup = _ObservedBoundaryBackup(queue, store) + future = pending_future() + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack", "params": {"StackName": "demo"}}, + tool_use_id="tool-write", + response_future=future, + continuation_frame=_permission_frame("tool-write"), + audit_context={"principal_ref": "aliyun:principal-fingerprint", "region": "cn-shanghai"}, + ) + + pending = await publish_interactive_permission_boundary( + queue, + permission_event=event, + permission_input_registry=registry, + task_id="task-1", + context_id="ctx-1", + iac_code_session_id=session_id, + permission_wait_cwd=str(workspace), + permission_wait_backup_service=backup, + wait_for_response=False, + ) + + assert backup.calls == [(BackupReason.INPUT_REQUIRED, True)] + assert len(queue.events) == 1 + assert dump(queue.events[0])["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + checkpoint = store.list_active() + assert len(checkpoint) == 1 + assert checkpoint[0]["phase"] == "WAITING" + assert checkpoint[0]["principalRef"] == "aliyun:principal-fingerprint" + assert checkpoint[0]["region"] == "cn-shanghai" + await registry.complete(pending) + future.cancel() + + +@pytest.mark.asyncio +async def test_external_permission_decision_is_backed_up_before_future_delivery(tmp_path, monkeypatch) -> None: + workspace, session_id, store, registry = _durable_permission_fixture(tmp_path, monkeypatch) + queue = FakeEventQueue() + backup = _ObservedBoundaryBackup(queue, store) + future = pending_future() + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack", "params": {"StackName": "demo"}}, + tool_use_id="tool-write", + response_future=future, + continuation_frame=_permission_frame("tool-write"), + ) + pending = await publish_interactive_permission_boundary( + queue, + permission_event=event, + permission_input_registry=registry, + task_id="task-1", + context_id="ctx-1", + iac_code_session_id=session_id, + permission_wait_cwd=str(workspace), + permission_wait_backup_service=backup, + wait_for_response=False, + ) + + approved = await registry.answer( + PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id=pending.input_id, + tool_use_id="tool-write", + decision="allow_once", + ) + ) + + assert approved is True + assert backup.calls == [(BackupReason.INPUT_REQUIRED, True), (BackupReason.INPUT_REQUIRED, True)] + assert future.result() is True + checkpoint = store.load(str(pending.boundary_id)) + assert checkpoint["decision"]["backupStatus"] == "committed" + assert checkpoint["decision"]["status"] == "applied" + await registry.complete(pending) + + +@pytest.mark.asyncio +async def test_failed_decision_backup_keeps_claim_retriable_without_delivering_future(tmp_path, monkeypatch) -> None: + workspace, session_id, store, registry = _durable_permission_fixture(tmp_path, monkeypatch) + queue = FakeEventQueue() + backup = _ObservedBoundaryBackup(queue, store) + future = pending_future() + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tool-write", + response_future=future, + continuation_frame=_permission_frame("tool-write"), + ) + pending = await publish_interactive_permission_boundary( + queue, + permission_event=event, + permission_input_registry=registry, + task_id="task-1", + context_id="ctx-1", + iac_code_session_id=session_id, + permission_wait_cwd=str(workspace), + permission_wait_backup_service=backup, + wait_for_response=False, + ) + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id=pending.input_id, + tool_use_id="tool-write", + decision="allow_once", + ) + + backup.fail = True + with pytest.raises(RuntimeError, match="shared backup failed"): + await registry.answer(response) + assert future.done() is False + checkpoint = store.load(str(pending.boundary_id)) + assert checkpoint["decision"]["status"] == "claimed" + assert checkpoint["decision"]["backupStatus"] == "pending" + + backup.fail = False + assert await registry.answer(response) is True + assert future.result() is True + checkpoint = store.load(str(pending.boundary_id)) + assert checkpoint["decision"]["backupStatus"] == "committed" + assert checkpoint["decision"]["status"] == "applied" + await registry.complete(pending) + + +@pytest.mark.asyncio +async def test_live_cloud_permission_rejects_changed_principal_before_claim(tmp_path, monkeypatch) -> None: + workspace, session_id, store, registry = _durable_permission_fixture(tmp_path, monkeypatch) + queue = FakeEventQueue() + backup = _ObservedBoundaryBackup(queue, store) + original = AliyunCredential( + mode="StsToken", + access_key_id="original-access-key-id", + access_key_secret="secret", + sts_token="token", + region_id="cn-hangzhou", + ) + monkeypatch.setattr(AliyunCredentials, "load", staticmethod(lambda: original)) + tool_input = {"product": "ros", "action": "CreateStack", "region_id": "cn-hangzhou"} + principal_ref, region = permission_execution_identity(tool_name="aliyun_api", tool_input=tool_input) + future = pending_future() + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input=tool_input, + tool_use_id="tool-write", + response_future=future, + continuation_frame=_permission_frame("tool-write"), + audit_context={"principal_ref": principal_ref, "region": region}, + ) + pending = await publish_interactive_permission_boundary( + queue, + permission_event=event, + permission_input_registry=registry, + task_id="task-1", + context_id="ctx-1", + iac_code_session_id=session_id, + permission_wait_cwd=str(workspace), + permission_wait_backup_service=backup, + wait_for_response=False, + ) + changed = AliyunCredential( + mode="StsToken", + access_key_id="changed-access-key-id", + access_key_secret="secret", + sts_token="token", + region_id="cn-hangzhou", + ) + monkeypatch.setattr(AliyunCredentials, "load", staticmethod(lambda: changed)) + + with pytest.raises(InvalidParamsError, match="cloud execution identity changed"): + await registry.answer( + PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id=pending.input_id, + tool_use_id="tool-write", + decision="allow_once", + ) + ) + + assert future.done() is False + assert store.load(str(pending.boundary_id))["decision"]["status"] == "none" + await registry.complete(pending) + future.cancel() + + +@pytest.mark.asyncio +async def test_failed_critical_permission_backup_is_not_visible_or_recoverable(tmp_path, monkeypatch) -> None: + workspace, session_id, store, registry = _durable_permission_fixture(tmp_path, monkeypatch) + queue = FakeEventQueue() + backup = _ObservedBoundaryBackup(queue, store, fail=True) + future = pending_future() + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tool-write", + response_future=future, + continuation_frame=_permission_frame("tool-write"), + ) + + with pytest.raises(RuntimeError, match="shared backup failed"): + await publish_interactive_permission_boundary( + queue, + permission_event=event, + permission_input_registry=registry, + task_id="task-1", + context_id="ctx-1", + iac_code_session_id=session_id, + permission_wait_cwd=str(workspace), + permission_wait_backup_service=backup, + wait_for_response=False, + ) + + assert queue.events == [] + assert store.list_active() == [] + assert future.result() is False + + +@pytest.mark.asyncio +async def test_uncommitted_shared_permission_backup_is_not_visible_or_recoverable(tmp_path, monkeypatch) -> None: + workspace, session_id, store, registry = _durable_permission_fixture(tmp_path, monkeypatch) + queue = FakeEventQueue() + backup = _ObservedBoundaryBackup(queue, store, shared_committed=False) + future = pending_future() + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tool-write", + response_future=future, + continuation_frame=_permission_frame("tool-write"), + ) + + with pytest.raises(SessionBackupBlocked, match="did not reach the shared target"): + await publish_interactive_permission_boundary( + queue, + permission_event=event, + permission_input_registry=registry, + task_id="task-1", + context_id="ctx-1", + iac_code_session_id=session_id, + permission_wait_cwd=str(workspace), + permission_wait_backup_service=backup, + wait_for_response=False, + ) + + assert queue.events == [] + assert store.list_active() == [] + assert future.result() is False + + +@pytest.mark.parametrize("resolution", ["auto_approve", "resolver_allow", "resolver_deny"]) +@pytest.mark.asyncio +async def test_a2a_internal_permission_resolution_creates_no_checkpoint_or_critical_backup( + tmp_path, + monkeypatch, + resolution, +) -> None: + workspace, session_id, store, registry = _durable_permission_fixture(tmp_path, monkeypatch) + queue = FakeEventQueue() + backup = _ObservedBoundaryBackup(queue, store) + future = pending_future() + event = PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="tool-1", + response_future=future, + ) + resolver = None + auto_approve = resolution == "auto_approve" + if resolution == "resolver_allow": + + def resolver(_request): + return True + elif resolution == "resolver_deny": + + def resolver(_request): + return False + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=event, + permission_resolver=resolver, + permission_input_registry=registry, + auto_approve_permissions=auto_approve, + iac_code_session_id=session_id, + permission_wait_cwd=str(workspace), + permission_wait_backup_service=backup, + ) + + assert future.result() is (resolution != "resolver_deny") + assert store.list_active() == [] + assert backup.calls == [] + + @pytest.mark.asyncio async def test_wrapped_permission_request_uses_inner_event() -> None: queue = FakeEventQueue() diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index 4c48e2b7..0ab2c87c 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -14,6 +14,7 @@ from iac_code.a2a.backup import backup_session_async from iac_code.a2a.executor import IacCodeA2AExecutor, _normal_handoff_has_backup_ack from iac_code.a2a.exposure import A2AExposureType +from iac_code.a2a.input_required import PermissionResponse from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore, A2ATaskSnapshot from iac_code.a2a.pipeline_executor import recoverable_task_id_from_sidecar @@ -33,6 +34,7 @@ ScopedMCPServerConfig, ) from iac_code.pipeline.engine.user_input import PipelineUserInput +from iac_code.services.permission_wait import RecoveredPermissionAuditBoundary from iac_code.services.session_backup import ( BackupReason, BackupResult, @@ -1869,6 +1871,9 @@ async def spy_publish_stream_event( auto_approve_permissions=False, exposure_types=None, iac_code_session_id=None, + permission_wait_cwd=None, + permission_wait_backup_service=None, + permission_wait_metrics=None, ): seen_artifact_stores.append(artifact_store) assert permission_input_registry is not None @@ -3788,12 +3793,10 @@ def test_accepts_skill_rich_presentation_metadata(self) -> None: executor = self._make_executor() assert ( - executor._resolve_candidate_presentation({"iac_code": {"candidatePresentation": " rich-v1 "}}) - == "rich-v1" + executor._resolve_candidate_presentation({"iac_code": {"candidatePresentation": " rich-v1 "}}) == "rich-v1" ) assert ( - executor._resolve_candidate_presentation({"iac_code": {"candidate_presentation": "RICH-V1"}}) - == "rich-v1" + executor._resolve_candidate_presentation({"iac_code": {"candidate_presentation": "RICH-V1"}}) == "rich-v1" ) def test_rejects_unknown_or_missing_presentation(self) -> None: @@ -4249,3 +4252,244 @@ def fake_register_cloud_tools(registry, credentials, services): await executor.execute(context, FakeEventQueue()) assert seen_access_key_ids == ["client-id"] + + +@pytest.mark.asyncio +async def test_suspending_permission_answer_waits_for_owner_then_resumes_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id="pwi-test", + tool_use_id="tool-1", + decision="allow_once", + ) + pending = SimpleNamespace(state="suspended_decision_claimed", boundary_id="pwb-test") + waits = [False, False, True] + completed: list[object] = [] + resumed: list[PermissionResponse] = [] + published: list[dict] = [] + + async def pending_for_response(_response): + return pending + + async def answer(_response): + return True + + async def wait_for_suspended_owner(_boundary_id): + return waits.pop(0) + + async def complete(value): + completed.append(value) + + async def resume(_context, _queue, *, response): + resumed.append(response) + return True + + async def publish(_queue, **kwargs): + published.append(kwargs) + + monkeypatch.setattr("iac_code.a2a.executor.parse_permission_response", lambda _message: response) + monkeypatch.setattr(executor._permission_input_registry, "pending_for_response", pending_for_response) + monkeypatch.setattr(executor._permission_input_registry, "answer", answer) + monkeypatch.setattr( + executor._permission_wait_coordinator, + "wait_for_suspended_owner", + wait_for_suspended_owner, + ) + monkeypatch.setattr(executor._permission_input_registry, "complete", complete) + monkeypatch.setattr(executor, "_resume_persisted_permission", resume) + monkeypatch.setattr(executor, "_publish_status", publish) + + await executor._execute( + FakeRequestContext(task_id="task-1", context_id="ctx-1"), + FakeEventQueue(), + context_id="ctx-1", + ) + + assert waits == [] + assert completed == [pending] + assert resumed == [response] + assert len(published) == 1 + assert published[0]["metadata"]["iac_code"]["permissionAck"]["recoveryPending"] is True + + +@pytest.mark.asyncio +async def test_normal_persisted_permission_recovery_publishes_final_and_terminal_state( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class RecoveryLoop: + async def resume_permission_boundary(self, _checkpoint): + yield MessageStartEvent(message_id="final") + yield TextDeltaEvent(text="Cleanup completed.") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + checkpoint = { + "boundaryId": "pwb-boundary1", + "phase": "SUSPENDED", + "permissionClass": "normal", + "decision": { + "status": "claimed", + "value": "allow_once", + "claimId": "claim-1", + "auditStatus": "recorded", + "backupStatus": "committed", + }, + } + resolved: list[dict] = [] + + class CheckpointStore: + def find(self, **_kwargs): + return checkpoint + + def reconcile_deadline(self, *_args, **_kwargs): + return checkpoint + + def claim_decision(self, *_args, **_kwargs): + return checkpoint, False + + def run_claim_audit_once(self, *_args, **_kwargs): + return checkpoint, False + + def begin_restore(self, _boundary_id): + checkpoint["phase"] = "RESTORING" + return checkpoint + + def resolve(self, _boundary_id, **kwargs): + checkpoint["phase"] = "RESOLVED" + resolved.append(kwargs) + return checkpoint + + backup_service = SnapshotReadingBackupService() + task_store = A2ATaskStore(metrics=NoOpA2AMetrics()) + context_record = await task_store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda session_id: FakeRuntime(session_id=session_id), + ) + SessionStorage().append( + str(tmp_path), + context_record.session_id, + Message(role="user", content="delete the stack"), + ) + task_record = await task_store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task_record.state = "input-required" + task_store.mirror_task(task_record) + runtime = FakeRuntime(agent_loop=RecoveryLoop(), session_id=context_record.session_id) + monkeypatch.setattr("iac_code.a2a.executor.PermissionWaitCheckpointStore", lambda *_args: CheckpointStore()) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda _options: runtime) + + executor = IacCodeA2AExecutor( + task_store=task_store, + model="qwen3.6-plus", + backup_service=backup_service, + ) + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id="input-1", + tool_use_id="tool-1", + decision="allow_once", + ) + queue = FakeEventQueue() + + assert await executor._resume_persisted_permission( + FakeRequestContext(task_id="task-1", context_id="ctx-1"), + queue, + response=response, + ) + + states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + final_events = [ + dump(event) + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + and dump(event).get("metadata", {}).get("iac_code", {}).get("assistantFinal", {}).get("complete") is True + ] + assert states[-1] == "TASK_STATE_INPUT_REQUIRED" + assert final_events[0]["status"]["message"]["parts"][0]["text"] == "Cleanup completed." + assert "".join(task_record.output_text) == "Cleanup completed." + assert task_record.state == "input-required" + assert checkpoint["phase"] == "RESOLVED" + assert len(resolved) == 1 + assert backup_service.calls == [(str(tmp_path), context_record.session_id, BackupReason.NORMAL_TURN_END, False)] + + +@pytest.mark.asyncio +async def test_restart_audit_rebuild_failure_precedes_permission_claim_and_backup(monkeypatch, tmp_path) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + task_record = SimpleNamespace(context_id="ctx-1") + context_record = SimpleNamespace(cwd=str(tmp_path), session_id="session-1") + + async def get_task_record(_task_id): + return task_record + + async def get_context_record(_context_id): + return context_record + + monkeypatch.setattr(store, "get_task_record", get_task_record) + monkeypatch.setattr(store, "get_context_record", get_context_record) + checkpoint = { + "boundaryId": "pwb-boundary1", + "phase": "SUSPENDED", + "permissionClass": "normal", + "decision": {"status": "none", "value": None}, + "principalRef": None, + "region": None, + } + store_calls: list[str] = [] + + class CheckpointStore: + def find(self, **_kwargs): + return checkpoint + + def reconcile_deadline(self, *_args, **_kwargs): + store_calls.append("reconcile") + return checkpoint + + def claim_decision(self, *_args, **_kwargs): + store_calls.append("claim") + return checkpoint, True + + monkeypatch.setattr( + "iac_code.a2a.executor.PermissionWaitCheckpointStore", + lambda *_args, **_kwargs: CheckpointStore(), + ) + monkeypatch.setattr( + "iac_code.a2a.executor.recover_permission_audit_boundary", + lambda *_args, **_kwargs: RecoveredPermissionAuditBoundary( + tool_name="write_file", + tool_input={"path": "template.yml"}, + tool_use_id="tool-1", + audit_context={"session_id": "session-1", "cwd": str(tmp_path)}, + ), + ) + + async def fail_rebuild(**_kwargs): + raise ValueError("current tool unavailable") + + monkeypatch.setattr(executor, "_rebuild_normal_permission_audit_event", fail_rebuild) + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id="input-1", + tool_use_id="tool-1", + decision="allow_once", + ) + + with pytest.raises(InvalidParamsError, match="permission_resume_invalid"): + await executor._resume_persisted_permission( + FakeRequestContext(task_id="task-1", context_id="ctx-1"), + FakeEventQueue(), + response=response, + ) + + assert store_calls == [] diff --git a/tests/a2a/test_input_required.py b/tests/a2a/test_input_required.py index b7615e0e..14b5fe3f 100644 --- a/tests/a2a/test_input_required.py +++ b/tests/a2a/test_input_required.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import sys import pytest @@ -12,6 +13,7 @@ from iac_code.a2a.events import publish_stream_event from iac_code.a2a.executor import IacCodeA2AExecutor from iac_code.a2a.input_required import ( + PERMISSION_QUERY_PREFIX, PermissionInputRegistry, PermissionResponse, parse_permission_response, @@ -24,6 +26,13 @@ from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher, _unified_input_projection from iac_code.a2a.runtime_overrides import a2a_request_context from iac_code.a2a.task_store import A2ATaskStore +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitCoordinator, + PermissionWaitPolicy, + build_permission_checkpoint, +) +from iac_code.services.session_storage import SessionStorage from iac_code.types.permissions import PermissionAuditMetadata, PermissionResult from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent @@ -59,6 +68,38 @@ def _permission_message( ) +def _text_permission_message( + *, + decision: str = "allow_once", + context_id: str = "ctx-1", + extra_part: bool = False, + extra_payload: dict[str, object] | None = None, + include_task_id: bool = True, +) -> Message: + payload: dict[str, object] = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": context_id, + "inputId": "permission-task-1-tool-1", + "toolUseId": "tool-1", + "decision": decision, + } + payload.update(extra_payload or {}) + parts = [Part(text="{} {}".format(PERMISSION_QUERY_PREFIX, json.dumps(payload)))] + if extra_part: + parts.append(Part(text="also allow")) + message = Message( + message_id="message-1", + context_id="ctx-1", + role=Role.ROLE_USER, + parts=parts, + ) + if include_task_id: + message.task_id = "task-1" + return message + + def test_permission_parser_requires_unique_json_part_and_exact_correlation() -> None: response = parse_permission_response(_permission_message()) assert response is not None @@ -75,6 +116,60 @@ def test_permission_parser_requires_unique_json_part_and_exact_correlation() -> parse_permission_response(extra_field) +def test_permission_parser_accepts_exact_json_text_part_for_text_only_gateways() -> None: + response = parse_permission_response(_text_permission_message(include_task_id=False)) + + assert response is not None + assert response.task_id == "task-1" + assert response.context_id == "ctx-1" + assert response.input_id == "permission-task-1-tool-1" + assert response.decision == "allow_once" + + +def test_json_text_permission_response_fails_closed_on_schema_or_correlation_mismatch() -> None: + with pytest.raises(InvalidParamsError, match="exactly one JSON TextPart"): + parse_permission_response(_text_permission_message(extra_part=True)) + with pytest.raises(InvalidParamsError, match="allow_once or deny"): + parse_permission_response(_text_permission_message(decision="always")) + with pytest.raises(InvalidParamsError, match="payload fields"): + parse_permission_response(_text_permission_message(extra_payload={"unexpected": "value"})) + with pytest.raises(InvalidParamsError, match="contextId"): + parse_permission_response(_text_permission_message(context_id="ctx-other")) + + +def test_non_control_text_continues_to_generic_input_path() -> None: + for text in ( + "allow", + '{"kind":"permission"}', + 'prefix {"kind":"permission"}', + ' IAC_CODE_PERMISSION: {"kind":"permission"}', + ): + message = Message( + message_id="message-1", + task_id="task-1", + context_id="ctx-1", + role=Role.ROLE_USER, + parts=[Part(text=text)], + ) + assert parse_permission_response(message) is None + + +def test_non_prefixed_text_does_not_attempt_json_decode(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_json_loads(_value: str): + raise AssertionError("ordinary query must not enter JSON decoding") + + monkeypatch.setattr("iac_code.a2a.input_required.json.loads", fail_json_loads) + message = Message( + message_id="message-1", + task_id="task-1", + context_id="ctx-1", + role=Role.ROLE_USER, + parts=[Part(text='ordinary query containing {"kind":"permission"}')], + ) + + assert parse_permission_response(message) is None + + def test_other_json_data_parts_continue_to_generic_input_path() -> None: data = Value() data.struct_value.update({"kind": "unrelated", "value": 1}) @@ -147,6 +242,204 @@ async def test_permission_mismatch_and_duplicate_reply_fail_closed(monkeypatch) await registry.answer(parsed) +@pytest.mark.asyncio +async def test_concurrent_duplicate_normal_answers_claim_live_continuation_once(monkeypatch, tmp_path) -> None: + registry = PermissionInputRegistry() + coordinator = PermissionWaitCoordinator(PermissionWaitPolicy()) + registry.set_permission_wait_coordinator(coordinator) + future = pending_future() + request = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tool-1", + response_future=future, + ) + pending = await registry.register(request, task_id="task-1", context_id="ctx-1", scope="normal") + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), "session-1") + checkpoint_store = PermissionWaitCheckpointStore(str(tmp_path), "session-1") + record = checkpoint_store.create( + build_permission_checkpoint( + session_id="session-1", + task_id="task-1", + context_id="ctx-1", + input_id=pending.input_id, + tool_use_id="tool-1", + tool_name="aliyun_api", + tool_input=request.tool_input, + permission_class="normal", + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}], + }, + policy=PermissionWaitPolicy(), + ) + ) + pending.boundary_id = record["boundaryId"] + pending.checkpoint_store = checkpoint_store + registry.activate_durable_boundary(pending, record) + monkeypatch.setattr("iac_code.a2a.input_required.emit_permission_boundary_audit", lambda *_a, **_k: True) + + continuation_calls = 0 + + async def continuation() -> None: + nonlocal continuation_calls + continuation_calls += 1 + await asyncio.sleep(0) + + pending.continuation = continuation + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id=pending.input_id, + tool_use_id="tool-1", + decision="allow_once", + ) + + async def answer_and_continue() -> bool: + approved = await registry.answer(response) + claimed = await registry.claim_continuation(pending) + if claimed is not None: + await claimed() + return approved + + assert await asyncio.gather(answer_and_continue(), answer_and_continue()) == [True, True] + assert continuation_calls == 1 + assert checkpoint_store.load(record["boundaryId"])["decision"]["status"] == "applied" + await registry.complete(pending) + + +@pytest.mark.asyncio +async def test_top_pipeline_durable_boundary_persists_canonical_transcript_reference(tmp_path) -> None: + registry = PermissionInputRegistry() + registry.set_permission_wait_coordinator(PermissionWaitCoordinator(PermissionWaitPolicy())) + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), "session-pipeline-ref") + request = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"action": "CreateStack"}, + tool_use_id="tool-1", + response_future=pending_future(), + audit_context={ + "root_session_id": "session-pipeline-ref", + "transcript_id": "transcript_att_0001", + }, + continuation_frame={ + "assistantMessageRef": "session.jsonl:2", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None}], + }, + ) + pending = await registry.register(request, task_id="task-1", context_id="ctx-1", scope="pipeline") + + record = await registry.open_durable_boundary( + pending, + cwd=str(tmp_path), + session_id="session-pipeline-ref", + permission_class="pipeline", + backup_service=None, + perform_backup=False, + ) + + assert record["continuationFrame"]["assistantMessageRef"] == ( + "pipeline/transcripts/transcript_att_0001/session.jsonl:2" + ) + + +@pytest.mark.asyncio +async def test_normal_successor_releases_old_registry_owner_before_publication(monkeypatch, tmp_path) -> None: + class BackupService: + def backup_session(self, *_args, **_kwargs) -> None: + return None + + registry = PermissionInputRegistry() + coordinator = PermissionWaitCoordinator(PermissionWaitPolicy()) + registry.set_permission_wait_coordinator(coordinator) + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), "session-successor") + common = { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1", "tool-2"], + } + first_request = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"action": "CreateStack"}, + tool_use_id="tool-1", + response_future=pending_future(), + continuation_frame={ + **common, + "currentIndex": 0, + "decisions": [ + {"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}, + {"toolUseId": "tool-2", "state": "not_evaluated", "source": None, "deniedResult": None}, + ], + }, + ) + first = await registry.register(first_request, task_id="task-1", context_id="ctx-1", scope="normal") + first_record = await registry.open_durable_boundary( + first, + cwd=str(tmp_path), + session_id="session-successor", + permission_class="normal", + backup_service=BackupService(), + perform_backup=False, + ) + registry.activate_durable_boundary(first, first_record) + monkeypatch.setattr("iac_code.a2a.input_required.emit_permission_boundary_audit", lambda *_a, **_k: True) + first_response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id=first.input_id, + tool_use_id="tool-1", + decision="allow_once", + ) + assert await registry.answer(first_response) is True + + second_request = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"action": "DeleteStack"}, + tool_use_id="tool-2", + response_future=pending_future(), + continuation_frame={ + **common, + "currentIndex": 1, + "decisions": [ + { + "toolUseId": "tool-1", + "state": "allow", + "source": "user", + "principalRef": None, + "region": None, + "deniedResult": None, + }, + {"toolUseId": "tool-2", "state": "pending", "source": None, "deniedResult": None}, + ], + "previousBoundaryId": first_record["boundaryId"], + }, + ) + second = await registry.register(second_request, task_id="task-1", context_id="ctx-1", scope="normal") + await registry.open_durable_boundary( + second, + cwd=str(tmp_path), + session_id="session-successor", + permission_class="normal", + backup_service=BackupService(), + perform_backup=False, + ) + + assert coordinator.has_live_boundary(first_record["boundaryId"]) is False + with pytest.raises(InvalidParamsError, match="pending permission"): + await registry.pending_for_response(first_response) + receipt = PermissionWaitCheckpointStore(str(tmp_path), "session-successor").load(first_record["boundaryId"]) + assert receipt["phase"] == "RESOLVED" + assert receipt["ack"]["nextBoundaryId"] == second.boundary_id + + def test_safe_summary_preserves_decision_values_and_redacts_secret() -> None: summary = permission_safe_summary( PermissionRequestEvent( @@ -240,9 +533,7 @@ def test_ros_deployment_permission_is_localized_and_preserves_safe_plan_summary( "stackName": "demo-stack", "template": "templates/demo.yml", "totalMonthlyCost": "¥88/月", - "resources": [ - {"name": "ECS", "spec": "2 vCPU / 4 GiB", "monthlyCost": "¥88/月"} - ], + "resources": [{"name": "ECS", "spec": "2 vCPU / 4 GiB", "monthlyCost": "¥88/月"}], }, }, ), @@ -325,7 +616,10 @@ def test_unknown_bash_permission_is_not_mislabeled_as_read_only() -> None: @pytest.mark.asyncio -async def test_pipeline_permission_uses_same_input_envelope_and_waits_serially(monkeypatch, tmp_path) -> None: +@pytest.mark.parametrize(("decision", "allowed"), [("allow_once", True), ("deny", False)]) +async def test_pipeline_permission_uses_same_input_envelope_and_waits_serially( + monkeypatch, tmp_path, decision: str, allowed: bool +) -> None: registry = PermissionInputRegistry() queue = FakeEventQueue() before_enqueue: list[dict[str, object]] = [] @@ -376,21 +670,19 @@ async def record_before_enqueue(envelope): assert before_enqueue[0]["status"] == "input_required" parsed = parse_permission_response( - _permission_message(decision="deny", input_id=dumped["metadata"]["iac_code"]["input"]["inputId"]) + _permission_message(decision=decision, input_id=dumped["metadata"]["iac_code"]["input"]["inputId"]) ) assert parsed is not None - assert await registry.answer(parsed) is False + assert await registry.answer(parsed) is allowed await publishing - assert future.result() is False + assert future.result() is allowed @pytest.mark.asyncio async def test_sub_pipeline_permissions_stay_working_and_resolve_independently(monkeypatch, tmp_path) -> None: registry = PermissionInputRegistry() store = A2ATaskStore() - await store.save( - Task(id="task-1", context_id="ctx-1", status=TaskStatus(state=TaskState.TASK_STATE_WORKING)) - ) + await store.save(Task(id="task-1", context_id="ctx-1", status=TaskStatus(state=TaskState.TASK_STATE_WORKING))) queue = FakeEventQueue() publisher = PipelineA2AEventPublisher( event_queue=queue, @@ -466,9 +758,7 @@ async def test_sub_pipeline_permissions_stay_working_and_resolve_independently(m task = await store.get("task-1") assert task is not None task_metadata = MessageToDict(task.metadata, preserving_proto_field_name=False) - assert [item["inputId"] for item in task_metadata["iac_code"]["pendingPermissions"]] == [ - requests[1]["inputId"] - ] + assert [item["inputId"] for item in task_metadata["iac_code"]["pendingPermissions"]] == [requests[1]["inputId"]] remaining = task_metadata["iac_code"]["pendingPermissions"][0] assert remaining["language"] == "zh" assert remaining["prompt"] == "是否允许本次操作:运行本地 Shell 命令?" diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index ccae8d3a..998f2a2e 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -36,6 +36,11 @@ from iac_code.pipeline.engine.interrupt import InterruptVerdict from iac_code.pipeline.engine.prerequisites import PrerequisiteDecision, PrerequisiteResolution from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitPolicy, + RecoveredPermissionAuditBoundary, +) from iac_code.services.session_backup import BackupReason, BackupResult, SessionBackupBlocked from iac_code.services.session_backup_state import NORMAL_HANDOFF_PROOF_KEY, BackupPublicationProof from iac_code.services.session_layout import UnsupportedSessionLayoutError @@ -44,6 +49,8 @@ from iac_code.types.stream_events import ( AskUserQuestionEvent, PermissionRequestEvent, + PermissionWaitOutcome, + PermissionWaitSuspended, SubPipelineStreamEvent, TextDeltaEvent, ) @@ -905,6 +912,57 @@ def _fake_runtime(): return SimpleNamespace(provider_manager=object(), tool_registry=FakeToolRegistry()) +@pytest.mark.asyncio +async def test_pipeline_restart_audit_rebuild_uses_restored_pipeline_and_closes_runtime(monkeypatch, tmp_path): + from iac_code.a2a import pipeline_executor as pipeline_executor_module + + calls: list[str] = [] + expected_event = object() + + class AuditRuntime: + provider_manager = object() + tool_registry = FakeToolRegistry() + + async def aclose(self): + calls.append("close") + + class AuditPipeline: + async def rebuild_permission_audit_event(self, checkpoint, recovered): + calls.append("rebuild") + assert checkpoint == {"boundaryId": "pwb-boundary"} + assert recovered.tool_use_id == "tool-1" + return expected_event + + class Backup: + def restore_session(self, cwd, session_id): + calls.append("restore") + assert cwd == str(tmp_path) + assert session_id == "session-1" + + executor = _pipeline_executor() + executor._backup_service = Backup() + runtime = AuditRuntime() + monkeypatch.setattr(pipeline_executor_module, "create_agent_runtime", lambda _options: runtime) + monkeypatch.setattr(executor, "_configure_agent_runtime_for_request", lambda value: calls.append("configure")) + monkeypatch.setattr(executor, "_create_pipeline", lambda **_kwargs: AuditPipeline()) + recovered = RecoveredPermissionAuditBoundary( + tool_name="aliyun_api", + tool_input={"product": "ROS", "action": "CreateStack"}, + tool_use_id="tool-1", + audit_context={"transcript_id": "transcript_att_0001"}, + ) + + event = await executor.rebuild_permission_audit_event( + cwd=str(tmp_path), + session_id="session-1", + checkpoint={"boundaryId": "pwb-boundary"}, + recovered=recovered, + ) + + assert event is expected_event + assert calls == ["restore", "configure", "rebuild", "close"] + + def _status_events(queue: FakeEventQueue) -> list[dict]: return [dump(event) for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] @@ -2995,7 +3053,7 @@ def fake_create_pipeline(*args, **kwargs): @pytest.mark.asyncio -async def test_pipeline_executor_runs_critical_backup_after_input_required_publication( +async def test_pipeline_executor_runs_critical_backup_before_input_required_publication( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -3015,13 +3073,13 @@ async def test_pipeline_executor_runs_critical_backup_after_input_required_publi monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) queue = FakeEventQueue() - def assert_input_required_published(reason: BackupReason) -> None: + def assert_input_required_not_yet_published(reason: BackupReason) -> None: if reason == BackupReason.INPUT_REQUIRED: - assert _pipeline_status_events(queue)[-1]["eventType"] == "input_required" + assert not _pipeline_status_events(queue) backup_service = RecordingBackupService( expected_task_states={BackupReason.INPUT_REQUIRED: "input-required"}, - on_backup=assert_input_required_published, + on_backup=assert_input_required_not_yet_published, ) store = A2ATaskStore(metrics=NoOpA2AMetrics()) @@ -3032,7 +3090,10 @@ def assert_input_required_published(reason: BackupReason) -> None: assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [ (BackupReason.INPUT_REQUIRED, True) ] - assert _pipeline_status_events(queue)[-1]["eventType"] == "input_required" + assert [event["eventType"] for event in _pipeline_status_events(queue)][-2:] == [ + "input_required", + "backup_committed", + ] assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" @@ -3054,6 +3115,21 @@ def __init__(self) -> None: tool_input={"cmd": "rm /tmp/demo"}, tool_use_id="tool-1", response_future=future, + continuation_frame={ + "assistantMessageRef": "pipeline/transcripts/transcript_att_0001/session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [ + { + "toolUseId": "tool-1", + "state": "pending", + "source": None, + "deniedResult": None, + } + ], + }, + audit_context={"transcript_id": "transcript_att_0001"}, ) ], session_dir=tmp_path / "sidecar", @@ -3067,6 +3143,11 @@ def pause_agent_loops(self) -> None: def resume_agent_loops(self) -> None: self.resume_calls += 1 + async def resume_permission_boundary(self, checkpoint): + del checkpoint + if False: + yield None + pipeline = PermissionPipeline() monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) @@ -3091,22 +3172,95 @@ def resume_agent_loops(self) -> None: and dump(event).get("metadata", {}).get("iac_code", {}).get("input", {}).get("kind") == "permission" ) - approved = await executor._permission_input_registry.answer( - PermissionResponse( - task_id="task-1", - context_id="ctx-1", - request_task_id="task-1", - input_id=permission_input["inputId"], - tool_use_id="tool-1", - decision="deny", - ) + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id=permission_input["inputId"], + tool_use_id="tool-1", + decision="deny", ) + pending = await executor._permission_input_registry.pending_for_response(response) + approved = await executor._permission_input_registry.answer(response) assert approved is False await execution + continuation = await executor._permission_input_registry.claim_continuation(pending) + assert continuation is not None + await continuation(queue, pending) assert future.result() is False assert pipeline.resume_calls == 1 +@pytest.mark.asyncio +async def test_pipeline_permission_resident_timer_survives_full_executor_publication( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + future = asyncio.get_running_loop().create_future() + + class PermissionPipeline(FakePipeline): + def __init__(self) -> None: + super().__init__( + [ + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "rm /tmp/demo"}, + tool_use_id="tool-timer", + response_future=future, + continuation_frame={ + "assistantMessageRef": "pipeline/transcripts/transcript_att_0001/session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-timer"], + "currentIndex": 0, + "decisions": [ + { + "toolUseId": "tool-timer", + "state": "pending", + "source": None, + "deniedResult": None, + } + ], + }, + audit_context={"transcript_id": "transcript_att_0001"}, + ) + ], + session_dir=tmp_path / "sidecar", + ) + + def pause_agent_loops(self) -> None: + return + + def resume_agent_loops(self) -> None: + return + + pipeline = PermissionPipeline() + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + backup_service = RecordingBackupService(expected_task_states={BackupReason.INPUT_REQUIRED: "input-required"}) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + backup_service=backup_service, + permission_wait_policy=PermissionWaitPolicy( + resident_timeout_seconds=0.01, + timeout_grace_seconds=0.02, + ), + ) + queue = FakeEventQueue() + + await asyncio.wait_for( + executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue), + timeout=1, + ) + + assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + context_record = await store.get_context_record("ctx-1") + checkpoint = PermissionWaitCheckpointStore(str(tmp_path), context_record.session_id).list_active()[0] + assert checkpoint["phase"] == "SUSPENDED" + + @pytest.mark.asyncio async def test_sub_pipeline_permission_stays_working_without_global_pause( monkeypatch: pytest.MonkeyPatch, @@ -3338,7 +3492,7 @@ def assert_committed_publication_before_backup(reason: BackupReason) -> None: @pytest.mark.asyncio -async def test_pipeline_backup_blocked_after_input_required_publishes_recoverable_state( +async def test_pipeline_backup_blocked_prevents_input_required_visibility( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -3372,8 +3526,8 @@ async def test_pipeline_backup_blocked_after_input_required_publishes_recoverabl await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) pipeline_events = _pipeline_status_events(queue) - assert [event["eventType"] for event in pipeline_events] == ["input_required", "backup_blocked"] - blocked = pipeline_events[1] + assert [event["eventType"] for event in pipeline_events] == ["backup_blocked"] + blocked = pipeline_events[0] assert blocked["status"] == "input_required" assert blocked["data"]["reason"] == "input_required" assert blocked["data"]["recoverable"] is True @@ -3385,10 +3539,13 @@ async def test_pipeline_backup_blocked_after_input_required_publishes_recoverabl assert fake_pipeline.sidecar_status == "backup_blocked" assert metrics.task_failed == 0 assert metrics.backup_blocked == [("input_required", True)] - assert [event["eventType"] for event in A2APipelineJournal(session_dir).read_all()] == [ + journal_events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in journal_events] == [ + "input_required", "input_required", "backup_blocked", ] + assert [event.get("visibility") for event in journal_events[:2]] == ["pending_backup", "committed"] snapshot = A2APipelineSnapshotStore(session_dir).load() assert snapshot is not None assert snapshot["status"] == "waiting_input" @@ -3432,7 +3589,7 @@ def _save_backup_blocked_sidecar(self, step_id, reason): await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - assert [event["eventType"] for event in _pipeline_status_events(queue)] == ["input_required"] + assert _pipeline_status_events(queue) == [] record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") assert record.state == "input-required" assert fake_pipeline.sidecar_status is None @@ -6287,10 +6444,14 @@ async def test_executor_preserves_running_sidecar_pause_as_input_required( assert fake_pipeline.continue_calls == 1 events = A2APipelineJournal(tmp_path / "sidecar").read_all() - assert events[-1]["eventType"] == "input_required" - assert events[-1]["status"] == "input_required" - assert events[-1]["data"]["kind"] == "pipeline_pause_confirmation" - assert "timeout" in events[-1]["data"]["reason"] + committed_input = next( + event + for event in reversed(events) + if event["eventType"] == "input_required" and event.get("visibility") == "committed" + ) + assert committed_input["status"] == "input_required" + assert committed_input["data"]["kind"] == "pipeline_pause_confirmation" + assert "timeout" in committed_input["data"]["reason"] statuses = _status_events(queue) assert statuses[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" @@ -10759,3 +10920,131 @@ async def resume_ask_user_question(self, answer, **kwargs): assert events assert received["supplemental_input"] == pipeline_input + + +@pytest.mark.asyncio +async def test_pipeline_permission_checkpoint_uses_dedicated_resume_stream( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A persisted tool permission must not be routed as ordinary Pipeline input.""" + + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + class PermissionResumePipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.sidecar_status = "waiting_input" + self.permission_checkpoints: list[dict] = [] + + async def resume_permission_boundary(self, checkpoint): + self.permission_checkpoints.append(checkpoint) + yield TextDeltaEvent(text="permission resumed") + + pipeline = PermissionResumePipeline(session_dir=tmp_path / "sidecar") + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + queue = FakeEventQueue() + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + checkpoint = { + "boundaryId": "pwb_boundary1", + "permissionClass": "pipeline", + "continuationFrame": {"currentIndex": 0}, + } + + await executor.execute( + context=FakeRequestContext(task_id="task-1", context_id="ctx-1"), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + pipeline_input="", + permission_checkpoint=checkpoint, + ) + + assert pipeline.permission_checkpoints == [checkpoint] + assert pipeline.run_prompts == [] + assert pipeline.resume_prompts == [] + assert "permission resumed" in task.output_text + + +@pytest.mark.asyncio +async def test_top_pipeline_permission_suspension_stays_input_required_without_failure_or_rollback( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + class SuspendedPermissionPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.sidecar_status = "running" + self.received_checkpoint = None + + async def resume_permission_boundary(self, checkpoint): + self.received_checkpoint = checkpoint + raise PermissionWaitSuspended(checkpoint["boundaryId"]) + if False: # pragma: no cover - make this an async generator + yield + + pipeline = SuspendedPermissionPipeline(session_dir=tmp_path / "sidecar") + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + queue = FakeEventQueue() + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + checkpoint = { + "boundaryId": "pwb_boundary1", + "permissionClass": "pipeline", + "pipelineCoordinates": {"step": {"id": "deploy", "runId": "step-deploy-1", "attempt": 1}}, + } + + await executor.execute( + context=FakeRequestContext(task_id="task-1", context_id="ctx-1"), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + pipeline_input="", + permission_checkpoint=checkpoint, + ) + + assert pipeline.received_checkpoint is checkpoint + assert checkpoint["pipelineCoordinates"]["step"]["id"] == "deploy" + assert task.state == "input-required" + dumped = [dump(event) for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + assert any( + event["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + and event.get("metadata", {}).get("iac_code", {}).get("permissionWait") + == {"status": "suspended", "resumable": True} + for event in dumped + ) + assert all(event["status"]["state"] != "TASK_STATE_FAILED" for event in dumped) + assert not any( + event.get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType", "").startswith("rollback_") + for event in dumped + ) diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py index c713342d..3bc0d54e 100644 --- a/tests/a2a/test_pipeline_stream.py +++ b/tests/a2a/test_pipeline_stream.py @@ -15,6 +15,8 @@ from iac_code.a2a.artifacts import A2AArtifactStore from iac_code.a2a.events import _METADATA_MAX_CHARS from iac_code.a2a.exposure import A2AExposureType +from iac_code.a2a.input_required import PermissionInputRegistry +from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_performance import A2A_EXTREME_PERFORMANCE_ENV @@ -34,8 +36,15 @@ pipeline_transport_delivery_required, pipeline_transport_delivery_tracking, ) +from iac_code.a2a.task_store import A2ATaskStore from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitCoordinator, + PermissionWaitPolicy, +) from iac_code.services.permissions.audit import fingerprint_text +from iac_code.services.session_storage import SessionStorage from iac_code.types.permissions import PermissionAuditMetadata from iac_code.types.stream_events import ( AskUserQuestionEvent, @@ -783,6 +792,118 @@ async def test_publish_nested_sub_pipeline_permission_resolves_inner_future(tmp_ assert permission["approved"] is False +@pytest.mark.asyncio +async def test_two_sub_pipeline_candidates_continue_while_one_permission_times_out(tmp_path: Path) -> None: + queue = FakeEventQueue() + context = PipelineA2AContext( + pipeline_run_id="run-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + parent_step_order=["evaluate_candidates"], + candidate_step_order=["template_generating"], + ) + registry = PermissionInputRegistry() + registry.set_permission_wait_coordinator( + PermissionWaitCoordinator(PermissionWaitPolicy(sub_pipeline_timeout_seconds=0.02)) + ) + task_store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task_record = await task_store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task_record.state = "working" + task_store.mirror_task(task_record) + session_id = "session-1" + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), session_id) + pipeline_dir = tmp_path / "pipeline" + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator(context), + journal=A2APipelineJournal(pipeline_dir), + snapshot_store=A2APipelineSnapshotStore(pipeline_dir), + permission_input_registry=registry, + task_store=task_store, + permission_wait_cwd=str(tmp_path), + permission_wait_session_id=session_id, + ) + await publisher.publish( + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=1717821600.0, + data={ + "sub_pipeline_id": "candidate-a", + "candidate_index": 0, + "candidate_name": "A", + "parent_step_id": "evaluate_candidates", + "total_steps": 1, + }, + ) + ) + permission_result: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publisher.publish( + SubPipelineStreamEvent( + sub_pipeline_id="candidate-a", + candidate_index=0, + inner=PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tool-candidate-a", + response_future=permission_result, + ), + ) + ) + await publisher.publish( + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=1717821601.0, + data={ + "sub_pipeline_id": "candidate-b", + "candidate_index": 1, + "candidate_name": "B", + "parent_step_id": "evaluate_candidates", + "total_steps": 1, + }, + ) + ) + await publisher.publish( + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821602.0, + data={ + "sub_pipeline_id": "candidate-b", + "candidate_index": 1, + "candidate_name": "B", + "parent_step_id": "evaluate_candidates", + }, + ) + ) + + assert permission_result.done() is False + await asyncio.wait_for(asyncio.shield(permission_result), timeout=0.5) + + assert permission_result.result() is False + events = publisher.journal.read_all_repairing_tail() + b_completed_index = next( + index + for index, event in enumerate(events) + if event.get("eventType") == "candidate_completed" and event.get("candidate", {}).get("name") == "B" + ) + timeout_index = next( + index + for index, event in enumerate(events) + if event.get("eventType") == "permission_resolved" and event.get("permission", {}).get("timedOut") is True + ) + assert b_completed_index < timeout_index + timeout = events[timeout_index] + assert timeout["permission"]["decision"] == "deny" + assert timeout["permission"]["automatic"] is True + task = await task_store.get_task_record("task-1") + assert task.state == "working" + assert PermissionWaitCheckpointStore(str(tmp_path), session_id).list_active() == [] + + @pytest.mark.asyncio async def test_publish_direct_permission_resolver_overrides_auto_approve(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) diff --git a/tests/a2a/test_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py index e73387da..188f4c75 100644 --- a/tests/a2a/test_transport_dispatcher.py +++ b/tests/a2a/test_transport_dispatcher.py @@ -11,6 +11,7 @@ from a2a.types import Message, Part, Role, SubscribeToTaskRequest, Task, TaskState, TaskStatus, TaskStatusUpdateEvent from google.protobuf.struct_pb2 import Value +from iac_code.a2a.input_required import PERMISSION_QUERY_PREFIX from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events @@ -530,23 +531,20 @@ async def test_message_stream_routes_permission_response_to_active_input_require context_id="ctx-1", status=TaskStatus(state=TaskState.TASK_STATE_WORKING), ) - data = Value() - data.struct_value.update( - { - "schemaVersion": 1, - "kind": "permission", - "requestTaskId": "task-1", - "inputId": "permission-task-1-tool-1", - "toolUseId": "tool-1", - "decision": "allow_once", - } - ) + response = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "ctx-1", + "inputId": "permission-task-1-tool-1", + "toolUseId": "tool-1", + "decision": "allow_once", + } message = Message( message_id="message-1", - task_id="task-1", context_id="ctx-1", role=Role.ROLE_USER, - parts=[Part(data=data, media_type="application/json")], + parts=[Part(text="{} {}".format(PERMISSION_QUERY_PREFIX, json.dumps(response)))], ) active_stream_called = False @@ -583,28 +581,26 @@ async def fail_sdk_stream(*_args, **_kwargs): assert events == [update] assert active_stream_called is True + assert message.task_id == "task-1" @pytest.mark.asyncio -async def test_sideband_permission_response_returns_one_short_ack_without_tapping_task(monkeypatch) -> None: +async def test_text_gateway_sideband_permission_response_hydrates_task_and_returns_short_ack(monkeypatch) -> None: call_context = ServerCallContext() - data = Value() - data.struct_value.update( - { - "schemaVersion": 1, - "kind": "permission", - "requestTaskId": "task-1", - "inputId": "permission-opaque", - "toolUseId": "tool-1", - "decision": "allow_once", - } - ) + response = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "ctx-1", + "inputId": "permission-opaque", + "toolUseId": "tool-1", + "decision": "allow_once", + } message = Message( message_id="message-1", - task_id="task-1", context_id="ctx-1", role=Role.ROLE_USER, - parts=[Part(data=data, media_type="application/json")], + parts=[Part(text="{} {}".format(PERMISSION_QUERY_PREFIX, json.dumps(response)))], ) ack_data = Value() ack_data.struct_value.update( @@ -645,6 +641,7 @@ async def fail_sdk_send(*_args, **_kwargs): params = SimpleNamespace(message=message) assert await handler.on_message_send(params, call_context) is ack + assert message.task_id == "task-1" assert await _collect_async(handler.on_message_send_stream(params, call_context)) == [ack] @@ -658,6 +655,13 @@ async def test_dispatcher_permission_followup_resumes_live_normal_stream(monkeyp tool_input={"cmd": "pwd"}, tool_use_id="tool-1", response_future=future, + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}], + }, ), TextDeltaEvent(text="after permission"), ] @@ -706,8 +710,8 @@ async def consume_first_stream() -> None: await asyncio.sleep(0.01) assert input_event is not None assert envelope is not None - assert await components.task_store.is_task_active(envelope["requestTaskId"]) - assert await components.handler._active_task_registry.get(envelope["requestTaskId"]) is not None + await asyncio.wait_for(first_task, timeout=_STREAM_TEST_TIMEOUT) + assert not await components.task_store.is_task_active(envelope["requestTaskId"]) async def consume_second_stream() -> list[dict]: return [ @@ -721,19 +725,23 @@ async def consume_second_stream() -> list[dict]: "message": { "messageId": "permission-message-second", "role": "ROLE_USER", - "taskId": envelope["requestTaskId"], "contextId": envelope["contextId"], "parts": [ { - "mediaType": "application/json", - "data": { - "schemaVersion": 1, - "kind": "permission", - "requestTaskId": envelope["requestTaskId"], - "inputId": envelope["inputId"], - "toolUseId": envelope["toolUseId"], - "decision": "allow_once", - }, + "text": "{} {}".format( + PERMISSION_QUERY_PREFIX, + json.dumps( + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": envelope["requestTaskId"], + "contextId": envelope["contextId"], + "inputId": envelope["inputId"], + "toolUseId": envelope["toolUseId"], + "decision": "allow_once", + }, + ), + ) } ], "metadata": {"iac_code": {"cwd": str(tmp_path)}}, @@ -754,7 +762,7 @@ async def consume_second_stream() -> list[dict]: assert future.done() and future.result() is True second_events = await asyncio.wait_for(second_task, timeout=_STREAM_TEST_TIMEOUT) assert all("error" not in event for event in second_events) - await asyncio.wait_for(first_task, timeout=_STREAM_TEST_TIMEOUT) + assert any("after permission" in json.dumps(event, ensure_ascii=False) for event in second_events) finally: if not second_task.done(): second_task.cancel() diff --git a/tests/a2a_e2e/test_permission_wait_restart.py b/tests/a2a_e2e/test_permission_wait_restart.py new file mode 100644 index 00000000..4d250aba --- /dev/null +++ b/tests/a2a_e2e/test_permission_wait_restart.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.integration +@pytest.mark.timeout(90) +@pytest.mark.parametrize( + ("mode", "decision", "expected_executions"), + [ + ("normal", "allow_once", 1), + ("normal", "deny", 0), + ("pipeline", "allow_once", 1), + ("pipeline", "deny", 0), + ], +) +def test_permission_wait_response_recovers_after_real_a2a_process_restart( + tmp_path: Path, + mode: str, + decision: str, + expected_executions: int, +) -> None: + repo_root = Path(__file__).resolve().parents[2] + runner = repo_root / "scripts" / "a2a" / "e2e" / "permission_wait" / "run_permission_wait_restart.py" + completed = subprocess.run( + [ + sys.executable, + str(runner), + "--run-dir", + str(tmp_path / "{}-{}".format(mode, decision)), + "--decision", + decision, + "--mode", + mode, + "--timeout", + "20", + ], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=80, + ) + + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout.strip().splitlines()[-1]) + assert result["passed"] is True + assert result["mode"] == mode + assert result["decision"] == decision + assert result["checkpointPhase"] == "RESOLVED" + assert result["toolExecutions"] == expected_executions + assert result["duplicateAcknowledged"] is True + assert result["conflictRejected"] is True + assert result["taskId"] + assert result["contextId"] + if mode == "normal": + assert result["assistantFinalPublished"] is True + assert result["terminalInputRequiredPublished"] is True + else: + assert result["pipelineCoordinatesPreserved"] is True + assert result["pipelineJournalOrdered"] is True + assert result["pipelineRollbackAbsent"] is True + assert result["parentStreamEndedAtPermissionBoundary"] is True + + +@pytest.mark.integration +@pytest.mark.timeout(90) +def test_pipeline_permission_after_candidate_selection_recovers_on_new_response_stream(tmp_path: Path) -> None: + repo_root = Path(__file__).resolve().parents[2] + runner = repo_root / "scripts" / "a2a" / "e2e" / "permission_wait" / "run_permission_wait_restart.py" + completed = subprocess.run( + [ + sys.executable, + str(runner), + "--run-dir", + str(tmp_path / "pipeline-candidate-first"), + "--decision", + "allow_once", + "--mode", + "pipeline", + "--candidate-first", + "--timeout", + "20", + ], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=80, + ) + + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout.strip().splitlines()[-1]) + assert result["passed"] is True + assert result["candidateSelectionBeforePermission"] is True + assert result["checkpointPhase"] == "RESOLVED" + assert result["toolExecutions"] == 1 + assert result["pipelineJournalOrdered"] is True diff --git a/tests/a2a_e2e/test_start_chat_permission_wait_runner.py b/tests/a2a_e2e/test_start_chat_permission_wait_runner.py new file mode 100644 index 00000000..68038426 --- /dev/null +++ b/tests/a2a_e2e/test_start_chat_permission_wait_runner.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import sys +from types import SimpleNamespace + +import pytest + + +def _runner(): + spec = importlib.util.spec_from_file_location( + "start_chat_permission_wait_runner", + "scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_real_runner_requires_explicit_cloud_opt_in() -> None: + runner = _runner() + + with pytest.raises(SystemExit, match="--allow-real-cloud"): + runner.run(SimpleNamespace(allow_real_cloud=False)) + + +def test_real_runner_allows_a_full_real_pipeline_turn_by_default(monkeypatch, tmp_path) -> None: + runner = _runner() + monkeypatch.setattr( + sys, + "argv", + ["runner", "--run-dir", str(tmp_path / "run"), "--mode", "pipeline"], + ) + + args = runner._parse_args() + + assert args.qoder_turn_timeout == 900.0 + assert (args.resident_timeout_seconds, args.sub_pipeline_timeout_seconds, args.timeout_grace_seconds) == ( + 300.0, + 300.0, + 30.0, + ) + assert args.skill_root == [runner.Path("~/.qoder/skills"), runner.Path("~/.qoderwork/skills")] + + +def test_real_runner_explicit_skill_root_does_not_also_install_defaults(monkeypatch, tmp_path) -> None: + runner = _runner() + root = tmp_path / "skills" + monkeypatch.setattr( + sys, + "argv", + ["runner", "--run-dir", str(tmp_path / "run"), "--mode", "normal", "--skill-root", str(root)], + ) + + args = runner._parse_args() + + assert args.skill_root == [root] + + +def test_real_runner_writes_fixed_start_chat_permission_policy(tmp_path) -> None: + runner = _runner() + path = tmp_path / "a2a.yml" + + runner._a2a_config( + path, + port=4567, + persistence=tmp_path / "state", + artifacts=tmp_path / "artifacts", + ) + + text = path.read_text(encoding="utf-8") + assert "auto_approve_permissions: false" in text + assert "resident_timeout_seconds: 300" in text + assert "sub_pipeline_timeout_seconds: 300" in text + assert "timeout_grace_seconds: 30" in text + + +def test_real_runner_can_shorten_permission_policy_for_diagnostic_runs(tmp_path) -> None: + runner = _runner() + path = tmp_path / "a2a.yml" + + runner._a2a_config( + path, + port=4567, + persistence=tmp_path / "state", + artifacts=tmp_path / "artifacts", + resident_timeout_seconds=2, + sub_pipeline_timeout_seconds=3, + timeout_grace_seconds=1, + ) + + text = path.read_text(encoding="utf-8") + assert "resident_timeout_seconds: 2" in text + assert "sub_pipeline_timeout_seconds: 3" in text + assert "timeout_grace_seconds: 1" in text + + +@pytest.mark.parametrize("mode", ["normal", "pipeline"]) +def test_real_runner_installs_skill_with_only_the_requested_mode(tmp_path, mode) -> None: + runner = _runner() + repo_root = tmp_path / "repo" + source = repo_root / "skills" / "alicloud-ros-agent" + (source / "agents").mkdir(parents=True) + (source / "scripts").mkdir() + (source / "SKILL.md").write_text("skill", encoding="utf-8") + (source / "agents" / "openai.yaml").write_text("name: test\n", encoding="utf-8") + (source / "scripts" / "ros_agent.py").write_text("# test\n", encoding="utf-8") + root = tmp_path / "skills" + + backups = runner._sync_skill(repo_root, [root], "127.0.0.1:56124", mode=mode) + + destination = root / "alicloud-ros-agent" + config = json.loads((destination / "config.json").read_text(encoding="utf-8")) + assert config["allowedAgentModes"] == [mode] + assert config["endpoint"] == "127.0.0.1:56124" + assert backups[0].destination == destination + assert backups[0].existed is False + + runner._restore_skill_installations(backups) + + assert not destination.exists() + + +def test_real_runner_restores_the_complete_existing_skill_installation(tmp_path) -> None: + runner = _runner() + repo_root = tmp_path / "repo" + source = repo_root / "skills" / "alicloud-ros-agent" + (source / "agents").mkdir(parents=True) + (source / "scripts").mkdir() + (source / "SKILL.md").write_text("new skill", encoding="utf-8") + (source / "agents" / "openai.yaml").write_text("name: new\n", encoding="utf-8") + (source / "scripts" / "ros_agent.py").write_text("# new\n", encoding="utf-8") + root = tmp_path / "skills" + destination = root / "alicloud-ros-agent" + (destination / "agents").mkdir(parents=True) + (destination / "scripts").mkdir() + (destination / "SKILL.md").write_text("old skill", encoding="utf-8") + (destination / "agents" / "legacy.yml").write_text("legacy\n", encoding="utf-8") + (destination / "scripts" / "legacy.py").write_text("# legacy\n", encoding="utf-8") + (destination / "config.json").write_text('{"endpoint":"old"}\n', encoding="utf-8") + + backups = runner._sync_skill(repo_root, [root], "127.0.0.1:56124", mode="normal") + runner._restore_skill_installations(backups) + + assert (destination / "SKILL.md").read_text(encoding="utf-8") == "old skill" + assert (destination / "agents" / "legacy.yml").read_text(encoding="utf-8") == "legacy\n" + assert (destination / "scripts" / "legacy.py").read_text(encoding="utf-8") == "# legacy\n" + assert not (destination / "agents" / "openai.yaml").exists() + assert not (destination / "scripts" / "ros_agent.py").exists() + assert json.loads((destination / "config.json").read_text(encoding="utf-8")) == {"endpoint": "old"} + + +def test_real_runner_rolls_back_the_complete_skill_when_sync_fails(monkeypatch, tmp_path) -> None: + runner = _runner() + repo_root = tmp_path / "repo" + source = repo_root / "skills" / "alicloud-ros-agent" + (source / "agents").mkdir(parents=True) + (source / "scripts").mkdir() + (source / "SKILL.md").write_text("new skill", encoding="utf-8") + (source / "agents" / "openai.yaml").write_text("name: new\n", encoding="utf-8") + (source / "scripts" / "ros_agent.py").write_text("# new\n", encoding="utf-8") + root = tmp_path / "skills" + destination = root / "alicloud-ros-agent" + destination.mkdir(parents=True) + (destination / "SKILL.md").write_text("old skill", encoding="utf-8") + monkeypatch.setattr( + runner, + "_write_json", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("injected config write failure")), + ) + + with pytest.raises(OSError, match="injected"): + runner._sync_skill(repo_root, [root], "127.0.0.1:56124", mode="normal") + + assert (destination / "SKILL.md").read_text(encoding="utf-8") == "old skill" + assert not (destination / "agents").exists() + assert not (destination / "scripts").exists() + + +def test_real_runner_refreshes_selected_source_before_copying(monkeypatch, tmp_path) -> None: + runner = _runner() + source = tmp_path / "source" + source.mkdir() + (source / ".cloud-credentials.yml").write_text( + "aliyun:\n mode: OAuth\n oauth_site_type: CN\n oauth_access_token: access\n oauth_refresh_token: refresh\n", + encoding="utf-8", + ) + observed = [] + + from iac_code.services.providers.aliyun import AliyunCredentials + + def refresh(credential): + observed.append((credential.mode, os.environ.get("IAC_CODE_CONFIG_DIR"))) + return credential + + monkeypatch.setattr(AliyunCredentials, "refresh_oauth_if_needed", staticmethod(refresh)) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "original")) + + runner._refresh_source_cloud_credentials(source) + + assert observed == [("OAuth", str(source))] + assert os.environ["IAC_CODE_CONFIG_DIR"] == str(tmp_path / "original") + + +def test_real_runner_auto_allows_incidental_tools_but_keeps_cloud_mutations_interactive(tmp_path) -> None: + runner = _runner() + settings = tmp_path / "settings.yml" + settings.write_text("model: qwen\npermissions:\n ask:\n - bash\n", encoding="utf-8") + + runner._configure_isolated_permissions(tmp_path) + + import yaml + + value = yaml.safe_load(settings.read_text(encoding="utf-8")) + assert value["model"] == "qwen" + assert value["permissions"] == { + "mode": "default", + "allow": [ + "read_file", + "write_file", + "edit_file", + "list_files", + "glob", + "grep", + "web_fetch", + "read_memory", + "write_memory", + "task_list", + "task_get", + "task_stop", + "agent", + "skill", + "aliyun_doc_search", + "aliyun_api_doc", + "ros_validate_template", + "ros_get_template_parameter_constraints", + "ros_preview_template", + "ros_estimate_template_cost", + "infraguard_scan", + "ask_user_question", + "show_architecture_diagram", + "show_candidate_detail", + "complete_step", + ], + "deny": ["bash(*)"], + "ask": [ + "aliyun_api", + "ros_deploy", + "ros_stack_group", + "ros_template", + "ros_template_scratch", + "ros_diagnostic", + "ros_resource_type_registration", + "ros_tag", + "ros_stack", + "ros_stack_instances", + ], + "additional_directories": [], + "audit": { + "include_tool_input": False, + "max_file_bytes": 10 * 1024 * 1024, + "max_files": 5, + }, + } + + +@pytest.mark.asyncio +async def test_real_runner_ros_deploy_write_requires_confirmation_in_default_mode(tmp_path) -> None: + runner = _runner() + settings = tmp_path / "settings.yml" + settings.write_text("model: qwen\n", encoding="utf-8") + stack_name = "pwait-pipeline-1234-stack" + runner._configure_isolated_permissions(tmp_path) + + from iac_code.pipeline.selling.tools.ros_deploy_tool import RosDeployTool + from iac_code.services.permissions.loader import load_permission_context + from iac_code.services.permissions.pipeline import check_tool_permission + + previous = os.environ.get("IAC_CODE_CONFIG_DIR") + os.environ["IAC_CODE_CONFIG_DIR"] = str(tmp_path) + try: + context = load_permission_context(str(tmp_path)) + finally: + if previous is None: + os.environ.pop("IAC_CODE_CONFIG_DIR", None) + else: + os.environ["IAC_CODE_CONFIG_DIR"] = previous + result = await check_tool_permission( + RosDeployTool(), + { + "action": "create", + "stack_name": stack_name, + "template_url": "template.yml", + "region_id": "cn-hangzhou", + }, + context, + ) + + assert result.behavior == "ask" + assert result.audit is not None + assert result.audit.rule == "ros_deploy" + + +def test_real_runner_uses_repository_prompt_with_run_scoped_names() -> None: + runner = _runner() + + prompt = runner._prompt_section( + "Deployment", + { + "run_id": "pwait-normal-1234", + "stack_name": "pwait-normal-1234-stack", + "vswitch_name": "pwait-normal-1234-vsw", + "mode": "Normal", + }, + ) + + assert "alicloud-ros-agent Skill" in prompt + assert "pwait-normal-1234-stack" in prompt + assert "pwait-normal-1234-vsw" in prompt + assert "Mermaid" in prompt + assert "不要创建或删除 VPC" in prompt + pipeline_prompt = runner._prompt_section( + "Deployment", + { + "run_id": "pwait-pipeline-1234", + "stack_name": "pwait-pipeline-1234-stack", + "vswitch_name": "pwait-pipeline-1234-vsw", + "mode": "Pipeline", + }, + ) + assert "恰好两个" in pipeline_prompt + assert "不同可用区" in pipeline_prompt + + +def test_real_runner_refuses_incomplete_read_only_unknown_and_out_of_scope_permissions(tmp_path) -> None: + runner = _runner() + + for is_read_only in (True, None): + with pytest.raises(AssertionError, match="non-read-only"): + runner._validate_permission_scope( + {"isReadOnly": is_read_only, "effect": "cloud_change", "target": "run-stack"}, + "run-stack", + tmp_path, + ) + with pytest.raises(AssertionError, match="non-read-only"): + runner._validate_permission_scope( + {"effect": "cloud_change", "target": "run-stack"}, + "run-stack", + tmp_path, + ) + for effect in (None, "local_execution", "read"): + with pytest.raises(AssertionError, match="effect"): + runner._validate_permission_scope( + {"isReadOnly": False, "effect": effect, "target": "run-stack"}, + "run-stack", + tmp_path, + ) + with pytest.raises(AssertionError, match="non-empty"): + runner._validate_permission_scope( + {"isReadOnly": False, "effect": "cloud_change", "target": ""}, + "run-stack", + tmp_path, + ) + with pytest.raises(AssertionError, match="outside"): + runner._validate_permission_scope( + {"isReadOnly": False, "effect": "cloud_change", "target": "other-stack"}, + "run-stack", + tmp_path, + ) + with pytest.raises(AssertionError, match="outside"): + runner._validate_permission_scope( + {"isReadOnly": False, "effect": "cloud_change", "target": "other-run-stack-shadow"}, + "run-stack", + tmp_path, + ) + runner._validate_permission_scope( + {"isReadOnly": False, "effect": "cloud_change", "target": "ros CreateStack; stack run-stack"}, + "run-stack", + tmp_path, + ) + runner._validate_permission_scope( + {"isReadOnly": False, "effect": "cloud_change", "target": "ros DeleteStack; stack stack-123"}, + "run-stack", + tmp_path, + allowed_stack_ids={"stack-123"}, + ) + with pytest.raises(AssertionError, match="escapes"): + runner._validate_permission_scope( + {"isReadOnly": False, "effect": "file_change", "target": "../outside.yml"}, + "run-stack", + tmp_path, + ) + inside = tmp_path / "template.yml" + runner._validate_permission_scope( + {"isReadOnly": False, "effect": "file_change", "target": inside.name}, + "run-stack", + tmp_path, + ) + + +def test_real_runner_cleans_only_exact_inventory_vswitch_ids(monkeypatch) -> None: + runner = _runner() + calls = [] + + def run(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", run) + + assert ( + runner._cleanup_exact_vswitches( + "aliyun", + "cn-hangzhou", + { + "vswitches": [ + {"vSwitchId": "vsw-run-1", "vSwitchName": "exact-run-name"}, + {"vSwitchName": "missing-id"}, + ] + }, + ) + is True + ) + + assert calls == [ + ( + [ + "aliyun", + "vpc", + "DeleteVSwitch", + "--RegionId", + "cn-hangzhou", + "--VSwitchId", + "vsw-run-1", + ], + {"check": False, "capture_output": True, "timeout": 60}, + ) + ] + + +def test_real_runner_treats_failed_exact_vswitch_delete_as_retryable(monkeypatch) -> None: + runner = _runner() + monkeypatch.setattr(runner.subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace(returncode=1)) + + assert ( + runner._cleanup_exact_vswitches( + "aliyun", + "cn-hangzhou", + {"vswitches": [{"vSwitchId": "vsw-run-1", "vSwitchName": "exact-run-name"}]}, + ) + is False + ) + + +def test_real_runner_records_local_and_shared_checkpoint_before_answer(tmp_path) -> None: + runner = _runner() + config_dir = tmp_path / "config" + shared_root = tmp_path / "shared" + relative = "projects/project/session/permission-waits/pwb_12345678.json" + checkpoint = { + "boundaryId": "pwb_12345678", + "inputId": "permission-1", + "permissionClass": "normal", + "phase": "WAITING", + "generation": 3, + } + for root in (config_dir, shared_root): + path = root / relative + path.parent.mkdir(parents=True) + path.write_text(json.dumps(checkpoint), encoding="utf-8") + job = { + "inputRequired": { + "inputId": "permission-1", + "kind": "permission", + "permissionClass": "normal", + "toolName": "ros_stack", + "isReadOnly": False, + "effect": "cloud_change", + } + } + + observation = runner._safe_permission_observation( + job=job, + config_dir=config_dir, + shared_root=shared_root, + observed_at=123.0, + ) + + assert observation == { + "observedAt": 123.0, + "inputId": "permission-1", + "kind": "permission", + "permissionClass": "normal", + "toolName": "ros_stack", + "toolUseId": None, + "isReadOnly": False, + "effect": "cloud_change", + "optionCount": 0, + "localCheckpoint": True, + "sharedCheckpoint": True, + "checkpointPhase": "WAITING", + "checkpointGeneration": 3, + } + + +def test_real_runner_proves_read_only_cloud_execution_from_tool_use_and_result(tmp_path) -> None: + runner = _runner() + session_dir = tmp_path / "config" / "projects" / "project" / "session" + session_dir.mkdir(parents=True) + tool_use_id = "tool-describe-vpcs" + (session_dir / "session.jsonl").write_text( + json.dumps( + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": tool_use_id, + "name": "aliyun_api", + "input": {"product": "vpc", "action": "DescribeVpcs"}, + } + ], + } + ) + + "\n", + encoding="utf-8", + ) + with (session_dir / "session.jsonl").open("a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": "bounded result", + "is_error": False, + } + ], + } + ) + + "\n", + ) + + assert runner._read_only_cloud_execution_evidence(tmp_path / "config") == [ + { + "toolUseId": tool_use_id, + "product": "vpc", + "action": "DescribeVpcs", + "source": "session_transcript", + "resultPersisted": True, + } + ] + + +def test_real_runner_removes_copied_credentials_and_full_transcripts(tmp_path) -> None: + runner = _runner() + config_dir = tmp_path / "config" + session = config_dir / "projects" / "project" / "session" / "session.jsonl" + session.parent.mkdir(parents=True) + session.write_text("full transcript\n", encoding="utf-8") + (config_dir / ".credentials.yml").write_text("model-secret\n", encoding="utf-8") + (config_dir / ".cloud-credentials.yml").write_text("cloud-secret\n", encoding="utf-8") + (config_dir / "settings.yml").write_text("model: test\n", encoding="utf-8") + + runner._remove_sensitive_run_data(config_dir) + + assert not session.exists() + assert not (config_dir / ".credentials.yml").exists() + assert not (config_dir / ".cloud-credentials.yml").exists() + assert (config_dir / "settings.yml").is_file() + + +def test_real_runner_requires_diagram_to_precede_same_turn_permission_question() -> None: + runner = _runner() + permission = [{"qoderTurn": 2, "permissionClass": "pipeline", "effect": "cloud_change"}] + + assert runner._architecture_preceded_deployment_permission( + [ + { + "turn": 2, + "assistantMermaid": True, + "firstMermaidBlockIndex": 1, + "firstCloudPermissionBlockIndex": 3, + } + ], + permission, + ) + assert not runner._architecture_preceded_deployment_permission( + [ + { + "turn": 2, + "assistantMermaid": True, + "firstMermaidBlockIndex": 3, + "firstCloudPermissionBlockIndex": 1, + } + ], + permission, + ) + assert runner._architecture_preceded_deployment_permission( + [{"turn": 1, "assistantMermaid": True}], + permission, + ) + + +def test_real_runner_records_assistant_diagram_and_cloud_permission_block_order(monkeypatch, tmp_path) -> None: + runner = _runner() + stdout = "\n".join( + json.dumps(item) + for item in ( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "```mermaid\ngraph TD\n```"}]}, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "content": json.dumps( + { + "inputRequired": { + "kind": "permission", + "effect": "cloud_change", + "isReadOnly": False, + } + } + ), + } + ] + }, + }, + ) + ) + monkeypatch.setattr( + runner.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=stdout, stderr=""), + ) + workspace = tmp_path / "workspace" + workspace.mkdir() + + evidence = runner._run_qoder( + args=SimpleNamespace( + qoder_cli=tmp_path / "qodercli", + qoder_config_dir=tmp_path / "qoder-config", + qoder_turn_timeout=30, + ), + env={}, + workspace=workspace, + session_id="session-1", + prompt="test", + turn=0, + resume=False, + run_dir=tmp_path / "run", + ) + + assert evidence["firstMermaidBlockIndex"] == 1 + assert evidence["firstCloudPermissionBlockIndex"] == 2 diff --git a/tests/a2a_e2e/test_sub_pipeline_permission_timeout.py b/tests/a2a_e2e/test_sub_pipeline_permission_timeout.py new file mode 100644 index 00000000..be6d3a92 --- /dev/null +++ b/tests/a2a_e2e/test_sub_pipeline_permission_timeout.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import importlib.util +import sys + +import pytest + + +def _fixture_runner(): + spec = importlib.util.spec_from_file_location( + "sub_pipeline_permission_timeout_runner", + "scripts/a2a/e2e/permission_wait/run_sub_pipeline_permission_timeout.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.mark.asyncio +async def test_real_agent_loop_candidates_continue_through_sub_pipeline_hard_timeout(tmp_path) -> None: + runner = _fixture_runner() + + result = await runner.run_scenario(run_dir=tmp_path / "run", timeout_seconds=0.02) + + assert result["passed"] is True + assert all(result["checks"].values()) diff --git a/tests/agent/test_agent_loop_permissions.py b/tests/agent/test_agent_loop_permissions.py index d1665616..8a01968e 100644 --- a/tests/agent/test_agent_loop_permissions.py +++ b/tests/agent/test_agent_loop_permissions.py @@ -3,13 +3,18 @@ import pytest from iac_code.agent.agent_loop import AgentLoop +from iac_code.agent.message import Message, ToolUseBlock from iac_code.providers.base import ToolDefinition +from iac_code.services.permission_wait import PermissionWaitPolicy, build_permission_checkpoint, canonical_digest +from iac_code.services.session_storage import SessionStorage from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult -from iac_code.types.permissions import PermissionResult +from iac_code.types.permissions import PermissionAuditMetadata, PermissionResult from iac_code.types.stream_events import ( MessageEndEvent, MessageStartEvent, PermissionRequestEvent, + PermissionWaitOutcome, + PermissionWaitSuspended, TextDeltaEvent, ToolResultEvent, ToolUseEndEvent, @@ -125,3 +130,961 @@ async def consume() -> None: assert captured_future.cancelled() is False assert captured_future.result() is False assert tool.executed is False + + +@pytest.mark.asyncio +async def test_permission_suspend_creates_no_denied_tool_result() -> None: + tool = WriteTool() + registry = ToolRegistry() + registry.register(tool) + loop = AgentLoop( + provider_manager=FakeProviderManager(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + ) + events = [] + + with pytest.raises(PermissionWaitSuspended): + async for event in loop.run_streaming("write"): + events.append(event) + if isinstance(event, PermissionRequestEvent): + assert event.continuation_frame is not None + assert event.continuation_frame["orderedToolUseIds"] == ["tool1"] + assert event.response_future is not None + event.response_future.set_result(PermissionWaitOutcome.SUSPEND) + + assert tool.executed is False + assert not any(isinstance(event, ToolResultEvent) for event in events) + + +@pytest.mark.asyncio +async def test_resume_permission_boundary_executes_ordered_multi_tool_batch_once() -> None: + class RecordingTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.values: list[str] = [] + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.values.append(tool_input["value"]) + return ToolResult.success("wrote {}".format(tool_input["value"])) + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + yield MessageStartEvent(message_id="continued") + yield TextDeltaEvent(text="Done.") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + tool = RecordingTool() + registry = ToolRegistry() + registry.register(tool) + assistant = Message( + role="assistant", + content=[ + ToolUseBlock(id="tool-1", name="write_test", input={"value": "first"}), + ToolUseBlock(id="tool-2", name="write_test", input={"value": "second"}), + ], + ) + digest = canonical_digest([block.model_dump(mode="json") for block in assistant.content]) + loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + checkpoint = { + "toolUseId": "tool-2", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "second"}}), + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-1"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": digest, + "orderedToolUseIds": ["tool-1", "tool-2"], + "currentIndex": 1, + "decisions": [ + { + "toolUseId": "tool-1", + "state": "allow", + "source": "user", + "principalRef": None, + "region": None, + "deniedResult": None, + }, + {"toolUseId": "tool-2", "state": "pending", "source": None, "deniedResult": None}, + ], + }, + } + + events = [event async for event in loop.resume_permission_boundary(checkpoint)] + + assert tool.values == ["first", "second"] + assert [event.tool_use_id for event in events if isinstance(event, ToolResultEvent)] == [ + "tool-1", + "tool-2", + ] + assert not any(isinstance(event, PermissionRequestEvent) for event in events) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("resume_messages", "loop_identity", "message_ref"), + [ + ( + [ + Message( + role="assistant", + content=[ToolUseBlock(id="tool-1", name="write_test", input={"value": "same"})], + ), + Message( + role="assistant", + content=[ToolUseBlock(id="tool-1", name="write_test", input={"value": "same"})], + ), + ], + {}, + "session.jsonl:0", + ), + ( + [ + Message( + role="assistant", + content=[ToolUseBlock(id="tool-1", name="write_test", input={"value": "same"})], + ) + ], + { + "session_id": "transcript_b", + "root_session_id": "root-session", + "transcript_id": "transcript_b", + }, + "pipeline/transcripts/transcript_a/session.jsonl:0", + ), + ], +) +async def test_resume_permission_boundary_requires_exact_runtime_message_reference( + resume_messages, + loop_identity, + message_ref, +) -> None: + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + raise AssertionError("message reference mismatch must fail before continuation") + yield + + tool = WriteTool() + registry = ToolRegistry() + registry.register(tool) + assistant = resume_messages[-1] + loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=resume_messages, + **loop_identity, + ) + checkpoint = { + "toolUseId": "tool-1", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "same"}}), + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-1"}, + "continuationFrame": { + "assistantMessageRef": message_ref, + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}], + }, + } + + with pytest.raises(ValueError, match="assistant message reference changed"): + async for _event in loop.resume_permission_boundary(checkpoint): + pass + + assert tool.executed is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("trailing_behavior", "audit_succeeds", "expected_values", "expected_error"), + [ + ("allow", True, ["first", "second", "third"], False), + ("allow", False, ["first", "second"], True), + ("deny", True, ["first", "second"], True), + ], +) +async def test_resume_permission_boundary_audits_later_policy_decisions( + monkeypatch, + trailing_behavior, + audit_succeeds, + expected_values, + expected_error, +) -> None: + class PolicyTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.values: list[str] = [] + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.values.append(tool_input["value"]) + return ToolResult.success("wrote {}".format(tool_input["value"])) + + async def check_permissions(self, input: dict, context: dict | None = None) -> PermissionResult: + value = input["value"] + if value == "second": + return PermissionResult(behavior="ask", message="Allow second?") + behavior = trailing_behavior if value == "third" else "allow" + return PermissionResult(behavior=behavior, message="Policy denied.") + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + yield MessageStartEvent(message_id="continued") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + tool = PolicyTool() + registry = ToolRegistry() + registry.register(tool) + tool_uses = [ + ToolUseBlock(id="tool-1", name="write_test", input={"value": "first"}), + ToolUseBlock(id="tool-2", name="write_test", input={"value": "second"}), + ToolUseBlock(id="tool-3", name="write_test", input={"value": "third"}), + ] + assistant = Message(role="assistant", content=tool_uses) + loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + checkpoint = { + "toolUseId": "tool-2", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "second"}}), + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-1"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id for tool_use in tool_uses], + "currentIndex": 1, + "decisions": [ + {"toolUseId": "tool-1", "state": "allow", "source": "policy", "deniedResult": None}, + {"toolUseId": "tool-2", "state": "pending", "source": None, "deniedResult": None}, + {"toolUseId": "tool-3", "state": "not_evaluated", "source": None, "deniedResult": None}, + ], + }, + } + audit_calls: list[str] = [] + + def audit(**kwargs) -> bool: + audit_calls.append(kwargs["decision"]) + return audit_succeeds + + monkeypatch.setattr("iac_code.agent.agent_loop._emit_no_prompt_permission_audit", audit) + + events = [event async for event in loop.resume_permission_boundary(checkpoint)] + + assert audit_calls == [trailing_behavior] + assert tool.values == expected_values + trailing_results = [ + event for event in events if isinstance(event, ToolResultEvent) and event.tool_use_id == "tool-3" + ] + assert len(trailing_results) == 1 + assert trailing_results[0].is_error is expected_error + + +@pytest.mark.asyncio +async def test_resume_permission_boundary_does_not_reuse_prior_policy_allow_when_now_ask() -> None: + class RecordingTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.values: list[str] = [] + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.values.append(tool_input["value"]) + return ToolResult.success("ok") + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + yield MessageStartEvent(message_id="continued") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + tool = RecordingTool() + registry = ToolRegistry() + registry.register(tool) + tool_uses = [ + ToolUseBlock(id="tool-1", name="write_test", input={"value": "first"}), + ToolUseBlock(id="tool-2", name="write_test", input={"value": "second"}), + ] + assistant = Message(role="assistant", content=tool_uses) + loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + checkpoint = { + "toolUseId": "tool-2", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "second"}}), + "principalRef": None, + "region": None, + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-2"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id for tool_use in tool_uses], + "currentIndex": 1, + "decisions": [ + {"toolUseId": "tool-1", "state": "allow", "source": "policy", "deniedResult": None}, + {"toolUseId": "tool-2", "state": "pending", "source": None, "deniedResult": None}, + ], + }, + } + + events = [event async for event in loop.resume_permission_boundary(checkpoint)] + + assert tool.values == ["second"] + first_result = next( + event for event in events if isinstance(event, ToolResultEvent) and event.tool_use_id == "tool-1" + ) + assert first_result.is_error is True + + +@pytest.mark.asyncio +async def test_resume_permission_boundary_revalidates_each_prior_user_approval_identity(monkeypatch) -> None: + class RecordingTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.values: list[str] = [] + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.values.append(tool_input["value"]) + return ToolResult.success("ok") + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + yield MessageStartEvent(message_id="continued") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + tool = RecordingTool() + registry = ToolRegistry() + registry.register(tool) + tool_uses = [ + ToolUseBlock(id="tool-1", name="write_test", input={"value": "first"}), + ToolUseBlock(id="tool-2", name="write_test", input={"value": "second"}), + ] + assistant = Message(role="assistant", content=tool_uses) + loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + checkpoint = { + "toolUseId": "tool-2", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "second"}}), + "principalRef": "principal-current", + "region": "cn-current", + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-2"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id for tool_use in tool_uses], + "currentIndex": 1, + "decisions": [ + { + "toolUseId": "tool-1", + "state": "allow", + "source": "user", + "principalRef": "principal-old", + "region": "cn-old", + "deniedResult": None, + }, + {"toolUseId": "tool-2", "state": "pending", "source": None, "deniedResult": None}, + ], + }, + } + monkeypatch.setattr( + "iac_code.agent.agent_loop.permission_execution_identity", + lambda **kwargs: ( + ("principal-new", "cn-new") + if kwargs["tool_input"]["value"] == "first" + else ("principal-current", "cn-current") + ), + ) + + events = [event async for event in loop.resume_permission_boundary(checkpoint)] + + assert tool.values == ["second"] + first_result = next( + event for event in events if isinstance(event, ToolResultEvent) and event.tool_use_id == "tool-1" + ) + assert first_result.is_error is True + + +@pytest.mark.asyncio +async def test_resume_permission_boundary_persists_hard_denies_across_successor(monkeypatch) -> None: + class RecordingTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.values: list[str] = [] + self.first_behavior = "deny" + self.second_behavior = "deny" + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.values.append(tool_input["value"]) + return ToolResult.success("ok") + + async def check_permissions(self, input: dict, context: dict | None = None) -> PermissionResult: + if input["value"] == "first": + return PermissionResult(behavior=self.first_behavior, message="Policy denied.") + if input["value"] == "second": + return PermissionResult(behavior=self.second_behavior, message="Policy denied.") + return PermissionResult(behavior="ask", message="Allow write?") + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + yield MessageStartEvent(message_id="continued") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + tool = RecordingTool() + registry = ToolRegistry() + registry.register(tool) + tool_uses = [ + ToolUseBlock(id="tool-1", name="write_test", input={"value": "first"}), + ToolUseBlock(id="tool-2", name="write_test", input={"value": "second"}), + ToolUseBlock(id="tool-3", name="write_test", input={"value": "third"}), + ] + assistant = Message(role="assistant", content=tool_uses) + message_digest = canonical_digest([block.model_dump(mode="json") for block in assistant.content]) + first_loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + audit_calls: list[tuple[str, str]] = [] + + def audit(**kwargs) -> bool: + audit_calls.append((kwargs["request"].id, kwargs["decision"])) + return True + + monkeypatch.setattr("iac_code.agent.agent_loop._emit_no_prompt_permission_audit", audit) + first_checkpoint = { + "boundaryId": "pwb_firstboundary", + "toolUseId": "tool-2", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "second"}}), + "principalRef": None, + "region": None, + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-2"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": message_digest, + "orderedToolUseIds": [tool_use.id for tool_use in tool_uses], + "currentIndex": 1, + "decisions": [ + {"toolUseId": "tool-1", "state": "allow", "source": "policy", "deniedResult": None}, + {"toolUseId": "tool-2", "state": "pending", "source": None, "deniedResult": None}, + {"toolUseId": "tool-3", "state": "not_evaluated", "source": None, "deniedResult": None}, + ], + }, + } + + first_stream = first_loop.resume_permission_boundary(first_checkpoint) + successor = await anext(first_stream) + assert isinstance(successor, PermissionRequestEvent) + assert successor.tool_use_id == "tool-3" + assert successor.continuation_frame is not None + assert successor.continuation_frame["decisions"][0]["state"] == "deny" + assert successor.continuation_frame["decisions"][0]["source"] == "policy" + assert successor.continuation_frame["decisions"][1]["state"] == "deny" + assert successor.continuation_frame["decisions"][1]["source"] == "policy" + assert audit_calls == [("tool-1", "deny"), ("tool-2", "deny")] + assert successor.response_future is not None + successor.response_future.set_result(PermissionWaitOutcome.SUSPEND) + with pytest.raises(PermissionWaitSuspended): + await anext(first_stream) + + tool.first_behavior = "allow" + tool.second_behavior = "allow" + second_loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + second_checkpoint = { + "toolUseId": "tool-3", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "third"}}), + "principalRef": None, + "region": None, + "decision": {"status": "claimed", "value": "deny", "claimId": "claim-3"}, + "continuationFrame": successor.continuation_frame, + } + + events = [event async for event in second_loop.resume_permission_boundary(second_checkpoint)] + + assert tool.values == [] + first_result = next( + event for event in events if isinstance(event, ToolResultEvent) and event.tool_use_id == "tool-1" + ) + second_result = next( + event for event in events if isinstance(event, ToolResultEvent) and event.tool_use_id == "tool-2" + ) + assert first_result.is_error is True + assert second_result.is_error is True + + +@pytest.mark.asyncio +async def test_live_successor_frame_records_each_user_approval_identity(monkeypatch) -> None: + class TwoToolProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="tool-1", name="write_test") + yield ToolUseEndEvent(tool_use_id="tool-1", name="write_test", input={"value": "first"}) + yield ToolUseStartEvent(tool_use_id="tool-2", name="write_test") + yield ToolUseEndEvent(tool_use_id="tool-2", name="write_test", input={"value": "second"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + tool = WriteTool() + registry = ToolRegistry() + registry.register(tool) + loop = AgentLoop( + provider_manager=TwoToolProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + ) + monkeypatch.setattr( + "iac_code.agent.agent_loop.permission_execution_identity", + lambda **kwargs: ( + "principal-{}".format(kwargs["tool_input"]["value"]), + "region-{}".format(kwargs["tool_input"]["value"]), + ), + ) + permission_events: list[PermissionRequestEvent] = [] + + async for event in loop.run_streaming("write twice"): + if not isinstance(event, PermissionRequestEvent): + continue + permission_events.append(event) + assert event.response_future is not None + if len(permission_events) == 1: + event.boundary_id = "pwb_firstboundary" + event.response_future.set_result(True) + else: + assert event.continuation_frame is not None + assert event.continuation_frame["decisions"][0] == { + "toolUseId": "tool-1", + "state": "allow", + "source": "user", + "deniedResult": None, + "principalRef": "principal-first", + "region": "region-first", + } + event.response_future.set_result(False) + + assert len(permission_events) == 2 + + +@pytest.mark.asyncio +async def test_permission_checkpoint_digest_uses_raw_transcript_input_before_prepare() -> None: + class PreparedTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.executed_input: dict | None = None + + def prepare_invocation_input(self, tool_input: dict) -> dict: + return {**tool_input, "region_id": "cn-prepared"} + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.executed_input = tool_input + return ToolResult.success("ok") + + class OneToolProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="tool-1", name="write_test") + yield ToolUseEndEvent(tool_use_id="tool-1", name="write_test", input={"value": "raw"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + yield MessageStartEvent(message_id="continued") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + tool = PreparedTool() + registry = ToolRegistry() + registry.register(tool) + live_loop = AgentLoop( + provider_manager=OneToolProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + ) + permission_event = None + with pytest.raises(PermissionWaitSuspended): + async for event in live_loop.run_streaming("write"): + if isinstance(event, PermissionRequestEvent): + permission_event = event + assert event.response_future is not None + event.response_future.set_result(PermissionWaitOutcome.SUSPEND) + assert permission_event is not None + assert permission_event.tool_input == {"value": "raw", "region_id": "cn-prepared"} + assert permission_event.continuation_frame is not None + raw_payload_digest = canonical_digest({"name": "write_test", "input": {"value": "raw"}}) + assert permission_event.continuation_frame["currentPayloadDigest"] == raw_payload_digest + + checkpoint = build_permission_checkpoint( + session_id="session-1", + task_id=None, + context_id="context-1", + input_id="input-1", + tool_use_id="tool-1", + tool_name="write_test", + tool_input=permission_event.tool_input, + permission_class="normal", + continuation_frame=permission_event.continuation_frame, + policy=PermissionWaitPolicy(), + ) + checkpoint["decision"] = {"status": "claimed", "value": "allow_once", "claimId": "claim-1"} + assert checkpoint["payloadDigest"] == raw_payload_digest + + assistant = Message( + role="assistant", + content=[ToolUseBlock(id="tool-1", name="write_test", input={"value": "raw"})], + ) + resumed_loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[Message(role="user", content="write"), assistant], + ) + _events = [event async for event in resumed_loop.resume_permission_boundary(checkpoint)] + + assert tool.executed_input == {"value": "raw", "region_id": "cn-prepared"} + + +@pytest.mark.asyncio +async def test_permission_recovery_after_tool_injected_messages_uses_persisted_transcript(tmp_path) -> None: + class InjectingTool(Tool): + @property + def name(self) -> str: + return "inject_skill" + + @property + def description(self) -> str: + return "Inject skill instructions." + + @property + def input_schema(self) -> dict: + return {"type": "object", "properties": {}} + + async def check_permissions(self, input: dict, context=None) -> PermissionResult: + return PermissionResult(behavior="allow") + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + return ToolResult( + content="skill loaded", + new_messages=[{"role": "user", "content": "persisted instructions"}], + ) + + class RecordingWriteTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.execution_count = 0 + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.execution_count += 1 + return ToolResult( + content="wrote {}".format(tool_input["value"]), + new_messages=[{"role": "user", "content": "post-approval instructions"}], + ) + + class TwoTurnProvider: + def __init__(self) -> None: + self.calls = 0 + + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + self.calls += 1 + if self.calls == 1: + yield MessageStartEvent(message_id="load-skill") + yield ToolUseStartEvent(tool_use_id="tool-skill", name="inject_skill") + yield ToolUseEndEvent(tool_use_id="tool-skill", name="inject_skill", input={}) + else: + yield MessageStartEvent(message_id="write") + yield ToolUseStartEvent(tool_use_id="tool-write", name="write_test") + yield ToolUseEndEvent(tool_use_id="tool-write", name="write_test", input={"value": "ok"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="continued") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + workspace = tmp_path / "workspace" + workspace.mkdir() + cwd = str(workspace) + session_id = "session-with-injected-messages" + storage = SessionStorage(projects_dir=tmp_path / "projects") + write_tool = RecordingWriteTool() + registry = ToolRegistry() + registry.register(InjectingTool()) + registry.register(write_tool) + live_loop = AgentLoop( + provider_manager=TwoTurnProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=2, + session_storage=storage, + session_id=session_id, + cwd=cwd, + ) + + permission_event = None + with pytest.raises(PermissionWaitSuspended): + async for event in live_loop.run_streaming("run"): + if isinstance(event, PermissionRequestEvent): + permission_event = event + assert event.response_future is not None + event.response_future.set_result(PermissionWaitOutcome.SUSPEND) + + assert permission_event is not None + assert permission_event.continuation_frame is not None + persisted = storage.load(cwd, session_id) + assert [message.role for message in persisted] == ["user", "assistant", "user", "user", "assistant"] + assert persisted[3].content == "persisted instructions" + assert permission_event.continuation_frame["assistantMessageRef"] == "session.jsonl:4" + assert len(persisted) == len(live_loop.context_manager.get_messages()) + + checkpoint = build_permission_checkpoint( + session_id=session_id, + task_id="task-1", + context_id="context-1", + input_id="input-1", + tool_use_id=permission_event.tool_use_id, + tool_name=permission_event.tool_name, + tool_input=permission_event.tool_input, + permission_class="normal", + continuation_frame=permission_event.continuation_frame, + policy=PermissionWaitPolicy(), + ) + checkpoint["decision"] = {"status": "claimed", "value": "allow_once", "claimId": "claim-1"} + resumed_loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + session_storage=storage, + session_id=session_id, + cwd=cwd, + resume_messages=persisted, + ) + + _events = [event async for event in resumed_loop.resume_permission_boundary(checkpoint)] + + assert write_tool.execution_count == 1 + persisted_after_resume = storage.load(cwd, session_id) + assert persisted_after_resume[-1].content == "post-approval instructions" + assert len(persisted_after_resume) == len(resumed_loop.context_manager.get_messages()) + + +@pytest.mark.asyncio +async def test_resume_permission_boundary_fails_closed_when_secondary_audits_fail(monkeypatch) -> None: + secondary_audit = PermissionAuditMetadata(scope="path_constraint", source="permission_pipeline") + + class RecordingTool(WriteTool): + def __init__(self) -> None: + super().__init__() + self.values: list[str] = [] + + async def check_permissions(self, input: dict, context: dict | None = None) -> PermissionResult: + return PermissionResult( + behavior="ask", + message="Allow write?", + audit_items=(secondary_audit,), + ) + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + self.values.append(tool_input["value"]) + return ToolResult.success("ok") + + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + yield MessageStartEvent(message_id="continued") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + tool = RecordingTool() + registry = ToolRegistry() + registry.register(tool) + tool_uses = [ + ToolUseBlock(id="tool-1", name="write_test", input={"value": "first"}), + ToolUseBlock(id="tool-2", name="write_test", input={"value": "second"}), + ] + assistant = Message(role="assistant", content=tool_uses) + loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + checkpoint = { + "toolUseId": "tool-1", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "first"}}), + "principalRef": None, + "region": None, + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-1"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id for tool_use in tool_uses], + "currentIndex": 0, + "decisions": [ + {"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}, + {"toolUseId": "tool-2", "state": "not_evaluated", "source": None, "deniedResult": None}, + ], + }, + } + audit_calls: list[tuple[str, str]] = [] + + def fail_secondary_audit(**kwargs) -> bool: + audit_calls.append((kwargs["request"].id, kwargs["decision"])) + return False + + monkeypatch.setattr("iac_code.agent.agent_loop._emit_permission_audit_items", fail_secondary_audit) + events = [] + async for event in loop.resume_permission_boundary(checkpoint): + events.append(event) + if isinstance(event, PermissionRequestEvent): + assert event.tool_use_id == "tool-2" + assert event.response_future is not None + event.response_future.set_result(True) + + assert audit_calls == [("tool-1", "allow"), ("tool-2", "allow")] + assert tool.values == [] + assert all( + event.is_error + for event in events + if isinstance(event, ToolResultEvent) and event.tool_use_id in {"tool-1", "tool-2"} + ) + + +@pytest.mark.asyncio +async def test_resume_permission_boundary_rejects_unavailable_current_tool() -> None: + assistant = Message( + role="assistant", + content=[ToolUseBlock(id="tool-1", name="removed_tool", input={"value": "raw"})], + ) + loop = AgentLoop( + provider_manager=FakeProviderManager(), + system_prompt="system", + tool_registry=ToolRegistry(), + max_turns=1, + resume_messages=[assistant], + ) + checkpoint = { + "toolUseId": "tool-1", + "payloadDigest": canonical_digest({"name": "removed_tool", "input": {"value": "raw"}}), + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-1"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}], + }, + } + + with pytest.raises(ValueError, match="current tool is unavailable"): + async for _event in loop.resume_permission_boundary(checkpoint): + pass + + +@pytest.mark.asyncio +async def test_resume_permission_boundary_rejects_changed_cloud_identity(monkeypatch) -> None: + class ContinueProvider: + def get_model_name(self) -> str: + return "fake" + + async def stream(self, messages, system, tools=None): + raise AssertionError("identity mismatch must fail before provider continuation") + yield + + tool = WriteTool() + registry = ToolRegistry() + registry.register(tool) + assistant = Message( + role="assistant", + content=[ToolUseBlock(id="tool-1", name="write_test", input={"value": "first"})], + ) + digest = canonical_digest([block.model_dump(mode="json") for block in assistant.content]) + loop = AgentLoop( + provider_manager=ContinueProvider(), + system_prompt="system", + tool_registry=registry, + max_turns=1, + resume_messages=[assistant], + ) + checkpoint = { + "toolUseId": "tool-1", + "payloadDigest": canonical_digest({"name": "write_test", "input": {"value": "first"}}), + "principalRef": "aliyun:original", + "region": "cn-shanghai", + "decision": {"status": "claimed", "value": "allow_once", "claimId": "claim-1"}, + "continuationFrame": { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": digest, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}], + }, + } + monkeypatch.setattr( + "iac_code.agent.agent_loop.permission_execution_identity", + lambda **_kwargs: ("aliyun:changed", "cn-shanghai"), + ) + + with pytest.raises(ValueError, match="cloud execution identity changed"): + async for _event in loop.resume_permission_boundary(checkpoint): + pass + + assert tool.executed is False diff --git a/tests/agent/test_permission_audit_integration.py b/tests/agent/test_permission_audit_integration.py index a17e980a..4c63a61d 100644 --- a/tests/agent/test_permission_audit_integration.py +++ b/tests/agent/test_permission_audit_integration.py @@ -252,6 +252,8 @@ async def test_agent_loop_prompt_event_carries_internal_audit_context(tmp_path): "cwd": str(tmp_path), "settings": settings, "metadata": metadata, + "principal_ref": None, + "region": None, } @@ -430,6 +432,8 @@ async def test_agent_loop_prompt_event_carries_transcript_audit_context(tmp_path "cwd": str(tmp_path), "settings": settings, "metadata": metadata, + "principal_ref": None, + "region": None, "audit_log_path": str(audit_log_path), } @@ -782,6 +786,68 @@ def fake_emit(record, settings=None): assert settings_seen == [settings] +@pytest.mark.asyncio +async def test_restart_audit_event_rebuilds_current_metadata_settings_and_rejects_snapshot(tmp_path): + metadata = _audit_metadata( + scope="settings_rule", + rule_source="project_settings", + rule="fake_permission(payload)", + reason_detail="current rule", + ) + settings = PermissionAuditSettings(include_tool_input=True, max_file_bytes=321, max_files=3) + + class PreparedPermissionTool(FakePermissionTool): + def prepare_invocation_input(self, tool_input: dict[str, Any]) -> dict[str, Any]: + return {**tool_input, "region_id": "cn-current"} + + registry = ToolRegistry() + registry.register( + PreparedPermissionTool( + PermissionResult( + behavior="ask", + audit=metadata, + snapshot_id="snapshot-audit-only", + ) + ) + ) + loop = AgentLoop( + provider_manager=FakeProvider([]), + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + session_id="session-restart-audit", + permission_context=ToolPermissionContext(cwd=str(tmp_path), audit_settings=settings), + ) + rejected: list[str | None] = [] + original_reject = loop._reject_owned_contract_snapshot + + def capture_reject(snapshot_id: str | None) -> None: + rejected.append(snapshot_id) + original_reject(snapshot_id) + + loop._reject_owned_contract_snapshot = capture_reject + + event = await loop.rebuild_permission_audit_event( + tool_name="fake_permission", + tool_input={"payload": "raw"}, + tool_use_id="tool-restart", + audit_context={ + "session_id": "session-restart-audit", + "cwd": str(tmp_path), + "audit_log_path": str(tmp_path / "canonical-audit.jsonl"), + }, + ) + + assert event.tool_input == {"payload": "raw", "region_id": "cn-current"} + assert event.permission_result is not None + assert event.permission_result.audit is metadata + assert event.audit_context["metadata"] is metadata + assert event.audit_context["settings"] is settings + assert event.audit_context["audit_log_path"] == str(tmp_path / "canonical-audit.jsonl") + assert rejected == ["snapshot-audit-only"] + assert "snapshot-audit-only" not in loop._owned_contract_snapshot_ids + + @pytest.mark.asyncio async def test_agent_loop_audits_no_prompt_deny_with_audit_metadata(monkeypatch, tmp_path): events, records, _settings_seen = await _run_fake_tool_with_audit( diff --git a/tests/cli/test_a2a_command.py b/tests/cli/test_a2a_command.py index 56a5e9e7..500e2462 100644 --- a/tests/cli/test_a2a_command.py +++ b/tests/cli/test_a2a_command.py @@ -103,6 +103,7 @@ def fake_run_server( push_consumer_name: str | None, push_lease_timeout_ms: int, auto_approve_permissions: bool, + permission_wait: object | None, thinking_exposure: list[str] | None, idle_shutdown_seconds: float, ) -> None: @@ -138,6 +139,7 @@ def fake_run_server( "push_consumer_name": push_consumer_name, "push_lease_timeout_ms": push_lease_timeout_ms, "auto_approve_permissions": auto_approve_permissions, + "permission_wait": permission_wait, "thinking_exposure": thinking_exposure, "idle_shutdown_seconds": idle_shutdown_seconds, } @@ -222,6 +224,7 @@ def fake_run_server( "push_consumer_name": "worker-a", "push_lease_timeout_ms": 120000, "auto_approve_permissions": True, + "permission_wait": None, "thinking_exposure": ["raw-thinking", "tool-trace"], "idle_shutdown_seconds": 1800, } diff --git a/tests/conftest.py b/tests/conftest.py index 1c0d1b5c..2f49a2e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,10 +21,6 @@ # var to exercise that default. os.environ.setdefault("IAC_CODE_A2A_EXTREME_PERFORMANCE", "0") -# Tests must never touch the developer's OS keychain. Explicit fake keyring -# backends still exercise keyring behavior where needed. -os.environ.setdefault("IAC_CODE_MCP_DISABLE_KEYRING", "1") - # Re-initialize i18n with English locale from iac_code.i18n import setup_i18n # noqa: E402 diff --git a/tests/desktop/test_controller.py b/tests/desktop/test_controller.py index 07d87929..7a16457f 100644 --- a/tests/desktop/test_controller.py +++ b/tests/desktop/test_controller.py @@ -5,6 +5,11 @@ import pytest from iac_code.desktop.controller import DesktopRuntimeController +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitPolicy, + build_permission_checkpoint, +) from iac_code.web.session_manager import WebSessionManager @@ -58,3 +63,51 @@ def cancel(self) -> None: assert events == ["cooperative-cancel", "task-cancel"] assert controller.committed_shutdown is True + + +def test_close_state_counts_suspended_permission_as_awaiting_not_active(tmp_path) -> None: + project = tmp_path / "project" + project.mkdir() + projects = tmp_path / "sessions" + manager = WebSessionManager(projects_dir=projects, cwd=project) + session = manager.create_session(session_id="desktop-permission-wait") + store = PermissionWaitCheckpointStore(session.cwd, session.session_id, storage=manager.storage) + record = store.create( + build_permission_checkpoint( + session_id=session.session_id, + task_id=None, + context_id=session.web_session_id, + input_id="permission-desktop", + tool_use_id="tool-desktop", + tool_name="aliyun_api", + tool_input={"action": "CreateStack"}, + permission_class="normal", + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-desktop"], + "currentIndex": 0, + "decisions": [ + { + "toolUseId": "tool-desktop", + "state": "pending", + "source": None, + "deniedResult": None, + } + ], + }, + policy=PermissionWaitPolicy(), + ) + ) + store.mark_suspended(record["boundaryId"]) + + restarted = WebSessionManager(projects_dir=projects, cwd=project) + restarted.create_session(session_id=session.session_id) + controller = DesktopRuntimeController(restarted, project) + + assert controller.close_state() == { + "type": "close-state", + "activeWorkCount": 0, + "awaitingUserInputCount": 1, + "quiescing": False, + } diff --git a/tests/mcp/test_manager.py b/tests/mcp/test_manager.py index 4fb55401..1664aa6b 100644 --- a/tests/mcp/test_manager.py +++ b/tests/mcp/test_manager.py @@ -356,6 +356,7 @@ async def test_handle_list_changed_refreshes_discovery_cache() -> None: client = FakeClient(tools=[{"name": "first", "inputSchema": {"type": "object"}}]) manager = MCPManager([scoped], client_factory=lambda config: client) await manager.connect_all() + initial_status_revision = manager.status_revision client.tools = [{"name": "second", "description": "Second", "inputSchema": {"type": "object"}}] await manager.handle_list_changed("ros", capability="tools") @@ -367,6 +368,7 @@ async def test_handle_list_changed_refreshes_discovery_cache() -> None: assert record.latest_refresh_at is not None assert record.latest_refresh_failure_reason is None assert record.metadata is not None + assert manager.status_revision > initial_status_revision assert manager.status_metadata() == { "servers": [ { diff --git a/tests/mcp/test_storage.py b/tests/mcp/test_storage.py index 38ea4bd8..1c526a33 100644 --- a/tests/mcp/test_storage.py +++ b/tests/mcp/test_storage.py @@ -1,14 +1,35 @@ from __future__ import annotations import hashlib +import sys import threading from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace import iac_code.mcp.storage as storage_module from iac_code.mcp.storage import MCPSecretStorage, _safe_lock_name +def test_default_secret_store_never_uses_system_keyring(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + keyring_calls: list[str] = [] + fake_keyring = SimpleNamespace( + set_password=lambda *_args: keyring_calls.append("set"), + get_password=lambda *_args: keyring_calls.append("get"), + delete_password=lambda *_args: keyring_calls.append("delete"), + ) + monkeypatch.setitem(sys.modules, "keyring", fake_keyring) + + storage = MCPSecretStorage() + storage.set_secret("mcp:access_token:test", "secret-token") + + assert storage.get_secret("mcp:access_token:test") == "secret-token" + assert keyring_calls == [] + stored_bytes = (tmp_path / "config" / "mcp" / "secrets.json.enc").read_bytes() + assert b"secret-token" not in stored_bytes + + def test_fallback_secret_store_uses_lock_for_file_io(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("IAC_CODE_MCP_DISABLE_KEYRING", "1") @@ -42,8 +63,8 @@ def test_safe_lock_name_does_not_use_plain_sha256() -> None: def test_storage_lock_serializes_storage_instances_in_process(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) - first_storage = MCPSecretStorage(keyring_backend=False) - second_storage = MCPSecretStorage(keyring_backend=False) + first_storage = MCPSecretStorage() + second_storage = MCPSecretStorage() first_entered = threading.Event() second_attempted = threading.Event() second_entered = threading.Event() diff --git a/tests/mcp/test_types.py b/tests/mcp/test_types.py index 89c678d8..71a288c3 100644 --- a/tests/mcp/test_types.py +++ b/tests/mcp/test_types.py @@ -1,5 +1,6 @@ import pytest +import iac_code.mcp.types as types_module from iac_code.mcp.types import ( MCPConfigError, MCPConfigScope, @@ -15,6 +16,26 @@ ) +def test_content_signature_derivation_is_cached(monkeypatch) -> None: + types_module._content_signature_digest.cache_clear() + calls = 0 + original = types_module.hashlib.pbkdf2_hmac + + def counting_pbkdf2_hmac(*args, **kwargs): + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(types_module.hashlib, "pbkdf2_hmac", counting_pbkdf2_hmac) + config = MCPServerConfig.from_mapping("terraform", {"command": "uvx", "args": ["terraform-mcp-server"]}) + + first = config.content_signature() + second = config.content_signature() + + assert first == second + assert calls == 1 + + def test_stdio_config_defaults_to_stdio_when_command_is_present() -> None: config = MCPServerConfig.from_mapping( "terraform", diff --git a/tests/pipeline/engine/test_pipeline_runner.py b/tests/pipeline/engine/test_pipeline_runner.py index 18efe46f..277740dd 100644 --- a/tests/pipeline/engine/test_pipeline_runner.py +++ b/tests/pipeline/engine/test_pipeline_runner.py @@ -10,7 +10,7 @@ import pytest import yaml -from iac_code.agent.message import Message, ToolResultBlock, create_compaction_summary_message +from iac_code.agent.message import Message, ToolResultBlock, ToolUseBlock, create_compaction_summary_message from iac_code.mcp.types import ( MCPConfigScope, MCPConnectionMetadata, @@ -27,6 +27,7 @@ from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage from iac_code.pipeline.engine.types import StepResult, StepStatus from iac_code.services.context_manager import ContextManager +from iac_code.services.permission_wait import RecoveredPermissionAuditBoundary from iac_code.services.session_backup import BackupReason, BackupResult, SessionBackupBlocked from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import ResourceObservedEvent @@ -453,6 +454,90 @@ def test_parent_attempt_created_on_step_start(tmp_path): assert runner._execution["active_attempt_id"] == "att_0001" +@pytest.mark.asyncio +async def test_rebuild_permission_audit_event_uses_exact_parent_attempt_and_unrepaired_transcript(tmp_path): + storage = DirectorySessionStorage(tmp_path / "projects") + runner = _build_two_step_runner(tmp_path, storage=storage, surface="a2a") + attempt = runner._ensure_parent_attempt("a") + runner._step_attempts["a"] = 2 + messages = [Message(role="assistant", content=[ToolUseBlock(id="tool-1", name="write_test", input={})])] + assert runner._transcript_storage is not None + runner._transcript_storage.save(str(tmp_path), attempt["transcript_id"], messages) + expected_event = object() + captured = {} + + class AuditAgentLoop: + async def rebuild_permission_audit_event(self, **kwargs): + captured["audit_kwargs"] = kwargs + return expected_event + + def build_agent_loop_context(step, context, session_id, **kwargs): + captured.update(step=step, context=context, session_id=session_id, build_kwargs=kwargs) + return types.SimpleNamespace(agent_loop=AuditAgentLoop()) + + runner._step_executor.build_agent_loop_context = build_agent_loop_context + recovered = RecoveredPermissionAuditBoundary( + tool_name="write_test", + tool_input={"value": "raw"}, + tool_use_id="tool-1", + audit_context={"transcript_id": attempt["transcript_id"]}, + ) + checkpoint = { + "pipelineCoordinates": { + "step": {"id": "a", "runId": "step-a-2", "attempt": 2}, + } + } + + event = await runner.rebuild_permission_audit_event(checkpoint, recovered) + + assert event is expected_event + assert captured["step"] is runner.state_machine.current_step + assert captured["context"] is runner.context + assert captured["session_id"] == "test" + assert captured["build_kwargs"] == { + "attempt_id": attempt["attempt_id"], + "transcript_id": attempt["transcript_id"], + "resume_messages": messages, + } + assert captured["audit_kwargs"] == { + "tool_name": "write_test", + "tool_input": {"value": "raw"}, + "tool_use_id": "tool-1", + "audit_context": {"transcript_id": attempt["transcript_id"]}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mutation", + [ + lambda runner, attempt: runner._execution.update(transcript_id="transcript-other"), + lambda runner, attempt: runner._attempts["items"][attempt["attempt_id"]].update(status="completed"), + lambda runner, attempt: runner._step_attempts.update(a=3), + ], +) +async def test_rebuild_permission_audit_event_rejects_changed_pipeline_identity(tmp_path, mutation): + storage = DirectorySessionStorage(tmp_path / "projects") + runner = _build_two_step_runner(tmp_path, storage=storage, surface="a2a") + attempt = runner._ensure_parent_attempt("a") + runner._step_attempts["a"] = 2 + assert runner._transcript_storage is not None + runner._transcript_storage.save(str(tmp_path), attempt["transcript_id"], [Message(role="assistant", content="x")]) + recovered = RecoveredPermissionAuditBoundary( + tool_name="write_test", + tool_input={}, + tool_use_id="tool-1", + audit_context={"transcript_id": attempt["transcript_id"]}, + ) + mutation(runner, attempt) + + with pytest.raises(ValueError, match="permission_resume_invalid"): + await runner.rebuild_permission_audit_event( + {"pipelineCoordinates": {"step": {"id": "a", "runId": "step-a-2", "attempt": 2}}}, + recovered, + ) + + def test_rollback_to_same_step_creates_new_attempt(tmp_path): runner = _build_two_step_runner(tmp_path) @@ -694,6 +779,44 @@ def test_pipeline_runner_mcp_status_uses_live_warning_list(tmp_path): assert event.data["mcp_status"]["warnings"][0]["code"] == "prompts_failed" +def test_pipeline_runner_rebuilds_mcp_status_only_after_revision_changes(tmp_path): + state = {"value": MCPConnectionState.FAILED} + list_connections_calls = 0 + + def list_connections(): + nonlocal list_connections_calls + list_connections_calls += 1 + return [ + SimpleNamespace( + name="remote", + state=state["value"], + error="initial failure" if state["value"] is MCPConnectionState.FAILED else None, + capability_errors={}, + tools=[], + resources=[], + prompts=[], + retry_count=0, + metadata=None, + ) + ] + + manager = SimpleNamespace(status_revision=0, list_connections=list_connections) + runner = _build_two_step_runner(tmp_path, mcp_manager=manager) + + assert runner._mcp_status_event(force=True) is not None + for _ in range(2_000): + assert runner._mcp_status_event() is None + assert list_connections_calls == 1 + + state["value"] = MCPConnectionState.CONNECTED + manager.status_revision += 1 + event = runner._mcp_status_event() + + assert event is not None + assert event.data["mcp_status"]["servers"][0]["state"] == "connected" + assert list_connections_calls == 2 + + @pytest.mark.asyncio async def test_pipeline_runner_emits_mcp_status_update_when_state_changes_during_run(tmp_path, monkeypatch): state = {"value": MCPConnectionState.FAILED, "error": "initial failure"} @@ -2242,6 +2365,42 @@ async def fake_execute(step, context, session_id, user_message=None, **kwargs): ) +@pytest.mark.asyncio +async def test_permission_checkpoint_resume_bypasses_interrupted_transcript_repair(tmp_path, monkeypatch): + storage = DirectorySessionStorage(tmp_path / "projects") + runner = _build_two_step_runner(tmp_path, storage=storage) + resume_messages = [Message(role="assistant", content="pending tool batch")] + checkpoint = {"boundaryId": "pwb_checkpoint", "phase": "RESTORING"} + captured = {} + + def fail_repair(_messages): + raise AssertionError("permission recovery must not synthesize interrupted tool results") + + assert runner._transcript_storage is not None + monkeypatch.setattr(runner._transcript_storage, "repair_interrupted", fail_repair) + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + captured["resume_messages"] = kwargs["resume_messages"] + captured["permission_checkpoint"] = kwargs["permission_checkpoint"] + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion={"ok": True}) + + runner._step_executor.execute = fake_execute + stream = runner._continue_from_current( + resume_messages=resume_messages, + resume_running_step=True, + permission_checkpoint=checkpoint, + ) + try: + async for _event in stream: + if captured: + break + finally: + await stream.aclose() + + assert captured["resume_messages"] == resume_messages + assert captured["permission_checkpoint"] is checkpoint + + def test_fresh_runner_after_terminal_sidecar_allocates_new_attempt(tmp_path): initial_runner = _build_two_step_runner(tmp_path) storage = DirectorySessionStorage(tmp_path / "projects") diff --git a/tests/services/permissions/test_pipeline.py b/tests/services/permissions/test_pipeline.py index bb50b1d6..72208c99 100644 --- a/tests/services/permissions/test_pipeline.py +++ b/tests/services/permissions/test_pipeline.py @@ -655,6 +655,105 @@ async def test_bare_aliyun_api_allow_does_not_auto_allow_aliyun_write(self): assert r.audit.operation["action"] == "CreateStack" assert r.audit.is_read_only is False + @pytest.mark.asyncio + async def test_bare_aliyun_api_ask_does_not_prompt_for_read_only_action(self): + r = await check_tool_permission( + AliyunApi(), + {"product": "ecs", "action": "DescribeAvailableResource"}, + _ctx(ask={"user_settings": ["aliyun_api"]}), + ) + + assert r.behavior == "allow" + assert r.reason is not None + assert r.reason.type == "read_only" + assert r.audit is not None + assert r.audit.scope == "read_only" + assert r.audit.operation["action"] == "DescribeAvailableResource" + assert r.audit.is_read_only is True + + @pytest.mark.asyncio + async def test_bare_aliyun_api_ask_still_prompts_for_write_action(self): + r = await check_tool_permission( + AliyunApi(), + {"product": "ros", "action": "CreateStack"}, + _ctx(ask={"user_settings": ["aliyun_api"]}), + ) + + assert r.behavior == "ask" + assert r.audit is not None + assert r.audit.rule_source == "user_settings" + assert r.audit.operation["action"] == "CreateStack" + assert r.audit.is_read_only is False + + @pytest.mark.asyncio + async def test_bare_operation_scoped_ask_does_not_prompt_for_read_only_action(self): + read_audit = PermissionAuditMetadata( + scope="read_only", + source="tool", + reason_type="read_only", + is_read_only=True, + operation={"product": "ros", "action": "GetTemplate"}, + ) + result = PermissionResult( + behavior="allow", + reason=PermissionDecisionReason(type="read_only", detail="read-only operation"), + audit=read_audit, + ) + + r = await check_tool_permission( + RuntimeOperationScopedResultTool(result), + {"action": "GetTemplate"}, + _ctx(ask={"user_settings": ["ros_template"]}), + ) + + assert r is result + + @pytest.mark.asyncio + async def test_bare_operation_scoped_ask_still_prompts_for_write_action(self): + write_audit = PermissionAuditMetadata( + scope="once", + source="tool", + is_read_only=False, + operation={"product": "ros", "action": "UpdateTemplate"}, + ) + result = PermissionResult(behavior="allow", audit=write_audit) + + r = await check_tool_permission( + RuntimeOperationScopedResultTool(result), + {"action": "UpdateTemplate"}, + _ctx(ask={"user_settings": ["ros_template"]}), + ) + + assert r.behavior == "ask" + assert r.audit is not None + assert r.audit.rule == "ros_template" + assert r.audit.is_read_only is False + + @pytest.mark.asyncio + async def test_explicit_non_cloud_auto_allow_keeps_only_non_read_only_aliyun_api_interactive(self): + ctx = _ctx( + allow={"user_settings": ["read_file", "write_file"]}, + ask={"user_settings": ["aliyun_api"]}, + ) + + assert (await check_tool_permission(FakeReadTool(), {}, ctx)).behavior == "allow" + assert (await check_tool_permission(FakeWriteTool(), {}, ctx)).behavior == "allow" + read_only = await check_tool_permission( + AliyunApi(), + {"product": "ecs", "action": "DescribeAvailableResource"}, + ctx, + ) + cloud_write = await check_tool_permission( + AliyunApi(), + {"product": "ros", "action": "CreateStack"}, + ctx, + ) + + assert read_only.behavior == "allow" + assert read_only.audit is not None and read_only.audit.is_read_only is True + assert cloud_write.behavior == "ask" + assert cloud_write.audit is not None and cloud_write.audit.is_read_only is False + @pytest.mark.asyncio async def test_bypass_mode_allows(self): ctx = _ctx(mode=PermissionMode.BYPASS_PERMISSIONS) @@ -725,7 +824,29 @@ async def test_operation_scoped_bypass_preserves_explicit_write_rule_audit(self) assert r.audit is rule_audit @pytest.mark.asyncio - async def test_operation_scoped_bypass_preserves_sticky_safety_ask(self): + async def test_operation_scoped_bypass_preserves_explicit_ask_rule(self): + rule_audit = PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + rule="create:run-stack", + reason_type="rule", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack"}, + ) + reason = PermissionDecisionReason(type="rule", detail="matched ask rule") + result = PermissionResult(behavior="ask", reason=reason, audit=rule_audit) + + r = await check_tool_permission( + RuntimeOperationScopedResultTool(result), + {"action": "CreateStack"}, + _ctx(mode=PermissionMode.BYPASS_PERMISSIONS), + ) + + assert r is result + + @pytest.mark.asyncio + async def test_operation_scoped_bypass_allows_sticky_safety_ask_without_explicit_rule(self): reason = PermissionDecisionReason(type="path_constraint", detail="path_constraint") r = await check_tool_permission( RuntimeOperationScopedResultTool(PermissionResult(behavior="ask", reason=reason)), @@ -733,8 +854,24 @@ async def test_operation_scoped_bypass_preserves_sticky_safety_ask(self): _ctx(mode=PermissionMode.BYPASS_PERMISSIONS), ) - assert r.behavior == "ask" - assert r.reason is reason + assert r.behavior == "allow" + assert r.reason is None + assert r.audit is not None + assert r.audit.reason_type == "bypass_permissions" + + @pytest.mark.asyncio + async def test_bypass_mode_allows_non_cloud_safety_check(self): + reason = PermissionDecisionReason(type="safety_check", detail="safety_check") + r = await check_tool_permission( + RuntimeResultTool(PermissionResult(behavior="ask", reason=reason)), + {}, + _ctx(mode=PermissionMode.BYPASS_PERMISSIONS), + ) + + assert r.behavior == "allow" + assert r.reason is None + assert r.audit is not None + assert r.audit.reason_type == "bypass_permissions" @pytest.mark.parametrize( ("tool", "tool_input"), diff --git a/tests/services/test_permission_wait.py b/tests/services/test_permission_wait.py new file mode 100644 index 00000000..adb4fb66 --- /dev/null +++ b/tests/services/test_permission_wait.py @@ -0,0 +1,911 @@ +from __future__ import annotations + +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta + +import pytest + +from iac_code.agent.message import Message, ToolUseBlock +from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage +from iac_code.services.permission_wait import ( + PermissionWaitCheckpointStore, + PermissionWaitCoordinator, + PermissionWaitPolicy, + build_permission_checkpoint, + canonical_digest, + canonicalize_permission_continuation_frame, + format_utc, + permission_execution_identity, + recover_permission_audit_boundary, + utc_now, +) +from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials +from iac_code.services.session_layout import SessionPaths +from iac_code.services.session_storage import SessionStorage +from iac_code.types.stream_events import PermissionWaitOutcome + + +def _store(tmp_path) -> PermissionWaitCheckpointStore: + storage = SessionStorage(projects_dir=tmp_path / "projects") + storage.ensure_v2_session_dir_for_new_session("/workspace", "session-1") + return PermissionWaitCheckpointStore("/workspace", "session-1", storage=storage) + + +def _record(store: PermissionWaitCheckpointStore, policy: PermissionWaitPolicy, **overrides): + values = { + "session_id": "session-1", + "task_id": "task-1", + "context_id": "context-1", + "input_id": "input-1", + "tool_use_id": "tool-1", + "tool_name": "aliyun_api", + "tool_input": {"api": "CreateStack"}, + "permission_class": "normal", + "continuation_frame": { + "assistantMessageRef": "session.jsonl:1", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None}], + }, + "policy": policy, + } + values.update(overrides) + record = build_permission_checkpoint(**values) + return store.create(record) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, (None, None, 30.0)), + ({}, (None, None, 30.0)), + ( + { + "resident_timeout_seconds": 300, + "sub_pipeline_timeout_seconds": 120.5, + "timeout_grace_seconds": 0, + }, + (300.0, 120.5, 0.0), + ), + ], +) +def test_permission_wait_policy_parsing(raw, expected) -> None: + policy = PermissionWaitPolicy.from_config(raw) + assert ( + policy.resident_timeout_seconds, + policy.sub_pipeline_timeout_seconds, + policy.timeout_grace_seconds, + ) == expected + + +@pytest.mark.parametrize( + "raw", + [ + [], + {"unknown": 1}, + {"resident_timeout_seconds": 0}, + {"resident_timeout_seconds": True}, + {"sub_pipeline_timeout_seconds": -1}, + {"timeout_grace_seconds": False}, + {"timeout_grace_seconds": float("inf")}, + ], +) +def test_permission_wait_policy_rejects_invalid_values(raw) -> None: + with pytest.raises(ValueError): + PermissionWaitPolicy.from_config(raw) + + +def test_pipeline_continuation_frame_is_bound_to_canonical_transcript() -> None: + frame = { + "assistantMessageRef": "session.jsonl:3", + "assistantMessageDigest": "a" * 64, + } + + canonical = canonicalize_permission_continuation_frame( + frame, + audit_context={"transcript_id": "transcript_att_0001"}, + ) + + assert canonical["assistantMessageRef"] == "pipeline/transcripts/transcript_att_0001/session.jsonl:3" + assert frame["assistantMessageRef"] == "session.jsonl:3" + with pytest.raises(ValueError, match="transcript context"): + canonicalize_permission_continuation_frame( + canonical, + audit_context={"transcript_id": "transcript_att_0002"}, + ) + + +def test_recover_permission_audit_boundary_reads_exact_pipeline_transcript(tmp_path) -> None: + cwd = "/workspace" + session_id = "session-pipeline" + transcript_id = "transcript_att_0001" + storage = SessionStorage(projects_dir=tmp_path / "projects") + root_session_dir = storage.ensure_v2_session_dir_for_new_session(cwd, session_id) + assert root_session_dir is not None + transcript_storage = PipelineTranscriptStorage(root_session_dir / "pipeline") + transcript_storage.append(cwd, transcript_id, Message(role="user", content="deploy")) + tool_uses = [ + ToolUseBlock(id="tool-read", name="aliyun_api", input={"action": "DescribeVSwitches"}), + ToolUseBlock(id="tool-write", name="aliyun_api", input={"action": "CreateStack"}), + ] + assistant = Message(role="assistant", content=tool_uses) + transcript_storage.append(cwd, transcript_id, assistant) + frame = { + "assistantMessageRef": f"pipeline/transcripts/{transcript_id}/session.jsonl:1", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id for tool_use in tool_uses], + "currentIndex": 1, + "decisions": [ + {"toolUseId": "tool-read", "state": "allow", "source": "policy"}, + {"toolUseId": "tool-write", "state": "pending", "source": None}, + ], + } + record = build_permission_checkpoint( + session_id=session_id, + task_id="task-1", + context_id="context-1", + input_id="input-1", + tool_use_id="tool-write", + tool_name="aliyun_api", + tool_input=tool_uses[1].input, + permission_class="pipeline", + continuation_frame=frame, + policy=PermissionWaitPolicy(), + ) + + recovered = recover_permission_audit_boundary( + record, + cwd=cwd, + session_id=session_id, + storage=storage, + ) + + assert recovered is not None + assert recovered.tool_use_id == "tool-write" + assert recovered.tool_input == {"action": "CreateStack"} + assert recovered.audit_context == { + "session_id": transcript_id, + "cwd": cwd, + "root_session_id": session_id, + "transcript_id": transcript_id, + "audit_log_path": str( + SessionPaths.require_supported(root_session_dir).transcript_permission_audit_path(transcript_id) + ), + } + + changed = {**record, "payloadDigest": "f" * 64} + assert ( + recover_permission_audit_boundary( + changed, + cwd=cwd, + session_id=session_id, + storage=storage, + ) + is None + ) + + +def test_recover_permission_audit_boundary_rejects_symlinked_transcript_parent(tmp_path) -> None: + cwd = "/workspace" + session_id = "session-pipeline-link" + transcript_id = "transcript_link" + storage = SessionStorage(projects_dir=tmp_path / "projects") + root_session_dir = storage.ensure_v2_session_dir_for_new_session(cwd, session_id) + assert root_session_dir is not None + outside_storage = PipelineTranscriptStorage(tmp_path / "outside" / "pipeline") + tool_use = ToolUseBlock(id="tool-write", name="aliyun_api", input={"action": "CreateStack"}) + assistant = Message(role="assistant", content=[tool_use]) + outside_storage.append(cwd, transcript_id, assistant) + outside_dir = outside_storage.session_dir(cwd, transcript_id) + transcripts_dir = root_session_dir / "pipeline" / "transcripts" + transcripts_dir.mkdir(parents=True) + try: + (transcripts_dir / transcript_id).symlink_to(outside_dir, target_is_directory=True) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"directory symlink unavailable: {exc}") + record = build_permission_checkpoint( + session_id=session_id, + task_id="task-1", + context_id="context-1", + input_id="input-1", + tool_use_id=tool_use.id, + tool_name=tool_use.name, + tool_input=tool_use.input, + permission_class="pipeline", + continuation_frame={ + "assistantMessageRef": f"pipeline/transcripts/{transcript_id}/session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id], + "currentIndex": 0, + "decisions": [{"toolUseId": tool_use.id, "state": "pending", "source": None}], + }, + policy=PermissionWaitPolicy(), + ) + + assert ( + recover_permission_audit_boundary( + record, + cwd=cwd, + session_id=session_id, + storage=storage, + ) + is None + ) + + +@pytest.mark.parametrize("pipeline", [False, True]) +def test_recover_permission_audit_boundary_rejects_matching_non_tail_message(tmp_path, pipeline) -> None: + cwd = "/workspace" + session_id = "session-non-tail" + transcript_id = "transcript_att_0001" + storage = SessionStorage(projects_dir=tmp_path / "projects") + root_session_dir = storage.ensure_v2_session_dir_for_new_session(cwd, session_id) + assert root_session_dir is not None + tool_use = ToolUseBlock(id="tool-write", name="write_file", input={"path": "template.yml"}) + assistant = Message(role="assistant", content=[tool_use]) + if pipeline: + transcript_storage = PipelineTranscriptStorage(root_session_dir / "pipeline") + transcript_storage.append(cwd, transcript_id, assistant) + transcript_storage.append(cwd, transcript_id, Message(role="user", content="later input")) + message_ref = f"pipeline/transcripts/{transcript_id}/session.jsonl:0" + permission_class = "pipeline" + else: + storage.append(cwd, session_id, assistant) + storage.append(cwd, session_id, Message(role="user", content="later input")) + message_ref = "session.jsonl:0" + permission_class = "normal" + record = build_permission_checkpoint( + session_id=session_id, + task_id="task-1", + context_id="context-1", + input_id="input-1", + tool_use_id=tool_use.id, + tool_name=tool_use.name, + tool_input=tool_use.input, + permission_class=permission_class, + continuation_frame={ + "assistantMessageRef": message_ref, + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id], + "currentIndex": 0, + "decisions": [{"toolUseId": tool_use.id, "state": "pending", "source": None}], + }, + policy=PermissionWaitPolicy(), + ) + + assert ( + recover_permission_audit_boundary( + record, + cwd=cwd, + session_id=session_id, + storage=storage, + ) + is None + ) + + +def test_checkpoint_claim_is_idempotent_and_conflict_fails(tmp_path) -> None: + store = _store(tmp_path) + record = _record(store, PermissionWaitPolicy()) + + claimed, created = store.claim_decision(record["boundaryId"], value="allow_once", source="user") + duplicate, duplicate_created = store.claim_decision(record["boundaryId"], value="allow_once", source="user") + + assert created is True + assert duplicate_created is False + assert duplicate["decision"] == claimed["decision"] + assert claimed["decision"]["auditStatus"] == "pending" + with pytest.raises(ValueError, match="conflicts"): + store.claim_decision(record["boundaryId"], value="deny", source="user") + + +def test_cross_process_store_serializes_one_authoritative_claim_audit(tmp_path) -> None: + projects = tmp_path / "projects" + storage = SessionStorage(projects_dir=projects) + storage.ensure_v2_session_dir_for_new_session("/workspace", "session-1") + first_store = PermissionWaitCheckpointStore("/workspace", "session-1", storage=storage) + record = _record(first_store, PermissionWaitPolicy()) + boundary_id = record["boundaryId"] + claimed, _created = first_store.claim_decision(boundary_id, value="allow_once", source="user") + claim_id = str(claimed["decision"]["claimId"]) + second_store = PermissionWaitCheckpointStore( + "/workspace", + "session-1", + storage=SessionStorage(projects_dir=projects), + ) + audit_started = threading.Event() + release_audit = threading.Event() + calls: list[str] = [] + + def failing_audit(_value: str) -> bool: + calls.append("owner-failed") + audit_started.set() + assert release_audit.wait(timeout=2) + return False + + def competing_success(_value: str) -> bool: + calls.append("duplicate-succeeded") + return True + + with ThreadPoolExecutor(max_workers=2) as pool: + owner = pool.submit( + first_store.run_claim_audit_once, + boundary_id, + claim_id=claim_id, + audit=failing_audit, + ) + assert audit_started.wait(timeout=2) + duplicate = pool.submit( + second_store.run_claim_audit_once, + boundary_id, + claim_id=claim_id, + audit=competing_success, + ) + release_audit.set() + owner_record, owner_created = owner.result(timeout=2) + duplicate_record, duplicate_created = duplicate.result(timeout=2) + + assert calls == ["owner-failed"] + assert owner_created is True + assert duplicate_created is False + assert owner_record["decision"]["value"] == "deny" + assert duplicate_record["decision"] == owner_record["decision"] + assert second_store.load(boundary_id)["decision"]["auditStatus"] == "failed" + + +def test_checkpoint_rejects_continuation_without_exactly_one_current_pending_tool(tmp_path) -> None: + store = _store(tmp_path) + record = build_permission_checkpoint( + session_id="session-1", + task_id="task-1", + context_id="context-1", + input_id="input-1", + tool_use_id="tool-1", + tool_name="aliyun_api", + tool_input={"api": "CreateStack"}, + permission_class="normal", + continuation_frame={ + "assistantMessageRef": "session.jsonl:1", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1", "tool-2"], + "currentIndex": 0, + "decisions": [ + {"toolUseId": "tool-1", "state": "pending", "source": None}, + {"toolUseId": "tool-2", "state": "pending", "source": None}, + ], + }, + policy=PermissionWaitPolicy(), + ) + + with pytest.raises(ValueError, match="pending boundary"): + store.create(record) + + +def test_checkpoint_rejects_noncanonical_continuation_message_reference(tmp_path) -> None: + store = _store(tmp_path) + record = build_permission_checkpoint( + session_id="session-1", + task_id="task-1", + context_id="context-1", + input_id="input-1", + tool_use_id="tool-1", + tool_name="aliyun_api", + tool_input={"api": "CreateStack"}, + permission_class="normal", + continuation_frame={ + "assistantMessageRef": "message-1", + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None}], + }, + policy=PermissionWaitPolicy(), + ) + + with pytest.raises(ValueError, match="message reference"): + store.create(record) + + +@pytest.mark.parametrize( + ("permission_class", "mode", "message_ref", "error"), + [ + ("normal", "pipeline", "session.jsonl:0", "checkpoint class"), + ("sub_pipeline", "normal", "session.jsonl:0", "checkpoint class"), + ("normal", "normal", "pipeline/transcripts/transcript-1/session.jsonl:0", "transcript class"), + ("pipeline", "pipeline", "session.jsonl:0", "transcript class"), + ], +) +def test_checkpoint_binds_permission_class_mode_and_transcript( + tmp_path, + permission_class, + mode, + message_ref, + error, +) -> None: + store = _store(tmp_path) + record = build_permission_checkpoint( + session_id="session-1", + task_id="task-1", + context_id="context-1", + input_id="input-1", + tool_use_id="tool-1", + tool_name="aliyun_api", + tool_input={"api": "CreateStack"}, + permission_class="normal", + continuation_frame={ + "assistantMessageRef": message_ref, + "assistantMessageDigest": "a" * 64, + "orderedToolUseIds": ["tool-1"], + "currentIndex": 0, + "decisions": [{"toolUseId": "tool-1", "state": "pending", "source": None}], + }, + policy=PermissionWaitPolicy(), + ) + record["permissionClass"] = permission_class + record["mode"] = mode + + with pytest.raises(ValueError, match=error): + store.create(record) + + +def test_cloud_execution_identity_is_non_secret_and_region_bound(monkeypatch) -> None: + credential = AliyunCredential( + mode="StsToken", + access_key_id="sts-access-key-id", + access_key_secret="must-not-be-persisted", + sts_token="must-not-be-persisted-either", + region_id="cn-hangzhou", + ) + monkeypatch.setattr(AliyunCredentials, "load", staticmethod(lambda: credential)) + + principal_ref, region = permission_execution_identity( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack", "region_id": "cn-shanghai"}, + ) + + assert principal_ref is not None and principal_ref.startswith("aliyun:") + assert "sts-access-key-id" not in principal_ref + assert "must-not-be-persisted" not in principal_ref + assert region == "cn-shanghai" + + +def test_local_execution_identity_does_not_depend_on_cloud_credentials(monkeypatch) -> None: + monkeypatch.setattr( + AliyunCredentials, + "load", + staticmethod(lambda: pytest.fail("local permissions must not read cloud credentials")), + ) + + assert permission_execution_identity(tool_name="bash", tool_input={"cmd": "pwd"}) == (None, None) + + +def test_checkpoint_normalizes_orphan_and_compacts_receipt(tmp_path) -> None: + store = _store(tmp_path) + record = _record(store, PermissionWaitPolicy()) + suspended = store.reconcile_deadline( + record["boundaryId"], + grace_seconds=30, + live_owner=False, + ) + assert suspended["phase"] == "SUSPENDED" + + claimed, _ = store.claim_decision(record["boundaryId"], value="deny", source="user") + restoring = store.begin_restore(record["boundaryId"]) + receipt = store.resolve( + record["boundaryId"], + result_digest="b" * 64, + ack={"decision": "deny"}, + ) + + assert claimed["decision"]["status"] == "claimed" + assert restoring["phase"] == "RESTORING" + assert receipt["phase"] == "RESOLVED" + assert "continuationFrame" not in receipt + assert receipt["ack"] == {"decision": "deny"} + + +def test_successor_boundary_atomically_replaces_active_owner_and_keeps_old_ack(tmp_path) -> None: + store = _store(tmp_path) + first = _record(store, PermissionWaitPolicy()) + first, _created = store.claim_decision(first["boundaryId"], value="allow_once", source="user") + first = store.mark_claim_backed_up(first["boundaryId"], claim_id=first["decision"]["claimId"]) + store.mark_applied(first["boundaryId"], claim_id=first["decision"]["claimId"]) + second = build_permission_checkpoint( + session_id="session-1", + task_id="task-1", + context_id="context-1", + input_id="input-2", + tool_use_id="tool-2", + tool_name="aliyun_api", + tool_input={"api": "DeleteStack"}, + permission_class="normal", + continuation_frame={ + "assistantMessageRef": "session.jsonl:1", + "assistantMessageDigest": "b" * 64, + "orderedToolUseIds": ["tool-1", "tool-2"], + "currentIndex": 1, + "decisions": [ + { + "toolUseId": "tool-1", + "state": "allow", + "source": "user", + "principalRef": None, + "region": None, + }, + {"toolUseId": "tool-2", "state": "pending", "source": None}, + ], + }, + policy=PermissionWaitPolicy(), + ) + + created = store.create_successor(second, previous_boundary_id=first["boundaryId"]) + + assert [record["boundaryId"] for record in store.list_active()] == [created["boundaryId"]] + receipt = store.load(first["boundaryId"]) + assert receipt["phase"] == "RESOLVED" + assert receipt["nextBoundaryId"] == created["boundaryId"] + assert receipt["ack"] == { + "decision": "allow_once", + "accepted": True, + "nextBoundaryId": created["boundaryId"], + } + assert "continuationFrame" not in receipt + + +def test_deadline_reconciliation_does_not_steal_an_active_restore(tmp_path) -> None: + store = _store(tmp_path) + record = _record(store, PermissionWaitPolicy()) + boundary_id = record["boundaryId"] + store.mark_suspended(boundary_id) + store.claim_decision(boundary_id, value="allow_once", source="user") + restoring = store.begin_restore(boundary_id) + + reconciled = store.reconcile_deadline( + boundary_id, + grace_seconds=30, + live_owner=False, + ) + + assert reconciled == restoring + assert reconciled["phase"] == "RESTORING" + + +def test_deadline_reconciliation_starts_absolute_grace_when_process_resumes(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=10, timeout_grace_seconds=30) + created_at = utc_now() - timedelta(seconds=120) + record = _record(store, policy, now=created_at) + + reconciled = store.reconcile_deadline( + record["boundaryId"], + now=created_at + timedelta(seconds=120), + grace_seconds=policy.timeout_grace_seconds, + live_owner=True, + ) + + assert reconciled["phase"] == "TIMEOUT_GRACE" + assert reconciled["graceDeadlineAt"] == format_utc(created_at + timedelta(seconds=150)) + + +def test_deadline_reconciliation_starts_full_grace_when_expiry_is_first_observed(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=10, timeout_grace_seconds=30) + created_at = utc_now() - timedelta(seconds=20) + record = _record(store, policy, now=created_at) + + reconciled = store.reconcile_deadline( + record["boundaryId"], + now=created_at + timedelta(seconds=20), + grace_seconds=policy.timeout_grace_seconds, + live_owner=True, + ) + + assert reconciled["phase"] == "TIMEOUT_GRACE" + assert reconciled["graceDeadlineAt"] == format_utc(created_at + timedelta(seconds=50)) + + +@pytest.mark.asyncio +async def test_live_reply_in_grace_wins_and_marks_applied(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=10, timeout_grace_seconds=30) + record = _record(store, policy) + boundary_id = record["boundaryId"] + store.transaction( + boundary_id, + lambda value: { + **value, + "residentDeadlineAt": format_utc(utc_now() - timedelta(seconds=1)), + }, + ) + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=store.load(boundary_id) or record, store=store, future=future) + + claimed, created = await coordinator.claim_live(boundary_id=boundary_id, value="allow_once") + + assert created is True + assert claimed["phase"] == "TIMEOUT_GRACE" + assert await future is True + assert store.load(boundary_id)["decision"]["status"] == "applied" + + +@pytest.mark.asyncio +async def test_live_reply_audits_before_future_delivery_and_records_result(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy() + record = _record(store, policy) + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=record, store=store, future=future) + observed: list[tuple[str, bool]] = [] + + def audit(value: str) -> bool: + observed.append((value, future.done())) + return True + + await coordinator.claim_live( + boundary_id=record["boundaryId"], + value="allow_once", + on_new_claim=audit, + ) + + assert observed == [("allow_once", False)] + assert await future is True + assert store.load(record["boundaryId"])["decision"]["auditStatus"] == "recorded" + + +@pytest.mark.asyncio +async def test_live_reply_commits_required_backup_before_future_delivery(tmp_path) -> None: + store = _store(tmp_path) + record = _record(store, PermissionWaitPolicy()) + boundary_id = record["boundaryId"] + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator() + coordinator.register_live(record=record, store=store, future=future) + observed: list[tuple[str, str, bool]] = [] + + async def backup(_record: dict) -> None: + decision = store.load(boundary_id)["decision"] + observed.append((decision["status"], decision["backupStatus"], future.done())) + + await coordinator.claim_live( + boundary_id=boundary_id, + value="allow_once", + before_delivery=backup, + ) + + assert observed == [("claimed", "pending", False)] + assert await future is True + decision = store.load(boundary_id)["decision"] + assert decision["backupStatus"] == "committed" + assert decision["status"] == "applied" + + +@pytest.mark.asyncio +async def test_canceled_reply_request_does_not_strand_durable_future_delivery(tmp_path) -> None: + store = _store(tmp_path) + record = _record(store, PermissionWaitPolicy()) + boundary_id = record["boundaryId"] + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator() + coordinator.register_live(record=record, store=store, future=future) + backup_started = asyncio.Event() + release_backup = asyncio.Event() + + async def backup(_record: dict) -> None: + backup_started.set() + await release_backup.wait() + + reply = asyncio.create_task( + coordinator.claim_live( + boundary_id=boundary_id, + value="allow_once", + before_delivery=backup, + ) + ) + await backup_started.wait() + reply.cancel() + with pytest.raises(asyncio.CancelledError): + await reply + release_backup.set() + + assert await asyncio.wait_for(future, timeout=1) is True + decision = store.load(boundary_id)["decision"] + assert decision["backupStatus"] == "committed" + assert decision["status"] == "applied" + + +@pytest.mark.asyncio +async def test_live_allow_audit_failure_is_durably_downgraded_before_delivery(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy() + record = _record(store, policy) + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=record, store=store, future=future) + + await coordinator.claim_live( + boundary_id=record["boundaryId"], + value="allow_once", + on_new_claim=lambda _value: False, + ) + + assert await future is False + decision = store.load(record["boundaryId"])["decision"] + assert decision["value"] == "deny" + assert decision["status"] == "applied" + assert decision["auditStatus"] == "failed" + + +@pytest.mark.asyncio +async def test_grace_expiry_suspends_without_denial(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=0.01, timeout_grace_seconds=0) + record = _record(store, policy) + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=record, store=store, future=future) + + assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + assert store.load(record["boundaryId"])["phase"] == "SUSPENDING" + coordinator.unregister_live(record["boundaryId"]) + assert store.load(record["boundaryId"])["phase"] == "SUSPENDED" + assert store.load(record["boundaryId"])["decision"]["status"] == "none" + + +@pytest.mark.asyncio +async def test_unexpected_resident_timer_cancellation_rearms_from_absolute_deadline(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=0.2, timeout_grace_seconds=0) + record = _record(store, policy) + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=record, store=store, future=future) + + owner = coordinator._owners[record["boundaryId"]] + assert owner.timer is not None + owner.timer.cancel() + + assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + assert store.load(record["boundaryId"])["phase"] == "SUSPENDING" + coordinator.unregister_live(record["boundaryId"]) + assert store.load(record["boundaryId"])["phase"] == "SUSPENDED" + + +@pytest.mark.asyncio +async def test_duplicate_live_registration_keeps_original_generation_fenced_timer(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=0.01, timeout_grace_seconds=0.05) + record = _record(store, policy) + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=record, store=store, future=future) + + for _ in range(100): + if store.load(record["boundaryId"])["phase"] == "TIMEOUT_GRACE": + break + await asyncio.sleep(0.005) + else: + pytest.fail("resident timer did not enter TIMEOUT_GRACE") + + coordinator.register_live(record=record, store=store, future=future) + + assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + assert store.load(record["boundaryId"])["phase"] == "SUSPENDING" + + +@pytest.mark.asyncio +async def test_duplicate_live_registration_rejects_different_future(tmp_path) -> None: + store = _store(tmp_path) + record = _record(store, PermissionWaitPolicy()) + coordinator = PermissionWaitCoordinator(PermissionWaitPolicy()) + first: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + second: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator.register_live(record=record, store=store, future=first) + + with pytest.raises(RuntimeError, match="different live owner"): + coordinator.register_live(record=record, store=store, future=second) + + +@pytest.mark.asyncio +async def test_resident_timer_retries_when_grace_callback_observes_unexpired_deadline( + monkeypatch, + tmp_path, +) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=0.01, timeout_grace_seconds=0.01) + record = _record(store, policy) + boundary_id = record["boundaryId"] + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + original_suspend_now = coordinator.suspend_now + attempts = 0 + + async def observe_early_grace_deadline(value: str) -> bool: + nonlocal attempts + attempts += 1 + if attempts == 1: + return False + return await original_suspend_now(value) + + monkeypatch.setattr(coordinator, "suspend_now", observe_early_grace_deadline) + coordinator.register_live(record=record, store=store, future=future) + + assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + assert attempts == 2 + assert store.load(boundary_id)["phase"] == "SUSPENDING" + + +@pytest.mark.asyncio +async def test_late_reply_waits_for_suspending_owner_before_recovery(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=0.01, timeout_grace_seconds=0) + record = _record(store, policy) + boundary_id = record["boundaryId"] + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=record, store=store, future=future) + + assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + claimed, created = await coordinator.claim_live(boundary_id=boundary_id, value="allow_once") + assert created is True + assert claimed["phase"] == "SUSPENDING" + waiter = asyncio.create_task(coordinator.wait_for_suspended_owner(boundary_id, timeout_seconds=1)) + await asyncio.sleep(0) + assert waiter.done() is False + + coordinator.unregister_live(boundary_id) + assert await waiter is True + + persisted = store.load(boundary_id) + assert persisted["phase"] == "SUSPENDED" + assert persisted["decision"]["status"] == "claimed" + assert persisted["decision"]["value"] == "allow_once" + + +@pytest.mark.asyncio +async def test_slow_suspending_owner_is_not_reclassified_as_crashed(tmp_path) -> None: + store = _store(tmp_path) + policy = PermissionWaitPolicy(resident_timeout_seconds=0.01, timeout_grace_seconds=0) + record = _record(store, policy) + boundary_id = record["boundaryId"] + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(policy) + coordinator.register_live(record=record, store=store, future=future) + + assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + await coordinator.claim_live(boundary_id=boundary_id, value="allow_once") + + assert await coordinator.wait_for_suspended_owner(boundary_id, timeout_seconds=0.01) is False + assert coordinator.has_live_boundary(boundary_id) is True + assert store.load(boundary_id)["phase"] == "SUSPENDING" + + coordinator.unregister_live(boundary_id) + assert await coordinator.wait_for_suspended_owner(boundary_id, timeout_seconds=0.01) is True + assert store.load(boundary_id)["phase"] == "SUSPENDED" + + +@pytest.mark.asyncio +async def test_stale_live_owner_generation_cannot_deliver_suspend(tmp_path) -> None: + store = _store(tmp_path) + record = _record(store, PermissionWaitPolicy()) + boundary_id = record["boundaryId"] + future: asyncio.Future[bool | PermissionWaitOutcome] = asyncio.get_running_loop().create_future() + coordinator = PermissionWaitCoordinator(PermissionWaitPolicy()) + coordinator.register_live(record=record, store=store, future=future) + store.transaction( + boundary_id, + lambda value: { + **value, + "phase": "SUSPENDING", + "generation": int(value["generation"]) + 1, + }, + ) + + assert await coordinator.suspend_now(boundary_id) is False + assert future.done() is False + assert store.load(boundary_id)["phase"] == "SUSPENDING" + coordinator.unregister_live(boundary_id) diff --git a/tests/skill_bridge/start_chat_connect_proxy.py b/tests/skill_bridge/start_chat_connect_proxy.py new file mode 100644 index 00000000..b0fc05fd --- /dev/null +++ b/tests/skill_bridge/start_chat_connect_proxy.py @@ -0,0 +1,142 @@ +"""Restricted CONNECT proxy for routing the real aliyun CLI to the local relay.""" + +from __future__ import annotations + +import argparse +import json +import select +import signal +import socket +import socketserver +import threading +import time +from typing import Any + +_MAX_HEADER_BYTES = 64 * 1024 + + +class _ProxyServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__( + self, + server_address: tuple[str, int], + *, + allowed_authority: str, + target_host: str, + target_port: int, + ) -> None: + super().__init__(server_address, _ProxyHandler) + self.allowed_authority = allowed_authority + self.target_host = target_host + self.target_port = target_port + self.metrics_lock = threading.Lock() + self.metrics: dict[str, Any] = { + "connections": 0, + "rejected": 0, + "clientToRelayBytes": 0, + "relayToClientBytes": 0, + } + + def add_metric(self, key: str, value: int = 1) -> None: + with self.metrics_lock: + self.metrics[key] += value + + +class _ProxyHandler(socketserver.BaseRequestHandler): + server: _ProxyServer + + def handle(self) -> None: + header = self._read_header() + if header is None: + self.server.add_metric("rejected") + return + first_line = header.split(b"\r\n", 1)[0].decode("ascii", "replace") + pieces = first_line.split() + if len(pieces) != 3 or pieces[0] != "CONNECT" or pieces[1] != self.server.allowed_authority: + self.request.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n") + self.server.add_metric("rejected") + return + try: + upstream = socket.create_connection((self.server.target_host, self.server.target_port), timeout=10) + except OSError: + self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + return + self.server.add_metric("connections") + self.request.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + try: + self._tunnel(upstream) + finally: + upstream.close() + + def _read_header(self) -> bytes | None: + data = bytearray() + while b"\r\n\r\n" not in data and len(data) < _MAX_HEADER_BYTES: + chunk = self.request.recv(4096) + if not chunk: + return None + data.extend(chunk) + return bytes(data) if b"\r\n\r\n" in data else None + + def _tunnel(self, upstream: socket.socket) -> None: + sockets = (self.request, upstream) + while True: + readable, _, _ = select.select(sockets, (), (), 30) + if not readable: + continue + for source in readable: + data = source.recv(64 * 1024) + if not data: + return + if source is self.request: + upstream.sendall(data) + self.server.add_metric("clientToRelayBytes", len(data)) + else: + self.request.sendall(data) + self.server.add_metric("relayToClientBytes", len(data)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Route one HTTPS authority to a local StartChat relay.") + parser.add_argument("--allowed-authority", default="ros.aliyuncs.com:443") + parser.add_argument("--target-host", default="127.0.0.1") + parser.add_argument("--target-port", type=int, required=True) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--metrics-file") + args = parser.parse_args(argv) + server = _ProxyServer( + (args.host, args.port), + allowed_authority=args.allowed_authority, + target_host=args.target_host, + target_port=args.target_port, + ) + + def stop(_signum: int, _frame: object) -> None: + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + print( + json.dumps( + {"host": args.host, "port": server.server_address[1], "protocol": "http-connect"}, + separators=(",", ":"), + ), + flush=True, + ) + try: + server.serve_forever() + finally: + if args.metrics_file: + with server.metrics_lock: + payload = {"schemaVersion": 1, "finishedAtUnixMs": int(time.time() * 1000), **server.metrics} + with open(args.metrics_file, "w", encoding="utf-8") as stream: + json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True) + stream.write("\n") + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/skill_bridge/start_chat_relay.py b/tests/skill_bridge/start_chat_relay.py new file mode 100644 index 00000000..c6f5d016 --- /dev/null +++ b/tests/skill_bridge/start_chat_relay.py @@ -0,0 +1,820 @@ +"""Offline ROS chat relay used by the external Skill integration tests. + +The HTTP surface deliberately mirrors only the published ROS StartChat and +StopChat OpenAPIs. Test configuration such as the A2A URL and workspace is +injected into the server constructor and is never accepted from an HTTP request. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import queue +import re +import signal +import ssl +import threading +import time +import uuid +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urlsplit +from urllib.request import ProxyHandler, Request, build_opener + +START_CHAT_PARAMETERS = frozenset( + { + "Query", + "SessionId", + "EnablePartialMessage", + "Mode", + "Attachments", + "AgentVersion", + "EnableThinking", + "RegionId", + "ClientContext", + } +) +STOP_CHAT_PARAMETERS = frozenset({"SessionId", "AgentVersion"}) +RPC_SYSTEM_PARAMETERS = frozenset( + { + "AccessKeyId", + "Action", + "Format", + "RegionId", + "SecurityToken", + "Signature", + "SignatureMethod", + "SignatureNonce", + "SignatureType", + "SignatureVersion", + "Timestamp", + "Version", + } +) +_ATTACHMENT_PARAMETER = re.compile(r"Attachments\.[1-9][0-9]*\.(?:Type|MimeType|Name|OssObjectKey)\Z") +_BOOLEAN_VALUES = frozenset({"true", "false"}) +_MODES = frozenset({"IaCCodeNormal", "IaCCodePipeline"}) +_TERMINAL_TASK_STATES = frozenset( + {"TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED"} +) +PERMISSION_QUERY_PREFIX = "IAC_CODE_PERMISSION:" +_MAX_UPSTREAM_NON_SSE_BYTES = 1024 * 1024 +_END = object() + + +class StartChatRequestError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +def _single_value_parameters(path: str, body: bytes) -> dict[str, str]: + split = urlsplit(path) + if split.path != "/": + raise StartChatRequestError( + "InvalidAction.NotFound", + "ROS chat actions are only available at the RPC root path.", + ) + combined: dict[str, list[str]] = {} + for encoded in (split.query, body.decode("utf-8") if body else ""): + for key, values in parse_qs(encoded, keep_blank_values=True).items(): + combined.setdefault(key, []).extend(values) + repeated = sorted(key for key, values in combined.items() if len(values) != 1) + if repeated: + raise StartChatRequestError("InvalidParameter", "Repeated parameter: {}".format(repeated[0])) + return {key: values[0] for key, values in combined.items()} + + +def parse_start_chat_request(path: str, body: bytes, headers: Any) -> dict[str, str]: + """Validate the exact OpenAPI request surface and return business parameters.""" + + parameters = _single_value_parameters(path, body) + action = parameters.get("Action") or headers.get("x-acs-action") + if action != "StartChat": + raise StartChatRequestError("InvalidAction", "Action must be StartChat.") + unknown = sorted( + key + for key in parameters + if key not in RPC_SYSTEM_PARAMETERS + and key not in START_CHAT_PARAMETERS + and _ATTACHMENT_PARAMETER.fullmatch(key) is None + ) + if unknown: + raise StartChatRequestError("InvalidParameter", "Unknown StartChat parameter: {}".format(unknown[0])) + query = parameters.get("Query") + if query is None or not query.strip(): + raise StartChatRequestError("InvalidParameter.Query", "Query is required.") + mode = parameters.get("Mode", "IaCCodeNormal") + if mode not in _MODES: + raise StartChatRequestError("InvalidParameter.Mode", "Mode is not supported.") + for name in ("EnablePartialMessage", "EnableThinking"): + value = parameters.get(name) + if value is not None and value.lower() not in _BOOLEAN_VALUES: + raise StartChatRequestError("InvalidParameter.{}".format(name), "{} must be true or false.".format(name)) + agent_version = parameters.get("AgentVersion") + if agent_version not in (None, "V2"): + raise StartChatRequestError("InvalidParameter.AgentVersion", "Only AgentVersion V2 is supported.") + if parameters.get("ClientContext") and mode != "IaCCodeNormal": + raise StartChatRequestError( + "InvalidParameter.ClientContextMode", + "ClientContext is only supported in IaCCodeNormal mode.", + ) + return { + key: value + for key, value in parameters.items() + if key in START_CHAT_PARAMETERS or _ATTACHMENT_PARAMETER.fullmatch(key) is not None + } + + +def parse_stop_chat_request(path: str, body: bytes, headers: Any) -> dict[str, str]: + """Validate the exact StopChat OpenAPI request surface.""" + + parameters = _single_value_parameters(path, body) + action = parameters.get("Action") or headers.get("x-acs-action") + if action != "StopChat": + raise StartChatRequestError("InvalidAction", "Action must be StopChat.") + unknown = sorted(key for key in parameters if key not in RPC_SYSTEM_PARAMETERS and key not in STOP_CHAT_PARAMETERS) + if unknown: + raise StartChatRequestError("InvalidParameter", "Unknown StopChat parameter: {}".format(unknown[0])) + session_id = parameters.get("SessionId") + if session_id is None or not session_id.strip(): + raise StartChatRequestError("InvalidParameter.SessionId", "SessionId is required.") + agent_version = parameters.get("AgentVersion") + if agent_version not in (None, "V2"): + raise StartChatRequestError("InvalidParameter.AgentVersion", "Only AgentVersion V2 is supported.") + return {key: value for key, value in parameters.items() if key in STOP_CHAT_PARAMETERS} + + +def _permission_query(value: str) -> dict[str, Any] | None: + if not value.startswith(PERMISSION_QUERY_PREFIX): + return None + try: + payload = json.loads(value[len(PERMISSION_QUERY_PREFIX) :].lstrip()) + except ValueError: + return None + if isinstance(payload, dict) and payload.get("schemaVersion") == 1 and payload.get("kind") == "permission": + return payload + return None + + +def _event_payload(value: dict[str, Any]) -> dict[str, Any]: + result = value.get("result") + if not isinstance(result, dict): + return value + for key in ("statusUpdate", "artifactUpdate", "task"): + nested = result.get(key) + if isinstance(nested, dict): + return nested + return result + + +def _iac_code_metadata(value: dict[str, Any]) -> dict[str, Any] | None: + payload = _event_payload(value) + metadata = payload.get("metadata") if isinstance(payload, dict) else None + iac_code = metadata.get("iac_code") if isinstance(metadata, dict) else None + return iac_code if isinstance(iac_code, dict) else None + + +def _pipeline_envelopes(iac_code: dict[str, Any]) -> list[dict[str, Any]]: + pipeline = iac_code.get("pipeline") + if isinstance(pipeline, dict): + return [pipeline] + batch = iac_code.get("pipelineBatch") + events = batch.get("events") if isinstance(batch, dict) else None + return [event for event in events if isinstance(event, dict)] if isinstance(events, list) else [] + + +def _sideband_input(value: dict[str, Any]) -> dict[str, Any] | None: + iac_code = _iac_code_metadata(value) + if iac_code is None: + return None + input_value = iac_code.get("input") + if not isinstance(input_value, dict) or input_value.get("kind") != "permission": + return None + if any( + envelope.get("eventType") == "permission_requested" and envelope.get("status") == "working" + for envelope in _pipeline_envelopes(iac_code) + ): + return input_value + return None + + +def _normal_handoff_ready(value: dict[str, Any]) -> bool: + iac_code = _iac_code_metadata(value) + if iac_code is None: + return False + return any( + envelope.get("eventType") == "pipeline_handoff_ready" + and envelope.get("visibility") in {None, "committed"} + and isinstance(envelope.get("data"), dict) + and envelope["data"].get("action") == "switch_to_normal" + and envelope["data"].get("targetMode") == "normal" + for envelope in _pipeline_envelopes(iac_code) + ) + + +def _permission_ack_input_id(value: dict[str, Any]) -> str | None: + def find(item: Any) -> str | None: + if isinstance(item, dict): + if item.get("kind") == "permission_ack" and item.get("accepted") is True: + input_id = item.get("inputId") + return input_id if isinstance(input_id, str) and input_id else None + for candidate in item.values(): + found = find(candidate) + if found: + return found + elif isinstance(item, list): + for candidate in item: + found = find(candidate) + if found: + return found + return None + + return find(value) + + +def _event_task_id(value: dict[str, Any]) -> str | None: + def find(item: Any) -> str | None: + if isinstance(item, dict): + candidate = item.get("taskId") + if isinstance(candidate, str) and candidate: + return candidate + for candidate in item.values(): + found = find(candidate) + if found: + return found + elif isinstance(item, list): + for candidate in item: + found = find(candidate) + if found: + return found + return None + + return find(value) + + +def _event_task_state(value: dict[str, Any]) -> str | None: + payload = _event_payload(value) + status = payload.get("status") if isinstance(payload, dict) else None + state = status.get("state") if isinstance(status, dict) else None + if not isinstance(state, str) and isinstance(payload, dict): + state = payload.get("state") + return state if isinstance(state, str) and state else None + + +def _is_serial_input_boundary(value: dict[str, Any]) -> bool: + iac_code = _iac_code_metadata(value) + if iac_code is None or not isinstance(iac_code.get("input"), dict): + return False + return _sideband_input(value) is None and not iac_code.get("pendingPermissions") + + +def _upstream_failure(session_id: str, code: str, message: str) -> dict[str, Any]: + return { + "id": session_id, + "object": "response", + "status": "failed", + "error": {"code": code, "message": message}, + } + + +@dataclass +class _UpstreamCall: + events: queue.Queue[object] = field(default_factory=queue.Queue) + acknowledged_input_ids: set[str] = field(default_factory=set) + thread: threading.Thread | None = None + last_task_state: str | None = None + + +@dataclass +class _Session: + session_id: str + mode: str + task_id: str | None = None + active_call: _UpstreamCall | None = None + pending_sideband: dict[str, dict[str, Any]] = field(default_factory=dict) + normal_handoff_ready: bool = False + state_lock: threading.Lock = field(default_factory=threading.Lock) + + +class StartChatRelay(ThreadingHTTPServer): + """HTTPS RPC relay limited to the StartChat and StopChat OpenAPIs.""" + + daemon_threads = True + + def __init__( + self, + server_address: tuple[str, int], + *, + a2a_url: str, + pipeline_a2a_url: str | None = None, + workspace: str, + ssl_context: ssl.SSLContext, + upstream_timeout: float = 15.0, + heartbeat_interval: float = 15.0, + metrics_path: str | None = None, + ) -> None: + super().__init__(server_address, _StartChatHandler) + self.a2a_url = a2a_url + self.pipeline_a2a_url = pipeline_a2a_url or a2a_url + self.workspace = workspace + self.upstream_timeout = upstream_timeout + if heartbeat_interval <= 0: + raise ValueError("heartbeat_interval must be positive") + self.heartbeat_interval = heartbeat_interval + self.sessions: dict[str, _Session] = {} + self.sessions_lock = threading.Lock() + self.metrics_path = pathlib.Path(metrics_path) if metrics_path else None + self.metrics_lock = threading.Lock() + self.request_metrics: list[dict[str, Any]] = [] + self.socket = ssl_context.wrap_socket(self.socket, server_side=True) + + def begin_request_metric(self, session: _Session, parameters: dict[str, str]) -> dict[str, Any]: + metric: dict[str, Any] = { + "action": "StartChat", + "startedAtUnixMs": int(time.time() * 1000), + "sessionId": session.session_id, + "mode": parameters.get("Mode", "IaCCodeNormal"), + "queryBytes": len(parameters["Query"].encode("utf-8")), + "queryKind": "permission" if _permission_query(parameters["Query"]) is not None else "conversation", + "returnedEventCount": 0, + "returnedSseBytes": 0, + "eventKinds": {}, + } + with self.metrics_lock: + self.request_metrics.append(metric) + return metric + + def begin_stop_metric(self, parameters: dict[str, str]) -> dict[str, Any]: + metric: dict[str, Any] = { + "action": "StopChat", + "startedAtUnixMs": int(time.time() * 1000), + "sessionId": parameters["SessionId"], + } + with self.metrics_lock: + self.request_metrics.append(metric) + return metric + + def finish_request_metric(self, metric: dict[str, Any]) -> None: + metric["finishedAtUnixMs"] = int(time.time() * 1000) + metric["durationMs"] = metric["finishedAtUnixMs"] - metric["startedAtUnixMs"] + self._write_metrics() + + def _write_metrics(self) -> None: + if self.metrics_path is None: + return + with self.metrics_lock: + payload = {"schemaVersion": 1, "requests": self.request_metrics} + self.metrics_path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.metrics_path.with_suffix(self.metrics_path.suffix + ".tmp") + temporary.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(self.metrics_path) + + def resolve_session(self, parameters: dict[str, str]) -> tuple[_Session, bool]: + requested = parameters.get("SessionId") + mode = parameters.get("Mode", "IaCCodeNormal") + with self.sessions_lock: + if requested: + session = self.sessions.get(requested) + if session is None: + raise StartChatRequestError("SessionNotFound", "The requested SessionId does not exist.") + if session.mode != mode: + raise StartChatRequestError("InvalidParameter.Mode", "A session cannot change mode.") + return session, False + session_id = str(uuid.uuid4()) + session = _Session(session_id=session_id, mode=mode) + self.sessions[session_id] = session + return session, True + + def start_a2a_call(self, session: _Session, parameters: dict[str, str]) -> _UpstreamCall: + query = parameters["Query"] + message: dict[str, Any] = { + "messageId": str(uuid.uuid4()), + "role": "ROLE_USER", + "contextId": session.session_id, + "parts": [{"text": query}], + "metadata": { + "iac_code": { + "cwd": self.workspace, + "thinking": {"enabled": parameters.get("EnableThinking", "true").lower() == "true"}, + } + }, + } + region_id = parameters.get("RegionId") + if region_id: + message["metadata"]["iac_code"]["alibaba_cloud_region_id"] = region_id + if session.mode == "IaCCodePipeline": + # The ROS Agent gateway requests iac-code's rich A2A candidate + # projection for Pipeline turns. This is internal gateway metadata, + # not an additional StartChat parameter or HTTP capability. + message["metadata"]["iac_code"]["candidatePresentation"] = "rich-v1" + # A text-only gateway can omit the outer taskId for the JSON permission + # response. iac-code must recover it from requestTaskId after parsing. + if session.task_id and _permission_query(query) is None and not session.normal_handoff_ready: + message["taskId"] = session.task_id + payload = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "SendStreamingMessage", + "params": { + "message": message, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + call = _UpstreamCall() + upstream_url = self.pipeline_a2a_url if session.mode == "IaCCodePipeline" else self.a2a_url + call.thread = threading.Thread( + target=self._consume_a2a, + args=(session, call, payload, upstream_url), + name="start-chat-a2a-stream", + daemon=True, + ) + call.thread.start() + return call + + def stop_session(self, session_id: str) -> str: + with self.sessions_lock: + session = self.sessions.get(session_id) + if session is None: + raise StartChatRequestError("SessionNotFound", "The requested SessionId does not exist.") + with session.state_lock: + task_id = session.task_id + active_call = session.active_call + last_state = active_call.last_task_state if active_call is not None else None + if not task_id or last_state in _TERMINAL_TASK_STATES: + return "NoActiveStream" + payload = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "CancelTask", + "params": {"id": task_id}, + } + upstream_url = self.pipeline_a2a_url if session.mode == "IaCCodePipeline" else self.a2a_url + request = Request( + upstream_url, + data=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), + headers={"A2A-Version": "1.0", "Accept": "application/json", "Content-Type": "application/json"}, + method="POST", + ) + try: + with build_opener(ProxyHandler({})).open(request, timeout=self.upstream_timeout) as response: + raw = response.read(1024 * 1024 + 1) + except (HTTPError, OSError, URLError): + return "Failed" + if len(raw) > 1024 * 1024: + return "Failed" + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeError, ValueError): + return "Failed" + if not isinstance(value, dict): + return "Failed" + error = value.get("error") + if isinstance(error, dict): + message = str(error.get("message") or "").lower() + return "NoActiveStream" if "cannot be canceled" in message or "not found" in message else "Failed" + state = _event_task_state(value) + if state == "TASK_STATE_CANCELED": + return "Stopped" + return "Stopping" if isinstance(value.get("result"), dict) else "Failed" + + def _consume_a2a( + self, + session: _Session, + call: _UpstreamCall, + payload: dict[str, Any], + upstream_url: str, + ) -> None: + request = Request( + upstream_url, + data=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), + headers={"A2A-Version": "1.0", "Accept": "text/event-stream", "Content-Type": "application/json"}, + method="POST", + ) + try: + non_sse_body = bytearray() + saw_sse_event = False + with build_opener(ProxyHandler({})).open(request, timeout=self.upstream_timeout) as response: + for raw_line in response: + line = raw_line.decode("utf-8", "replace").strip() + if not line.startswith("data:"): + remaining = _MAX_UPSTREAM_NON_SSE_BYTES - len(non_sse_body) + if not saw_sse_event and remaining > 0: + non_sse_body.extend(raw_line[:remaining]) + continue + try: + event = json.loads(line[5:].strip()) + except ValueError: + continue + if not isinstance(event, dict): + continue + saw_sse_event = True + task_id = _event_task_id(event) + if task_id: + with session.state_lock: + session.task_id = task_id + task_state = _event_task_state(event) + if task_state: + call.last_task_state = task_state + self._observe_sideband_state(session, event) + ack_input_id = _permission_ack_input_id(event) + if ack_input_id: + call.acknowledged_input_ids.add(ack_input_id) + call.events.put(event) + if not saw_sse_event: + code = "A2AEmptyStream" + message = "A2A ended without an SSE event." + try: + value = json.loads(non_sse_body.decode("utf-8")) + except (UnicodeError, ValueError): + value = None + error = value.get("error") if isinstance(value, dict) else None + if isinstance(error, dict): + code = " ".join(str(error.get("code") or "A2AJsonRpcError").split())[:160] + message = " ".join(str(error.get("message") or "A2A returned a JSON-RPC error.").split())[:2000] + call.events.put(_upstream_failure(session.session_id, code, message)) + except HTTPError as exc: + call.events.put( + _upstream_failure( + session.session_id, + "A2AHttpError", + "A2A returned HTTP {}.".format(exc.code), + ) + ) + except (OSError, URLError) as exc: + call.events.put(_upstream_failure(session.session_id, "A2AConnectionError", str(exc))) + finally: + call.events.put(_END) + + @staticmethod + def _observe_sideband_state(session: _Session, event: dict[str, Any]) -> None: + iac_code = _iac_code_metadata(event) + if iac_code is None: + return + direct = _sideband_input(event) + pending = iac_code.get("pendingPermissions") + with session.state_lock: + if _normal_handoff_ready(event): + session.normal_handoff_ready = True + if isinstance(pending, list): + session.pending_sideband = { + input_id: dict(item) + for item in pending + if isinstance(item, dict) and isinstance((input_id := item.get("inputId")), str) and input_id + } + if direct is not None: + input_id = direct.get("inputId") + if isinstance(input_id, str) and input_id: + session.pending_sideband[input_id] = dict(direct) + + +class _StartChatHandler(BaseHTTPRequestHandler): + server: StartChatRelay + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + self._request_metric: dict[str, Any] | None = None + try: + try: + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) if length else b"" + parameters = _single_value_parameters(self.path, body) + action = parameters.get("Action") or self.headers.get("x-acs-action") + except (StartChatRequestError, UnicodeDecodeError, ValueError) as exc: + error = ( + exc + if isinstance(exc, StartChatRequestError) + else StartChatRequestError("InvalidParameter", str(exc)) + ) + self._json_error(error) + return + if action == "StopChat": + self._do_stop_chat(body) + else: + self._do_start_chat(body) + finally: + if self._request_metric is not None: + self.server.finish_request_metric(self._request_metric) + + def _do_start_chat(self, body: bytes) -> None: + try: + parameters = parse_start_chat_request(self.path, body, self.headers) + session, _created = self.server.resolve_session(parameters) + self._request_metric = self.server.begin_request_metric(session, parameters) + except StartChatRequestError as error: + self._json_error(error) + return + except (UnicodeDecodeError, ValueError) as exc: + error = StartChatRequestError("InvalidParameter", str(exc)) + self._json_error(error) + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + self.close_connection = True + + self._active_session = session + permission_payload = _permission_query(parameters["Query"]) + if permission_payload is not None: + input_id = permission_payload.get("inputId") + with session.state_lock: + is_sideband = isinstance(input_id, str) and input_id in session.pending_sideband + response_call = self.server.start_a2a_call(session, parameters) + self._relay_until_end(response_call) + if is_sideband and isinstance(input_id, str) and input_id in response_call.acknowledged_input_ids: + with session.state_lock: + session.pending_sideband.pop(input_id, None) + if not is_sideband and session.mode != "IaCCodePipeline": + with session.state_lock: + parent_call = session.active_call + if parent_call is not None: + parent_ended = self._relay_until_serial_boundary(parent_call) + if parent_ended: + with session.state_lock: + if session.active_call is parent_call: + session.active_call = None + return + + call = self.server.start_a2a_call(session, parameters) + with session.state_lock: + owns_parent_stream = session.active_call is None + if owns_parent_stream: + session.active_call = call + if not owns_parent_stream: + self._relay_until_end(call) + return + parent_ended = ( + self._relay_until_end(call) + if session.mode == "IaCCodePipeline" + else self._relay_until_serial_boundary(call) + ) + if parent_ended: + with session.state_lock: + if session.active_call is call: + session.active_call = None + + def _do_stop_chat(self, body: bytes) -> None: + try: + parameters = parse_stop_chat_request(self.path, body, self.headers) + self._request_metric = self.server.begin_stop_metric(parameters) + status = self.server.stop_session(parameters["SessionId"]) + except StartChatRequestError as error: + if self._request_metric is not None: + self._request_metric["errorCode"] = error.code + self._json_error(error) + return + self._request_metric["stopStatus"] = status + response = json.dumps( + {"Status": status, "SessionId": parameters["SessionId"], "RequestId": str(uuid.uuid4())}, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(response))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(response) + self.close_connection = True + + def _write_event(self, event: dict[str, Any]) -> bool: + try: + data = json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + self.wfile.write(b"data: " + data + b"\n\n") + self.wfile.flush() + self._observe_returned_event(event, len(data) + 8) + return True + except (BrokenPipeError, ConnectionResetError): + return False + + def _relay_until_end(self, call: _UpstreamCall) -> bool: + while True: + try: + item = call.events.get(timeout=self.server.heartbeat_interval) + except queue.Empty: + if not self._write_event({"object": "heartbeat"}): + return False + continue + if item is _END: + return True + assert isinstance(item, dict) + if not self._write_event(item): + return False + + def _relay_until_serial_boundary(self, call: _UpstreamCall) -> bool: + while True: + try: + item = call.events.get(timeout=self.server.heartbeat_interval) + except queue.Empty: + if not self._write_event({"object": "heartbeat"}): + return False + continue + if item is _END: + return True + assert isinstance(item, dict) + if not self._write_event(item): + return False + if _is_serial_input_boundary(item): + return False + + def _observe_returned_event(self, event: dict[str, Any], wire_bytes: int) -> None: + if self._request_metric is None: + return + self._request_metric["returnedEventCount"] += 1 + self._request_metric["returnedSseBytes"] += wire_bytes + kinds = self._request_metric["eventKinds"] + result = event.get("result") + if isinstance(result, dict): + for key in ("statusUpdate", "artifactUpdate", "task", "message"): + if isinstance(result.get(key), dict): + kinds[key] = kinds.get(key, 0) + 1 + iac_code = _iac_code_metadata(event) + if iac_code is None: + return + input_value = iac_code.get("input") + if isinstance(input_value, dict) and isinstance(input_value.get("kind"), str): + key = "input:" + input_value["kind"] + kinds[key] = kinds.get(key, 0) + 1 + for envelope in _pipeline_envelopes(iac_code): + event_type = envelope.get("eventType") + if isinstance(event_type, str) and event_type: + key = "pipeline:" + event_type + kinds[key] = kinds.get(key, 0) + 1 + + def _json_error(self, error: StartChatRequestError) -> None: + body = json.dumps( + {"Code": error.code, "Message": error.message, "RequestId": str(uuid.uuid4())}, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + self.send_response(400) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + self.close_connection = True + + def log_message(self, _format: str, *args: object) -> None: + return + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run the local HTTPS ROS chat E2E relay.") + parser.add_argument("--a2a-url", required=True) + parser.add_argument("--pipeline-a2a-url") + parser.add_argument("--workspace", required=True) + parser.add_argument("--cert-file", required=True) + parser.add_argument("--key-file", required=True) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--upstream-timeout", type=float, default=900.0) + parser.add_argument("--heartbeat-interval", type=float, default=15.0) + parser.add_argument("--metrics-file") + args = parser.parse_args(argv) + + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain(args.cert_file, args.key_file) + server = StartChatRelay( + (args.host, args.port), + a2a_url=args.a2a_url, + pipeline_a2a_url=args.pipeline_a2a_url, + workspace=args.workspace, + ssl_context=ssl_context, + upstream_timeout=args.upstream_timeout, + heartbeat_interval=args.heartbeat_interval, + metrics_path=args.metrics_file, + ) + + def stop(_signum: int, _frame: object) -> None: + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + print( + json.dumps( + {"host": args.host, "port": server.server_address[1], "protocol": "https"}, + separators=(",", ":"), + ), + flush=True, + ) + try: + server.serve_forever() + finally: + server.server_close() + server._write_metrics() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py new file mode 100644 index 00000000..18cb8e3e --- /dev/null +++ b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py @@ -0,0 +1,3891 @@ +from __future__ import annotations + +import argparse +import ast +import importlib.util +import io +import json +import os +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BRIDGE_PATH = ROOT / "skills/alicloud-ros-agent/scripts/ros_agent.py" + + +def _load_bridge(): + spec = importlib.util.spec_from_file_location("alicloud_ros_agent_skill_bridge", BRIDGE_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +bridge = _load_bridge() + + +def _clear_code_credential_env(monkeypatch) -> None: + for name in bridge.ACCESS_KEY_ID_ENV_NAMES + bridge.ACCESS_KEY_SECRET_ENV_NAMES + bridge.SECURITY_TOKEN_ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +def _clear_region_and_profile_env(monkeypatch) -> None: + for name in bridge.REGION_ENV_NAMES + bridge.PROFILE_ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +def _chat_args(**overrides): + values = { + "aliyun_path": "aliyun", + "endpoint": "ros.aliyuncs.com", + "connect_timeout": 10, + "read_timeout": 600, + "profile": None, + "region_id": "cn-hangzhou", + "no_thinking": False, + "mode": "normal", + "session_id": None, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def _status_event(*, state="TASK_STATE_WORKING", text="", metadata=None): + message = {"role": "ROLE_AGENT", "parts": [{"text": text}]} if text else None + status = {"state": state} + if message is not None: + status["message"] = message + return { + "result": { + "statusUpdate": { + "taskId": "task-1", + "contextId": "session-1", + "status": status, + "metadata": {"iac_code": metadata or {}, "iacCodeSessionId": "iac-session-1"}, + } + } + } + + +def test_bridge_parses_as_python_38_and_uses_only_standard_library_imports() -> None: + source = BRIDGE_PATH.read_text(encoding="utf-8") + tree = ast.parse(source, feature_version=(3, 8)) + imported_modules = { + alias.name.split(".", 1)[0] for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names + } + imported_modules.update( + node.module.split(".", 1)[0] for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module + ) + assert imported_modules <= { + "argparse", + "contextlib", + "ctypes", + "errno", + "fcntl", + "hashlib", + "http", + "importlib", + "json", + "msvcrt", + "os", + "pathlib", + "re", + "secrets", + "shutil", + "socket", + "ssl", + "subprocess", + "sys", + "tempfile", + "time", + "typing", + "urllib", + "uuid", + } + assert "access-key-id" not in source.lower() + assert "access-key-secret" not in source.lower() + + +def test_build_command_forces_post_rpc_without_explicit_version_or_credentials(monkeypatch) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + command = bridge.build_command( + _chat_args( + mode="pipeline", + profile="skill-profile", + session_id="session-1", + no_thinking=True, + ), + "创建 VPC", + '{"preferredLanguage":"zh"}', + [ + { + "Type": "image", + "MimeType": "image/png", + "Name": "diagram.png", + "OssObjectKey": "user/workspace/diagram.png", + } + ], + ) + + assert command[:3] == ["/usr/local/bin/aliyun", "ros", "StartChat"] + assert "--force" in command + assert command[command.index("--method") + 1] == "POST" + assert command[command.index("--Mode") + 1] == "IaCCodePipeline" + assert "--PipelineName" not in command + assert command[command.index("--SessionId") + 1] == "session-1" + assert command[command.index("--EnablePartialMessage") + 1] == "true" + assert command[command.index("--EnableThinking") + 1] == "false" + assert command[command.index("--Attachments.1.OssObjectKey") + 1] == "user/workspace/diagram.png" + assert command[command.index("--Query") + 1] == "创建 VPC" + assert command[command.index("--user-agent") + 1] == bridge.USER_AGENT + assert "--version" not in command + assert not any("access-key" in value.lower() for value in command) + + +def test_build_command_rejects_non_aliyun_endpoint(monkeypatch) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + with pytest.raises(bridge.BridgeError, match="aliyuncs.com"): + bridge.build_command(_chat_args(endpoint="https://attacker.example"), "hello", None, []) + + +def test_build_command_supports_loopback_endpoint_through_native_cli(monkeypatch) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + command = bridge.build_command(_chat_args(endpoint="127.0.0.1:56124"), "hello", None, []) + + assert command[command.index("--endpoint") + 1] == "127.0.0.1:56124" + assert "--secure" in command + assert "--skip-secure-verify" in command + + +def test_build_stop_command_uses_only_published_stop_chat_inputs(monkeypatch) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + command = bridge.build_stop_command( + { + "aliyunPath": "aliyun", + "endpoint": "127.0.0.1:56124", + "connectTimeout": 10, + "profile": "skill-profile", + "regionId": "cn-hangzhou", + }, + "session-1", + ) + + assert command[:3] == ["/usr/local/bin/aliyun", "ros", "StopChat"] + assert command[command.index("--method") + 1] == "POST" + assert command[command.index("--AgentVersion") + 1] == "V2" + assert command[command.index("--SessionId") + 1] == "session-1" + assert command[command.index("--profile") + 1] == "skill-profile" + assert command[command.index("--region") + 1] == "cn-hangzhou" + assert command[command.index("--user-agent") + 1] == bridge.USER_AGENT + assert "--secure" in command + assert "--skip-secure-verify" in command + assert "--Query" not in command + assert "--Mode" not in command + assert not any("access-key" in value.lower() for value in command) + + +def test_optional_skill_config_defaults_and_applies_endpoint_and_mode_policy(tmp_path: Path) -> None: + missing = tmp_path / "missing.json" + assert bridge.load_skill_config(missing) == {} + + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "transport": "aliyun_cli", + "endpoint": "127.0.0.1:56124", + "allowedAgentModes": ["normal"], + "managerIdleSeconds": 45, + "enableThinking": False, + "aliyunCLIProfile": "fixed-profile", + } + ), + encoding="utf-8", + ) + config = bridge.load_skill_config(config_path) + args = argparse.Namespace(command="chat", endpoint=None, mode="normal", profile=None, no_thinking=False) + bridge.apply_skill_config(args, config) + + assert args.endpoint == "127.0.0.1:56124" + assert args.transport == "aliyun_cli" + assert config["allowedAgentModes"] == ["normal"] + assert args.manager_idle_seconds == 45 + assert args.no_thinking is True + assert args.profile == "fixed-profile" + assert args.profile_pinned is True + + follow = argparse.Namespace(command="follow") + bridge.apply_skill_config(follow, config) + assert follow.manager_idle_seconds == 45 + + disallowed = argparse.Namespace(command="chat", endpoint=None, mode="pipeline") + with pytest.raises(bridge.BridgeError, match="not allowed"): + bridge.apply_skill_config(disallowed, config) + + conflicting = argparse.Namespace(command="chat", endpoint="ros.aliyuncs.com", mode="normal") + with pytest.raises(bridge.BridgeError, match="conflicts"): + bridge.apply_skill_config(conflicting, config) + + +def test_transport_cannot_be_overridden_by_a_bridge_command() -> None: + parser = bridge.build_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["check", "--transport", "aliyun_cli"]) + with pytest.raises(SystemExit): + parser.parse_args(["start", "--prompt-file", "prompt.txt", "--transport", "aliyun_cli"]) + + +def test_profile_and_thinking_fixed_by_config_reject_conflicting_start_flags() -> None: + config = {"enableThinking": True, "aliyunCLIProfile": "fixed-profile"} + wrong_profile = argparse.Namespace( + command="start", + endpoint=None, + mode="normal", + profile="other-profile", + no_thinking=False, + ) + with pytest.raises(bridge.BridgeError) as profile_error: + bridge.apply_skill_config(wrong_profile, config) + assert profile_error.value.code == "config_conflict" + + wrong_thinking = argparse.Namespace( + command="start", + endpoint=None, + mode="normal", + profile="fixed-profile", + no_thinking=True, + ) + with pytest.raises(bridge.BridgeError) as thinking_error: + bridge.apply_skill_config(wrong_thinking, config) + assert thinking_error.value.code == "config_conflict" + + +@pytest.mark.parametrize( + "value", + [ + [], + {"unknown": True}, + {"transport": "unsupported"}, + {"transport": True}, + {"endpoint": "https://127.0.0.1:56124"}, + {"endpoint": "attacker.example"}, + {"allowedAgentModes": []}, + {"allowedAgentModes": ["normal", "normal"]}, + {"allowedAgentModes": ["unsupported"]}, + {"managerIdleSeconds": True}, + {"managerIdleSeconds": 0}, + {"managerIdleSeconds": 1.5}, + {"managerIdleSeconds": bridge.MAX_MANAGER_IDLE_SECONDS + 1}, + {"enableThinking": "false"}, + {"enableThinking": 1}, + {"aliyunCLIProfile": None}, + {"aliyunCLIProfile": " padded"}, + {"aliyunCLIProfile": "bad\nprofile"}, + ], +) +def test_skill_config_rejects_invalid_or_unsupported_values(tmp_path: Path, value) -> None: + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps(value), encoding="utf-8") + + with pytest.raises(bridge.BridgeError) as error: + bridge.load_skill_config(config_path) + + assert error.value.code == "invalid_config" + + +def test_main_reads_skill_config_before_dispatch(monkeypatch, tmp_path: Path, capsys) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "transport": "code", + "endpoint": "localhost:56124", + "allowedAgentModes": ["pipeline"], + "managerIdleSeconds": 75, + "enableThinking": False, + "aliyunCLIProfile": "fixed-profile", + } + ), + encoding="utf-8", + ) + captured = {} + + def fake_run_start(args): + captured["endpoint"] = args.endpoint + captured["transport"] = args.transport + captured["mode"] = args.mode + captured["managerIdleSeconds"] = args.manager_idle_seconds + captured["enableThinking"] = not args.no_thinking + captured["profile"] = args.profile + return {"ok": True, "state": "turn-completed"} + + monkeypatch.setattr(bridge, "SKILL_CONFIG_PATH", config_path) + monkeypatch.setattr(bridge, "run_start_job", fake_run_start) + exit_code = bridge.main( + [ + "start", + "--prompt-file", + str(tmp_path / "unused.txt"), + "--mode", + "pipeline", + ] + ) + + assert exit_code == 0 + assert captured == { + "endpoint": "localhost:56124", + "transport": "code", + "mode": "pipeline", + "managerIdleSeconds": 75, + "enableThinking": False, + "profile": "fixed-profile", + } + assert json.loads(capsys.readouterr().out)["ok"] is True + + +def test_managed_start_persists_effective_environment_identity_region_and_thinking(monkeypatch, tmp_path: Path) -> None: + _clear_code_credential_env(monkeypatch) + _clear_region_and_profile_env(monkeypatch) + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_ID", "fake-env-ak") + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_SECRET", "fake-env-secret") + monkeypatch.setenv("ALIBABACLOUD_REGION_ID", "cn-shenzhen") + prompt = tmp_path / "prompt.txt" + prompt.write_text("创建 VPC", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(bridge, "ensure_manager", lambda _idle: "manager") + captured = {} + + def fake_manager_request(record, path, payload, timeout): + captured.update({"record": record, "path": path, "payload": payload, "timeout": timeout}) + return {"ok": True, "jobId": "job-1", "cursor": 0, "state": "submitted"} + + monkeypatch.setattr(bridge, "_manager_request", fake_manager_request) + args = bridge.build_parser().parse_args(["start", "--prompt-file", str(prompt)]) + bridge.apply_skill_config(args, {"enableThinking": False}) + + result = bridge.run_start_job(args) + + assert result["ok"] is True + assert captured["payload"]["profile"] is None + assert captured["payload"]["credentialSource"] == "environment" + assert captured["payload"]["regionId"] == "cn-shenzhen" + assert captured["payload"]["noThinking"] is True + + +def test_check_returns_safe_current_profile_and_effective_skill_policy(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + captured = {} + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda profile: { + "name": profile or "test-profile", + "mode": "OAuth", + "language": "zh", + "regionId": "cn-hangzhou", + }, + ) + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: {"sdk": True}) + monkeypatch.setattr( + bridge, + "_code_credentials", + lambda sdk, aliyun_path, profile, region_id, credential_source: captured.update( + { + "sdk": sdk, + "aliyunPath": aliyun_path, + "profile": profile, + "regionId": region_id, + "credentialSource": credential_source, + } + ), + ) + args = argparse.Namespace(command="check", aliyun_path="aliyun") + bridge.apply_skill_config( + args, + {"endpoint": "127.0.0.1:56124", "allowedAgentModes": ["normal"]}, + ) + + result = bridge.run_check(args) + + assert result == { + "ok": True, + "cli": None, + "version": None, + "transport": "code", + "endpoint": "127.0.0.1:56124", + "allowedAgentModes": ["normal"], + "managerIdleSeconds": bridge.MANAGER_IDLE_SECONDS, + "enableThinking": True, + "aliyunCLIProfile": "", + "currentProfile": { + "configured": True, + "name": "test-profile", + "mode": "OAuth", + "language": "zh", + "regionId": "cn-hangzhou", + }, + } + assert captured == { + "sdk": {"sdk": True}, + "aliyunPath": "aliyun", + "profile": "test-profile", + "regionId": "cn-hangzhou", + "credentialSource": "profile", + } + + +def test_check_rejects_an_unavailable_selected_profile(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: {}) + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda _profile: (_ for _ in ()).throw( + bridge.BridgeError("credential_failed", "The selected Alibaba Cloud CLI Profile is not configured.") + ), + ) + args = argparse.Namespace(command="check", aliyun_path="aliyun") + bridge.apply_skill_config(args, {}) + + with pytest.raises(bridge.BridgeError) as error: + bridge.run_check(args) + assert error.value.code == "credential_failed" + + +def test_code_check_prefers_cli_compatible_environment_credentials(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + monkeypatch.setenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "fake-env-ak") + monkeypatch.setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "fake-env-secret") + monkeypatch.setenv("ALIBABA_CLOUD_SECURITY_TOKEN", "fake-env-token") + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", "cn-shanghai") + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: pytest.fail("environment mode must not require CLI")) + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: {}) + args = argparse.Namespace(command="check", aliyun_path="aliyun") + bridge.apply_skill_config(args, {}) + + result = bridge.run_check(args) + + assert result["currentProfile"] == { + "configured": True, + "mode": "Environment", + "regionId": "cn-shanghai", + } + assert result["cli"] is None + assert result["version"] is None + assert "fake-env" not in json.dumps(result) + + +def test_environment_credential_alias_order_matches_aliyun_cli_and_partial_values_fail(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + monkeypatch.setenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "first-ak") + monkeypatch.setenv("ACCESS_KEY_ID", "last-ak") + monkeypatch.setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "first-secret") + monkeypatch.setenv("ACCESS_KEY_SECRET", "last-secret") + monkeypatch.setenv("ALICLOUD_SECURITY_TOKEN", "token") + + assert bridge._environment_credentials() == ("first-ak", "first-secret", "token") + + _clear_code_credential_env(monkeypatch) + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_ID", "orphan-ak") + with pytest.raises(bridge.BridgeError) as error: + bridge._environment_credentials() + assert error.value.code == "credential_failed" + + +def test_code_start_identity_uses_environment_region_without_requiring_cli(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + _clear_region_and_profile_env(monkeypatch) + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_ID", "fake-env-ak") + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_SECRET", "fake-env-secret") + monkeypatch.setenv("ALIBABACLOUD_REGION_ID", "cn-shanghai") + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda _profile: pytest.fail("environment credentials must not require a CLI Profile"), + ) + args = SimpleNamespace( + transport="code", + profile=None, + profile_pinned=False, + region_id=None, + ) + + bridge._resolve_start_identity(args) + + assert args.profile is None + assert args.credential_source == "environment" + assert args.region_id == "cn-shanghai" + + +def test_code_start_identity_defaults_environment_region_to_hangzhou(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + _clear_region_and_profile_env(monkeypatch) + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_ID", "fake-env-ak") + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_SECRET", "fake-env-secret") + args = SimpleNamespace( + transport="code", + profile=None, + profile_pinned=False, + region_id=None, + ) + + bridge._resolve_start_identity(args) + + assert args.credential_source == "environment" + assert args.region_id == "cn-hangzhou" + + +def test_pinned_profile_identity_ignores_environment_credentials_and_uses_environment_region(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + _clear_region_and_profile_env(monkeypatch) + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_ID", "fake-env-ak") + monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_SECRET", "fake-env-secret") + monkeypatch.setenv("ALIBABACLOUD_REGION_ID", "cn-beijing") + captured = [] + + def fake_profile(profile): + captured.append(profile) + return {"name": profile, "mode": "AK", "regionId": "cn-shanghai"} + + monkeypatch.setattr(bridge, "_selected_cli_profile_record", fake_profile) + args = SimpleNamespace( + transport="code", + profile="fixed-profile", + profile_pinned=True, + region_id=None, + ) + + bridge._resolve_start_identity(args) + + assert captured == ["fixed-profile"] + assert args.profile == "fixed-profile" + assert args.credential_source == "profile" + assert args.region_id == "cn-beijing" + + +def test_aliyun_cli_check_does_not_load_optional_sdk_packages(monkeypatch) -> None: + def fake_run(command, **_kwargs): + if command[-1] == "version": + return SimpleNamespace(returncode=0, stdout=b"3.4.11\n", stderr=b"") + return SimpleNamespace(returncode=0, stdout=b"", stderr=b"") + + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda profile: {"name": profile or "default", "mode": "AK", "regionId": "cn-hangzhou"}, + ) + monkeypatch.setattr(bridge.subprocess, "run", fake_run) + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: pytest.fail("CLI transport must not load SDK packages")) + args = argparse.Namespace(command="check", aliyun_path="aliyun") + bridge.apply_skill_config(args, {"transport": "aliyun_cli"}) + + result = bridge.run_check(args) + + assert result["ok"] is True + assert result["transport"] == "aliyun_cli" + + +def test_workspace_json_inputs_validate_context_and_flatten_attachments(tmp_path: Path) -> None: + context = tmp_path / "context.json" + context.write_text('{"preferredLanguage": "zh"}', encoding="utf-8") + attachments = tmp_path / "attachments.json" + attachments.write_text( + json.dumps( + [ + { + "type": "image", + "mime_type": "image/webp", + "name": "map.webp", + "oss_object_key": "user/workspace/map.webp", + } + ] + ), + encoding="utf-8", + ) + + assert bridge.load_client_context(tmp_path, str(context)) == '{"preferredLanguage":"zh"}' + assert bridge.load_attachments(tmp_path, str(attachments)) == [ + { + "Type": "image", + "MimeType": "image/webp", + "Name": "map.webp", + "OssObjectKey": "user/workspace/map.webp", + } + ] + + +def test_permission_query_projects_only_correlated_control_fields(tmp_path: Path) -> None: + permission_file = tmp_path / "permission.json" + permission_file.write_text( + json.dumps( + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "toolName": "bash", + "safeSummary": "pwd", + "permissionClass": "pipeline", + } + ), + encoding="utf-8", + ) + + query, response = bridge.load_permission_query( + tmp_path, + str(permission_file), + "allow_once", + "session-1", + "pipeline", + ) + + assert query.startswith(bridge.PERMISSION_QUERY_PREFIX + " ") + assert json.loads(query[len(bridge.PERMISSION_QUERY_PREFIX) :]) == { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + } + assert response == { + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + } + + +def test_permission_query_rejects_session_or_mode_mismatch(tmp_path: Path) -> None: + permission_file = tmp_path / "permission.json" + permission_file.write_text( + json.dumps( + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "sub_pipeline", + } + ), + encoding="utf-8", + ) + + with pytest.raises(bridge.BridgeError, match="contextId"): + bridge.load_permission_query(tmp_path, str(permission_file), "deny", "session-other", "pipeline") + with pytest.raises(bridge.BridgeError, match="permissionClass"): + bridge.load_permission_query(tmp_path, str(permission_file), "deny", "session-1", "normal") + + +def test_prompt_file_must_be_utf8_nonempty_and_inside_workspace(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("hello", encoding="utf-8") + with pytest.raises(bridge.BridgeError, match="inside"): + bridge.read_prompt(workspace, str(outside)) + + empty = workspace / "empty.txt" + empty.write_text(" ", encoding="utf-8") + with pytest.raises(bridge.BridgeError, match="empty"): + bridge.read_prompt(workspace, str(empty)) + + +def test_sse_parser_handles_heartbeats_multiline_data_and_raw_json() -> None: + lines = [ + ": comment\n", + 'data: {"object":"heartbeat"}\n', + "\n", + 'data: {"value":\n', + "data: 1}\n", + "\n", + '{"result":{"ok":true}}\n', + ] + events = list(bridge.iter_sse_payloads(lines)) + assert [event[0] for event in events] == [ + {"object": "heartbeat"}, + {"value": 1}, + {"result": {"ok": True}}, + ] + + +def test_sse_parser_rejects_an_unterminated_event_as_soon_as_its_cumulative_limit_is_exceeded( + monkeypatch, +) -> None: + monkeypatch.setattr(bridge, "MAX_SSE_EVENT_BYTES", 30) + + with pytest.raises(bridge.BridgeError, match="event exceeded"): + list(bridge.iter_sse_payloads(["data: 1234567890\n", "data: 1234567890\n"])) + + +def test_sse_line_and_event_limits_allow_realistic_large_start_chat_payloads() -> None: + assert bridge.MAX_SSE_LINE_BYTES == 16 * 1024 * 1024 + assert bridge.MAX_SSE_EVENT_BYTES == 16 * 1024 * 1024 + + +def test_permission_response_acknowledgement_requires_the_full_response_identity() -> None: + response = {"inputId": "permission-1", "toolUseId": "tool-1", "decision": "allow_once"} + acknowledgement = { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + } + + assert bridge._permission_response_is_acknowledged(response, acknowledgement) + for field, value in (("inputId", "permission-2"), ("toolUseId", "tool-2"), ("decision", "deny")): + mismatched = dict(acknowledgement) + mismatched[field] = value + assert not bridge._permission_response_is_acknowledged(response, mismatched) + missing_schema = dict(acknowledgement) + missing_schema.pop("schemaVersion") + assert not bridge._permission_response_is_acknowledged(response, missing_schema) + + +def test_stream_summary_projects_completed_turn_identity_and_artifacts() -> None: + summary = bridge.StreamSummary() + summary.apply({"object": "heartbeat"}) + summary.apply( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + text="template ready", + metadata={"assistantFinal": {"complete": True}}, + ) + ) + summary.apply( + { + "result": { + "artifactUpdate": { + "taskId": "task-1", + "contextId": "session-1", + "artifact": { + "artifactId": "artifact-1", + "name": "template.yaml", + "parts": [{"url": "file:///workspace/template.yaml"}], + "metadata": {"mediaType": "application/yaml", "byteSize": 100}, + }, + } + } + } + ) + + result = summary.to_result(0, "") + assert result["ok"] is True + assert result["state"] == "turn-completed" + assert result["presentationRequired"] is True + assert result["wireState"] == "TASK_STATE_INPUT_REQUIRED" + assert result["sessionId"] == "session-1" + assert result["taskId"] == "task-1" + assert result["iacCodeSessionId"] == "iac-session-1" + assert result["finalText"] == "template ready" + assert result["finalTextComplete"] is True + assert result["heartbeatCount"] == 1 + assert result["artifacts"][0]["name"] == "template.yaml" + + +def test_stream_summary_final_snapshot_replaces_streamed_deltas() -> None: + summary = bridge.StreamSummary() + summary.apply(_status_event(state="TASK_STATE_WORKING", text="template ")) + summary.apply(_status_event(state="TASK_STATE_WORKING", text="ready")) + summary.apply( + _status_event( + state="TASK_STATE_WORKING", + text="template ready", + metadata={"assistantFinal": {"complete": True}}, + ) + ) + summary.apply(_status_event(state="TASK_STATE_INPUT_REQUIRED")) + + result = summary.to_result(0, "") + + assert result["state"] == "turn-completed" + assert result["finalText"] == "template ready" + assert result["finalTextComplete"] is True + + +def test_stream_summary_projects_input_required_and_pipeline_milestone() -> None: + envelope = { + "schemaVersion": 1, + "kind": "candidate_selection", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "input-1", + "prompt": "Choose", + "options": [{"id": "candidate-a", "label": "A", "summary": "small"}], + "required": True, + } + summary = bridge.StreamSummary() + summary.apply( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + metadata={ + "input": envelope, + "pipeline": { + "eventType": "input_required", + "status": "input_required", + "step": {"id": "confirm_and_select", "name": "Confirm"}, + "data": {"message": "Select a candidate"}, + }, + }, + ) + ) + result = summary.to_result(0, "") + assert result["state"] == "input-required" + assert result["presentationRequired"] is True + assert result["inputRequired"]["inputId"] == "input-1" + assert result["inputRequired"]["options"][0]["id"] == "candidate-a" + assert result["milestones"][0]["eventType"] == "input_required" + + +def test_stream_summary_keeps_all_sub_pipeline_pending_permissions() -> None: + pending = [ + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-{}".format(index), + "toolUseId": "tool-{}".format(index), + "toolName": "bash", + "prompt": "Allow candidate {}?".format(index), + "options": [ + {"id": "allow_once", "label": "Allow once"}, + {"id": "deny", "label": "Deny"}, + ], + "required": True, + } + for index in range(2) + ] + summary = bridge.StreamSummary(mode="pipeline") + summary.apply( + _status_event( + state="TASK_STATE_WORKING", + metadata={"pendingPermissions": pending}, + ) + ) + + result = summary.to_result(0, "") + + assert result["state"] == "input-required" + assert result["inputRequired"]["inputId"] == "permission-0" + assert result["inputRequired"]["permissionClass"] == "sub_pipeline" + assert result["inputRequired"]["permissionRef"].startswith("p-") + assert [item["inputId"] for item in result["pendingPermissions"]] == ["permission-0", "permission-1"] + assert {item["permissionClass"] for item in result["pendingPermissions"]} == {"sub_pipeline"} + assert len({item["permissionRef"] for item in result["pendingPermissions"]}) == 2 + + summary.apply(_status_event(state="TASK_STATE_WORKING", metadata={"pendingPermissions": []})) + resolved = summary.to_result(0, "") + assert resolved["state"] == "working" + assert "inputRequired" not in resolved + assert "pendingPermissions" not in resolved + + +def test_stream_summary_recognizes_sideband_envelope_without_pending_projection() -> None: + permission = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-sideband", + "toolUseId": "tool-sideband", + "toolName": "bash", + } + summary = bridge.StreamSummary(mode="pipeline") + summary.apply( + _status_event( + state="TASK_STATE_WORKING", + metadata={ + "input": permission, + "pipeline": {"eventType": "permission_requested", "status": "working"}, + }, + ) + ) + result = summary.to_result(0, "") + + assert result["inputRequired"]["permissionClass"] == "sub_pipeline" + assert result["pendingPermissions"] == [result["inputRequired"]] + + summary.apply(_status_event(state="TASK_STATE_WORKING", metadata={"pendingPermissions": []})) + summary.apply(_status_event(state="TASK_STATE_INPUT_REQUIRED", metadata={"input": permission})) + resolved = summary.to_result(0, "") + + assert resolved["state"] == "input-required" + assert "inputRequired" not in resolved + + +@pytest.mark.parametrize( + ("mode", "permission_class"), + [("normal", "normal"), ("pipeline", "pipeline")], +) +def test_stream_summary_classifies_serial_permission_by_run_mode(mode: str, permission_class: str) -> None: + permission = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "toolName": "bash", + "prompt": "Allow?", + "options": [ + {"id": "allow_once", "label": "Allow once"}, + {"id": "deny", "label": "Deny"}, + ], + "required": True, + } + summary = bridge.StreamSummary(mode=mode) + summary.apply( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + metadata={"input": permission}, + ) + ) + + result = summary.to_result(0, "") + + assert result["state"] == "input-required" + assert result["inputRequired"]["permissionClass"] == permission_class + + +def test_stream_summary_projects_sideband_permission_ack() -> None: + summary = bridge.StreamSummary("session-1", mode="pipeline") + summary.apply( + { + "result": { + "messageId": "permission-ack-1", + "taskId": "task-1", + "contextId": "session-1", + "role": "ROLE_AGENT", + "parts": [ + { + "mediaType": "application/json", + "data": { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + }, + } + ], + } + } + ) + + result = summary.to_result(0, "") + + assert result["state"] == "permission-responded" + assert result["permissionAck"]["accepted"] is True + assert result["permissionAck"]["inputId"] == "permission-1" + + +def test_stream_summary_selects_unacknowledged_pending_permission_after_ack() -> None: + permissions = [ + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-{}".format(index), + "toolUseId": "tool-{}".format(index), + "toolName": "bash", + "prompt": "Allow candidate {}?".format(index), + "options": [{"id": "allow_once", "label": "Allow once"}, {"id": "deny", "label": "Deny"}], + "required": True, + } + for index in range(2) + ] + summary = bridge.StreamSummary("session-1", mode="pipeline") + summary.apply(_status_event(state="TASK_STATE_WORKING", metadata={"pendingPermissions": permissions})) + summary.apply( + { + "result": { + "messageId": "permission-ack-1", + "taskId": "task-1", + "contextId": "session-1", + "role": "ROLE_AGENT", + "parts": [ + { + "mediaType": "application/json", + "data": { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-0", + "toolUseId": "tool-0", + "decision": "allow_once", + "accepted": True, + }, + } + ], + } + } + ) + + result = summary.to_result(0, "") + + assert result["state"] == "input-required" + assert result["inputRequired"]["inputId"] == "permission-1" + assert [item["inputId"] for item in result["pendingPermissions"]] == ["permission-1"] + assert result["permissionAck"]["inputId"] == "permission-0" + + +def test_stream_summary_ignores_permission_echo_after_sideband_ack() -> None: + summary = bridge.StreamSummary("session-1", mode="pipeline") + summary.apply( + { + "result": { + "messageId": "permission-ack-1", + "taskId": "task-1", + "contextId": "session-1", + "role": "ROLE_AGENT", + "parts": [ + { + "mediaType": "application/json", + "data": { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + }, + } + ], + } + } + ) + summary.apply( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + metadata={ + "input": { + "kind": "permission", + "inputId": "permission-1", + "toolUseId": "tool-1", + "toolName": "write_file", + } + }, + ) + ) + + result = summary.to_result(0, "") + + assert result["state"] == "permission-responded" + assert result["permissionAck"]["accepted"] is True + assert "inputRequired" not in result + + +def test_stream_projection_preserves_bounded_permission_suspend_and_recovery() -> None: + suspended = bridge._project_stream_event( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + metadata={ + "permissionWait": {"status": "suspended", "resumable": True, "ignored": "secret"}, + }, + ), + "normal", + 1, + ) + recovered = bridge._project_stream_event( + _status_event( + state="TASK_STATE_WORKING", + metadata={ + "permissionRecovered": { + "inputId": "permission-1", + "toolUseId": "tool-1", + "ignored": "secret", + } + }, + ), + "normal", + 2, + ) + + assert suspended["type"] == "permission-wait" + assert suspended["permissionWait"] == {"status": "suspended", "resumable": True} + assert recovered["type"] == "permission-recovered" + assert recovered["permissionRecovered"] == {"inputId": "permission-1", "toolUseId": "tool-1"} + assert "secret" not in json.dumps([suspended, recovered]) + + summary = bridge.StreamSummary("session-1", mode="normal") + summary.apply( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + metadata={"permissionWait": suspended["permissionWait"]}, + ) + ) + result = summary.to_result(0, "") + assert result["permissionWait"] == {"status": "suspended", "resumable": True} + + summary.apply( + _status_event( + state="TASK_STATE_WORKING", + metadata={"permissionRecovered": recovered["permissionRecovered"]}, + ) + ) + result = summary.to_result(0, "") + assert "permissionWait" not in result + assert result["permissionRecovered"] == {"inputId": "permission-1", "toolUseId": "tool-1"} + + +def test_stream_projection_excludes_internal_pipeline_handoff_context() -> None: + projection = bridge._project_stream_event( + _status_event( + state="TASK_STATE_COMPLETED", + metadata={ + "pipeline": { + "eventType": "pipeline_handoff_ready", + "status": "completed", + "data": { + "action": "switch_to_normal", + "targetMode": "normal", + "message": "[Pipeline Handoff Context] internal injected context", + }, + } + }, + ), + "pipeline", + 1, + ) + + assert projection["type"] == "terminal" + assert projection["normalHandoffReady"] is True + assert "milestones" not in projection + assert "Pipeline Handoff Context" not in json.dumps(projection) + + summary = bridge.StreamSummary(mode="pipeline") + summary.apply( + _status_event( + state="TASK_STATE_COMPLETED", + metadata={ + "pipeline": { + "eventType": "pipeline_handoff_ready", + "status": "completed", + "visibility": "committed", + "data": { + "action": "switch_to_normal", + "targetMode": "normal", + "message": "[Pipeline Handoff Context] internal injected context", + }, + } + }, + ) + ) + result = summary.to_result(0, "") + assert result["state"] == "completed" + assert result["normalHandoffReady"] is True + assert result["conversationMode"] == "normal" + assert "Pipeline Handoff Context" not in json.dumps(result) + + +def test_pipeline_stream_summary_does_not_regress_terminal_handoff_on_trailing_working() -> None: + summary = bridge.StreamSummary(mode="pipeline") + summary.apply( + _status_event( + state="TASK_STATE_COMPLETED", + metadata={ + "pipelineBatch": { + "events": [ + { + "eventType": "step_completed", + "status": "completed", + "step": {"id": "deploying", "name": "Deploy"}, + "data": { + "conclusionField": "deployment", + "conclusion": { + "status": "success", + "stack_id": "stack-1", + "resources_created": ["vsw-1"], + }, + }, + }, + { + "eventType": "pipeline_handoff_ready", + "status": "completed", + "data": {"action": "switch_to_normal", "targetMode": "normal"}, + }, + ] + } + }, + ) + ) + summary.apply( + _status_event( + state="TASK_STATE_WORKING", + text="final deployment summary", + metadata={"assistantFinal": {"complete": True}}, + ) + ) + + result = summary.to_result(0, "") + + assert result["state"] == "completed" + assert result["wireState"] == "TASK_STATE_COMPLETED" + assert result["normalHandoffReady"] is True + assert result["conversationMode"] == "normal" + assert result["pipelineResult"] == { + "status": "success", + "stack_id": "stack-1", + "resources_created": ["vsw-1"], + } + + +@pytest.mark.parametrize( + "stale_metadata", + [ + { + "input": { + "schemaVersion": 1, + "kind": "candidate_selection", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "stale-candidate", + "prompt": "Choose", + "options": [{"id": "candidate-a", "label": "A"}], + "required": True, + } + }, + { + "pendingPermissions": [ + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "stale-permission", + "toolUseId": "tool-1", + "toolName": "aliyun_api", + "prompt": "Allow?", + "required": True, + } + ] + }, + ], +) +def test_stream_summary_terminal_state_rejects_stale_trailing_wait_boundaries(stale_metadata: dict) -> None: + summary = bridge.StreamSummary(mode="pipeline") + summary.apply(_status_event(state="TASK_STATE_COMPLETED")) + summary.apply(_status_event(state="TASK_STATE_WORKING", metadata=stale_metadata)) + + result = summary.to_result(0, "") + + assert result["state"] == "completed" + assert result["wireState"] == "TASK_STATE_COMPLETED" + assert "inputRequired" not in result + assert "pendingPermissions" not in result + assert "permissionWait" not in result + + +def test_managed_primary_terminal_hides_trailing_stale_input_before_finish(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "a" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "artifacts": [], + }, + ) + summary = bridge.StreamSummary(mode="pipeline") + completed = _status_event(state="TASK_STATE_COMPLETED") + stale_input = _status_event( + state="TASK_STATE_WORKING", + metadata={ + "input": { + "schemaVersion": 1, + "kind": "candidate_selection", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "stale-candidate", + "prompt": "Choose", + "options": [{"id": "candidate-a", "label": "A"}], + "required": True, + } + }, + ) + for payload in (completed, stale_input): + summary.apply(payload) + bridge._append_projection( + job_id, + bridge._project_managed_stream_event(payload, summary, "pipeline", 1, "primary", None), + ) + + job = bridge._load_state_json(job_path) + followed = bridge._follow_job_local(job_id, 0, 0) + + assert job["state"] == "working" + assert job["primaryStreamTerminalSeen"] is True + assert "inputRequired" not in job + assert "pendingPermissions" not in job + assert followed["state"] == "working" + assert "inputRequired" not in followed + assert all("inputRequired" not in event for event in bridge._read_spool(spool)) + + +def test_managed_sideband_terminal_hides_its_stale_input_without_closing_parent(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "b" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "activeRequestSeq": 2, + "workerPid": os.getpid(), + "sidebandWorkerPid": os.getpid(), + "sidebandWorkerToken": "sideband-token", + "artifacts": [], + }, + ) + summary = bridge.StreamSummary(mode="pipeline") + completed = _status_event(state="TASK_STATE_COMPLETED") + stale_input = _status_event( + state="TASK_STATE_WORKING", + metadata={ + "pendingPermissions": [ + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "stale-permission", + "toolUseId": "tool-1", + "toolName": "aliyun_api", + "prompt": "Allow?", + "required": True, + } + ] + }, + ) + for payload in (completed, stale_input): + summary.apply(payload) + bridge._append_projection( + job_id, + bridge._project_managed_stream_event( + payload, + summary, + "pipeline", + 2, + "sideband", + "sideband-token", + ), + ) + + job = bridge._load_state_json(job_path) + followed = bridge._follow_job_local(job_id, 0, 0) + + assert job["state"] == "working" + assert job["workerPid"] == os.getpid() + assert "primaryStreamTerminalSeen" not in job + assert "inputRequired" not in job + assert "pendingPermissions" not in job + assert followed["state"] == "working" + assert "inputRequired" not in followed + assert all("inputRequired" not in event for event in bridge._read_spool(spool)) + + +def test_pipeline_raw_input_boundary_stays_input_required_and_projects_deployment_result() -> None: + summary = bridge.StreamSummary(mode="pipeline") + summary.apply( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + metadata={ + "pipelineBatch": { + "events": [ + { + "eventType": "step_completed", + "status": "completed", + "step": {"id": "deploy"}, + "data": { + "conclusionField": "deployment", + "conclusion": { + "status": "success", + "stack_id": "stack-1", + "resources_created": ["vpc-1"], + "outputs": {"VpcId": "vpc-1"}, + }, + }, + } + ] + } + }, + ) + ) + result = summary.to_result(0, "") + assert result["state"] == "input-required" + assert "inputRequired" not in result + assert result["pipelineResult"] == { + "status": "success", + "stack_id": "stack-1", + "resources_created": ["vpc-1"], + "outputs": {"VpcId": "vpc-1"}, + } + + +def test_cli_failure_redacts_secrets_from_error() -> None: + summary = bridge.StreamSummary("session-1") + result = summary.to_result( + 1, + 'AccessKeySecret=super-secret Authorization: Bearer bearer-secret {"SecurityToken":"token-secret"}', + ) + message = result["error"]["message"] + assert result["state"] == "failed" + assert "super-secret" not in message + assert "bearer-secret" not in message + assert "token-secret" not in message + assert message.count("[REDACTED]") == 3 + + +def test_run_chat_consumes_fake_cli_stream_without_network(monkeypatch, tmp_path: Path) -> None: + prompt = tmp_path / "prompt.txt" + prompt.write_text("hello", encoding="utf-8") + output = ( + "data: " + + json.dumps( + _status_event( + state="TASK_STATE_INPUT_REQUIRED", + text="done", + metadata={"assistantFinal": {"complete": True}}, + ), + separators=(",", ":"), + ) + + "\n\n" + ) + captured = {} + + class FakeProcess: + def __init__(self): + self.stdout = io.StringIO(output) + + def wait(self, timeout=None): + del timeout + return 0 + + def poll(self): + return 0 + + def terminate(self): + return None + + def kill(self): + return None + + def fake_popen(command, **kwargs): + captured["command"] = command + captured["cwd"] = kwargs["cwd"] + return FakeProcess() + + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + monkeypatch.setattr(bridge.subprocess, "Popen", fake_popen) + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: pytest.fail("CLI transport must not load SDK packages")) + args = SimpleNamespace( + cwd=str(tmp_path), + prompt_file=str(prompt), + client_context_file=None, + attachments_file=None, + session_id=None, + aliyun_path="aliyun", + transport="aliyun_cli", + endpoint="ros.aliyuncs.com", + connect_timeout=10, + read_timeout=600, + profile=None, + region_id="cn-hangzhou", + no_thinking=False, + mode="normal", + ) + + result = bridge.run_chat(args) + assert result["state"] == "turn-completed" + assert result["finalText"] == "done" + assert captured["cwd"] == str(tmp_path) + assert captured["command"][captured["command"].index("--Query") + 1] == "hello" + + +def test_code_transport_streams_sdk_signed_request_without_cli_response_buffering(monkeypatch, tmp_path: Path) -> None: + prompt = tmp_path / "prompt.txt" + prompt.write_text("hello", encoding="utf-8") + event = _status_event( + state="TASK_STATE_COMPLETED", + text="done", + metadata={"assistantFinal": {"complete": True}}, + ) + captured = {} + + class FakeResponse: + headers = {"Content-Type": "text/event-stream; charset=utf-8"} + + def __init__(self): + self.closed = False + self.lines = iter([("data: " + json.dumps(event, separators=(",", ":")) + "\n\n").encode()]) + + def __iter__(self): + return self + + def __next__(self): + return next(self.lines) + + def close(self): + self.closed = True + + response = FakeResponse() + + def fake_open( + operation, parameters, endpoint, profile, region_id, aliyun_path, connect_timeout, read_timeout, **kwargs + ): + captured.update( + { + "operation": operation, + "parameters": parameters, + "endpoint": endpoint, + "profile": profile, + "regionId": region_id, + "aliyunPath": aliyun_path, + "connectTimeout": connect_timeout, + "readTimeout": read_timeout, + "options": kwargs, + } + ) + return response + + monkeypatch.setattr(bridge.subprocess, "Popen", lambda *_args, **_kwargs: pytest.fail("must not invoke CLI")) + monkeypatch.setattr(bridge, "_open_code_request", fake_open) + args = SimpleNamespace( + cwd=str(tmp_path), + prompt_file=str(prompt), + client_context_file=None, + attachments_file=None, + session_id=None, + aliyun_path="aliyun", + transport="code", + endpoint="127.0.0.1:56124", + connect_timeout=10, + read_timeout=600, + profile="skill-profile", + region_id="cn-hangzhou", + no_thinking=False, + mode="normal", + ) + + result = bridge.run_chat(args) + + assert result["state"] == "turn-completed" + assert result["finalText"] == "done" + assert captured["operation"] == "StartChat" + assert captured["parameters"]["Query"] == "hello" + assert captured["parameters"]["Mode"] == "IaCCodeNormal" + assert captured["endpoint"] == "127.0.0.1:56124" + assert captured["profile"] == "skill-profile" + assert captured["regionId"] == "cn-hangzhou" + assert captured["aliyunPath"] == "aliyun" + assert response.closed is True + + +def test_open_code_request_loads_cli_profile_and_streams_with_sdk_signing(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + captured = {} + + class FakeCredentials: + def get_access_key_id(self): + return "fake-ak" + + def get_access_key_secret(self): + return "fake-secret" + + def get_security_token(self): + return "fake-token" + + class FakeProvider: + def __init__(self, profile_name=None): + captured["profile"] = profile_name + + def get_credentials(self): + return FakeCredentials() + + class FakeCoreCredentials: + def __init__(self, *values): + captured["credentials"] = values + + class FakeRequest: + def __init__(self, **values): + captured["requestInit"] = values + self.headers = {} + self.query = {} + + def set_protocol_type(self, value): + captured["protocol"] = value + + def set_method(self, value): + captured["method"] = value + + def add_header(self, name, value): + self.headers[name] = value + + def add_query_param(self, name, value): + self.query[name] = value + + class FakeSigned: + def get_method(self): + return "POST" + + def get_url(self): + return "/?Action=StartChat&Signature=fake" + + def get_body(self): + return None + + def get_headers(self): + return {"Host": "127.0.0.1:56124", "Authorization": "fake"} + + class FakeClient: + def __init__(self, **values): + captured["client"] = values + + def append_user_agent(self, key, value): + captured["userAgent"] = (key, value) + + def _make_http_response(self, endpoint, request, read_timeout, connect_timeout): + captured["signed"] = { + "endpoint": endpoint, + "headers": request.headers, + "query": request.query, + "readTimeout": read_timeout, + "connectTimeout": connect_timeout, + } + return FakeSigned() + + class FakeRaw: + def read(self, _maximum, decode_content=False): + captured["decodeContent"] = decode_content + return b"" + + class FakeSdkResponse: + status_code = 200 + headers = {"Content-Type": "text/event-stream"} + raw = FakeRaw() + + def iter_lines(self, **_kwargs): + return iter([]) + + def close(self): + captured["responseClosed"] = True + + class FakeSession: + def request(self, **values): + captured["http"] = values + return FakeSdkResponse() + + def close(self): + captured["sessionClosed"] = True + + sdk = { + "CLIProfileCredentialsProvider": FakeProvider, + "AccessKeyCredential": FakeCoreCredentials, + "StsTokenCredential": FakeCoreCredentials, + "AcsClient": FakeClient, + "CommonRequest": FakeRequest, + "protocolType": SimpleNamespace(HTTPS="https"), + "methodType": SimpleNamespace(POST="POST"), + "requests": SimpleNamespace(Session=FakeSession), + } + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: sdk) + monkeypatch.setattr(bridge, "_selected_cli_profile", lambda profile: (profile, "AK")) + + response = bridge._open_code_request( + "StartChat", + {"AgentVersion": "V2", "Query": "hello"}, + "127.0.0.1:56124", + "skill-profile", + "cn-hangzhou", + "aliyun", + 10, + 600, + ) + + assert captured["profile"] == "skill-profile" + assert captured["credentials"] == ("fake-ak", "fake-secret", "fake-token") + assert captured["userAgent"] == ("AlibabaCloud-Agent-Skills", "alibabacloud-ros-agent") + assert captured["requestInit"] == { + "domain": "127.0.0.1:56124", + "version": "2019-09-10", + "action_name": "StartChat", + "product": "ROS", + } + assert captured["signed"]["query"] == {"AgentVersion": "V2", "Query": "hello"} + assert captured["http"]["url"] == "https://127.0.0.1:56124/?Action=StartChat&Signature=fake" + assert captured["http"]["stream"] is True + assert captured["http"]["verify"] is False + response.close() + assert captured["responseClosed"] is True + assert captured["sessionClosed"] is True + + +def test_code_credentials_use_environment_before_cli_profile(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + monkeypatch.setenv("ALICLOUD_ACCESS_KEY_ID", "fake-env-ak") + monkeypatch.setenv("ALICLOUD_ACCESS_KEY_SECRET", "fake-env-secret") + monkeypatch.setenv("ALICLOUD_SECURITY_TOKEN", "fake-env-token") + captured = {} + + class FakeCredential: + def __init__(self, *values): + captured["values"] = values + + sdk = { + "CLIProfileCredentialsProvider": lambda **_kwargs: pytest.fail("environment credentials must win"), + "AccessKeyCredential": FakeCredential, + "StsTokenCredential": FakeCredential, + } + monkeypatch.setattr(bridge, "_selected_cli_profile", lambda *_args: pytest.fail("must not inspect Profile")) + + credential = bridge._code_credentials(sdk, "aliyun", "ignored-profile", "cn-hangzhou") + + assert isinstance(credential, FakeCredential) + assert captured["values"] == ("fake-env-ak", "fake-env-secret", "fake-env-token") + + +def test_code_credentials_with_profile_source_do_not_fall_back_to_environment(monkeypatch) -> None: + _clear_code_credential_env(monkeypatch) + monkeypatch.setenv("ALICLOUD_ACCESS_KEY_ID", "fake-env-ak") + monkeypatch.setenv("ALICLOUD_ACCESS_KEY_SECRET", "fake-env-secret") + captured = {} + + class FakeCredentials: + def get_access_key_id(self): + return "fake-profile-ak" + + def get_access_key_secret(self): + return "fake-profile-secret" + + def get_security_token(self): + return None + + class FakeProvider: + def __init__(self, profile_name=None): + captured["profile"] = profile_name + + def get_credentials(self): + return FakeCredentials() + + class FakeCredential: + def __init__(self, *values): + captured["values"] = values + + sdk = { + "CLIProfileCredentialsProvider": FakeProvider, + "AccessKeyCredential": FakeCredential, + "StsTokenCredential": FakeCredential, + } + monkeypatch.setattr(bridge, "_selected_cli_profile", lambda profile: (profile, "AK")) + + credential = bridge._code_credentials( + sdk, + "aliyun", + "fixed-profile", + "cn-hangzhou", + "profile", + ) + + assert isinstance(credential, FakeCredential) + assert captured["profile"] == "fixed-profile" + assert captured["values"] == ("fake-profile-ak", "fake-profile-secret") + + +def test_code_credentials_delegate_oauth_refresh_to_native_cli(monkeypatch, tmp_path: Path) -> None: + _clear_code_credential_env(monkeypatch) + config_path = tmp_path / "config.json" + commands = [] + captured = {} + config_path.write_text( + json.dumps( + { + "current": "oauth-profile", + "profiles": [ + { + "name": "oauth-profile", + "mode": "OAuth", + "access_key_id": "fake-expired-ak", + "access_key_secret": "fake-expired-secret", + "sts_token": "fake-expired-token", + "sts_expiration": int(time.time()) - 1, + } + ], + } + ), + encoding="utf-8", + ) + + def fake_run(command, **kwargs): + commands.append((command, kwargs)) + assert command[1:3] == ["ros", "DescribeRegions"] + config_path.write_text( + json.dumps( + { + "current": "oauth-profile", + "profiles": [ + { + "name": "oauth-profile", + "mode": "OAuth", + "access_key_id": "fake-refreshed-ak", + "access_key_secret": "fake-refreshed-secret", + "sts_token": "fake-refreshed-token", + "sts_expiration": int(time.time()) + 3600, + } + ], + } + ), + encoding="utf-8", + ) + return SimpleNamespace(returncode=0, stdout=b"", stderr=b"") + + class FakeCredential: + def __init__(self, *values): + captured["values"] = values + + sdk = { + "CLIProfileCredentialsProvider": lambda **_kwargs: pytest.fail("OAuth must be refreshed by native CLI"), + "AccessKeyCredential": FakeCredential, + "StsTokenCredential": FakeCredential, + } + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + monkeypatch.setattr(bridge.subprocess, "run", fake_run) + monkeypatch.setattr(bridge, "_cli_config_path", lambda: config_path) + + credential = bridge._code_credentials(sdk, "aliyun", "oauth-profile", "cn-hangzhou") + + assert isinstance(credential, FakeCredential) + assert captured["values"] == ("fake-refreshed-ak", "fake-refreshed-secret", "fake-refreshed-token") + assert len(commands) == 1 + refresh_command, refresh_options = commands[0] + assert "--dryrun" in refresh_command + assert refresh_command[refresh_command.index("--profile") + 1] == "oauth-profile" + assert refresh_command[refresh_command.index("--region") + 1] == "cn-hangzhou" + assert refresh_command[refresh_command.index("--user-agent") + 1] == bridge.USER_AGENT + assert refresh_options["stdout"] == bridge.subprocess.DEVNULL + assert refresh_options["stderr"] == bridge.subprocess.DEVNULL + + +def test_code_credentials_reuse_unexpired_oauth_sts_without_starting_cli(monkeypatch, tmp_path: Path) -> None: + _clear_code_credential_env(monkeypatch) + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "current": "oauth-profile", + "profiles": [ + { + "name": "oauth-profile", + "mode": "OAuth", + "access_key_id": "fake-cached-ak", + "access_key_secret": "fake-cached-secret", + "sts_token": "fake-cached-token", + "sts_expiration": int(time.time()) + 3600, + } + ], + } + ), + encoding="utf-8", + ) + captured = {} + + class FakeCredential: + def __init__(self, *values): + captured["values"] = values + + sdk = { + "CLIProfileCredentialsProvider": lambda **_kwargs: pytest.fail("OAuth must not use SDK refresh"), + "AccessKeyCredential": FakeCredential, + "StsTokenCredential": FakeCredential, + } + monkeypatch.setattr(bridge, "_cli_config_path", lambda: config_path) + monkeypatch.setattr(bridge.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("CLI must not start")) + + credential = bridge._code_credentials(sdk, "aliyun", None, "cn-hangzhou") + + assert isinstance(credential, FakeCredential) + assert captured["values"] == ("fake-cached-ak", "fake-cached-secret", "fake-cached-token") + + +def test_code_transport_uses_same_profile_and_endpoint_for_stop_chat(monkeypatch, tmp_path: Path) -> None: + captured = {} + + class FakeResponse: + def read(self, _maximum): + return b'{"Status":"Stopped","SessionId":"session-1","RequestId":"request-1"}' + + def close(self): + captured["closed"] = True + + def fake_open( + operation, parameters, endpoint, profile, region_id, aliyun_path, connect_timeout, read_timeout, **kwargs + ): + captured.update( + { + "operation": operation, + "parameters": parameters, + "endpoint": endpoint, + "profile": profile, + "regionId": region_id, + "aliyunPath": aliyun_path, + "connectTimeout": connect_timeout, + "readTimeout": read_timeout, + "options": kwargs, + } + ) + return FakeResponse() + + monkeypatch.setattr(bridge, "_open_code_request", fake_open) + result = bridge._run_stop_chat( + { + "workspace": str(tmp_path), + "transport": "code", + "endpoint": "127.0.0.1:56124", + "profile": "skill-profile", + "regionId": "cn-hangzhou", + "aliyunPath": "aliyun", + }, + "session-1", + ) + + assert result == {"status": "Stopped", "sessionId": "session-1", "requestId": "request-1"} + assert captured["operation"] == "StopChat" + assert captured["parameters"] == {"AgentVersion": "V2", "SessionId": "session-1"} + assert captured["endpoint"] == "127.0.0.1:56124" + assert captured["profile"] == "skill-profile" + assert captured["regionId"] == "cn-hangzhou" + assert captured["aliyunPath"] == "aliyun" + assert captured["options"] == {"credential_source": None, "error_code": "stop_chat_failed"} + assert captured["closed"] is True + + +def test_run_respond_sends_json_as_the_only_start_chat_control_payload(monkeypatch, tmp_path: Path) -> None: + permission_file = tmp_path / "permission.json" + permission_file.write_text( + json.dumps( + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "sub_pipeline", + } + ), + encoding="utf-8", + ) + output = ( + "data: " + + json.dumps( + { + "result": { + "messageId": "permission-ack-1", + "taskId": "task-1", + "contextId": "session-1", + "role": "ROLE_AGENT", + "parts": [ + { + "mediaType": "application/json", + "data": { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "deny", + "accepted": True, + }, + } + ], + } + }, + separators=(",", ":"), + ) + + "\n\n" + ) + captured = {} + + class FakeProcess: + def __init__(self): + self.stdout = io.StringIO(output) + + def wait(self, timeout=None): + del timeout + return 0 + + def poll(self): + return 0 + + def terminate(self): + return None + + def kill(self): + return None + + def fake_popen(command, **kwargs): + captured["command"] = command + return FakeProcess() + + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + monkeypatch.setattr(bridge.subprocess, "Popen", fake_popen) + args = SimpleNamespace( + cwd=str(tmp_path), + input_file=str(permission_file), + decision="deny", + session_id="session-1", + aliyun_path="aliyun", + endpoint="ros.aliyuncs.com", + connect_timeout=10, + read_timeout=600, + profile=None, + region_id="cn-hangzhou", + no_thinking=True, + mode="pipeline", + ) + + result = bridge.run_respond(args) + command = captured["command"] + query_text = command[command.index("--Query") + 1] + assert query_text.startswith(bridge.PERMISSION_QUERY_PREFIX + " ") + query = json.loads(query_text[len(bridge.PERMISSION_QUERY_PREFIX) :]) + + assert result["state"] == "permission-responded" + assert result["permissionResponse"]["decision"] == "deny" + assert query["decision"] == "deny" + assert command[command.index("--EnableThinking") + 1] == "false" + assert "--ClientContext" not in command + assert not any(value.startswith("--Attachments.") for value in command) + + +def _wait_for_pid_exit(pid: int, timeout: float = 4.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and bridge._pid_alive(pid): + time.sleep(0.05) + + +def test_manager_is_loopback_authenticated_reused_and_recovers_after_idle_shutdown(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + monkeypatch.setattr(bridge, "MANAGER_IDLE_SECONDS", 0.3) + + first = bridge.ensure_manager() + second = bridge.ensure_manager() + + assert second == first + assert first["port"] > 0 + assert len(first["token"]) >= 32 + assert len(first["generation"]) == 32 + assert bridge._pid_alive(first["pid"]) + if os.name != "nt": + assert bridge._manager_record_path().stat().st_mode & 0o777 == 0o600 + + reconfigured = bridge.ensure_manager(0.1) + assert reconfigured["pid"] == first["pid"] + assert reconfigured["generation"] == first["generation"] + assert reconfigured["idleSeconds"] == 0.1 + + _wait_for_pid_exit(first["pid"]) + assert not bridge._pid_alive(first["pid"]) + + third = bridge.ensure_manager(0.1) + assert third["generation"] != first["generation"] + assert third["token"] != first["token"] + assert third["pid"] != first["pid"] + _wait_for_pid_exit(third["pid"]) + + +def test_manager_idle_countdown_starts_after_sse_worker_exits(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + workspace = tmp_path / "workspace" + workspace.mkdir() + fake_cli = tmp_path / "aliyun" + fake_cli.write_text( + "#!{}\n".format(sys.executable) + + "import json, time\n" + + "time.sleep(0.6)\n" + + "event = {'result': {'statusUpdate': {'taskId': 'task-1', 'contextId': 'session-1', " + + "'status': {'state': 'TASK_STATE_INPUT_REQUIRED', 'message': {'role': 'ROLE_AGENT', " + + "'parts': [{'text': 'done'}]}}, 'metadata': {'iac_code': {'assistantFinal': " + + "{'complete': True}}, 'iacCodeSessionId': 'iac-1'}}}}\n" + + "print('data: ' + json.dumps(event), flush=True)\n" + + "print('', flush=True)\n", + encoding="utf-8", + ) + fake_cli.chmod(0o755) + + manager = bridge.ensure_manager(0.2) + started = bridge._manager_request( + manager, + "/start", + { + "workspace": str(workspace), + "prompt": "explain VPC", + "mode": "normal", + "transport": "aliyun_cli", + "endpoint": "ros.aliyuncs.com", + "regionId": "cn-hangzhou", + "aliyunPath": str(fake_cli), + }, + ) + time.sleep(0.35) + assert bridge._pid_alive(manager["pid"]) + + _root, job_path, _spool = bridge._job_paths(started["jobId"]) + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if not isinstance(bridge._load_state_json(job_path).get("workerPid"), int): + break + time.sleep(0.02) + assert bridge._load_state_json(job_path)["state"] == "turn-completed" + assert bridge._pid_alive(manager["pid"]) + time.sleep(0.08) + assert bridge._pid_alive(manager["pid"]) + + _wait_for_pid_exit(manager["pid"]) + assert not bridge._pid_alive(manager["pid"]) + + +def test_manager_failed_start_removes_record_and_terminates_spawn(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + + class FailedProcess: + pid = 987654 + + def poll(self): + return 1 + + monkeypatch.setattr(bridge.subprocess, "Popen", lambda *_args, **_kwargs: FailedProcess()) + + with pytest.raises(bridge.BridgeError, match="health check"): + bridge.ensure_manager() + + assert not bridge._manager_record_path().exists() + + +def test_managed_worker_outlives_start_and_follow_returns_step_start_before_final(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + workspace = tmp_path / "workspace" + workspace.mkdir() + fake_cli = tmp_path / "aliyun" + fake_cli.write_text( + "#!{}\n".format(sys.executable) + + "import json, time\n" + + "def emit(value):\n" + + " print('data: ' + json.dumps(value), flush=True)\n" + + " print('', flush=True)\n" + + "def status(state, text='', metadata=None):\n" + + " body = {'state': state}\n" + + " if text:\n" + + " body['message'] = {'role': 'ROLE_AGENT', 'parts': [{'text': text}]}\n" + + " return {'result': {'statusUpdate': {'taskId': 'task-1', 'contextId': 'session-1', " + + "'status': body, 'metadata': {'iac_code': metadata or {}, 'iacCodeSessionId': 'iac-1'}}}}\n" + + "time.sleep(0.35)\n" + + "emit(status('TASK_STATE_WORKING', metadata={'pipeline': {'eventType': 'step_started', " + + "'step': {'id': 'intent_parsing', 'name': 'Understand'}}}))\n" + + "time.sleep(0.35)\n" + + "emit(status('TASK_STATE_WORKING', 'done', {'assistantFinal': {'complete': True}}))\n" + + "emit(status('TASK_STATE_INPUT_REQUIRED'))\n", + encoding="utf-8", + ) + fake_cli.chmod(0o755) + + started = bridge._start_job_local( + { + "workspace": str(workspace), + "prompt": "创建测试模板", + "mode": "normal", + "transport": "aliyun_cli", + "endpoint": "ros.aliyuncs.com", + "regionId": "cn-hangzhou", + "aliyunPath": str(fake_cli), + } + ) + + assert started["state"] == "submitted" + assert bridge._pid_alive(started["workerPid"]) + _root, job_path, _spool = bridge._job_paths(started["jobId"]) + assert bridge._load_state_json(job_path)["readTimeout"] == bridge.DEFAULT_READ_TIMEOUT_SECONDS + + first = bridge._follow_job_local(started["jobId"], 0, 3) + assert first["state"] == "working" + assert first["boundaryReached"] is True + assert first["presentationRequired"] is True + assert first["userUpdates"] == ["步骤开始:Understand"] + assert first["sessionId"] == "session-1" + assert "finalText" not in first + assert bridge._pid_alive(started["workerPid"]) + + second = bridge._follow_job_local(started["jobId"], first["cursor"], 3) + assert second["state"] == "turn-completed" + assert second["finalText"] == "done" + _wait_for_pid_exit(started["workerPid"]) + assert not bridge._pid_alive(started["workerPid"]) + + +def test_worker_failed_start_cleans_request_and_marks_job_failed(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "0" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "submitted", + "mode": "normal", + "activeRequestSeq": 1, + "turn": 1, + "artifacts": [], + }, + ) + + def fail_spawn(*_args, **_kwargs): + raise OSError("spawn failed") + + monkeypatch.setattr(bridge.subprocess, "Popen", fail_spawn) + with pytest.raises(bridge.BridgeError, match="could not be started"): + bridge._spawn_worker(job_id, {"requestSeq": 1, "prompt": "hello"}) + + job = bridge._load_state_json(job_path) + assert job["state"] == "failed" + assert job["error"]["code"] == "worker_start_failed" + assert not list(root.glob("request-*.json")) + + +def test_follow_timeout_keeps_worker_and_cursor_can_continue(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "a" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "zh", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + + timed_out = bridge._follow_job_local(job_id, 0, 0) + assert timed_out["followTimedOut"] is True + assert timed_out["cursor"] == 1 + assert timed_out["milestones"] == [] + assert bridge._pid_alive(os.getpid()) + + bridge._append_projection( + job_id, + { + "type": "milestone", + "state": "working", + "requestSeq": 1, + "milestones": [{"eventType": "step_started", "step": {"id": "deploying", "name": "Deploy"}}], + }, + ) + continued = bridge._follow_job_local(job_id, timed_out["cursor"], 0) + assert continued["boundaryReached"] is True + assert "followTimedOut" not in continued + assert continued["userUpdates"] == ["步骤开始:Deploy"] + assert continued["cursor"] == 2 + + +def test_repeated_follow_timeouts_advance_cursor_without_remote_progress(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "b" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + + first = bridge._follow_job_local(job_id, 0, 0) + second = bridge._follow_job_local(job_id, first["cursor"], 0) + + assert first["followTimedOut"] is True + assert second["followTimedOut"] is True + assert (first["cursor"], second["cursor"]) == (1, 2) + assert first["milestones"] == second["milestones"] == [] + assert "userUpdates" not in first + assert "userUpdates" not in second + assert [item["type"] for item in bridge._read_spool(spool)] == ["follow-heartbeat", "follow-heartbeat"] + + +def test_follow_timeout_does_not_consume_step_boundary_arriving_after_heartbeat(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "c" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + follow_timeout_result = bridge._follow_timeout_result + + def record_then_publish_boundary(current_job_id: str, start_cursor: int): + result = follow_timeout_result(current_job_id, start_cursor) + bridge._append_projection( + current_job_id, + { + "type": "milestone", + "state": "working", + "requestSeq": 1, + "milestones": [{"eventType": "step_started", "step": {"id": "deploying", "name": "Deploy"}}], + }, + ) + return result + + monkeypatch.setattr(bridge, "_follow_timeout_result", record_then_publish_boundary) + + timed_out = bridge._follow_job_local(job_id, 0, 0) + assert timed_out["followTimedOut"] is True + assert timed_out["cursor"] == 1 + assert timed_out["milestones"] == [] + assert "boundaryReached" not in timed_out + assert "userUpdates" not in timed_out + + monkeypatch.setattr(bridge, "_follow_timeout_result", follow_timeout_result) + continued = bridge._follow_job_local(job_id, timed_out["cursor"], 0) + assert continued["boundaryReached"] is True + assert continued["cursor"] == 2 + assert continued["userUpdates"] == ["Step started: Deploy"] + + +@pytest.mark.parametrize("gate", ["terminal", "input-required"]) +def test_follow_timeout_snapshot_does_not_skip_step_before_new_gate(monkeypatch, tmp_path: Path, gate: str) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = ("d" if gate == "terminal" else "e") * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + follow_timeout_result = bridge._follow_timeout_result + + def record_then_publish_gate(current_job_id: str, start_cursor: int): + result = follow_timeout_result(current_job_id, start_cursor) + bridge._append_projection( + current_job_id, + { + "type": "milestone", + "state": "working", + "requestSeq": 1, + "milestones": [{"eventType": "step_completed", "step": {"id": "planning", "name": "Plan"}}], + }, + ) + if gate == "terminal": + bridge._finish_job( + current_job_id, + 1, + {"state": "completed", "pipelineResult": {"status": "completed"}}, + os.getpid(), + ) + else: + bridge._append_projection( + current_job_id, + { + "type": "input-required", + "state": "input-required", + "requestSeq": 1, + "inputRequired": { + "schemaVersion": 1, + "kind": "candidate_selection", + "inputId": "selection-1", + }, + }, + ) + return result + + monkeypatch.setattr(bridge, "_follow_timeout_result", record_then_publish_gate) + timed_out = bridge._follow_job_local(job_id, 0, 0) + + assert timed_out["state"] == "working" + assert timed_out["followTimedOut"] is True + assert timed_out["cursor"] == 1 + assert timed_out["milestones"] == [] + assert "boundaryReached" not in timed_out + + monkeypatch.setattr(bridge, "_follow_timeout_result", follow_timeout_result) + continued = bridge._follow_job_local(job_id, timed_out["cursor"], 0) + assert continued["boundaryReached"] is True + assert continued["userUpdates"] == ["Step completed: Plan"] + if gate == "terminal": + assert continued["state"] == "completed" + assert continued["pipelineResult"] == {"status": "completed"} + assert continued["cursor"] == 3 + else: + assert continued["state"] == "input-required" + assert continued["inputRequired"]["inputId"] == "selection-1" + assert continued["cursor"] == 3 + + +@pytest.mark.parametrize("gate", ["terminal", "input-required"]) +def test_follow_observation_rechecks_step_and_gate_arriving_after_snapshot( + monkeypatch, tmp_path: Path, gate: str +) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = ("1" if gate == "terminal" else "2") * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + follow_ready_result = bridge._follow_ready_result + injected = False + + def snapshot_then_publish_gate(current_job_id: str, start_cursor: int): + nonlocal injected + result = follow_ready_result(current_job_id, start_cursor) + if injected: + return result + injected = True + bridge._append_projection( + current_job_id, + { + "type": "milestone", + "state": "working", + "requestSeq": 1, + "milestones": [{"eventType": "step_completed", "step": {"id": "planning", "name": "Plan"}}], + }, + ) + if gate == "terminal": + bridge._finish_job( + current_job_id, + 1, + {"state": "completed", "pipelineResult": {"status": "completed"}}, + os.getpid(), + ) + else: + bridge._append_projection( + current_job_id, + { + "type": "input-required", + "state": "input-required", + "requestSeq": 1, + "inputRequired": { + "schemaVersion": 1, + "kind": "candidate_selection", + "inputId": "selection-1", + }, + }, + ) + return result + + monkeypatch.setattr(bridge, "_follow_ready_result", snapshot_then_publish_gate) + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["boundaryReached"] is True + assert result["userUpdates"] == ["Step completed: Plan"] + assert "followTimedOut" not in result + if gate == "terminal": + assert result["state"] == "completed" + assert result["pipelineResult"] == {"status": "completed"} + assert result["cursor"] == 2 + else: + assert result["state"] == "input-required" + assert result["inputRequired"]["inputId"] == "selection-1" + assert result["cursor"] == 2 + + +def test_follow_dead_worker_check_does_not_overwrite_concurrent_completion(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "3" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": 987654, + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + checked = False + + def complete_then_report_dead(pid: int) -> bool: + nonlocal checked + assert pid == 987654 + if checked: + return False + checked = True + bridge._append_projection( + job_id, + { + "type": "milestone", + "state": "working", + "requestSeq": 1, + "milestones": [{"eventType": "step_completed", "step": {"id": "deploying", "name": "Deploy"}}], + }, + ) + assert bridge._finish_job( + job_id, + 1, + {"state": "completed", "pipelineResult": {"status": "completed"}}, + 987654, + ) + return False + + monkeypatch.setattr(bridge, "_pid_alive", complete_then_report_dead) + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["state"] == "completed" + assert result["pipelineResult"] == {"status": "completed"} + assert result["userUpdates"] == ["Step completed: Deploy"] + assert result["cursor"] == 2 + assert "error" not in result + persisted = bridge._load_state_json(job_path) + assert persisted["state"] == "completed" + assert "error" not in persisted + assert [item["state"] for item in bridge._read_spool(spool) if item["type"] == "result-boundary"] == ["completed"] + + +def test_follow_sideband_dead_check_does_not_consume_parent_step_after_concurrent_ack( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "4" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + permission = { + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "sub_pipeline", + "decision": "allow_once", + } + acknowledgement = { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + } + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "sidebandWorkerPid": 987654, + "sidebandWorkerToken": "worker-token", + "sidebandResponse": permission, + "lastPermissionResponse": { + "inputId": permission["inputId"], + "toolUseId": permission["toolUseId"], + "decision": "allow_once", + }, + "sidebandResponseInputId": permission["inputId"], + "pendingPermissions": [permission], + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + checked = False + + def acknowledge_then_report_dead(pid: int) -> bool: + nonlocal checked + if pid == os.getpid(): + return True + assert pid == 987654 + if checked: + return False + checked = True + bridge._finish_sideband_job( + job_id, + 1, + "worker-token", + {"ok": True, "state": "permission-responded", "permissionAck": acknowledgement}, + 987654, + ) + bridge._append_projection( + job_id, + { + "type": "milestone", + "state": "working", + "requestSeq": 1, + "milestones": [{"eventType": "step_completed", "step": {"id": "evaluating", "name": "Evaluate"}}], + }, + ) + return False + + monkeypatch.setattr(bridge, "_pid_alive", acknowledge_then_report_dead) + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["state"] == "working" + assert result["boundaryReached"] is True + assert result["userUpdates"] == ["Step completed: Evaluate"] + assert result["cursor"] == 1 + assert "followTimedOut" not in result + job = bridge._load_state_json(job_path) + assert job["permissionAck"] == acknowledgement + assert "sidebandWorkerToken" not in job + assert "sidebandError" not in job + + +def test_follow_does_not_treat_permission_ack_as_pipeline_boundary(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "7" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "permission-responded", + "mode": "pipeline", + "preferredLanguage": "zh", + "activeRequestSeq": 2, + "workerPid": os.getpid(), + "permissionAck": {"kind": "permission_ack", "accepted": True}, + "createdAt": int(time.time()), + "turn": 1, + "artifacts": [], + }, + ) + + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["state"] == "permission-responded" + assert result["followTimedOut"] is True + assert result["presentationRequired"] is True + + +def test_follow_reports_detached_stream_after_permission_ack(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "8" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "permission-responded", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 2, + "permissionAck": {"kind": "permission_ack", "accepted": True}, + "turn": 1, + "artifacts": [], + }, + ) + + result = bridge._follow_job_local(job_id, 0, 1) + + assert result["state"] == "failed" + assert result["error"]["code"] == "stream_detached" + assert result["presentationRequired"] is True + + +def test_managed_job_ignores_stale_permission_projection_after_ack(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "f" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + acknowledgement = { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + } + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "turn": 1, + "permissionAck": acknowledgement, + "artifacts": [], + }, + ) + + bridge._append_projection( + job_id, + { + "type": "input-required", + "state": "input-required", + "requestSeq": 1, + "inputRequired": { + "kind": "permission", + "inputId": "permission-1", + "toolUseId": "tool-1", + "toolName": "write_file", + "permissionClass": "pipeline", + }, + }, + ) + + job = bridge._load_state_json(job_path) + assert job["state"] == "working" + assert "inputRequired" not in job + assert spool.read_text(encoding="utf-8") == "" + + +def test_follow_includes_already_queued_step_boundaries_with_existing_input(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "b" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + pending = { + "kind": "candidate_selection", + "requestTaskId": "task-1", + "contextId": "ctx-1", + "inputId": "candidate-1", + "prompt": "Choose", + "options": [{"id": "one", "label": "One"}], + } + events = [ + { + "type": "milestone", + "requestSeq": 1, + "milestones": [{"eventType": "step_started", "step": {"id": "intent_parsing"}}], + }, + { + "type": "milestone", + "requestSeq": 1, + "milestones": [{"eventType": "step_completed", "step": {"id": "intent_parsing"}}], + }, + {"type": "input-required", "requestSeq": 1, "inputRequired": pending}, + ] + spool.write_text("".join(json.dumps(value) + "\n" for value in events), encoding="utf-8") + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "input-required", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "turn": 1, + "inputRequired": pending, + "artifacts": [], + }, + ) + + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["boundaryReached"] is True + assert result["cursor"] == 3 + assert result["state"] == "input-required" + assert result["inputRequired"] == pending + assert result["userUpdates"] == [ + "Step started: intent_parsing", + "Step completed: intent_parsing", + ] + + +def test_follow_reports_dead_worker_instead_of_waiting_forever(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "c" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "normal", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": 99999999, + "turn": 1, + "artifacts": [], + }, + ) + + result = bridge._follow_job_local(job_id, 0, 0) + assert result["state"] == "failed" + assert result["error"]["code"] == "worker_exited" + assert result["presentationRequired"] is True + + +def test_remote_failed_status_promotes_safe_text_to_diagnostic_error(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "9" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "normal", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "turn": 1, + "artifacts": [], + }, + ) + + bridge._finish_job( + job_id, + 1, + {"ok": False, "state": "failed", "latestText": "Invalid A2A workspace metadata."}, + 0, + ) + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["state"] == "failed" + assert result["error"] == { + "code": "remote_task_failed", + "message": "Invalid A2A workspace metadata.", + } + assert result["presentationRequired"] is True + + +def test_candidate_projection_keeps_architecture_and_cost_details() -> None: + value = { + "schemaVersion": 1, + "kind": "candidate_selection", + "requestTaskId": "task-1", + "contextId": "ctx-1", + "inputId": "candidate-1", + "prompt": "Choose", + "options": [ + { + "id": "one", + "label": "Plan One", + "summary": "Existing VPC with one VSwitch", + "architectureDiagram": "flowchart LR\n VPC --> VSwitch", + "totalMonthlyCost": "0 CNY/month", + "costItems": [{"name": "VSwitch", "spec": "standard", "monthlyCost": "0 CNY/month"}], + } + ], + } + + projected = bridge._safe_input(value) + assert projected is not None + assert projected["options"][0]["architectureDiagram"].startswith("flowchart LR") + assert projected["options"][0]["totalMonthlyCost"] == "0 CNY/month" + assert projected["options"][0]["costItems"][0]["name"] == "VSwitch" + + +def test_step_updates_include_bounded_conclusion_and_candidate_coordinate() -> None: + intent = bridge._safe_milestone( + { + "eventType": "step_completed", + "step": {"id": "intent_parsing", "name": "Understand requirements"}, + "data": { + "conclusionField": "intent", + "conclusion": { + "user_message_summary": "在已有 VPC 中部署一个新 VSwitch", + "non_functional": {"region_preference": "cn-hangzhou"}, + "resource_intents": [ + {"product": "VPC", "action": "use_existing"}, + {"product": "VSwitch", "action": "create"}, + ], + }, + }, + } + ) + assert intent is not None + update = bridge._format_user_update(intent, "zh") + assert update.startswith("步骤完成:Understand requirements") + assert "结论" in update + assert "VPC (复用)" in update + assert "VSwitch (新建)" in update + + candidate = bridge._format_user_update( + { + "eventType": "candidate_step_started", + "candidate": {"id": "candidate-a", "name": "低成本方案"}, + "candidateStep": {"id": "cost", "name": "成本估算", "index": 2, "total": 3}, + }, + "zh", + ) + assert candidate == "候选步骤开始:低成本方案 · 2/3 成本估算" + + +def test_step_boundary_can_include_authoritative_result_and_artifacts(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "1" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + artifact = {"name": "template.yaml", "uri": "file:///workspace/template.yaml"} + events = [ + { + "type": "milestone", + "requestSeq": 1, + "milestones": [{"eventType": "step_completed", "step": {"id": "reviewing"}}], + }, + {"type": "result-boundary", "requestSeq": 1, "state": "turn-completed"}, + ] + spool.write_text("".join(json.dumps(value) + "\n" for value in events), encoding="utf-8") + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "turn-completed", + "mode": "normal", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "turn": 1, + "finalText": "done", + "finalTextComplete": True, + "artifacts": [artifact], + }, + ) + + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["boundaryReached"] is True + assert result["state"] == "turn-completed" + assert result["finalText"] == "done" + assert result["artifacts"] == [artifact] + + +def test_working_step_boundary_does_not_repeat_artifacts(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "5" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + artifact = {"name": "template.yaml", "uri": "file:///workspace/template.yaml"} + spool.write_text( + json.dumps( + { + "type": "milestone", + "requestSeq": 1, + "milestones": [{"eventType": "step_completed", "step": {"id": "reviewing"}}], + } + ) + + "\n", + encoding="utf-8", + ) + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": os.getpid(), + "turn": 1, + "artifacts": [artifact], + }, + ) + + result = bridge._follow_job_local(job_id, 0, 0) + + assert result["boundaryReached"] is True + assert result["state"] == "working" + assert "artifacts" not in result + + +@pytest.mark.parametrize("kind", ["ask_user_question", "candidate_selection"]) +def test_managed_continue_uses_natural_language_for_business_input(monkeypatch, tmp_path: Path, kind: str) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = ("2" if kind == "ask_user_question" else "3") * 32 + workspace = tmp_path / kind + workspace.mkdir() + answer = workspace / "answer.txt" + answer.write_text("使用 cn-hangzhou-h 区的第一个方案", encoding="utf-8") + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(workspace), + "state": "input-required", + "mode": "pipeline", + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "zh", + "activeRequestSeq": 1, + "turn": 1, + "inputRequired": {"kind": kind, "inputId": "input-1"}, + "artifacts": [], + }, + ) + captured = {} + + def spawn(_job_id, request): + captured.update(request) + return 12345 + + monkeypatch.setattr(bridge, "_spawn_worker", spawn) + result = bridge._continue_job_local({"jobId": job_id, "promptFile": str(answer)}) + + assert result["state"] == "submitted" + assert captured["prompt"] == "使用 cn-hangzhou-h 区的第一个方案" + assert not captured["prompt"].startswith(bridge.PERMISSION_QUERY_PREFIX) + + +def test_managed_continue_reuses_completed_pipeline_normal_handoff(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "4" * 32 + workspace = tmp_path / "pipeline-handoff" + workspace.mkdir() + prompt = workspace / "next.txt" + prompt.write_text("删除刚部署的资源栈", encoding="utf-8") + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(workspace), + "state": "completed", + "mode": "pipeline", + "conversationMode": "normal", + "normalHandoffReady": True, + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-pipeline-1", + "taskId": "task-pipeline-1", + "preferredLanguage": "zh", + "activeRequestSeq": 1, + "turn": 1, + "pipelineResult": {"status": "success", "stack_id": "stack-1"}, + "artifacts": [], + }, + ) + captured = {} + + def spawn(captured_job_id, request): + captured["jobId"] = captured_job_id + captured["request"] = request + return 12345 + + monkeypatch.setattr(bridge, "_spawn_worker", spawn) + + result = bridge._continue_job_local({"jobId": job_id, "promptFile": str(prompt)}) + + assert result["jobId"] == job_id + assert result["mode"] == "pipeline" + assert result["conversationMode"] == "normal" + assert result["sessionId"] == "session-pipeline-1" + assert result["turn"] == 2 + assert captured["jobId"] == job_id + assert captured["request"]["mode"] == "pipeline" + assert captured["request"]["summaryMode"] == "normal" + assert captured["request"]["sessionId"] == "session-pipeline-1" + job = bridge._load_state_json(job_path) + assert job["taskHistory"] == ["task-pipeline-1"] + assert "taskId" not in job + assert "pipelineResult" not in job + + +@pytest.mark.parametrize( + ("stop_status", "result_state", "persisted_state", "ok"), + [ + ("Stopped", "canceled", "canceled", True), + ("Stopping", "canceling", "working", True), + ("NoActiveStream", "not-active", "working", True), + ("Failed", "cancel-failed", "working", False), + ], +) +def test_cancel_managed_job_calls_stop_chat_and_preserves_authoritative_state( + monkeypatch, + tmp_path: Path, + stop_status: str, + result_state: str, + persisted_state: str, + ok: bool, +) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "6" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.write_text('{"type":"milestone","requestSeq":1,"milestones":[]}\n', encoding="utf-8") + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(tmp_path), + "state": "working", + "mode": "pipeline", + "conversationMode": "pipeline", + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "zh", + "activeRequestSeq": 1, + "turn": 1, + "inputRequired": {"kind": "permission"}, + "artifacts": [], + }, + ) + captured = {} + + def stop_chat(job, session_id): + captured["job"] = job + captured["sessionId"] = session_id + return {"status": stop_status, "sessionId": session_id, "requestId": "request-1"} + + monkeypatch.setattr(bridge, "_run_stop_chat", stop_chat) + + result = bridge._cancel_job_local({"jobId": job_id}) + + assert captured["sessionId"] == "session-1" + assert captured["job"]["endpoint"] == "ros.aliyuncs.com" + assert result["ok"] is ok + assert result["state"] == result_state + assert result["stopStatus"] == stop_status + assert result["cursor"] == 1 + assert result["presentationRequired"] is True + job = bridge._load_state_json(job_path) + assert job["state"] == persisted_state + assert job["stopStatus"] == stop_status + if stop_status == "Stopped": + assert "inputRequired" not in job + else: + assert job["inputRequired"]["kind"] == "permission" + + +def test_parser_exposes_managed_commands_without_synchronous_chat() -> None: + parser = bridge.build_parser() + start = parser.parse_args(["start", "--prompt-file", "/workspace/prompt.txt"]) + follow = parser.parse_args(["follow", "--job-id", "a" * 32, "--cursor", "4"]) + continued = parser.parse_args(["continue", "--job-id", "a" * 32, "--prompt-file", "/workspace/next.txt"]) + respond = parser.parse_args(["respond", "--job-id", "a" * 32, "--permission-ref", "p-1234", "--decision", "deny"]) + cancel = parser.parse_args(["cancel", "--job-id", "a" * 32]) + + assert start.command == "start" + assert start.read_timeout == bridge.DEFAULT_READ_TIMEOUT_SECONDS == 1800 + assert follow.wait_seconds == bridge.DEFAULT_FOLLOW_SECONDS + assert continued.command == "continue" + assert respond.command == "respond" + assert respond.input_file is None + assert respond.permission_ref == "p-1234" + assert cancel.command == "cancel" + choices = next(action for action in parser._actions if isinstance(action, argparse._SubParsersAction)).choices + assert "chat" not in choices + assert "cancel" in choices + + with pytest.raises(SystemExit): + parser.parse_args(["start", "--cwd", "/workspace", "--prompt-file", "/workspace/prompt.txt"]) + + +def test_request_from_legacy_job_uses_current_stream_read_timeout_default() -> None: + request = bridge._request_from_job( + { + "activeRequestSeq": 2, + "workspace": "/workspace", + "mode": "pipeline", + "endpoint": "ros.aliyuncs.com", + }, + "continue", + ) + + assert request["readTimeout"] == bridge.DEFAULT_READ_TIMEOUT_SECONDS == 1800 + assert request["transport"] == "aliyun_cli" + + +@pytest.mark.parametrize( + ("mode", "permission_class", "decision"), + [ + ("normal", "normal", "allow_once"), + ("normal", "normal", "deny"), + ("pipeline", "pipeline", "allow_once"), + ("pipeline", "pipeline", "deny"), + ], +) +def test_managed_respond_preserves_serial_permission_correlation( + monkeypatch, tmp_path: Path, mode: str, permission_class: str, decision: str +) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = ("d" if mode == "normal" else "e") * 32 + workspace = tmp_path / mode + workspace.mkdir() + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + pending = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": permission_class, + } + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(workspace), + "state": "input-required", + "mode": mode, + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "turn": 1, + "inputRequired": pending, + "artifacts": [], + }, + ) + captured = {} + + def spawn(captured_job_id, request): + captured["jobId"] = captured_job_id + captured["request"] = request + return 12345 + + monkeypatch.setattr(bridge, "_spawn_worker", spawn) + result = bridge._respond_job_local({"jobId": job_id, "decision": decision}) + + assert result["permissionResponse"]["decision"] == decision + assert captured["jobId"] == job_id + assert captured["request"]["prompt"].startswith(bridge.PERMISSION_QUERY_PREFIX + " ") + query = json.loads(captured["request"]["prompt"][len(bridge.PERMISSION_QUERY_PREFIX) :]) + assert query["inputId"] == pending["inputId"] + assert query["toolUseId"] == pending["toolUseId"] + assert query["decision"] == decision + + +def test_managed_respond_requires_short_ref_for_multiple_permissions(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "b" * 32 + workspace = tmp_path / "multiple" + workspace.mkdir() + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + permissions = [ + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-{}".format(index), + "toolUseId": "tool-{}".format(index), + "permissionClass": "sub_pipeline", + } + for index in range(2) + ] + original_job = { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(workspace), + "state": "input-required", + "mode": "pipeline", + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": 4321, + "turn": 1, + "inputRequired": permissions[0], + "pendingPermissions": permissions, + "artifacts": [], + } + bridge._atomic_json(job_path, original_job) + monkeypatch.setattr( + bridge, + "_spawn_worker", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("ambiguous response must not spawn")), + ) + + with pytest.raises(bridge.BridgeError) as error: + bridge._respond_job_local({"jobId": job_id, "decision": "allow_once"}) + + assert error.value.code == "permission_selection_required" + assert bridge._load_state_json(job_path) == original_job + + +def test_managed_respond_runs_sub_pipeline_permission_beside_live_parent_worker(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "6" * 32 + workspace = tmp_path / "pipeline" + workspace.mkdir() + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + permissions = [] + for index in range(2): + permissions.append( + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-{}".format(index + 1), + "toolUseId": "tool-{}".format(index + 1), + "permissionClass": "sub_pipeline", + } + ) + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(workspace), + "state": "input-required", + "mode": "pipeline", + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": 4321, + "turn": 1, + "inputRequired": permissions[0], + "pendingPermissions": permissions, + "artifacts": [], + }, + ) + monkeypatch.setattr(bridge, "_pid_alive", lambda pid: pid == 4321) + captured = {} + + def spawn(captured_job_id, request): + captured["jobId"] = captured_job_id + captured["request"] = request + return 12345 + + monkeypatch.setattr(bridge, "_spawn_worker", spawn) + + result = bridge._respond_job_local( + { + "jobId": job_id, + "permissionRef": bridge._permission_ref(permissions[0]), + "decision": "allow_once", + } + ) + job = bridge._load_state_json(job_path) + + assert result["workerPid"] == 12345 + assert captured["jobId"] == job_id + assert captured["request"]["workerRole"] == "sideband" + assert captured["request"]["requestSeq"] == 1 + worker_token = captured["request"]["workerToken"] + assert job["workerPid"] == 4321 + assert job["activeRequestSeq"] == 1 + assert job["state"] == "working" + assert job["pendingPermissions"] == [permissions[1]] + assert job["inputRequired"] == permissions[1] + assert job["sidebandResponseInputId"] == permissions[0]["inputId"] + + acknowledgement = { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": permissions[0]["inputId"], + "toolUseId": permissions[0]["toolUseId"], + "decision": "allow_once", + "accepted": True, + } + bridge._append_projection( + job_id, + { + "type": "permission-ack", + "state": "permission-responded", + "requestSeq": 1, + "workerRole": "sideband", + "workerToken": worker_token, + "permissionAck": acknowledgement, + }, + ) + bridge._finish_sideband_job( + job_id, + 1, + worker_token, + {"ok": True, "state": "permission-responded", "permissionAck": acknowledgement}, + 12345, + ) + job = bridge._load_state_json(job_path) + + assert job["workerPid"] == 4321 + assert job["activeRequestSeq"] == 1 + assert job["state"] == "input-required" + assert job["inputRequired"] == permissions[1] + assert job["permissionAck"] == acknowledgement + assert "sidebandWorkerToken" not in job + + +def test_sideband_sub_pipeline_terminal_does_not_complete_parent(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "9" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + permission = { + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "sub_pipeline", + "decision": "allow_once", + } + acknowledgement = { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + } + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "activeRequestSeq": 4, + "workerPid": 4321, + "sidebandWorkerPid": 12345, + "sidebandWorkerToken": "worker-token", + "sidebandResponse": permission, + "lastPermissionResponse": { + "inputId": permission["inputId"], + "toolUseId": permission["toolUseId"], + "decision": "allow_once", + }, + "sidebandResponseInputId": permission["inputId"], + "inputRequired": permission, + "pendingPermissions": [permission], + "artifacts": [], + }, + ) + + bridge._finish_sideband_job( + job_id, + 4, + "worker-token", + { + "ok": True, + "state": "completed", + "permissionAck": acknowledgement, + "pipelineResult": {"child": "only"}, + "normalHandoffReady": True, + }, + 12345, + ) + job = bridge._load_state_json(job_path) + + assert job["state"] == "working" + assert job["workerPid"] == 4321 + assert "pipelineResult" not in job + assert "normalHandoffReady" not in job + assert "conversationMode" not in job + assert "inputRequired" not in job + assert "pendingPermissions" not in job + assert "sidebandWorkerToken" not in job + + +def test_sideband_finish_rejects_ack_with_only_a_matching_input_id(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "a" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + permission = { + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "sub_pipeline", + } + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "working", + "mode": "pipeline", + "activeRequestSeq": 1, + "workerPid": 4321, + "sidebandWorkerPid": 12345, + "sidebandWorkerToken": "worker-token", + "sidebandResponse": permission, + "lastPermissionResponse": { + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + }, + "artifacts": [], + }, + ) + + bridge._finish_sideband_job( + job_id, + 1, + "worker-token", + { + "state": "permission-responded", + "permissionAck": { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-other", + "decision": "allow_once", + "accepted": True, + }, + }, + 12345, + ) + job = bridge._load_state_json(job_path) + + assert job["state"] == "input-required" + assert job["inputRequired"] == permission + assert job["sidebandError"]["code"] == "permission_not_acknowledged" + assert "permissionAck" not in job + assert "acknowledgedPermissionIds" not in job + + +def test_sideband_finish_does_not_regress_terminal_parent(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "8" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + permission = {"inputId": "permission-1", "toolUseId": "tool-1", "decision": "allow_once"} + acknowledgement = { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + } + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "completed", + "mode": "pipeline", + "activeRequestSeq": 2, + "sidebandWorkerPid": 12345, + "sidebandWorkerToken": "worker-token", + "sidebandResponse": permission, + "lastPermissionResponse": { + "inputId": permission["inputId"], + "toolUseId": permission["toolUseId"], + "decision": "allow_once", + }, + "artifacts": [], + }, + ) + + bridge._finish_sideband_job( + job_id, + 2, + "worker-token", + {"ok": True, "state": "permission-responded", "permissionAck": acknowledgement}, + 12345, + ) + job = bridge._load_state_json(job_path) + + assert job["state"] == "completed" + assert "sidebandWorkerToken" not in job + + +def test_managed_respond_returns_durable_ack_for_same_duplicate_and_rejects_conflict( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "d" * 32 + workspace = tmp_path / "duplicate-response" + workspace.mkdir() + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + permission = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "normal", + } + permission_path = workspace / "permission.json" + permission_path.write_text(json.dumps(permission), encoding="utf-8") + response = { + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + } + acknowledgement = { + "schemaVersion": 1, + "kind": "permission_ack", + "inputId": "permission-1", + "toolUseId": "tool-1", + "decision": "allow_once", + "accepted": True, + } + bridge._atomic_json( + job_path, + { + "jobId": job_id, + "workspace": str(workspace), + "state": "turn-completed", + "mode": "normal", + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "en", + "activeRequestSeq": 2, + "turn": 1, + "lastPermissionResponse": response, + "permissionAck": acknowledgement, + "artifacts": [], + }, + ) + monkeypatch.setattr( + bridge, + "_spawn_worker", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("duplicate must not spawn a worker")), + ) + + duplicate = bridge._respond_job_local( + {"jobId": job_id, "inputFile": str(permission_path), "decision": "allow_once"} + ) + + assert duplicate["state"] == "permission-responded" + assert duplicate["duplicate"] is True + assert duplicate["permissionResponse"] == response + assert duplicate["permissionAck"] == acknowledgement + with pytest.raises(bridge.BridgeError, match="conflicts with the stored decision"): + bridge._respond_job_local({"jobId": job_id, "inputFile": str(permission_path), "decision": "deny"}) + + +def test_managed_respond_waits_for_top_pipeline_parent_worker_to_reach_eof(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "7" * 32 + workspace = tmp_path / "top-pipeline" + workspace.mkdir() + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + pending = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "pipeline", + } + permission_path = workspace / "permission.json" + permission_path.write_text(json.dumps(pending), encoding="utf-8") + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(workspace), + "state": "input-required", + "mode": "pipeline", + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "workerPid": 4321, + "turn": 1, + "inputRequired": pending, + "artifacts": [], + }, + ) + monkeypatch.setattr(bridge, "_pid_alive", lambda pid: pid == 4321) + monkeypatch.setattr( + bridge, + "_spawn_worker", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("top permission must wait for parent EOF")), + ) + + with pytest.raises(bridge.BridgeError) as error: + bridge._respond_job_local({"jobId": job_id, "inputFile": str(permission_path), "decision": "allow_once"}) + job = bridge._load_state_json(job_path) + + assert error.value.code == "job_busy" + assert error.value.retryable is True + assert job["workerPid"] == 4321 + assert job["activeRequestSeq"] == 1 + assert job["state"] == "input-required" + assert job["inputRequired"] == pending + assert "lastPermissionResponse" not in job + + +def test_managed_respond_rejects_sub_pipeline_permission_without_live_parent_stream( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "5" * 32 + workspace = tmp_path / "pipeline-detached" + workspace.mkdir() + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + pending = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": "session-1", + "inputId": "permission-1", + "toolUseId": "tool-1", + "permissionClass": "sub_pipeline", + } + permission_path = workspace / "permission.json" + permission_path.write_text(json.dumps(pending), encoding="utf-8") + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "workspace": str(workspace), + "state": "input-required", + "mode": "pipeline", + "endpoint": "ros.aliyuncs.com", + "sessionId": "session-1", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "turn": 1, + "inputRequired": pending, + "pendingPermissions": [pending], + "artifacts": [], + }, + ) + + with pytest.raises(bridge.BridgeError) as error: + bridge._respond_job_local({"jobId": job_id, "inputFile": str(permission_path), "decision": "allow_once"}) + + assert error.value.code == "stream_detached" + + +def test_follow_result_remains_bounded_with_large_final_text(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + job_id = "f" * 32 + root, job_path, spool = bridge._job_paths(job_id) + bridge._secure_directory(root) + spool.touch() + bridge._atomic_json( + job_path, + { + "schemaVersion": 1, + "jobId": job_id, + "state": "turn-completed", + "mode": "normal", + "preferredLanguage": "en", + "activeRequestSeq": 1, + "turn": 1, + "finalText": "结果" * 50000, + "finalTextComplete": True, + "artifacts": [], + }, + ) + + result = bridge._job_result(job_id, 0) + assert len(bridge._json_bytes(result)) <= bridge.MAX_FOLLOW_BYTES + assert result["finalTextComplete"] is False diff --git a/tests/skill_bridge/test_iac_code_bridge.py b/tests/skill_bridge/test_iac_code_bridge.py index b7e9280f..c1dfb7e4 100644 --- a/tests/skill_bridge/test_iac_code_bridge.py +++ b/tests/skill_bridge/test_iac_code_bridge.py @@ -105,6 +105,7 @@ def test_bridge_parses_as_python_38_and_uses_only_standard_library_imports() -> "fcntl", "hashlib", "json", + "math", "msvcrt", "os", "pathlib", @@ -542,12 +543,10 @@ def finish_cleanup(_args): assert len(captured_payloads) == 2 assert all( - payload["params"]["message"]["metadata"]["iac_code"]["cleanupOnly"] is True - for payload in captured_payloads + payload["params"]["message"]["metadata"]["iac_code"]["cleanupOnly"] is True for payload in captured_payloads ) assert all( - payload["params"]["message"]["metadata"]["iac_code"]["channel"] == "skill/host" - for payload in captured_payloads + payload["params"]["message"]["metadata"]["iac_code"]["channel"] == "skill/host" for payload in captured_payloads ) assert captured_payloads[0]["params"]["message"]["contextId"] == "ctx-pipeline-1" assert result["state"] == "completed" @@ -862,6 +861,15 @@ def test_runtime_identity_is_shared_across_workspaces() -> None: assert normal_record != pipeline_record +def test_default_permission_wait_policy_preserves_legacy_runtime_identity() -> None: + target = "darwin-arm64-macos-cp312" + legacy_identity = "\0".join([bridge.RUNTIME_TAG, target, "normal", ""]) + legacy_key = hashlib.sha256(legacy_identity.encode("utf-8")).hexdigest()[:24] + + assert bridge._runtime_key("normal", "", target) == legacy_key + assert bridge._runtime_key("normal", "", target, None) == legacy_key + + def test_ensure_server_uses_stable_root_and_skill_cwd_policy(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setattr(bridge, "_free_port", lambda: 41242) @@ -894,6 +902,48 @@ def popen(command, **kwargs): assert config["idle_shutdown_seconds"] == 1800 +def test_ensure_server_projects_permission_wait_policy_only_into_server_config(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(bridge, "_free_port", lambda: 41243) + monkeypatch.setattr(bridge, "_runtime_matches", lambda *_args: True) + + class Process: + pid = 12346 + + def poll(self): + return None + + monkeypatch.setattr(bridge.subprocess, "Popen", lambda *_args, **_kwargs: Process()) + artifact = {"target": "darwin-arm64-macos-cp312"} + policy = { + "residentTimeoutSeconds": 300.0, + "subPipelineTimeoutSeconds": 300.0, + "timeoutGraceSeconds": 30.0, + } + + record = bridge.ensure_server(tmp_path / "iac-code", artifact, "normal", "", policy) + + config = bridge._load_json(Path(record["logPath"]).with_name("a2a.json")) + assert config["permission_wait"] == { + "resident_timeout_seconds": 300.0, + "sub_pipeline_timeout_seconds": 300.0, + "timeout_grace_seconds": 30.0, + } + assert record["permissionWaitPolicy"] == policy + assert bridge._runtime_record_path("normal", "", artifact["target"], policy) != bridge._runtime_record_path( + "normal", "", artifact["target"] + ) + payload = bridge._worker_payload( + { + "workspace": "/tmp/work", + "preferredLanguage": "en", + "permissionWaitPolicy": policy, + }, + prompt="Deploy a VPC", + ) + assert "permissionWaitPolicy" not in payload["params"]["message"]["metadata"]["iac_code"] + + def test_ensure_server_terminates_failed_spawn_and_removes_record(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setattr(bridge, "_free_port", lambda: 41242) @@ -1138,9 +1188,7 @@ def test_candidate_presentation_survives_bounded_bridge_projection() -> None: "summary": "单 ECS 低成本方案。", "architectureDiagram": "flowchart LR\nU[用户] --> E[ECS]", "totalMonthlyCost": "¥88/月", - "costItems": [ - {"name": "ECS", "spec": "2核4G", "monthlyCost": "¥88/月"} - ], + "costItems": [{"name": "ECS", "spec": "2核4G", "monthlyCost": "¥88/月"}], } ], "required": True, @@ -1206,6 +1254,59 @@ def test_installed_skill_channel_config_is_optional_and_validated(monkeypatch, t bridge._skill_telemetry_channel() +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ({}, {"residentTimeoutSeconds": None, "subPipelineTimeoutSeconds": None, "timeoutGraceSeconds": 30.0}), + ( + { + "residentTimeoutSeconds": 300, + "subPipelineTimeoutSeconds": 120.5, + "timeoutGraceSeconds": 0, + }, + { + "residentTimeoutSeconds": 300.0, + "subPipelineTimeoutSeconds": 120.5, + "timeoutGraceSeconds": 0.0, + }, + ), + ], +) +def test_skill_permission_wait_policy_is_normalized(raw, expected) -> None: + assert bridge._normalize_permission_wait_policy(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + [], + {"unknown": 1}, + {"residentTimeoutSeconds": 0}, + {"residentTimeoutSeconds": True}, + {"subPipelineTimeoutSeconds": -1}, + {"timeoutGraceSeconds": None}, + {"timeoutGraceSeconds": float("inf")}, + {"residentTimeoutSeconds": 10**1000}, + {"subPipelineTimeoutSeconds": bridge.MAX_PERMISSION_WAIT_SECONDS + 1}, + {"timeoutGraceSeconds": bridge.MAX_PERMISSION_WAIT_SECONDS + 1}, + ], +) +def test_skill_permission_wait_policy_rejects_invalid_values(raw) -> None: + with pytest.raises(bridge.BridgeError) as error: + bridge._normalize_permission_wait_policy(raw) + assert error.value.code == "skill_configuration_invalid" + + +def test_installed_skill_config_rejects_unknown_top_level_fields(monkeypatch, tmp_path: Path) -> None: + installed_skill = tmp_path / "installed-skill" + installed_skill.mkdir() + (installed_skill / "config.json").write_text('{"channel":"codex","unexpected":true}', encoding="utf-8") + monkeypatch.setattr(bridge, "SKILL_ROOT", installed_skill) + + with pytest.raises(bridge.BridgeError, match="Unknown installed Skill config fields"): + bridge._skill_config() + + def test_job_results_repeat_preferred_language(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) job_id = "8" * 32 @@ -2545,9 +2646,11 @@ def test_skill_contract_uses_implicit_trigger_normal_default_and_follow() -> Non assert "`llm_not_configured`" in skill assert "`cloud_credentials_not_configured`" in skill assert "optional `config.json` beside this `SKILL.md`" in skill - assert '{"channel":""}' in skill + assert '"permissionWaitPolicy"' in skill + assert '"residentTimeoutSeconds"' in skill + assert "never sends the policy through A2A message metadata" in skill assert "adds the `skill/` prefix" in skill - assert "Never derive a channel from the user's request" in skill + assert "Never derive these values from the user's request" in skill assert "python3 scripts/iac_code.py cache list" in skill assert "cache clean --candidates --confirm" in skill assert "remove only downloaded Runtime packages" in skill diff --git a/tests/skill_bridge/test_start_chat_relay.py b/tests/skill_bridge/test_start_chat_relay.py new file mode 100644 index 00000000..c8f348cd --- /dev/null +++ b/tests/skill_bridge/test_start_chat_relay.py @@ -0,0 +1,1358 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import json +import queue +import shutil +import socket +import ssl +import subprocess +import sys +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from iac_code.a2a.app import create_app +from iac_code.a2a.executor import publish_stream_event as publish_stream_event_default +from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator +from iac_code.a2a.pipeline_journal import A2APipelineJournal +from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore +from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher +from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent, TextDeltaEvent + +ROOT = Path(__file__).resolve().parents[2] +RELAY_PATH = Path(__file__).with_name("start_chat_relay.py") +BRIDGE_PATH = ROOT / "skills/alicloud-ros-agent/scripts/ros_agent.py" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +relay = _load_module("start_chat_test_relay", RELAY_PATH) +bridge = _load_module("start_chat_test_bridge", BRIDGE_PATH) + + +def _clear_code_credential_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in bridge.ACCESS_KEY_ID_ENV_NAMES + bridge.ACCESS_KEY_SECRET_ENV_NAMES + bridge.SECURITY_TOKEN_ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +def test_relay_accepts_only_published_start_chat_parameters() -> None: + parameters = relay.parse_start_chat_request( + "/?Action=StartChat&Version=2019-09-10&Query=hello&AgentVersion=V2&" + "EnablePartialMessage=true&EnableThinking=false&Mode=IaCCodeNormal&RegionId=cn-hangzhou&" + "Attachments.1.Type=image&Attachments.1.MimeType=image%2Fpng&Attachments.1.OssObjectKey=demo.png", + b"", + {"x-acs-action": "StartChat"}, + ) + + assert parameters == { + "Query": "hello", + "AgentVersion": "V2", + "EnablePartialMessage": "true", + "EnableThinking": "false", + "Mode": "IaCCodeNormal", + "RegionId": "cn-hangzhou", + "Attachments.1.Type": "image", + "Attachments.1.MimeType": "image/png", + "Attachments.1.OssObjectKey": "demo.png", + } + with pytest.raises(relay.StartChatRequestError, match="PipelineName"): + relay.parse_start_chat_request( + "/?Action=StartChat&Query=hello&PipelineName=selling", + b"", + {"x-acs-action": "StartChat"}, + ) + with pytest.raises(relay.StartChatRequestError, match="RPC root"): + relay.parse_start_chat_request( + "/health?Action=StartChat&Query=hello", + b"", + {"x-acs-action": "StartChat"}, + ) + + +def test_relay_accepts_only_published_stop_chat_parameters() -> None: + parameters = relay.parse_stop_chat_request( + "/?Action=StopChat&Version=2019-09-10&SessionId=session-1&AgentVersion=V2", + b"", + {"x-acs-action": "StopChat"}, + ) + + assert parameters == {"SessionId": "session-1", "AgentVersion": "V2"} + with pytest.raises(relay.StartChatRequestError, match="Query"): + relay.parse_stop_chat_request( + "/?Action=StopChat&SessionId=session-1&Query=cancel", + b"", + {"x-acs-action": "StopChat"}, + ) + with pytest.raises(relay.StartChatRequestError, match="SessionId"): + relay.parse_stop_chat_request( + "/?Action=StopChat", + b"", + {"x-acs-action": "StopChat"}, + ) + + +def test_relay_stop_chat_calls_a2a_cancel_task(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:1/", + pipeline_a2a_url="http://127.0.0.1:2/", + workspace=str(tmp_path), + ssl_context=_tls_context(tmp_path), + ) + session = relay._Session(session_id="session-1", mode="IaCCodePipeline", task_id="task-1") + server.sessions[session.session_id] = session + captured = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _maximum): + return json.dumps( + { + "jsonrpc": "2.0", + "id": "request-1", + "result": {"id": "task-1", "status": {"state": "TASK_STATE_CANCELED"}}, + } + ).encode("utf-8") + + class Opener: + def open(self, request, timeout): + captured["url"] = request.full_url + captured["timeout"] = timeout + captured["payload"] = json.loads(request.data.decode("utf-8")) + return Response() + + monkeypatch.setattr(relay, "build_opener", lambda *_args: Opener()) + try: + status = server.stop_session("session-1") + + assert status == "Stopped" + assert captured["url"] == "http://127.0.0.1:2/" + assert captured["payload"]["method"] == "CancelTask" + assert captured["payload"]["params"] == {"id": "task-1"} + finally: + server.server_close() + + +def test_pipeline_start_chat_requests_rich_a2a_candidate_projection(tmp_path: Path) -> None: + server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:1/", + pipeline_a2a_url="http://127.0.0.1:2/", + workspace=str(tmp_path), + ssl_context=_tls_context(tmp_path), + ) + captured = [] + + def consume(_session, call, payload, upstream_url): + captured.append((payload, upstream_url)) + call.events.put(relay._END) + + server._consume_a2a = consume + try: + session = relay._Session(session_id="session-1", mode="IaCCodePipeline") + call = server.start_a2a_call( + session, + { + "Query": "deploy", + "Mode": "IaCCodePipeline", + "EnableThinking": "true", + "RegionId": "cn-hangzhou", + }, + ) + call.thread.join(timeout=2) + payload, upstream_url = captured[0] + iac_code = payload["params"]["message"]["metadata"]["iac_code"] + assert iac_code["candidatePresentation"] == "rich-v1" + assert upstream_url == "http://127.0.0.1:2/" + + handoff_event = { + "result": { + "statusUpdate": { + "taskId": "task-1", + "contextId": "session-1", + "status": {"state": "TASK_STATE_COMPLETED"}, + "metadata": { + "iac_code": { + "pipeline": { + "eventType": "pipeline_handoff_ready", + "visibility": "committed", + "data": {"action": "switch_to_normal", "targetMode": "normal"}, + } + } + }, + } + } + } + server._observe_sideband_state(session, handoff_event) + assert session.normal_handoff_ready is True + continued = server.start_a2a_call( + session, + {"Query": "delete the deployed stack", "Mode": "IaCCodePipeline", "EnableThinking": "true"}, + ) + continued.thread.join(timeout=2) + continued_message = captured[1][0]["params"]["message"] + assert continued_message["contextId"] == "session-1" + assert "taskId" not in continued_message + assert captured[1][1] == "http://127.0.0.1:2/" + finally: + server.server_close() + + +def test_relay_projects_http_200_json_rpc_error_as_failed_sse(monkeypatch: pytest.MonkeyPatch) -> None: + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def __iter__(self): + yield json.dumps( + { + "jsonrpc": "2.0", + "id": "request-1", + "error": { + "code": -32602, + "message": "permission_resume_invalid: canonical permission request changed.", + }, + } + ).encode("utf-8") + + class Opener: + def open(self, _request, timeout): + assert timeout == 3 + return Response() + + monkeypatch.setattr(relay, "build_opener", lambda *_args: Opener()) + server = object.__new__(relay.StartChatRelay) + server.upstream_timeout = 3 + session = relay._Session(session_id="session-1", mode="IaCCodeNormal") + call = relay._UpstreamCall() + + server._consume_a2a(session, call, {"jsonrpc": "2.0"}, "http://127.0.0.1:1/") + + assert call.events.get_nowait() == { + "id": "session-1", + "object": "response", + "status": "failed", + "error": { + "code": "-32602", + "message": "permission_resume_invalid: canonical permission request changed.", + }, + } + assert call.events.get_nowait() is relay._END + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _tls_context(tmp_path: Path) -> ssl.SSLContext: + openssl = shutil.which("openssl") + if openssl is None: + pytest.skip("openssl is required for the local HTTPS StartChat relay") + key = tmp_path / "relay-key.pem" + certificate = tmp_path / "relay-cert.pem" + subprocess.run( + [ + openssl, + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key), + "-out", + str(certificate), + "-days", + "1", + "-nodes", + "-subj", + "/CN=127.0.0.1", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certificate, key) + return context + + +def _start_uvicorn(app, port: int): + uvicorn = pytest.importorskip("uvicorn") + server = uvicorn.Server( + uvicorn.Config( + app, + host="127.0.0.1", + port=port, + log_level="error", + access_log=False, + ) + ) + thread = threading.Thread(target=server.run, name="test-iac-code-a2a", daemon=True) + thread.start() + deadline = time.monotonic() + 10 + while not server.started and thread.is_alive() and time.monotonic() < deadline: + time.sleep(0.01) + assert server.started + return server, thread + + +def _aliyun_start_chat_command( + aliyun: str, + endpoint: str, + query: str, + *, + session_id: str | None = None, + mode: str = "normal", +) -> list[str]: + command = bridge.build_command( + SimpleNamespace( + aliyun_path=aliyun, + endpoint="ros.aliyuncs.com", + connect_timeout=3, + read_timeout=15, + profile=None, + region_id="cn-hangzhou", + no_thinking=True, + mode=mode, + session_id=session_id, + ), + query, + None, + [], + ) + command[command.index("--endpoint") + 1] = endpoint + query_index = command.index("--Query") + command[query_index:query_index] = [ + "--skip-secure-verify", + "--mode", + "AK", + "--access-key-id", + "fake-access-key-id", + "--access-key-secret", + "fake-access-key-secret", + "--retry-count", + "0", + ] + return command + + +def _aliyun_start_chat( + aliyun: str, + endpoint: str, + query: str, + *, + session_id: str | None = None, + mode: str = "normal", +) -> tuple[str, str]: + command = _aliyun_start_chat_command( + aliyun, + endpoint, + query, + session_id=session_id, + mode=mode, + ) + completed = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + check=False, + ) + assert completed.returncode == 0, bridge.sanitize_text(completed.stderr, 1000) + return completed.stdout, completed.stderr + + +def _aliyun_stop_chat(aliyun: str, endpoint: str, session_id: str) -> dict: + command = bridge.build_stop_command( + { + "aliyunPath": aliyun, + "endpoint": endpoint, + "connectTimeout": 3, + "profile": None, + "regionId": "cn-hangzhou", + }, + session_id, + ) + input_index = command.index("--AgentVersion") + command[input_index:input_index] = [ + "--mode", + "AK", + "--access-key-id", + "fake-access-key-id", + "--access-key-secret", + "fake-access-key-secret", + "--retry-count", + "0", + ] + completed = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + check=False, + ) + assert completed.returncode == 0, bridge.sanitize_text(completed.stderr, 1000) + value = json.loads(completed.stdout) + assert isinstance(value, dict) + return value + + +def _summarize_sse( + stdout: str, + stderr: str, + *, + session_id: str | None = None, + mode: str = "normal", +) -> dict: + summary = bridge.StreamSummary(session_id, mode=mode) + diagnostics = [] + for payload, raw in bridge.iter_sse_payloads(stdout.splitlines(keepends=True)): + if payload is None: + summary.malformed_event_count += 1 + diagnostics.append(raw) + else: + summary.apply(payload) + return summary.to_result(0, stderr or "\n".join(diagnostics)) + + +def _permission_response_query(permission: dict, decision: str) -> str: + return "{} {}".format( + bridge.PERMISSION_QUERY_PREFIX, + json.dumps( + { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": permission["requestTaskId"], + "contextId": permission["contextId"], + "inputId": permission["inputId"], + "toolUseId": permission["toolUseId"], + "decision": decision, + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ), + ) + + +def test_code_transport_streams_through_sdk_to_endpoint_hook( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _clear_code_credential_env(monkeypatch) + relay_server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:1/", + workspace=str(tmp_path), + ssl_context=_tls_context(tmp_path), + ) + captured = {} + working_event = { + "result": { + "statusUpdate": { + "taskId": "task-code-1", + "contextId": "session-code-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": {"role": "ROLE_AGENT", "parts": [{"text": "working"}]}, + }, + "metadata": {"iac_code": {}, "iacCodeSessionId": "iac-code-1"}, + } + } + } + completed_event = { + "result": { + "statusUpdate": { + "taskId": "task-code-1", + "contextId": "session-code-1", + "status": { + "state": "TASK_STATE_COMPLETED", + "message": {"role": "ROLE_AGENT", "parts": [{"text": "code transport done"}]}, + }, + "metadata": { + "iac_code": {"assistantFinal": {"complete": True}}, + "iacCodeSessionId": "iac-code-1", + }, + } + } + } + + def start_a2a_call(session, parameters): + captured["parameters"] = parameters + call = relay._UpstreamCall() + captured["call"] = call + call.events.put(working_event) + return call + + relay_server.start_a2a_call = start_a2a_call + relay_thread = threading.Thread(target=relay_server.serve_forever, name="test-code-relay", daemon=True) + relay_thread.start() + endpoint = "127.0.0.1:{}".format(relay_server.server_address[1]) + + class FakeCredentials: + def get_access_key_id(self): + return "fake-access-key-id" + + def get_access_key_secret(self): + return "fake-access-key-secret" + + def get_security_token(self): + return "fake-security-token" + + class FakeProvider: + def __init__(self, profile_name=None): + captured["profile"] = profile_name + + def get_credentials(self): + return FakeCredentials() + + sdk = bridge._load_code_sdk() + sdk["CLIProfileCredentialsProvider"] = FakeProvider + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: sdk) + monkeypatch.setattr(bridge, "_selected_cli_profile", lambda profile: (profile, "AK")) + args = SimpleNamespace( + aliyun_path="not-used", + transport="code", + endpoint=endpoint, + connect_timeout=3, + read_timeout=15, + profile="sdk-profile", + region_id="cn-hangzhou", + no_thinking=True, + mode="normal", + session_id=None, + ) + first_payload = threading.Event() + outcome = {} + + def consume() -> None: + try: + outcome["result"] = bridge._consume_start_chat( + args, + tmp_path, + "create a VPC", + None, + [], + on_payload=lambda _payload, _summary: first_payload.set(), + ) + except BaseException as exc: # pragma: no cover - asserted in the main test thread + outcome["error"] = exc + + consumer_thread = threading.Thread(target=consume, name="test-code-consumer", daemon=True) + + try: + consumer_thread.start() + + assert first_payload.wait(timeout=3), "a flushed SSE event must be delivered before the stream closes" + assert consumer_thread.is_alive() + + captured["call"].events.put(completed_event) + captured["call"].events.put(relay._END) + consumer_thread.join(timeout=5) + + assert not consumer_thread.is_alive() + assert "error" not in outcome + result = outcome["result"] + + assert result["state"] == "turn-completed" + assert result["finalText"] == "code transport done" + assert captured["profile"] == "sdk-profile" + assert captured["parameters"]["Query"] == "create a VPC" + assert captured["parameters"]["Mode"] == "IaCCodeNormal" + finally: + if consumer_thread.is_alive() and "call" in captured: + captured["call"].events.put(completed_event) + captured["call"].events.put(relay._END) + consumer_thread.join(timeout=5) + relay_server.shutdown() + relay_server.server_close() + relay_thread.join(timeout=5) + + +def test_stop_chat_round_trip_through_real_aliyun_cli( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + aliyun = shutil.which("aliyun") + if aliyun is None: + pytest.skip("Alibaba Cloud CLI is not installed") + metrics_path = tmp_path / "relay-metrics.json" + relay_server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:1/", + pipeline_a2a_url="http://127.0.0.1:2/", + workspace=str(tmp_path), + ssl_context=_tls_context(tmp_path), + metrics_path=str(metrics_path), + ) + session = relay._Session(session_id="session-cli-stop", mode="IaCCodePipeline", task_id="task-1") + relay_server.sessions[session.session_id] = session + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _maximum): + return json.dumps( + {"jsonrpc": "2.0", "result": {"id": "task-1", "status": {"state": "TASK_STATE_CANCELED"}}} + ).encode("utf-8") + + class Opener: + def open(self, _request, timeout): + assert timeout == relay_server.upstream_timeout + return Response() + + monkeypatch.setattr(relay, "build_opener", lambda *_args: Opener()) + relay_thread = threading.Thread(target=relay_server.serve_forever, name="test-stop-chat-relay", daemon=True) + relay_thread.start() + endpoint = "127.0.0.1:{}".format(relay_server.server_address[1]) + try: + result = _aliyun_stop_chat(aliyun, endpoint, session.session_id) + + assert result["Status"] == "Stopped" + assert result["SessionId"] == session.session_id + assert isinstance(result["RequestId"], str) + deadline = time.monotonic() + 2 + while not metrics_path.exists() and time.monotonic() < deadline: + time.sleep(0.01) + metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + assert metrics["requests"] == [ + { + "action": "StopChat", + "durationMs": metrics["requests"][0]["durationMs"], + "finishedAtUnixMs": metrics["requests"][0]["finishedAtUnixMs"], + "sessionId": session.session_id, + "startedAtUnixMs": metrics["requests"][0]["startedAtUnixMs"], + "stopStatus": "Stopped", + } + ] + finally: + relay_server.shutdown() + relay_server.server_close() + relay_thread.join(timeout=5) + + +def test_stop_chat_cancels_live_a2a_stream_through_real_aliyun_cli( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + aliyun = shutil.which("aliyun") + if aliyun is None: + pytest.skip("Alibaba Cloud CLI is not installed") + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.delenv("IAC_CODE_MODE", raising=False) + started: queue.Queue[bool] = queue.Queue() + + class SlowLoop: + async def run_streaming(self, _prompt: str): + started.put(True) + yield TextDeltaEvent(text="working before cancellation") + await asyncio.sleep(60) + + def runtime_factory(options): + return SimpleNamespace(agent_loop=SlowLoop(), session_id=options.session_id) + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", runtime_factory) + a2a_port = _free_port() + app = create_app( + host="127.0.0.1", + port=a2a_port, + token=None, + model="qwen3.6-plus", + persistence_dir=tmp_path / "a2a-state", + artifact_dir=tmp_path / "artifacts", + ) + a2a_server, a2a_thread = _start_uvicorn(app, a2a_port) + relay_server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:{}/".format(a2a_port), + workspace=str(workspace), + ssl_context=_tls_context(tmp_path), + ) + relay_thread = threading.Thread(target=relay_server.serve_forever, name="test-live-stop-relay", daemon=True) + relay_thread.start() + endpoint = "127.0.0.1:{}".format(relay_server.server_address[1]) + command = bridge.build_command( + SimpleNamespace( + aliyun_path=aliyun, + endpoint=endpoint, + connect_timeout=3, + read_timeout=30, + profile=None, + region_id="cn-hangzhou", + no_thinking=True, + mode="normal", + session_id=None, + ), + "run until canceled", + None, + [], + ) + query_index = command.index("--Query") + command[query_index:query_index] = [ + "--mode", + "AK", + "--access-key-id", + "fake-access-key-id", + "--access-key-secret", + "fake-access-key-secret", + "--retry-count", + "0", + ] + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + try: + assert started.get(timeout=10) is True + deadline = time.monotonic() + 10 + session = None + while time.monotonic() < deadline: + with relay_server.sessions_lock: + values = list(relay_server.sessions.values()) + if values and values[0].task_id: + session = values[0] + break + time.sleep(0.05) + assert session is not None + + stopped = _aliyun_stop_chat(aliyun, endpoint, session.session_id) + stdout, stderr = process.communicate(timeout=15) + result = _summarize_sse(stdout, stderr, session_id=session.session_id) + + assert stopped["Status"] == "Stopped" + assert result["state"] == "canceled" + assert result["sessionId"] == session.session_id + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + relay_server.shutdown() + relay_server.server_close() + relay_thread.join(timeout=5) + a2a_server.should_exit = True + a2a_thread.join(timeout=10) + + +@pytest.mark.parametrize(("decision", "allowed"), [("allow_once", True), ("deny", False)]) +def test_normal_permission_round_trip_through_real_aliyun_cli_and_a2a( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + decision: str, + allowed: bool, +) -> None: + aliyun = shutil.which("aliyun") + if aliyun is None: + pytest.skip("Alibaba Cloud CLI is not installed") + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.delenv("IAC_CODE_MODE", raising=False) + decisions: queue.Queue[bool] = queue.Queue() + prompts: queue.Queue[str] = queue.Queue() + + from iac_code.agent.message import Message, ToolUseBlock + from iac_code.services.permission_wait import canonical_digest + from iac_code.services.session_storage import SessionStorage + + class PermissionLoop: + def __init__(self, options): + self.options = options + + async def run_streaming(self, prompt: str): + prompts.put(prompt) + tool_use = ToolUseBlock(id="tool-normal-1", name="bash", input={"cmd": "pwd"}) + assistant = Message(role="assistant", content=[tool_use]) + storage = SessionStorage() + storage.ensure_v2_session_dir_for_new_session(str(self.options.cwd), str(self.options.session_id)) + storage.append(str(self.options.cwd), str(self.options.session_id), assistant) + response = asyncio.get_running_loop().create_future() + yield PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="tool-normal-1", + response_future=response, + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest( + [block.model_dump(mode="json") for block in assistant.content] + ), + "orderedToolUseIds": ["tool-normal-1"], + "currentIndex": 0, + "decisions": [ + {"toolUseId": "tool-normal-1", "state": "pending", "source": None, "deniedResult": None} + ], + }, + audit_context={"session_id": str(self.options.session_id), "cwd": str(self.options.cwd)}, + ) + decisions.put(response.result()) + yield TextDeltaEvent(text="normal permission resolved") + + def runtime_factory(options): + return SimpleNamespace(agent_loop=PermissionLoop(options), session_id=options.session_id) + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", runtime_factory) + monkeypatch.setattr("iac_code.a2a.input_required.emit_permission_boundary_audit", lambda *_a, **_k: True) + + a2a_port = _free_port() + app = create_app( + host="127.0.0.1", + port=a2a_port, + token=None, + model="qwen3.6-plus", + persistence_dir=tmp_path / "a2a-state", + artifact_dir=tmp_path / "artifacts", + ) + a2a_server, a2a_thread = _start_uvicorn(app, a2a_port) + relay_server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:{}/".format(a2a_port), + workspace=str(workspace), + ssl_context=_tls_context(tmp_path), + ) + relay_thread = threading.Thread(target=relay_server.serve_forever, name="test-start-chat-relay", daemon=True) + relay_thread.start() + endpoint = "127.0.0.1:{}".format(relay_server.server_address[1]) + + try: + first_stdout, first_stderr = _aliyun_start_chat(aliyun, endpoint, "request normal permission") + first = _summarize_sse(first_stdout, first_stderr) + assert first["state"] == "input-required" + assert first["inputRequired"]["kind"] == "permission" + assert first["inputRequired"]["permissionClass"] == "normal" + assert first["sessionId"] + + permission = first["inputRequired"] + response_query = _permission_response_query(permission, decision) + second_stdout, second_stderr = _aliyun_start_chat( + aliyun, + endpoint, + response_query, + session_id=first["sessionId"], + ) + second = _summarize_sse(second_stdout, second_stderr, session_id=first["sessionId"]) + + assert decisions.get(timeout=2) is allowed + assert prompts.get(timeout=2) == "request normal permission" + assert second["ok"] is True + assert second["state"] == "turn-completed" + assert "normal permission resolved" in second["finalText"] + assert second["sessionId"] == first["sessionId"] + finally: + relay_server.shutdown() + relay_server.server_close() + relay_thread.join(timeout=5) + a2a_server.should_exit = True + a2a_thread.join(timeout=10) + + +def test_normal_consecutive_permissions_return_at_each_serial_boundary( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + aliyun = shutil.which("aliyun") + if aliyun is None: + pytest.skip("Alibaba Cloud CLI is not installed") + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.delenv("IAC_CODE_MODE", raising=False) + decisions: queue.Queue[tuple[str, bool]] = queue.Queue() + + from iac_code.agent.message import Message, ToolUseBlock + from iac_code.services.permission_wait import canonical_digest + from iac_code.services.session_storage import SessionStorage + + class ConsecutivePermissionLoop: + def __init__(self, options): + self.options = options + + async def run_streaming(self, _prompt: str): + storage = SessionStorage() + storage.ensure_v2_session_dir_for_new_session(str(self.options.cwd), str(self.options.session_id)) + for index in range(2): + response = asyncio.get_running_loop().create_future() + tool_use_id = "tool-normal-{}".format(index + 1) + tool_input = {"path": "template-{}.yaml".format(index + 1)} + assistant = Message( + role="assistant", + content=[ToolUseBlock(id=tool_use_id, name="write_file", input=tool_input)], + ) + storage.append(str(self.options.cwd), str(self.options.session_id), assistant) + yield PermissionRequestEvent( + tool_name="write_file", + tool_input=tool_input, + tool_use_id=tool_use_id, + response_future=response, + continuation_frame={ + "assistantMessageRef": "session.jsonl:{}".format(index), + "assistantMessageDigest": canonical_digest( + [block.model_dump(mode="json") for block in assistant.content] + ), + "orderedToolUseIds": [tool_use_id], + "currentIndex": 0, + "decisions": [ + {"toolUseId": tool_use_id, "state": "pending", "source": None, "deniedResult": None} + ], + }, + audit_context={"session_id": str(self.options.session_id), "cwd": str(self.options.cwd)}, + ) + decisions.put((tool_use_id, response.result())) + yield TextDeltaEvent(text="both permissions resolved") + + def runtime_factory(options): + return SimpleNamespace(agent_loop=ConsecutivePermissionLoop(options), session_id=options.session_id) + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", runtime_factory) + monkeypatch.setattr("iac_code.a2a.input_required.emit_permission_boundary_audit", lambda *_a, **_k: True) + + a2a_port = _free_port() + app = create_app( + host="127.0.0.1", + port=a2a_port, + token=None, + model="qwen3.6-plus", + persistence_dir=tmp_path / "a2a-state", + artifact_dir=tmp_path / "artifacts", + ) + a2a_server, a2a_thread = _start_uvicorn(app, a2a_port) + relay_server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:{}/".format(a2a_port), + workspace=str(workspace), + ssl_context=_tls_context(tmp_path), + ) + relay_thread = threading.Thread(target=relay_server.serve_forever, name="test-start-chat-relay", daemon=True) + relay_thread.start() + endpoint = "127.0.0.1:{}".format(relay_server.server_address[1]) + + try: + stdout, stderr = _aliyun_start_chat(aliyun, endpoint, "request consecutive permissions") + result = _summarize_sse(stdout, stderr) + assert result["state"] == "input-required" + assert result["inputRequired"]["toolUseId"] == "tool-normal-1" + + stdout, stderr = _aliyun_start_chat( + aliyun, + endpoint, + _permission_response_query(result["inputRequired"], "allow_once"), + session_id=result["sessionId"], + ) + second = _summarize_sse(stdout, stderr, session_id=result["sessionId"]) + assert second["state"] == "input-required" + assert second["inputRequired"]["toolUseId"] == "tool-normal-2" + + stdout, stderr = _aliyun_start_chat( + aliyun, + endpoint, + _permission_response_query(second["inputRequired"], "allow_once"), + session_id=result["sessionId"], + ) + third = _summarize_sse(stdout, stderr, session_id=result["sessionId"]) + assert third["state"] == "turn-completed" + assert third["finalText"] == "both permissions resolved" + assert decisions.get(timeout=2) == ("tool-normal-1", True) + assert decisions.get(timeout=2) == ("tool-normal-2", True) + finally: + relay_server.shutdown() + relay_server.server_close() + relay_thread.join(timeout=5) + a2a_server.should_exit = True + a2a_thread.join(timeout=10) + + +def test_top_pipeline_permission_ends_parent_start_chat_and_continues_on_reply_stream( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + aliyun = shutil.which("aliyun") + if aliyun is None: + pytest.skip("Alibaba Cloud CLI is not installed") + from iac_code.a2a import executor as executor_module + from iac_code.a2a import pipeline_executor as pipeline_executor_module + from scripts.a2a.e2e.permission_wait.permission_wait_fixture_server import ( + _create_fixture_pipeline, + _create_fixture_runtime, + ) + + workspace = tmp_path / "workspace" + workspace.mkdir() + execution_log = tmp_path / "tool-executions.log" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(workspace)) + + def runtime_factory(options): + return _create_fixture_runtime(options, execution_log=execution_log) + + monkeypatch.setattr(executor_module, "create_agent_runtime", runtime_factory) + monkeypatch.setattr(pipeline_executor_module, "create_agent_runtime", runtime_factory) + monkeypatch.setattr( + pipeline_executor_module, + "create_pipeline", + lambda *unused_args, **kwargs: _create_fixture_pipeline(execution_log=execution_log, **kwargs), + ) + + a2a_port = _free_port() + app = create_app( + host="127.0.0.1", + port=a2a_port, + token=None, + model="permission-wait-fixture", + persistence_dir=tmp_path / "a2a-state", + artifact_dir=tmp_path / "artifacts", + auto_approve_permissions=False, + permission_wait={ + "resident_timeout_seconds": None, + "sub_pipeline_timeout_seconds": None, + "timeout_grace_seconds": 30, + }, + ) + a2a_server, a2a_thread = _start_uvicorn(app, a2a_port) + relay_server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:{}/".format(a2a_port), + workspace=str(workspace), + ssl_context=_tls_context(tmp_path), + heartbeat_interval=0.05, + ) + relay_thread = threading.Thread(target=relay_server.serve_forever, name="test-start-chat-relay", daemon=True) + relay_thread.start() + endpoint = "127.0.0.1:{}".format(relay_server.server_address[1]) + parent_process = None + + try: + parent_process = subprocess.Popen( + _aliyun_start_chat_command(aliyun, endpoint, "request top pipeline permission", mode="pipeline"), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + deadline = time.monotonic() + 10 + checkpoint = None + session = None + while time.monotonic() < deadline: + matches = sorted((tmp_path / "config").rglob("permission-waits/pwb_*.json")) + if matches: + checkpoint = json.loads(matches[0].read_text(encoding="utf-8")) + with relay_server.sessions_lock: + sessions = list(relay_server.sessions.values()) + session = sessions[0] if sessions else None + if session is not None: + break + assert parent_process.poll() is None + time.sleep(0.02) + assert checkpoint is not None + assert session is not None + assert checkpoint["permissionClass"] == "pipeline" + parent_stdout, parent_stderr = parent_process.communicate(timeout=10) + parent = _summarize_sse(parent_stdout, parent_stderr, session_id=session.session_id, mode="pipeline") + assert parent_process.returncode == 0 + assert parent.get("inputRequired", {}).get("kind") == "permission" + assert '"eventType":"step_completed"' not in parent_stdout + assert '"eventType":"pipeline_completed"' not in parent_stdout + with session.state_lock: + assert session.active_call is None + + permission = { + "requestTaskId": checkpoint["taskId"], + "contextId": checkpoint["contextId"], + "inputId": checkpoint["inputId"], + "toolUseId": checkpoint["toolUseId"], + } + reply_stdout, reply_stderr = _aliyun_start_chat( + aliyun, + endpoint, + _permission_response_query(permission, "allow_once"), + session_id=session.session_id, + mode="pipeline", + ) + reply = _summarize_sse(reply_stdout, reply_stderr, session_id=session.session_id, mode="pipeline") + assert '"inputReceived"' in reply_stdout, (reply, bridge.sanitize_text(reply_stdout + reply_stderr, 2000)) + assert '"eventType":"step_completed"' in reply_stdout + assert '"eventType":"pipeline_completed"' in reply_stdout + assert execution_log.read_text(encoding="utf-8").splitlines() == ["executed"] + resolved_checkpoint = json.loads(matches[0].read_text(encoding="utf-8")) + assert resolved_checkpoint["phase"] == "RESOLVED" + assert "continuationFrame" not in resolved_checkpoint + finally: + if parent_process is not None and parent_process.poll() is None: + parent_process.terminate() + parent_process.wait(timeout=5) + relay_server.shutdown() + relay_server.server_close() + relay_thread.join(timeout=5) + a2a_server.should_exit = True + a2a_thread.join(timeout=10) + + +def test_sub_pipeline_permissions_round_trip_through_real_aliyun_cli_and_a2a( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + aliyun = shutil.which("aliyun") + if aliyun is None: + pytest.skip("Alibaba Cloud CLI is not installed") + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.delenv("IAC_CODE_MODE", raising=False) + outcomes: queue.Queue[list[bool]] = queue.Queue() + prompts: queue.Queue[str] = queue.Queue() + + class SidebandSetup: + def __init__(self, events: list[SubPipelineStreamEvent]) -> None: + self.events = events + + class SidebandLoop: + async def run_streaming(self, prompt: str): + prompts.put(prompt) + futures = [asyncio.get_running_loop().create_future() for _ in range(2)] + events = [ + SubPipelineStreamEvent( + sub_pipeline_id="candidate-{}".format(index), + candidate_index=index, + inner=PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "echo candidate-{}".format(index)}, + tool_use_id="tool-sideband-{}".format(index), + response_future=future, + ), + ) + for index, future in enumerate(futures) + ] + yield SidebandSetup([events[0]]) + await asyncio.sleep(0.25) + yield SidebandSetup([events[1]]) + outcomes.put(list(await asyncio.gather(*futures))) + yield TextDeltaEvent(text="sub pipeline permissions resolved") + + def runtime_factory(options): + return SimpleNamespace(agent_loop=SidebandLoop(), session_id=options.session_id) + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", runtime_factory) + components = create_runtime_components( + model="qwen3.6-plus", + host="127.0.0.1", + port=0, + persistence_dir=tmp_path / "a2a-state", + artifact_dir=tmp_path / "artifacts", + ) + publisher_holder: dict[str, PipelineA2AEventPublisher] = {} + + async def publish_with_sideband( + event_queue, + *, + task_id, + context_id, + event, + permission_input_registry=None, + **kwargs, + ): + if not isinstance(event, SidebandSetup): + return await publish_stream_event_default( + event_queue, + task_id=task_id, + context_id=context_id, + event=event, + permission_input_registry=permission_input_registry, + **kwargs, + ) + publisher = PipelineA2AEventPublisher( + event_queue=event_queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id=context_id, + task_id=task_id, + context_id=context_id, + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline-sideband"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline-sideband"), + permission_input_registry=permission_input_registry, + task_store=components.task_store, + ) + publisher_holder["publisher"] = publisher + for sideband_event in event.events: + await publisher.publish_sub_pipeline_permission(sideband_event) + return None + + monkeypatch.setattr("iac_code.a2a.executor.publish_stream_event", publish_with_sideband) + monkeypatch.setattr("iac_code.a2a.pipeline_stream.emit_permission_boundary_audit", lambda *_a, **_k: True) + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.create_runtime_components", lambda **_kwargs: components) + + a2a_port = _free_port() + app = create_app( + host="127.0.0.1", + port=a2a_port, + token=None, + model="qwen3.6-plus", + persistence_dir=tmp_path / "unused-state", + artifact_dir=tmp_path / "unused-artifacts", + ) + a2a_server, a2a_thread = _start_uvicorn(app, a2a_port) + relay_server = relay.StartChatRelay( + ("127.0.0.1", 0), + a2a_url="http://127.0.0.1:{}/".format(a2a_port), + workspace=str(workspace), + ssl_context=_tls_context(tmp_path), + ) + relay_thread = threading.Thread(target=relay_server.serve_forever, name="test-start-chat-relay", daemon=True) + relay_thread.start() + endpoint = "127.0.0.1:{}".format(relay_server.server_address[1]) + parent_process = None + + try: + parent_process = subprocess.Popen( + _aliyun_start_chat_command( + aliyun, + endpoint, + "start pipeline candidates", + mode="pipeline", + ), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + deadline = time.monotonic() + 10 + session = None + first_permission = None + while time.monotonic() < deadline: + with relay_server.sessions_lock: + sessions = list(relay_server.sessions.values()) + if sessions: + session = sessions[0] + with session.state_lock: + pending = list(session.pending_sideband.values()) + if pending: + first_permission = pending[0] + break + assert parent_process.poll() is None + time.sleep(0.02) + assert session is not None + assert first_permission is not None + assert parent_process.poll() is None + assert first_permission["kind"] == "permission" + assert publisher_holder["publisher"] is not None + with session.state_lock: + parent_call = session.active_call + assert parent_call is not None + + stdout, stderr = _aliyun_start_chat( + aliyun, + endpoint, + _permission_response_query(first_permission, "allow_once"), + session_id=session.session_id, + mode="pipeline", + ) + first_ack = _summarize_sse(stdout, stderr, session_id=session.session_id, mode="pipeline") + assert first_ack["state"] == "permission-responded" + assert first_ack["permissionAck"]["accepted"] is True + assert first_ack["permissionAck"]["inputId"] == first_permission["inputId"] + assert parent_process.poll() is None + with session.state_lock: + assert session.active_call is parent_call + + deadline = time.monotonic() + 10 + second_permission = None + while time.monotonic() < deadline: + with session.state_lock: + remaining = list(session.pending_sideband.values()) + second_permission = next( + (item for item in remaining if item.get("inputId") != first_permission["inputId"]), + None, + ) + if second_permission is not None: + break + time.sleep(0.02) + assert second_permission is not None + assert second_permission["inputId"] != first_permission["inputId"] + + stdout, stderr = _aliyun_start_chat( + aliyun, + endpoint, + _permission_response_query(second_permission, "deny"), + session_id=session.session_id, + mode="pipeline", + ) + second_ack = _summarize_sse(stdout, stderr, session_id=session.session_id, mode="pipeline") + assert second_ack["state"] == "permission-responded" + assert second_ack["permissionAck"]["accepted"] is True + assert second_ack["permissionAck"]["inputId"] == second_permission["inputId"] + + parent_stdout, parent_stderr = parent_process.communicate(timeout=20) + final = _summarize_sse(parent_stdout, parent_stderr, session_id=session.session_id, mode="pipeline") + assert parent_process.returncode == 0 + assert final["state"] in {"turn-completed", "input-required"} + assert final.get("finalText", final.get("latestText")) == "sub pipeline permissions resolved" + assert "inputRequired" not in final + assert parent_call.thread is not None + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + with session.state_lock: + if session.active_call is None: + break + time.sleep(0.01) + with session.state_lock: + assert session.active_call is None + assert not session.pending_sideband + + assert outcomes.get(timeout=3) == [True, False] + assert prompts.get(timeout=2) == "start pipeline candidates" + finally: + if parent_process is not None and parent_process.poll() is None: + parent_process.terminate() + parent_process.wait(timeout=5) + relay_server.shutdown() + relay_server.server_close() + relay_thread.join(timeout=5) + a2a_server.should_exit = True + a2a_thread.join(timeout=10) diff --git a/tests/tools/cloud/aliyun/test_aliyun_api.py b/tests/tools/cloud/aliyun/test_aliyun_api.py index 4a07f3c0..7775b510 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api.py @@ -14,6 +14,7 @@ import pytest +from iac_code.services.permissions.pipeline import check_tool_permission from iac_code.services.providers.aliyun import AliyunCredential from iac_code.services.providers.aliyun_credentials_runtime import ECS_CREDENTIAL_ERROR_CODES from iac_code.services.providers.aliyun_oauth import AliyunOAuthError, AliyunOAuthReloginRequired @@ -41,7 +42,7 @@ from iac_code.tools.cloud.aliyun.result_contract import ALIYUN_BODY_CONTRACT_VERSION, ALIYUN_HTTP_METADATA_KEY from iac_code.tools.cloud.aliyun.retry_policy import RetryBudget, RetryExhausted, RetryReason, TransportFailure from iac_code.tools.tool_executor import ToolCallRequest, ToolExecutor -from iac_code.types.permissions import InvocationBinding, ToolPermissionContext +from iac_code.types.permissions import InvocationBinding, PermissionMode, ToolPermissionContext from iac_code.types.stream_events import ResourceObservedEvent from tests.tools.cloud.aliyun._ecs_ram_role_fakes import FakeEcsRuntime @@ -1813,6 +1814,90 @@ async def test_canonical_product_rechecks_deny_rules_after_alias_resolution() -> assert transport.calls == [] +@pytest.mark.asyncio +async def test_runtime_read_only_action_ignores_aliyun_api_ask_rule() -> None: + services, _, endpoint_resolver, transport = _production_services() + tool = AliyunApi(services=services) + tool_input = tool.prepare_invocation_input( + { + "product": "Ecs", + "action": "DescribeInstances", + "region_id": "cn-hangzhou", + } + ) + binding = InvocationBinding( + "runtime", + "session", + "read-only-ask", + "aliyun_api", + canonical_input_sha256(tool_input), + ) + + permission = await check_tool_permission( + tool, + tool_input, + ToolPermissionContext( + invocation_binding=binding, + ask_rules={ + "user_settings": [ + "aliyun_api", + "aliyun_api(Ecs:DescribeInstances)", + ] + }, + ), + ) + + assert permission.behavior == "allow" + assert permission.reason is not None and permission.reason.type == "read_only" + assert permission.audit is not None and permission.audit.is_read_only is True + assert endpoint_resolver.calls == [] + assert transport.calls == [] + + +@pytest.mark.asyncio +async def test_runtime_write_action_honors_aliyun_api_ask_rule() -> None: + services, _, endpoint_resolver, transport = _production_services() + tool = AliyunApi(services=services) + tool_input = tool.prepare_invocation_input( + { + "product": "ROS", + "action": "CreateStack", + "params": {"StackName": "test-stack"}, + "region_id": "cn-hangzhou", + } + ) + binding = InvocationBinding( + "runtime", + "session", + "write-ask", + "aliyun_api", + canonical_input_sha256(tool_input), + ) + + permission = await check_tool_permission( + tool, + tool_input, + ToolPermissionContext( + mode=PermissionMode.BYPASS_PERMISSIONS, + invocation_binding=binding, + ask_rules={ + "user_settings": [ + "aliyun_api", + "aliyun_api(ROS:CreateStack)", + ] + }, + ), + ) + + assert permission.behavior == "ask" + assert permission.audit is not None + assert permission.audit.is_read_only is False + assert permission.audit.rule_source == "user_settings" + assert {reason.type for reason in permission.reasons or []} == {"rule", "untrusted_write"} + assert endpoint_resolver.calls == [] + assert transport.calls == [] + + @pytest.mark.asyncio async def test_canonical_ros_product_rechecks_pipeline_guard_after_alias_resolution() -> None: services, openmeta, endpoint_resolver, transport = _production_services() diff --git a/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py index 1e0dd8c7..b72d7925 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py @@ -1618,7 +1618,7 @@ async def test_runtime_permission_pipeline_preserves_sanitized_audit_for_each_me @pytest.mark.asyncio -async def test_global_bypass_cannot_auto_allow_sensitive_body_file_cloud_write(tmp_path) -> None: +async def test_global_bypass_auto_allows_sensitive_body_file_cloud_write_with_audit(tmp_path) -> None: project = tmp_path / "project" project.mkdir() body_file = project / ".env" @@ -1644,16 +1644,20 @@ async def test_global_bypass_cannot_auto_allow_sensitive_body_file_cloud_write(t result = await check_tool_permission(tool, tool_input, context) - assert result.behavior == "ask" - assert result.reasons is not None - assert [reason.type for reason in result.reasons] == ["safety_check", "untrusted_write"] + assert result.behavior == "allow" + assert result.reasons is None + assert result.audit is not None + assert result.audit.reason_type == "bypass_permissions" + assert result.audit.is_read_only is False + assert result.audit.operation["product"] == "ecs" + assert result.audit.operation["action"] == "CreateInstance" assert result.snapshot_id is not None assert len(runtime.contract_resolver.calls) == 1 assert runtime.contract_store.size == 1 @pytest.mark.asyncio -async def test_global_bypass_cannot_auto_allow_out_of_project_body_file_cloud_write(tmp_path) -> None: +async def test_global_bypass_auto_allows_out_of_project_body_file_cloud_write_with_audit(tmp_path) -> None: project = tmp_path / "project" outside = tmp_path / "outside" project.mkdir() @@ -1681,9 +1685,13 @@ async def test_global_bypass_cannot_auto_allow_out_of_project_body_file_cloud_wr result = await check_tool_permission(tool, tool_input, context) - assert result.behavior == "ask" - assert result.reasons is not None - assert [reason.type for reason in result.reasons] == ["path_constraint", "untrusted_write"] + assert result.behavior == "allow" + assert result.reasons is None + assert result.audit is not None + assert result.audit.reason_type == "bypass_permissions" + assert result.audit.is_read_only is False + assert result.audit.operation["product"] == "ecs" + assert result.audit.operation["action"] == "CreateInstance" assert result.snapshot_id is not None assert len(runtime.contract_resolver.calls) == 1 assert runtime.contract_store.size == 1 diff --git a/tests/web/test_permission_wait_recovery.py b/tests/web/test_permission_wait_recovery.py new file mode 100644 index 00000000..128b88e0 --- /dev/null +++ b/tests/web/test_permission_wait_recovery.py @@ -0,0 +1,556 @@ +from __future__ import annotations + +import asyncio +import json + +import pytest + +from iac_code.agent.message import Message, ToolUseBlock +from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage +from iac_code.services.permission_wait import PermissionWaitPolicy, canonical_digest +from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings, PermissionResult +from iac_code.types.stream_events import PermissionRequestEvent +from iac_code.web.runtime import WebSessionRuntime +from iac_code.web.session_manager import WebSessionManager + + +def _seed_permission_turn(manager: WebSessionManager, session): + tool_use = ToolUseBlock( + id="tool-create-stack", + name="aliyun_api", + input={"product": "ROS", "action": "CreateStack", "params": {"StackName": "test"}}, + ) + assistant = Message(role="assistant", content=[tool_use]) + manager.storage.append(session.cwd, session.session_id, assistant) + frame = { + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id], + "currentIndex": 0, + "decisions": [ + { + "toolUseId": tool_use.id, + "state": "pending", + "source": None, + "deniedResult": None, + } + ], + } + future = asyncio.get_running_loop().create_future() + event = PermissionRequestEvent( + tool_name=tool_use.name, + tool_input=tool_use.input, + tool_use_id=tool_use.id, + response_future=future, + continuation_frame=frame, + audit_context={"session_id": session.session_id, "cwd": session.cwd}, + ) + return event, future + + +async def _rebuild_audit_event(_session, _checkpoint, recovered): + metadata = PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="aliyun_api(ROS:CreateStack)", + reason_type="rule", + reason_detail="current permission rule", + is_read_only=False, + operation={"product": "ROS", "action": "CreateStack", "operation_type": "write"}, + ) + return PermissionRequestEvent( + tool_name=recovered.tool_name, + tool_input=recovered.tool_input, + tool_use_id=recovered.tool_use_id, + permission_result=PermissionResult(behavior="ask", audit=metadata), + audit_context={ + **recovered.audit_context, + "metadata": metadata, + "settings": PermissionAuditSettings(include_tool_input=True, max_file_bytes=1234, max_files=2), + }, + ) + + +def test_web_permission_wait_policy_defaults_to_unlimited() -> None: + policy = PermissionWaitPolicy.from_config(None) + + assert policy.resident_timeout_seconds is None + assert policy.sub_pipeline_timeout_seconds is None + assert policy.timeout_grace_seconds == 30 + + +@pytest.mark.asyncio +async def test_web_checkpoint_exists_before_permission_request_is_visible(tmp_path, monkeypatch) -> None: + manager = WebSessionManager(projects_dir=tmp_path / "projects", cwd=tmp_path / "project") + session = manager.create_session(session_id="web-permission-order") + event, _future = _seed_permission_turn(manager, session) + store = manager.permission_checkpoint_store(session) + original_append = session.events.append + observed: list[str] = [] + + def append(event_type, payload): + if event_type == "permission.request": + active = store.list_active() + assert len(active) == 1 + assert active[0]["inputId"] == payload["requestId"] + observed.append(active[0]["phase"]) + return original_append(event_type, payload) + + monkeypatch.setattr(session.events, "append", append) + request_id = await manager.open_permission_request( + session, + { + "toolName": event.tool_name, + "toolUseId": event.tool_use_id, + "toolInput": event.tool_input, + "message": "Allow deployment?", + }, + permission_event=event, + permission_class="normal", + ) + + assert observed == ["WAITING"] + assert session.pending_permissions[request_id].boundary_id == store.list_active()[0]["boundaryId"] + + +@pytest.mark.asyncio +async def test_web_pipeline_recovery_audits_canonical_step_transcript(tmp_path) -> None: + projects = tmp_path / "projects" + cwd = tmp_path / "project" + manager = WebSessionManager(projects_dir=projects, cwd=cwd) + session = manager.create_session( + session_id="web-pipeline-permission", + mode="pipeline", + task_id="task-1", + context_id="context-1", + ) + transcript_id = "transcript_att_0001" + tool_use = ToolUseBlock( + id="tool-create-stack", + name="aliyun_api", + input={"product": "ROS", "action": "CreateStack"}, + ) + assistant = Message(role="assistant", content=[tool_use]) + transcript_storage = PipelineTranscriptStorage( + manager.storage.session_dir(session.cwd, session.session_id) / "pipeline" + ) + transcript_storage.append(session.cwd, transcript_id, assistant) + future = asyncio.get_running_loop().create_future() + event = PermissionRequestEvent( + tool_name=tool_use.name, + tool_input=tool_use.input, + tool_use_id=tool_use.id, + response_future=future, + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": canonical_digest([block.model_dump(mode="json") for block in assistant.content]), + "orderedToolUseIds": [tool_use.id], + "currentIndex": 0, + "decisions": [{"toolUseId": tool_use.id, "state": "pending", "source": None}], + }, + audit_context={ + "session_id": transcript_id, + "cwd": session.cwd, + "root_session_id": session.session_id, + "transcript_id": transcript_id, + }, + ) + request_id = await manager.open_permission_request( + session, + {"toolName": tool_use.name, "toolUseId": tool_use.id, "message": "Allow deployment?"}, + permission_event=event, + permission_class="pipeline", + ) + checkpoint = manager.permission_checkpoint_store(session).list_active()[0] + assert checkpoint["continuationFrame"]["assistantMessageRef"] == ( + f"pipeline/transcripts/{transcript_id}/session.jsonl:0" + ) + + manager.cancel_pending_requests_for_shutdown(session) + restarted = WebSessionManager(projects_dir=projects, cwd=cwd) + restarted_session = restarted.create_session(session_id=session.session_id, mode="pipeline") + result = await restarted.resolve_durable_permission( + request_id, + {"choice": "allow_once"}, + session_id=session.session_id, + audit_event_rebuilder=_rebuild_audit_event, + ) + + assert result["decision"] == "allow_once" + audit_path = ( + restarted.storage.session_dir(restarted_session.cwd, restarted_session.session_id) + / "pipeline" + / "transcripts" + / transcript_id + / "permission-audit.jsonl" + ) + rows = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()] + assert [row["tool_use_id"] for row in rows] == [tool_use.id] + assert rows[0]["operation"] == { + "action": "CreateStack", + "is_read_only": False, + "operation_type": "write", + "product": "ROS", + } + assert rows[0]["rule_source"] == "project_settings" + assert rows[0]["tool_input_redacted"] is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("restart", [False, True]) +async def test_web_audit_failure_does_not_install_always_allow_rule(tmp_path, monkeypatch, restart) -> None: + projects = tmp_path / "projects" + cwd = tmp_path / "project" + manager = WebSessionManager(projects_dir=projects, cwd=cwd) + session = manager.create_session(session_id="web-audit-failed-{}".format(restart)) + event, future = _seed_permission_turn(manager, session) + request_id = await manager.open_permission_request( + session, + { + "toolName": event.tool_name, + "toolUseId": event.tool_use_id, + "toolInput": event.tool_input, + "message": "Always allow deployment?", + }, + permission_event=event, + permission_class="normal", + ) + target_manager = manager + target_session = session + if restart: + manager.cancel_pending_requests_for_shutdown(session) + target_manager = WebSessionManager(projects_dir=projects, cwd=cwd) + target_session = target_manager.create_session(session_id=session.session_id) + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_boundary_audit", + lambda *_args, **_kwargs: False, + ) + + result = await target_manager.resolve_durable_permission( + request_id, + {"choice": "always_allow"}, + session_id=target_session.session_id, + audit_event_rebuilder=_rebuild_audit_event if restart else None, + ) + + assert result["decision"] == "deny" + assert target_session.permission_context is None + if not restart: + assert future.result() is False + + +@pytest.mark.asyncio +async def test_web_restart_rehydrates_safe_prompt_and_claims_old_permission_once(tmp_path) -> None: + projects = tmp_path / "projects" + cwd = tmp_path / "project" + manager = WebSessionManager(projects_dir=projects, cwd=cwd) + session = manager.create_session(session_id="web-permission-restart") + event, _future = _seed_permission_turn(manager, session) + request_id = await manager.open_permission_request( + session, + { + "toolName": event.tool_name, + "toolUseId": event.tool_use_id, + "toolInput": event.tool_input, + "message": "Allow deployment?", + }, + permission_event=event, + permission_class="normal", + ) + + restarted = WebSessionManager(projects_dir=projects, cwd=cwd) + restored_session = restarted.create_session(session_id=session.session_id) + restored = restored_session.pending_permissions[request_id] + + assert restored.payload["resumable"] is True + assert restored.payload["toolInput"] == {} + assert restored.payload["permissionWaitStatus"] == "suspended" + + result = await restarted.resolve_durable_permission( + request_id, + {"choice": "allow_once"}, + session_id=session.session_id, + audit_event_rebuilder=_rebuild_audit_event, + ) + + assert result["resolved"] is True + assert result["needsRecovery"] is True + assert result["decision"] == "allow_once" + checkpoint = result["checkpoint"] + assert checkpoint["phase"] == "SUSPENDED" + assert checkpoint["decision"]["status"] == "claimed" + assert checkpoint["decision"]["auditStatus"] == "recorded" + audit_path = restarted.storage.session_dir(str(cwd.resolve()), session.session_id) / "permission-audit.jsonl" + audit_rows = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()] + assert [row["tool_use_id"] for row in audit_rows] == [event.tool_use_id] + + # Rehydrate again to exercise the persisted idempotent claim. The same + # answer is accepted without adding a second decision audit row. + duplicate_manager = WebSessionManager(projects_dir=projects, cwd=cwd) + duplicate_session = duplicate_manager.create_session(session_id=session.session_id) + duplicate = await duplicate_manager.resolve_durable_permission( + request_id, + {"choice": "allow_once"}, + session_id=session.session_id, + ) + assert duplicate["decision"] == "allow_once" + duplicate_rows = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()] + assert len(duplicate_rows) == 1 + + store = duplicate_manager.permission_checkpoint_store(duplicate_session) + store.resolve( + checkpoint["boundaryId"], + result_digest="result-digest", + ack={"decision": "allow_once", "accepted": True}, + ) + receipt_manager = WebSessionManager(projects_dir=projects, cwd=cwd) + receipt_manager.create_session(session_id=session.session_id) + receipt = await receipt_manager.resolve_durable_permission( + request_id, + {"choice": "allow_once"}, + session_id=session.session_id, + ) + assert receipt["resolved"] is True + assert receipt["duplicate"] is True + assert receipt["needsRecovery"] is False + with pytest.raises(ValueError, match="conflicts with receipt"): + await receipt_manager.resolve_durable_permission( + request_id, + {"choice": "reject_once"}, + session_id=session.session_id, + ) + + +@pytest.mark.asyncio +async def test_web_permission_cancel_and_answer_use_checkpoint_lock(tmp_path) -> None: + manager = WebSessionManager(projects_dir=tmp_path / "projects", cwd=tmp_path / "project") + session = manager.create_session(session_id="web-permission-cancel") + event, future = _seed_permission_turn(manager, session) + request_id = await manager.open_permission_request( + session, + { + "toolName": event.tool_name, + "toolUseId": event.tool_use_id, + "toolInput": event.tool_input, + "message": "Allow deployment?", + }, + permission_event=event, + permission_class="normal", + ) + pending = session.pending_permissions[request_id] + assert pending.boundary_id is not None + assert pending.checkpoint_store is not None + + pending.checkpoint_store.claim_decision( + pending.boundary_id, + value="allow_once", + source="user", + ) + manager.cancel_permission_request(request_id, session_id=session.session_id) + + assert request_id in session.pending_permissions + assert future.cancelled() is False + assert pending.checkpoint_store.load(pending.boundary_id)["decision"]["value"] == "allow_once" + + second_manager = WebSessionManager(projects_dir=tmp_path / "projects-2", cwd=tmp_path / "project-2") + second_session = second_manager.create_session(session_id="web-permission-cancel-wins") + second_event, second_future = _seed_permission_turn(second_manager, second_session) + second_id = await second_manager.open_permission_request( + second_session, + { + "toolName": second_event.tool_name, + "toolUseId": second_event.tool_use_id, + "toolInput": second_event.tool_input, + "message": "Allow deployment?", + }, + permission_event=second_event, + permission_class="normal", + ) + second_pending = second_session.pending_permissions[second_id] + second_manager.cancel_permission_request(second_id, session_id=second_session.session_id) + + assert second_id not in second_session.pending_permissions + assert second_future.cancelled() is True + assert second_pending.checkpoint_store.load(second_pending.boundary_id)["phase"] == "CANCELED" + + +@pytest.mark.asyncio +async def test_web_duplicate_live_answer_stays_ack_only_until_boundary_receipt(tmp_path) -> None: + manager = WebSessionManager(projects_dir=tmp_path / "projects", cwd=tmp_path / "project") + session = manager.create_session(session_id="web-permission-live-duplicate") + event, future = _seed_permission_turn(manager, session) + request_id = await manager.open_permission_request( + session, + { + "toolName": event.tool_name, + "toolUseId": event.tool_use_id, + "toolInput": event.tool_input, + "message": "Allow deployment?", + }, + permission_event=event, + permission_class="normal", + ) + pending = session.pending_permissions[request_id] + assert pending.boundary_id is not None + + first = await manager.resolve_durable_permission( + request_id, + {"choice": "allow_once"}, + session_id=session.session_id, + ) + duplicate = await manager.resolve_durable_permission( + request_id, + {"choice": "allow_once"}, + session_id=session.session_id, + ) + + assert first["duplicate"] is False + assert first["needsRecovery"] is False + assert duplicate["duplicate"] is True + assert duplicate["needsRecovery"] is False + assert future.result() is True + assert request_id in session.pending_permissions + assert manager.permission_wait_coordinator.has_live_boundary(pending.boundary_id) is True + + manager.resolve_permission_boundaries(session, [pending.boundary_id]) + + assert request_id not in session.pending_permissions + assert manager.permission_wait_coordinator.has_live_boundary(pending.boundary_id) is False + assert pending.checkpoint_store.load(pending.boundary_id)["phase"] == "RESOLVED" + + +@pytest.mark.asyncio +async def test_web_lifecycle_shutdown_orphans_durable_wait_for_restart_recovery(tmp_path) -> None: + projects = tmp_path / "projects" + cwd = tmp_path / "project" + manager = WebSessionManager(projects_dir=projects, cwd=cwd) + session = manager.create_session(session_id="web-permission-shutdown") + event, future = _seed_permission_turn(manager, session) + request_id = await manager.open_permission_request( + session, + { + "toolName": event.tool_name, + "toolUseId": event.tool_use_id, + "toolInput": event.tool_input, + "message": "Allow deployment?", + }, + permission_event=event, + permission_class="normal", + ) + pending = session.pending_permissions[request_id] + assert pending.boundary_id is not None + waiter = asyncio.create_task( + WebSessionRuntime(session, manager=manager)._await_permission_request(request_id, event) + ) + await asyncio.sleep(0) + + manager.cancel_pending_requests_for_shutdown(session) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + + checkpoint = pending.checkpoint_store.load(pending.boundary_id) + assert checkpoint["phase"] == "SUSPENDED" + assert checkpoint["decision"]["status"] == "none" + assert future.done() is False + assert request_id in session.pending_permissions + assert manager.permission_wait_coordinator.has_live_boundary(pending.boundary_id) is False + + restarted = WebSessionManager(projects_dir=projects, cwd=cwd) + restarted.create_session(session_id=session.session_id) + recovered = await restarted.resolve_durable_permission( + request_id, + {"choice": "allow_once"}, + session_id=session.session_id, + audit_event_rebuilder=_rebuild_audit_event, + ) + assert recovered["resolved"] is True + assert recovered["needsRecovery"] is True + assert recovered["checkpoint"]["phase"] == "SUSPENDED" + + +@pytest.mark.asyncio +async def test_web_successor_replaces_old_live_owner_and_old_answer_returns_receipt(tmp_path) -> None: + manager = WebSessionManager(projects_dir=tmp_path / "projects", cwd=tmp_path / "project") + session = manager.create_session(session_id="web-permission-successor") + tools = [ + ToolUseBlock(id="tool-1", name="aliyun_api", input={"action": "CreateStack"}), + ToolUseBlock(id="tool-2", name="aliyun_api", input={"action": "DeleteStack"}), + ] + assistant = Message(role="assistant", content=tools) + manager.storage.append(session.cwd, session.session_id, assistant) + digest = canonical_digest([block.model_dump(mode="json") for block in tools]) + first_future = asyncio.get_running_loop().create_future() + first_event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input=tools[0].input, + tool_use_id=tools[0].id, + response_future=first_future, + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": digest, + "orderedToolUseIds": ["tool-1", "tool-2"], + "currentIndex": 0, + "decisions": [ + {"toolUseId": "tool-1", "state": "pending", "source": None, "deniedResult": None}, + {"toolUseId": "tool-2", "state": "not_evaluated", "source": None, "deniedResult": None}, + ], + }, + audit_context={"session_id": session.session_id, "cwd": session.cwd}, + ) + first_id = await manager.open_permission_request( + session, + {"toolName": "aliyun_api", "toolUseId": "tool-1", "message": "Allow first?"}, + permission_event=first_event, + permission_class="normal", + ) + await manager.resolve_durable_permission(first_id, {"choice": "allow_once"}, session_id=session.session_id) + first_boundary = session.pending_permissions[first_id].boundary_id + assert first_boundary is not None + + second_future = asyncio.get_running_loop().create_future() + second_event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input=tools[1].input, + tool_use_id=tools[1].id, + response_future=second_future, + continuation_frame={ + "assistantMessageRef": "session.jsonl:0", + "assistantMessageDigest": digest, + "orderedToolUseIds": ["tool-1", "tool-2"], + "currentIndex": 1, + "decisions": [ + { + "toolUseId": "tool-1", + "state": "allow", + "source": "user", + "principalRef": None, + "region": None, + "deniedResult": None, + }, + {"toolUseId": "tool-2", "state": "pending", "source": None, "deniedResult": None}, + ], + "previousBoundaryId": first_boundary, + }, + audit_context={"session_id": session.session_id, "cwd": session.cwd}, + ) + second_id = await manager.open_permission_request( + session, + {"toolName": "aliyun_api", "toolUseId": "tool-2", "message": "Allow second?"}, + permission_event=second_event, + permission_class="normal", + ) + + assert first_id not in session.pending_permissions + assert second_id in session.pending_permissions + assert manager.permission_wait_coordinator.has_live_boundary(first_boundary) is False + receipt = manager.permission_checkpoint_store(session).load(first_boundary) + assert receipt["phase"] == "RESOLVED" + duplicate = await manager.resolve_durable_permission( + first_id, + {"choice": "allow_once"}, + session_id=session.session_id, + ) + assert duplicate["duplicate"] is True + assert duplicate["needsRecovery"] is False diff --git a/website/docs/mcp/oauth-and-security.md b/website/docs/mcp/oauth-and-security.md index ae851159..ba54c301 100644 --- a/website/docs/mcp/oauth-and-security.md +++ b/website/docs/mcp/oauth-and-security.md @@ -72,11 +72,13 @@ The model can call that tool to provide the user with the OAuth URL. After the f IaC Code stores OAuth tokens and MCP client secrets through `MCPSecretStorage`: -1. It tries the operating-system keyring when available. -2. If keyring is disabled or unavailable, it stores encrypted fallback data under `/mcp/`. -3. File permissions are restricted for the fallback key and encrypted secret store. +1. It stores encrypted data in `/mcp/secrets.json.enc`. +2. The encryption key is stored in `/mcp/secrets.key`. +3. File permissions are restricted for both files. -Set `IAC_CODE_MCP_DISABLE_KEYRING=1` to force encrypted fallback storage, which is useful for isolated tests. +MCP secret storage does not access the operating-system keyring, avoiding system authorization prompts during +background status checks. Auth state that existed only in the keyring is not migrated automatically; authorize the +MCP server once to create the encrypted local entry. Use this command to clear stored auth state: @@ -93,7 +95,7 @@ iac-code mcp remove secure-reviewer --scope user ``` Use `reset-auth` when you want to reauthorize an existing server. Use `mcp remove` when the server config itself -should disappear; both paths clear keyring and encrypted-fallback entries managed by `MCPSecretStorage`. +should disappear; both paths clear encrypted entries managed by `MCPSecretStorage`. ## Project Trust diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md index 1289389a..b387a4db 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -72,11 +72,13 @@ Das Modell kann dieses Tool aufrufen, um dem Benutzer die OAuth-URL bereitzustel IaC-Code speichert OAuth-Tokens und MCP-Client-Geheimnisse über `MCPSecretStorage`: -1. Es versucht den Betriebssystemschlüsselbund, sofern verfügbar. -2. Wenn der Schlüsselring deaktiviert oder nicht verfügbar ist, speichert er verschlüsselte Fallback-Daten unter `/mcp/`. -3. Die Dateiberechtigungen sind für den Fallback-Schlüssel und den verschlüsselten Geheimspeicher eingeschränkt. +1. Verschlüsselte Daten werden in `/mcp/secrets.json.enc` gespeichert. +2. Der Verschlüsselungsschlüssel liegt in `/mcp/secrets.key`. +3. Die Dateiberechtigungen sind für beide Dateien eingeschränkt. -Legen Sie `IAC_CODE_MCP_DISABLE_KEYRING=1` fest, um einen verschlüsselten Fallback-Speicher zu erzwingen, was für isolierte Tests nützlich ist. +Der MCP-Geheimspeicher greift nicht auf den Betriebssystemschlüsselbund zu. Dadurch entstehen bei +Statusprüfungen im Hintergrund keine Systemdialoge. Nur im Schlüsselbund vorhandene Anmeldedaten werden nicht +automatisch migriert; autorisieren Sie den MCP-Server einmal neu, um den lokalen verschlüsselten Eintrag anzulegen. Verwenden Sie diesen Befehl, um den gespeicherten Authentifizierungsstatus zu löschen: @@ -93,8 +95,8 @@ iac-code mcp remove secure-reviewer --scope user ``` Verwenden Sie `reset-auth`, wenn Sie einen bestehenden server neu autorisieren moechten. Verwenden Sie `mcp remove`, -wenn auch der server config verschwinden soll; beide Pfade loeschen keyring und encrypted fallback entries, die -`MCPSecretStorage` verwaltet. +wenn auch der server config verschwinden soll; beide Pfade loeschen die von `MCPSecretStorage` verwalteten +verschluesselten Eintraege. ## Project Trust diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md index 8151051e..ff514ab3 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -72,11 +72,13 @@ El modelo puede llamar a esa herramienta para proporcionar al usuario la URL de El código IaC almacena tokens OAuth y secretos del cliente MCP a través de `MCPSecretStorage`: -1. Prueba el conjunto de claves del sistema operativo cuando está disponible. -2. Si el conjunto de claves está deshabilitado o no está disponible, almacena datos de reserva cifrados en `/mcp/`. -3. Los permisos de archivos están restringidos para la clave alternativa y el almacén secreto cifrado. +1. Los datos cifrados se guardan en `/mcp/secrets.json.enc`. +2. La clave de cifrado se guarda en `/mcp/secrets.key`. +3. Los permisos de ambos archivos están restringidos. -Configure `IAC_CODE_MCP_DISABLE_KEYRING=1` para forzar el almacenamiento alternativo cifrado, lo cual es útil para pruebas aisladas. +El almacén de secretos MCP no accede al llavero del sistema operativo, por lo que las comprobaciones de estado en +segundo plano no muestran solicitudes de autorización del sistema. El estado que solo existía en el llavero no se +migra automáticamente; autorice el servidor MCP una vez para crear la entrada local cifrada. Utilice este comando para borrar el estado de autenticación almacenado: @@ -93,7 +95,7 @@ iac-code mcp remove secure-reviewer --scope user ``` Use `reset-auth` para volver a autorizar un server existente. Use `mcp remove` cuando tambien deba desaparecer el -server config; ambos caminos limpian keyring y encrypted fallback entries administradas por `MCPSecretStorage`. +server config; ambos caminos eliminan las entradas cifradas administradas por `MCPSecretStorage`. ## Project Trust diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md index 42b646f4..87ff6fb3 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -72,11 +72,13 @@ Le modèle peut appeler cet outil pour fournir à l'utilisateur l'URL OAuth. Une IaC Code stocke les jetons OAuth et les secrets du client MCP via `MCPSecretStorage` : -1. Il essaie le trousseau de clés du système d'exploitation lorsqu'il est disponible. -2. Si le trousseau de clés est désactivé ou indisponible, il stocke les données de secours chiffrées sous `/mcp/`. -3. Les autorisations de fichiers sont limitées pour la clé de secours et le magasin de secrets chiffrés. +1. Les données chiffrées sont stockées dans `/mcp/secrets.json.enc`. +2. La clé de chiffrement est stockée dans `/mcp/secrets.key`. +3. Les autorisations des deux fichiers sont restreintes. -Définissez `IAC_CODE_MCP_DISABLE_KEYRING=1` pour forcer le stockage de secours chiffré, ce qui est utile pour les tests isolés. +Le stockage des secrets MCP n'accède pas au trousseau du système d'exploitation. Les contrôles d'état en +arrière-plan ne déclenchent donc pas de demandes d'autorisation système. Un état présent uniquement dans le +trousseau n'est pas migré automatiquement ; autorisez le serveur MCP une fois pour créer l'entrée locale chiffrée. Utilisez cette commande pour effacer l'état d'authentification stocké : @@ -93,7 +95,7 @@ iac-code mcp remove secure-reviewer --scope user ``` Utilisez `reset-auth` pour reautoriser un server existant. Utilisez `mcp remove` lorsque le server config doit aussi -disparaitre; les deux chemins nettoient les entrees keyring et encrypted fallback entries gerees par `MCPSecretStorage`. +disparaitre; les deux chemins suppriment les entrees chiffrees gerees par `MCPSecretStorage`. ## Project Trust diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md index bb4e5aef..23430c53 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -72,11 +72,11 @@ mcp____authenticate IaC コードは、`MCPSecretStorage`を通じて OAuth トークンと MCP クライアント シークレットを保存します。 -1. 利用可能な場合は、オペレーティング システムのキーリングを試行します。 -2. キーリングが無効になっているか使用できない場合は、暗号化されたフォールバック データが `/mcp/` に保存されます。 -3. ファイルのアクセス許可は、フォールバック キーと暗号化されたシークレット ストアに対して制限されます。 +1. 暗号化データは `/mcp/secrets.json.enc` に保存されます。 +2. 暗号化キーは `/mcp/secrets.key` に保存されます。 +3. 両方のファイルのアクセス権限が制限されます。 -`IAC_CODE_MCP_DISABLE_KEYRING=1`を設定すると、暗号化されたフォールバック ストレージが強制的に使用されます。これは、分離されたテストに役立ちます。 +MCP シークレットストレージは OS のキーリングにアクセスしないため、バックグラウンドの状態確認でシステムの認可ダイアログは表示されません。キーリングにのみ存在した認証状態は自動移行されないため、MCP server を一度再認可してローカルの暗号化エントリを作成してください。 保存されている認証状態をクリアするには、次のコマンドを使用します。 @@ -93,7 +93,7 @@ iac-code mcp remove secure-reviewer --scope user ``` 既存 server を再認可したいだけなら `reset-auth` を使います。server config 自体も消す場合は `mcp remove` を使います。 -どちらの経路も `MCPSecretStorage` が管理する keyring と encrypted fallback entries を消去します。 +どちらの経路も `MCPSecretStorage` が管理する暗号化エントリを消去します。 ## Project Trust diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md index 59db695e..0bdfd329 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -72,11 +72,13 @@ O modelo pode chamar essa ferramenta para fornecer ao usuário a URL do OAuth. A O Código IaC armazena tokens OAuth e segredos do cliente MCP por meio de `MCPSecretStorage`: -1. Ele testa o chaveiro do sistema operacional quando disponível. -2. Se o chaveiro estiver desabilitado ou indisponível, ele armazena dados de fallback criptografados em `/mcp/`. -3. As permissões de arquivo são restritas para a chave substituta e o armazenamento secreto criptografado. +1. Os dados criptografados são armazenados em `/mcp/secrets.json.enc`. +2. A chave de criptografia é armazenada em `/mcp/secrets.key`. +3. As permissões de ambos os arquivos são restritas. -Defina `IAC_CODE_MCP_DISABLE_KEYRING=1` para forçar o armazenamento alternativo criptografado, o que é útil para testes isolados. +O armazenamento de segredos MCP não acessa o chaveiro do sistema operacional. Assim, verificações de estado em +segundo plano não exibem pedidos de autorização do sistema. O estado que existia apenas no chaveiro não é +migrado automaticamente; autorize o servidor MCP uma vez para criar a entrada local criptografada. Use este comando para limpar o estado de autenticação armazenado: @@ -93,7 +95,7 @@ iac-code mcp remove secure-reviewer --scope user ``` Use `reset-auth` para reautorizar um server existente. Use `mcp remove` quando o server config tambem deve sumir; -ambos os caminhos limpam keyring e encrypted fallback entries gerenciadas por `MCPSecretStorage`. +ambos os caminhos removem as entradas criptografadas gerenciadas por `MCPSecretStorage`. ## Project Trust diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md index 2ce3e65b..1d5e8088 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -72,11 +72,11 @@ mcp____authenticate IaC 代码通过 `MCPSecretStorage` 存储 OAuth 令牌和 MCP 客户端机密: -1. 它会尝试操作系统密钥环(如果可用)。 -2. 如果密钥环被禁用或不可用,它将在 `/mcp/` 下存储加密的后备数据。 -3. 后备密钥和加密秘密存储的文件权限受到限制。 +1. 加密数据存储在 `/mcp/secrets.json.enc`。 +2. 加密密钥存储在 `/mcp/secrets.key`。 +3. 两个文件都会限制访问权限。 -设置 `IAC_CODE_MCP_DISABLE_KEYRING=1` 以强制加密回退存储,这对于隔离测试很有用。 +MCP 密钥存储不会访问操作系统密钥环,从而避免后台状态检查引发系统授权弹窗。仅存在于密钥环中的旧认证状态不会自动迁移;重新授权一次 MCP server 即可创建本地加密记录。 使用此命令清除存储的身份验证状态: @@ -93,7 +93,7 @@ iac-code mcp remove secure-reviewer --scope user ``` 当你只想重新授权现有 server 时使用 `reset-auth`。当 server config 本身也应消失时使用 `mcp remove`; -两条路径都会清理 `MCPSecretStorage` 管理的 keyring 和 encrypted fallback entries。 +两条路径都会清理 `MCPSecretStorage` 管理的本地加密记录。 ## Project Trust From 91374b3829e58b40eb40abddfe75ce101f89d1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 25 Aug 2026 17:21:29 +0800 Subject: [PATCH 2/7] fix(e2e): arm backup selection race before dispatch --- scripts/a2a/e2e/README.md | 9 +- scripts/a2a/e2e/README.zh-CN.md | 6 +- scripts/a2a/e2e/run_recovery_scenarios.py | 90 +++++++++++++------- tests/a2a_e2e/test_run_recovery_scenarios.py | 5 ++ 4 files changed, 70 insertions(+), 40 deletions(-) diff --git a/scripts/a2a/e2e/README.md b/scripts/a2a/e2e/README.md index 1f4975d3..f08ea568 100644 --- a/scripts/a2a/e2e/README.md +++ b/scripts/a2a/e2e/README.md @@ -204,9 +204,10 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \ --scenario selection-during-backup ``` -An E2E-only fixture delays the Step 4 `input_required` backup by at least 10 -seconds. The client submits its selection as soon as the event arrives. The -scenario proves that dispatch happened inside the backup started/finished +The runner arms an E2E-only fixture before the initial request so that the Step +4 `input_required` backup is delayed by at least 10 seconds. It submits the +selection as soon as the backup started marker appears and continues to verify +the candidate-selection event. The scenario proves that dispatch happened inside the backup started/finished window, the message was consumed as candidate input, and no `interrupt_received` / `interrupt_classified` event was emitted. @@ -269,7 +270,7 @@ the rest of the tests. | `redaction-step4` | Force A2A safe mode and stop when the real mini-app backend/database task reaches step 4 candidate selection | None; no candidate selection is submitted | Canonical password parameters are not placeholders; public A2A passwords equal canonical values; token counters stay numeric when present; known server paths become `[PATH]` only in the public copy; deployment never starts. | | `scenario1` | After pipeline completion and one normal-chat follow-up | Ask what the previous normal-chat question was | Normal-chat history survives restart; VSwitch evidence exists. | | `scenario1-performance-backup` | Full `scenario1` with `IAC_CODE_A2A_EXTREME_PERFORMANCE=true` and `IAC_CODE_CONFIG_BACKUP_DIR=/session-backup` | After the Step4 backup is durable, stop the server, remove the matching primary session under `projects`, restart, and select without `taskId`; later normal-chat recovery also omits `taskId` | Only the backup session exists before restart; restart alone does not recreate the primary session; selection without `taskId` restores the primary session from backup and hydrates the recovered task; full scenario1 passes. | -| `selection-during-backup` | Step 4 `input_required` is published, then an E2E fixture blocks its backup for at least 10 seconds | Immediately send `你随便选一个方案。` with the active pipeline `taskId` while backup is running | Dispatch falls inside the backup started/finished window; the request is queued and consumed as candidate input; no interrupt events are emitted; the pipeline completes. | +| `selection-during-backup` | Arm the E2E fixture before the initial request; when the Step 4 `input_required` backup starts, it writes a started marker and blocks for at least 10 seconds | Immediately send `你随便选一个方案。` with the active pipeline `taskId` while backup is running, then verify the Step 4 event | Dispatch falls inside the backup started/finished window; the request is queued and consumed as candidate input; no interrupt events are emitted; the pipeline completes. | | `selection-waiting` | Step 4 waits for candidate selection | `你随便选一个方案。` without `taskId` | Waiting step4 task is recovered and selected; VSwitch evidence exists. | | `ask-waiting` | `ask_user_question` waits for user input | Clarification answers without `taskId` | Pending ask input is recovered and pipeline completes; VSwitch evidence exists. | | `image-initial` | Initial user message is the static `initial.png` image fixture | Candidate selection text | The image starts the pipeline, reaches step4 selection, completes, and produces VSwitch evidence. | diff --git a/scripts/a2a/e2e/README.zh-CN.md b/scripts/a2a/e2e/README.zh-CN.md index 9b7efbd5..4d059bea 100644 --- a/scripts/a2a/e2e/README.zh-CN.md +++ b/scripts/a2a/e2e/README.zh-CN.md @@ -206,8 +206,8 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \ --scenario selection-during-backup ``` -该场景通过仅注入 server 子进程的 E2E fixture,把 step4 `input_required` backup 至少阻塞 10 秒; -客户端收到候选选择事件后立即提交方案。场景会验证选择请求确实在 backup 的 started/finished 窗口内发出、 +该场景会在首轮请求前 arm 仅注入 server 子进程的 E2E fixture,把 step4 `input_required` backup 至少阻塞 10 秒; +runner 收到 backup started 标记后立即提交方案,同时继续核验候选选择事件。场景会验证选择请求确实在 backup 的 started/finished 窗口内发出、 最终被消费为 candidate selection,并且没有产生 `interrupt_received` / `interrupt_classified`。 如果要跑完整真实 E2E 矩阵: @@ -266,7 +266,7 @@ provider、tool、真实云调用场景默认会被保护住。只有确认要 | `redaction-step4` | 强制 A2A safe mode,真实小程序后端/数据库需求到达 step4 候选方案选择即停止 | 无;不提交方案选择 | canonical 密码参数不是脱敏占位符;A2A 密码值与 canonical 一致;存在的 token 统计仍为数字;已知服务器路径只在 A2A 副本中变成 `[PATH]`;不进入部署。 | | `scenario1` | pipeline 完成并完成一轮 normal-chat follow-up 后 | 询问上一条 normal-chat 问题是什么 | normal-chat 历史重启后仍可用;存在 VSwitch 证据。 | | `scenario1-performance-backup` | 完整 `scenario1`,并强制 `IAC_CODE_A2A_EXTREME_PERFORMANCE=true`、`IAC_CODE_CONFIG_BACKUP_DIR=/session-backup` | step4 backup 落盘后停服并删除主 `projects` 下对应 session,重启后不带 `taskId` 选择;后续 normal-chat 恢复也不带 `taskId` | 重启前只有 backup session;重启本身不会重建主 session;省略 `taskId` 的选择会从 backup restore 主 session 并 hydrate 到恢复 task;完整 scenario1 通过。 | -| `selection-during-backup` | step4 `input_required` 已发出,E2E fixture 将随后执行的 backup 至少阻塞 10 秒 | backup 仍在执行时,携带原 task 的 `taskId` 立即发送 `你随便选一个方案。` | 请求时间落在 backup started/finished 窗口内;选择被排队并作为 candidate input 消费;不产生 interrupt 事件;pipeline 完成。 | +| `selection-during-backup` | 首轮请求前 arm E2E fixture;step4 `input_required` backup 开始后至少阻塞 10 秒并写出 started 标记 | backup 仍在执行时,携带原 task 的 `taskId` 立即发送 `你随便选一个方案。`,随后核验 step4 事件 | 请求时间落在 backup started/finished 窗口内;选择被排队并作为 candidate input 消费;不产生 interrupt 事件;pipeline 完成。 | | `selection-waiting` | step4 等待候选方案选择时 | 不带 `taskId` 发送 `你随便选一个方案。` | 能恢复等待中的 step4 task 并完成选择;存在 VSwitch 证据。 | | `ask-waiting` | `ask_user_question` 等待用户输入时 | 不带 `taskId` 发送澄清回答 | 能恢复 pending ask 输入并完成 pipeline;存在 VSwitch 证据。 | | `image-initial` | 首轮用户消息就是静态 `initial.png` 图片 fixture | 文本选择候选方案 | 图片能启动 pipeline,进入 step4 选择,最终完成并产生 VSwitch 证据。 | diff --git a/scripts/a2a/e2e/run_recovery_scenarios.py b/scripts/a2a/e2e/run_recovery_scenarios.py index bfb1a985..bff28ee7 100644 --- a/scripts/a2a/e2e/run_recovery_scenarios.py +++ b/scripts/a2a/e2e/run_recovery_scenarios.py @@ -420,6 +420,20 @@ def done(self) -> bool: def start(self) -> None: self._thread.start() + def wait_until_request_started(self, *, timeout: float) -> None: + deadline = time.monotonic() + timeout + with self._condition: + while self.request_started_monotonic is None: + if self._done: + if self.exception is not None: + message = f"{self.name} ended before request dispatch: {self.exception}" + raise RuntimeError(message) from self.exception + raise RuntimeError(f"{self.name} ended before request dispatch") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"Timed out waiting for request dispatch in {self.name}") + self._condition.wait(min(remaining, 0.1)) + def join(self, timeout: float | None = None) -> StreamSummary: self._thread.join(timeout) if self._thread.is_alive(): @@ -464,8 +478,10 @@ def _run(self) -> None: message_id=str(uuid.uuid4()), images=self.images, ) - self.request_started_at = time.time() - self.request_started_monotonic = time.monotonic() + with self._condition: + self.request_started_at = time.time() + self.request_started_monotonic = time.monotonic() + self._condition.notify_all() _append_jsonl( self.run_dir / "requests.jsonl", {"name": self.name, "payload": payload, "at": _utc_now()}, @@ -549,8 +565,17 @@ def __init__(self, args: argparse.Namespace, *, scenario: str) -> None: value for value in (fixture_path, existing_pythonpath) if value ) self.server_env["IAC_CODE_E2E_BACKUP_DELAY_SECONDS"] = str(BACKUP_DELAY_SECONDS) - self.server_env["IAC_CODE_E2E_BACKUP_DELAY_CONTROL"] = str( - (self.run_dir / "selection-backup-delay").resolve() + control = (self.run_dir / "selection-backup-delay").resolve() + self.server_env["IAC_CODE_E2E_BACKUP_DELAY_CONTROL"] = str(control) + for marker in ("arm", "started", "finished"): + _backup_delay_marker_path(control, marker).unlink(missing_ok=True) + _write_json( + _backup_delay_marker_path(control, "arm"), + { + "armedAt": time.time(), + "scenario": scenario, + "delaySeconds": BACKUP_DELAY_SECONDS, + }, ) self.notes.append(f"armed E2E-only input_required backup delay fixture for {BACKUP_DELAY_SECONDS:.0f}s") if args.deterministic: @@ -660,6 +685,7 @@ def start_stream( context_id: str | None = None, task_id: str | None = None, images: list[dict[str, Any]] | None = None, + wait_for_identity: bool = True, ) -> BackgroundStream: stream = BackgroundStream( server_url=self.server_url, @@ -674,12 +700,14 @@ def start_stream( redaction_env=self.server_env, ) stream.start() - stream.wait_for( - lambda _event, summary: bool(summary.context_id and summary.task_id), - description="task identity", - timeout=self.args.event_timeout, - ) - self._remember_identity(stream.summary) + stream.wait_until_request_started(timeout=self.args.event_timeout) + if wait_for_identity: + stream.wait_for( + lambda _event, summary: bool(summary.context_id and summary.task_id), + description="task identity", + timeout=self.args.event_timeout, + ) + self._remember_identity(stream.summary) self.summaries[name] = stream.summary return stream @@ -1148,38 +1176,34 @@ def callback(h: ScenarioHarness) -> None: def run_selection_during_backup(args: argparse.Namespace, scenario: str) -> int: def callback(h: ScenarioHarness) -> None: + control = _backup_delay_control_path(h) + initial_stream = h.start_stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="") + started = _wait_for_backup_delay_marker(control, "started", timeout=args.event_timeout) + h.snapshots["backup_delay_started"] = started + h.checks["input_required backup delay started"] = started.get("delaySeconds") == BACKUP_DELAY_SECONDS + h.checks["initial stream was open when backup delay started"] = not initial_stream.done + + h.checks["backup was unfinished when selection request was dispatched"] = not _backup_delay_marker_path( + control, "finished" + ).exists() + selection_stream = h.start_stream( + prompt=args.selection_prompt, + name="02-select-during-backup", + wait_for_identity=False, + ) + initial_streams = _wait_for_with_intervening_ask_inputs( h, - [h.start_stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="")], + [initial_stream], _input_required_step("confirm_and_select"), description="step4 candidate selection input_required", timeout=args.event_timeout, name_prefix="01-initial", ) - h.checks["initial reached step4 input_required before stream completed"] = any( - stream.summary.last_input_required_step_id == "confirm_and_select" and not stream.done - for stream in initial_streams + h.checks["initial reached step4 input_required"] = any( + stream.summary.last_input_required_step_id == "confirm_and_select" for stream in initial_streams ) - control = _backup_delay_control_path(h) - arm_path = _backup_delay_marker_path(control, "arm") - _write_json( - arm_path, - { - "armedAt": time.time(), - "scenario": scenario, - "delaySeconds": BACKUP_DELAY_SECONDS, - }, - ) - started = _wait_for_backup_delay_marker(control, "started", timeout=min(10.0, args.event_timeout)) - h.snapshots["backup_delay_started"] = started - h.checks["input_required backup delay started"] = started.get("delaySeconds") == BACKUP_DELAY_SECONDS - - h.checks["backup was unfinished when selection request was dispatched"] = not _backup_delay_marker_path( - control, "finished" - ).exists() - selection_stream = h.start_stream(prompt=args.selection_prompt, name="02-select-during-backup") - for stream in initial_streams: stream.join(timeout=args.stream_timeout) selection = selection_stream.join(timeout=args.stream_timeout) diff --git a/tests/a2a_e2e/test_run_recovery_scenarios.py b/tests/a2a_e2e/test_run_recovery_scenarios.py index 83bb74ea..422d6cd9 100644 --- a/tests/a2a_e2e/test_run_recovery_scenarios.py +++ b/tests/a2a_e2e/test_run_recovery_scenarios.py @@ -1074,6 +1074,11 @@ def test_selection_during_backup_configures_e2e_only_delay(tmp_path: Path) -> No (tmp_path / "run" / "selection-backup-delay").resolve() ) assert str(runner.BACKUP_DELAY_FIXTURE_ROOT.resolve()) == harness.server_env["PYTHONPATH"].split(os.pathsep)[0] + arm = json.loads( + runner._backup_delay_marker_path(tmp_path / "run" / "selection-backup-delay", "arm").read_text(encoding="utf-8") + ) + assert arm["scenario"] == runner.SELECTION_DURING_BACKUP_SCENARIO + assert arm["delaySeconds"] == 10.0 def test_backup_delay_sitecustomize_delays_armed_input_required_backup(tmp_path: Path) -> None: From 258d3fd35e6b4c467ca4ef0ffec53338da4214c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 25 Aug 2026 18:52:16 +0800 Subject: [PATCH 3/7] fix(ci): harden ROS bridge checks across platforms --- .../alicloud-ros-agent/scripts/ros_agent.py | 63 +++++++++++++++++-- tests/a2a/test_pipeline_executor.py | 10 +-- tests/skill_bridge/start_chat_relay.py | 1 + .../test_alicloud_ros_agent_bridge.py | 49 +++++++++++---- tests/web/test_blocking_flow.py | 4 +- 5 files changed, 104 insertions(+), 23 deletions(-) diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py index 07098d6c..f1fb2d5c 100644 --- a/skills/alicloud-ros-agent/scripts/ros_agent.py +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -146,13 +146,19 @@ def _state_root() -> pathlib.Path: def _secure_directory(path: pathlib.Path) -> None: + # The bridge only calls this with its process-local state root or a child + # path derived from a validated job identifier. + # codeql[py/path-injection] path.mkdir(parents=True, exist_ok=True) if os.name != "nt": + # codeql[py/path-injection] os.chmod(str(path), 0o700) def _atomic_json(path: pathlib.Path, value: Dict[str, Any], mode: int = 0o600) -> None: _secure_directory(path.parent) + # Atomic state files are always beneath the bridge-owned state directory. + # codeql[py/path-injection] descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: @@ -160,15 +166,21 @@ def _atomic_json(path: pathlib.Path, value: Dict[str, Any], mode: int = 0o600) - handle.flush() os.fsync(handle.fileno()) if os.name != "nt": + # codeql[py/path-injection] os.chmod(temporary, mode) + # codeql[py/path-injection] os.replace(temporary, str(path)) finally: with contextlib.suppress(OSError): + # codeql[py/path-injection] os.unlink(temporary) def _load_state_json(path: pathlib.Path, code: str = "job_not_found") -> Dict[str, Any]: try: + # Callers pass only bridge state paths derived from validated local + # identifiers; remote StartChat payloads cannot select this path. + # codeql[py/path-injection] with path.open("r", encoding="utf-8") as handle: value = json.load(handle) except (OSError, ValueError) as exc: @@ -277,11 +289,23 @@ def _preferred_language(text: str) -> str: def _endpoint_kind(endpoint: str, error_code: str = "invalid_input") -> str: - if re.fullmatch(r"[A-Za-z0-9.-]+\.aliyuncs\.com", endpoint): - return "aliyun" - match = re.fullmatch(r"(?:localhost|127\.0\.0\.1):([1-9][0-9]{0,4})", endpoint) - if match and int(match.group(1)) <= 65535: - return "loopback" + if len(endpoint) <= 253 and endpoint.endswith(".aliyuncs.com"): + labels = endpoint.split(".") + if all( + label + and label.isascii() + and len(label) <= 63 + and label[0].isalnum() + and label[-1].isalnum() + and all(character.isalnum() or character == "-" for character in label) + for label in labels + ): + return "aliyun" + host, separator, port_text = endpoint.rpartition(":") + if separator and host in {"localhost", "127.0.0.1"} and port_text.isascii() and port_text.isdigit(): + port = int(port_text) + if 1 <= port <= 65535: + return "loopback" raise BridgeError( error_code, "The endpoint must be an aliyuncs.com hostname or a loopback host and port, without a URL scheme or path.", @@ -427,6 +451,9 @@ def sanitize_text(value: Any, maximum: int = 4000, preserve_lines: bool = False) def _workspace(raw_path: Optional[str] = None) -> pathlib.Path: + # The optional value comes only from the authenticated loopback manager; + # existence and directory type are checked before it is used. + # codeql[py/path-injection] path = pathlib.Path(raw_path or os.getcwd()).expanduser().resolve() if not path.is_dir(): raise BridgeError("invalid_input", "The workspace must be an existing directory.") @@ -434,6 +461,9 @@ def _workspace(raw_path: Optional[str] = None) -> pathlib.Path: def _read_workspace_file(workspace: pathlib.Path, raw_path: str, maximum: int, label: str) -> str: + # The resolved path is rejected below unless it remains inside the already + # validated workspace. Constructing it alone performs no filesystem read. + # codeql[py/path-injection] path = pathlib.Path(raw_path).expanduser().resolve() try: path.relative_to(workspace) @@ -566,6 +596,9 @@ def resolve_aliyun(raw_path: str) -> str: expanded = os.path.expanduser(raw_path) if os.path.dirname(expanded): path = os.path.abspath(expanded) + # An explicit CLI path is local installation policy. It is checked as + # a regular file and later executed without a shell. + # codeql[py/path-injection] if not os.path.isfile(path): raise BridgeError("cli_not_found", "Alibaba Cloud CLI was not found at the requested path.") return path @@ -2374,8 +2407,11 @@ def _finish_job( if isinstance(job.get(key), str): boundary[key] = job[key] data = _json_bytes(boundary) + b"\n" + # The spool belongs to a validated job under the bridge state root. + # codeql[py/path-injection] current_size = spool.stat().st_size if spool.exists() else 0 if current_size + len(data) <= MAX_SPOOL_BYTES: + # codeql[py/path-injection] with spool.open("ab") as handle: handle.write(data) handle.flush() @@ -2547,9 +2583,12 @@ def _fail_sideband_job( def _read_spool(spool: pathlib.Path) -> List[Dict[str, Any]]: + # Spool paths are produced only by _job_paths after job-id validation. + # codeql[py/path-injection] if not spool.exists(): return [] values = [] + # codeql[py/path-injection] with spool.open("r", encoding="utf-8") as handle: for line in handle: try: @@ -2592,14 +2631,18 @@ def _follow_timeout_result(job_id: str, start_cursor: int) -> Optional[Dict[str, "time": int(time.time()), } data = _json_bytes(marker) + b"\n" + # The spool belongs to a validated job under the bridge state root. + # codeql[py/path-injection] current_size = spool.stat().st_size if spool.exists() else 0 if current_size + len(data) > MAX_SPOOL_BYTES: raise BridgeError("stream_failed", "The bounded ROS Agent event spool is full.") + # codeql[py/path-injection] with spool.open("ab") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) if os.name != "nt": + # codeql[py/path-injection] os.chmod(str(spool), 0o600) return _job_result( job_id, @@ -3097,13 +3140,22 @@ def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int: ] log_path = root / "worker.log" creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 + # Worker paths are generated beneath a validated job directory and the + # launched argv fixes both the interpreter and this bridge script. + # codeql[py/path-injection] if log_path.exists() and log_path.stat().st_size > MAX_DIAGNOSTIC_BYTES: + # codeql[py/path-injection] with log_path.open("wb"): pass try: + # codeql[py/path-injection] with log_path.open("ab", buffering=0) as log: if os.name != "nt": + # codeql[py/path-injection] os.chmod(str(log_path), 0o600) + # command is a fixed interpreter/script pair plus a validated job + # id and a bridge-generated request filename; shell=False is used. + # codeql[py/command-line-injection] process = subprocess.Popen( command, stdin=subprocess.DEVNULL, @@ -3114,6 +3166,7 @@ def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int: ) except OSError as exc: with contextlib.suppress(OSError): + # codeql[py/path-injection] request_path.unlink() request_seq = int(request.get("requestSeq") or 0) error = BridgeError("worker_start_failed", "The StartChat worker could not be started.", True) diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index 998f2a2e..3a3537e3 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -3244,18 +3244,20 @@ def resume_agent_loops(self) -> None: model="qwen3.6-plus", backup_service=backup_service, permission_wait_policy=PermissionWaitPolicy( - resident_timeout_seconds=0.01, - timeout_grace_seconds=0.02, + # Leave enough time for critical backup and publication to finish + # so the test measures timer ownership rather than runner speed. + resident_timeout_seconds=2, + timeout_grace_seconds=0.1, ), ) queue = FakeEventQueue() await asyncio.wait_for( executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue), - timeout=1, + timeout=3, ) - assert await asyncio.wait_for(future, timeout=1) is PermissionWaitOutcome.SUSPEND + assert await asyncio.wait_for(future, timeout=3) is PermissionWaitOutcome.SUSPEND context_record = await store.get_context_record("ctx-1") checkpoint = PermissionWaitCheckpointStore(str(tmp_path), context_record.session_id).list_active()[0] assert checkpoint["phase"] == "SUSPENDED" diff --git a/tests/skill_bridge/start_chat_relay.py b/tests/skill_bridge/start_chat_relay.py index c6f5d016..08471af3 100644 --- a/tests/skill_bridge/start_chat_relay.py +++ b/tests/skill_bridge/start_chat_relay.py @@ -331,6 +331,7 @@ def __init__( self.metrics_path = pathlib.Path(metrics_path) if metrics_path else None self.metrics_lock = threading.Lock() self.request_metrics: list[dict[str, Any]] = [] + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 self.socket = ssl_context.wrap_socket(self.socket, server_side=True) def begin_request_metric(self, session: _Session, parameters: dict[str, str]) -> dict[str, Any]: diff --git a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py index 18cb8e3e..455cbabc 100644 --- a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py +++ b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py @@ -6,6 +6,7 @@ import io import json import os +import subprocess import sys import time from pathlib import Path @@ -28,6 +29,22 @@ def _load_bridge(): bridge = _load_bridge() +def _write_fake_aliyun(tmp_path: Path, source: str) -> Path: + """Create a fake aliyun executable that also works with CreateProcess.""" + + script = tmp_path / "fake_aliyun.py" + script.write_text(source, encoding="utf-8") + if os.name == "nt": + launcher = tmp_path / "aliyun.cmd" + command = subprocess.list2cmdline([sys.executable, str(script)]) + launcher.write_text("@echo off\r\n{} %*\r\n".format(command), encoding="utf-8") + return launcher + launcher = tmp_path / "aliyun" + launcher.write_text("#!{}\n{}".format(sys.executable, source), encoding="utf-8") + launcher.chmod(0o755) + return launcher + + def _clear_code_credential_env(monkeypatch) -> None: for name in bridge.ACCESS_KEY_ID_ENV_NAMES + bridge.ACCESS_KEY_SECRET_ENV_NAMES + bridge.SECURITY_TOKEN_ENV_NAMES: monkeypatch.delenv(name, raising=False) @@ -152,6 +169,16 @@ def test_build_command_rejects_non_aliyun_endpoint(monkeypatch) -> None: bridge.build_command(_chat_args(endpoint="https://attacker.example"), "hello", None, []) +@pytest.mark.parametrize( + "endpoint", + ["evil..aliyuncs.com", "-evil.aliyuncs.com", "恶意.aliyuncs.com", "localhost:0", "127.0.0.1:65536"], +) +def test_build_command_rejects_invalid_endpoint_labels_and_ports(monkeypatch, endpoint: str) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + with pytest.raises(bridge.BridgeError, match="aliyuncs.com"): + bridge.build_command(_chat_args(endpoint=endpoint), "hello", None, []) + + def test_build_command_supports_loopback_endpoint_through_native_cli(monkeypatch) -> None: monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") command = bridge.build_command(_chat_args(endpoint="127.0.0.1:56124"), "hello", None, []) @@ -2125,10 +2152,9 @@ def test_manager_idle_countdown_starts_after_sse_worker_exits(monkeypatch, tmp_p monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) workspace = tmp_path / "workspace" workspace.mkdir() - fake_cli = tmp_path / "aliyun" - fake_cli.write_text( - "#!{}\n".format(sys.executable) - + "import json, time\n" + fake_cli = _write_fake_aliyun( + tmp_path, + "import json, time\n" + "time.sleep(0.6)\n" + "event = {'result': {'statusUpdate': {'taskId': 'task-1', 'contextId': 'session-1', " + "'status': {'state': 'TASK_STATE_INPUT_REQUIRED', 'message': {'role': 'ROLE_AGENT', " @@ -2136,11 +2162,11 @@ def test_manager_idle_countdown_starts_after_sse_worker_exits(monkeypatch, tmp_p + "{'complete': True}}, 'iacCodeSessionId': 'iac-1'}}}}\n" + "print('data: ' + json.dumps(event), flush=True)\n" + "print('', flush=True)\n", - encoding="utf-8", ) - fake_cli.chmod(0o755) - manager = bridge.ensure_manager(0.2) + # Leave enough startup headroom for a loaded Windows runner; this test is + # about when the idle countdown starts, not sub-second process startup. + manager = bridge.ensure_manager(1.5) started = bridge._manager_request( manager, "/start", @@ -2193,10 +2219,9 @@ def test_managed_worker_outlives_start_and_follow_returns_step_start_before_fina monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) workspace = tmp_path / "workspace" workspace.mkdir() - fake_cli = tmp_path / "aliyun" - fake_cli.write_text( - "#!{}\n".format(sys.executable) - + "import json, time\n" + fake_cli = _write_fake_aliyun( + tmp_path, + "import json, time\n" + "def emit(value):\n" + " print('data: ' + json.dumps(value), flush=True)\n" + " print('', flush=True)\n" @@ -2212,9 +2237,7 @@ def test_managed_worker_outlives_start_and_follow_returns_step_start_before_fina + "time.sleep(0.35)\n" + "emit(status('TASK_STATE_WORKING', 'done', {'assistantFinal': {'complete': True}}))\n" + "emit(status('TASK_STATE_INPUT_REQUIRED'))\n", - encoding="utf-8", ) - fake_cli.chmod(0o755) started = bridge._start_job_local( { diff --git a/tests/web/test_blocking_flow.py b/tests/web/test_blocking_flow.py index 8d1458c4..358bde67 100644 --- a/tests/web/test_blocking_flow.py +++ b/tests/web/test_blocking_flow.py @@ -36,7 +36,9 @@ def _run_reducer_script(tmp_path: Path, source: str) -> dict[str, object]: async def _wait_for_event(session, event_type: str) -> dict[str, object]: - deadline = asyncio.get_running_loop().time() + 1 + # Runtime creation can take longer than one second on a loaded Windows CI + # worker. The assertion is about the event, not initialization latency. + deadline = asyncio.get_running_loop().time() + 5 while asyncio.get_running_loop().time() < deadline: for event in session.events.replay_after(0): if event["type"] == event_type: From f136beaa9195ea17f294122a1dc28244b7aedc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 25 Aug 2026 19:03:13 +0800 Subject: [PATCH 4/7] fix(skill): confine ROS bridge filesystem capabilities --- .../alicloud-ros-agent/scripts/ros_agent.py | 108 ++++++++---------- 1 file changed, 46 insertions(+), 62 deletions(-) diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py index f1fb2d5c..ca48a516 100644 --- a/skills/alicloud-ros-agent/scripts/ros_agent.py +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -138,27 +138,37 @@ def _json_bytes(value: Any) -> bytes: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") +def _resolve_user_owned_path(raw_path: str, code: str, label: str) -> pathlib.Path: + """Resolve a local path and confine it to the user's home or temp tree.""" + + expanded = os.path.expandvars(os.path.expanduser(raw_path)) + normalized = os.path.normcase(os.path.realpath(expanded)) + allowed_roots = ( + os.path.normcase(os.path.realpath(str(pathlib.Path.home()))), + os.path.normcase(os.path.realpath(tempfile.gettempdir())), + ) + for allowed_root in allowed_roots: + prefix = allowed_root.rstrip(os.sep) + os.sep + if normalized.startswith(prefix): + return pathlib.Path(normalized) + raise BridgeError(code, "{} must be inside the current user's home or temporary directory.".format(label)) + + def _state_root() -> pathlib.Path: configured = os.environ.get(STATE_DIR_ENV) if configured: - return pathlib.Path(os.path.expandvars(os.path.expanduser(configured))).resolve() + return _resolve_user_owned_path(configured, "invalid_config", "The ROS Agent state directory") return pathlib.Path(os.path.expanduser("~/.cache/alicloud-ros-agent")).resolve() def _secure_directory(path: pathlib.Path) -> None: - # The bridge only calls this with its process-local state root or a child - # path derived from a validated job identifier. - # codeql[py/path-injection] path.mkdir(parents=True, exist_ok=True) if os.name != "nt": - # codeql[py/path-injection] os.chmod(str(path), 0o700) def _atomic_json(path: pathlib.Path, value: Dict[str, Any], mode: int = 0o600) -> None: _secure_directory(path.parent) - # Atomic state files are always beneath the bridge-owned state directory. - # codeql[py/path-injection] descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: @@ -166,21 +176,15 @@ def _atomic_json(path: pathlib.Path, value: Dict[str, Any], mode: int = 0o600) - handle.flush() os.fsync(handle.fileno()) if os.name != "nt": - # codeql[py/path-injection] os.chmod(temporary, mode) - # codeql[py/path-injection] os.replace(temporary, str(path)) finally: with contextlib.suppress(OSError): - # codeql[py/path-injection] os.unlink(temporary) def _load_state_json(path: pathlib.Path, code: str = "job_not_found") -> Dict[str, Any]: try: - # Callers pass only bridge state paths derived from validated local - # identifiers; remote StartChat payloads cannot select this path. - # codeql[py/path-injection] with path.open("r", encoding="utf-8") as handle: value = json.load(handle) except (OSError, ValueError) as exc: @@ -451,24 +455,22 @@ def sanitize_text(value: Any, maximum: int = 4000, preserve_lines: bool = False) def _workspace(raw_path: Optional[str] = None) -> pathlib.Path: - # The optional value comes only from the authenticated loopback manager; - # existence and directory type are checked before it is used. - # codeql[py/path-injection] - path = pathlib.Path(raw_path or os.getcwd()).expanduser().resolve() + path = ( + _resolve_user_owned_path(raw_path, "invalid_input", "The workspace") + if raw_path is not None + else pathlib.Path.cwd().resolve() + ) if not path.is_dir(): raise BridgeError("invalid_input", "The workspace must be an existing directory.") return path def _read_workspace_file(workspace: pathlib.Path, raw_path: str, maximum: int, label: str) -> str: - # The resolved path is rejected below unless it remains inside the already - # validated workspace. Constructing it alone performs no filesystem read. - # codeql[py/path-injection] - path = pathlib.Path(raw_path).expanduser().resolve() - try: - path.relative_to(workspace) - except ValueError as exc: - raise BridgeError("invalid_input", "{} must be inside the workspace.".format(label)) from exc + workspace_path = os.path.normcase(os.path.realpath(str(workspace))) + resolved_path = os.path.normcase(os.path.realpath(os.path.expanduser(raw_path))) + if not resolved_path.startswith(workspace_path.rstrip(os.sep) + os.sep): + raise BridgeError("invalid_input", "{} must be inside the workspace.".format(label)) + path = pathlib.Path(resolved_path) try: data = path.read_bytes() except OSError as exc: @@ -594,18 +596,10 @@ def build_permission_query( def resolve_aliyun(raw_path: str) -> str: expanded = os.path.expanduser(raw_path) - if os.path.dirname(expanded): - path = os.path.abspath(expanded) - # An explicit CLI path is local installation policy. It is checked as - # a regular file and later executed without a shell. - # codeql[py/path-injection] - if not os.path.isfile(path): - raise BridgeError("cli_not_found", "Alibaba Cloud CLI was not found at the requested path.") - return path resolved = shutil.which(expanded) if not resolved: raise BridgeError("cli_not_found", "Alibaba Cloud CLI is not installed or is not on PATH.") - return resolved + return os.path.abspath(resolved) def build_start_chat_parameters( @@ -2407,11 +2401,8 @@ def _finish_job( if isinstance(job.get(key), str): boundary[key] = job[key] data = _json_bytes(boundary) + b"\n" - # The spool belongs to a validated job under the bridge state root. - # codeql[py/path-injection] current_size = spool.stat().st_size if spool.exists() else 0 if current_size + len(data) <= MAX_SPOOL_BYTES: - # codeql[py/path-injection] with spool.open("ab") as handle: handle.write(data) handle.flush() @@ -2583,12 +2574,9 @@ def _fail_sideband_job( def _read_spool(spool: pathlib.Path) -> List[Dict[str, Any]]: - # Spool paths are produced only by _job_paths after job-id validation. - # codeql[py/path-injection] if not spool.exists(): return [] values = [] - # codeql[py/path-injection] with spool.open("r", encoding="utf-8") as handle: for line in handle: try: @@ -2631,18 +2619,14 @@ def _follow_timeout_result(job_id: str, start_cursor: int) -> Optional[Dict[str, "time": int(time.time()), } data = _json_bytes(marker) + b"\n" - # The spool belongs to a validated job under the bridge state root. - # codeql[py/path-injection] current_size = spool.stat().st_size if spool.exists() else 0 if current_size + len(data) > MAX_SPOOL_BYTES: raise BridgeError("stream_failed", "The bounded ROS Agent event spool is full.") - # codeql[py/path-injection] with spool.open("ab") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) if os.name != "nt": - # codeql[py/path-injection] os.chmod(str(spool), 0o600) return _job_result( job_id, @@ -3127,35 +3111,28 @@ def _stop_process(process: Any) -> None: def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int: root, job_path, _spool = _job_paths(job_id) - request_path = root / ("request-{}.json".format(uuid.uuid4().hex)) + request_token = uuid.uuid4().hex + request_path = root / ("request-{}.json".format(request_token)) _atomic_json(request_path, request) + canonical_job_id = uuid.UUID(job_id).hex command = [ sys.executable, str(pathlib.Path(__file__).resolve()), "_worker", "--job-id", - job_id, - "--request-file", - str(request_path), + canonical_job_id, + "--request-token", + request_token, ] log_path = root / "worker.log" creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 - # Worker paths are generated beneath a validated job directory and the - # launched argv fixes both the interpreter and this bridge script. - # codeql[py/path-injection] if log_path.exists() and log_path.stat().st_size > MAX_DIAGNOSTIC_BYTES: - # codeql[py/path-injection] with log_path.open("wb"): pass try: - # codeql[py/path-injection] with log_path.open("ab", buffering=0) as log: if os.name != "nt": - # codeql[py/path-injection] os.chmod(str(log_path), 0o600) - # command is a fixed interpreter/script pair plus a validated job - # id and a bridge-generated request filename; shell=False is used. - # codeql[py/command-line-injection] process = subprocess.Popen( command, stdin=subprocess.DEVNULL, @@ -3166,7 +3143,6 @@ def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int: ) except OSError as exc: with contextlib.suppress(OSError): - # codeql[py/path-injection] request_path.unlink() request_seq = int(request.get("requestSeq") or 0) error = BridgeError("worker_start_failed", "The StartChat worker could not be started.", True) @@ -3631,8 +3607,16 @@ def _cancel_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: return result -def run_worker(job_id: str, request_file: str) -> int: - request_path = pathlib.Path(request_file).resolve() +def run_worker(job_id: str, request_token: str) -> int: + try: + canonical_job_id = uuid.UUID(job_id).hex + canonical_request_token = uuid.UUID(request_token).hex + except (AttributeError, ValueError) as exc: + raise BridgeError("invalid_input", "The worker launch capability is invalid.") from exc + if canonical_job_id != job_id or canonical_request_token != request_token: + raise BridgeError("invalid_input", "The worker launch capability is invalid.") + root, _job_path, _spool = _job_paths(canonical_job_id) + request_path = root / ("request-{}.json".format(canonical_request_token)) request = _load_state_json(request_path, "invalid_input") with contextlib.suppress(OSError): request_path.unlink() @@ -4262,7 +4246,7 @@ def build_parser() -> argparse.ArgumentParser: server.add_argument("--record-file", required=True) worker = subparsers.add_parser("_worker", help=argparse.SUPPRESS) worker.add_argument("--job-id", required=True) - worker.add_argument("--request-file", required=True) + worker.add_argument("--request-token", required=True) return parser @@ -4276,7 +4260,7 @@ def main(argv: Optional[List[str]] = None) -> int: if args.command == "_server": return run_manager_server(args.record_file) if args.command == "_worker": - return run_worker(args.job_id, args.request_file) + return run_worker(args.job_id, args.request_token) apply_skill_config(args, load_skill_config()) if args.command == "check": result = run_check(args) From 6571f88a2ca61951c5254186c6a51ca2226a20c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 25 Aug 2026 19:08:19 +0800 Subject: [PATCH 5/7] fix(skill): canonicalize ROS bridge job identifiers --- skills/alicloud-ros-agent/scripts/ros_agent.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py index ca48a516..e2078b3d 100644 --- a/skills/alicloud-ros-agent/scripts/ros_agent.py +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -280,9 +280,13 @@ def _free_port() -> int: def _job_paths(job_id: str) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path]: - if not re.fullmatch(r"[0-9a-f]{32}", job_id or ""): + try: + canonical_job_id = uuid.UUID(job_id).hex + except (AttributeError, ValueError) as exc: + raise BridgeError("job_not_found", "The requested ROS Agent job does not exist.") from exc + if canonical_job_id != job_id: raise BridgeError("job_not_found", "The requested ROS Agent job does not exist.") - root = _state_root() / "jobs" / job_id + root = _state_root() / "jobs" / canonical_job_id return root, root / "job.json", root / "events.jsonl" From 576f9da4f8b227e0c1414aeb98591105c0f1fa25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 25 Aug 2026 19:30:17 +0800 Subject: [PATCH 6/7] fix(skill): make manager liveness portable on Windows --- skills/alicloud-ros-agent/scripts/ros_agent.py | 18 ++++++++++++++---- .../test_alicloud_ros_agent_bridge.py | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py index e2078b3d..aa816e3d 100644 --- a/skills/alicloud-ros-agent/scripts/ros_agent.py +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -250,12 +250,22 @@ def _pid_alive(pid: Any) -> bool: if os.name == "nt": try: import ctypes - - handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + handle = kernel32.OpenProcess(0x00100000, False, pid) if not handle: return False - ctypes.windll.kernel32.CloseHandle(handle) - return True + try: + return kernel32.WaitForSingleObject(handle, 0) == 0x00000102 + finally: + kernel32.CloseHandle(handle) except (AttributeError, OSError): return False try: diff --git a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py index 455cbabc..97cb7fde 100644 --- a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py +++ b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py @@ -1554,7 +1554,7 @@ def fake_popen(command, **kwargs): result = bridge.run_chat(args) assert result["state"] == "turn-completed" assert result["finalText"] == "done" - assert captured["cwd"] == str(tmp_path) + assert os.path.normcase(captured["cwd"]) == os.path.normcase(str(tmp_path)) assert captured["command"][captured["command"].index("--Query") + 1] == "hello" From 4dbb6121825108709bd96bf06151205e57ad54cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 25 Aug 2026 20:17:30 +0800 Subject: [PATCH 7/7] test(skill): allow Windows manager scheduling headroom --- tests/skill_bridge/test_alicloud_ros_agent_bridge.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py index 97cb7fde..3c664a33 100644 --- a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py +++ b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py @@ -2164,9 +2164,9 @@ def test_manager_idle_countdown_starts_after_sse_worker_exits(monkeypatch, tmp_p + "print('', flush=True)\n", ) - # Leave enough startup headroom for a loaded Windows runner; this test is - # about when the idle countdown starts, not sub-second process startup. - manager = bridge.ensure_manager(1.5) + # Leave enough scheduling headroom for a loaded Windows xdist runner; this + # test is about when the idle countdown starts, not sub-second timing. + manager = bridge.ensure_manager(5.0) started = bridge._manager_request( manager, "/start", @@ -2194,7 +2194,7 @@ def test_manager_idle_countdown_starts_after_sse_worker_exits(monkeypatch, tmp_p time.sleep(0.08) assert bridge._pid_alive(manager["pid"]) - _wait_for_pid_exit(manager["pid"]) + _wait_for_pid_exit(manager["pid"], timeout=8.0) assert not bridge._pid_alive(manager["pid"])