diff --git a/skills/alicloud-ros-agent/SKILL.md b/skills/alicloud-ros-agent/SKILL.md index 34b5207c..6da22a04 100644 --- a/skills/alicloud-ros-agent/SKILL.md +++ b/skills/alicloud-ros-agent/SKILL.md @@ -5,7 +5,7 @@ description: Use Alibaba Cloud ROS Agent through its StartChat API for remote in # 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. +Use the bridge at `scripts/ros_agent.py`. Its default code transport uses Alibaba Cloud credentials and Tea OpenAPI V3 signing to send ROS RPCs 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 @@ -96,7 +96,7 @@ Unknown fields, invalid values, and duplicate modes fail closed. Never edit `con 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`. +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 ROS RPC with API version `2019-09-10` and `ACS3-HMAC-SHA256` signing, so it does not depend on generated StartChat metadata. The `aliyun_cli` transport requires CLI metadata for StartChat and StopChat and does not bypass API validation. Both transports identify every StartChat and StopChat request with the user-agent segment `AlibabaCloud-Agent-Skills/alibabacloud-ros-agent`. ## Architecture before deployment confirmation diff --git a/skills/alicloud-ros-agent/requirements-code.txt b/skills/alicloud-ros-agent/requirements-code.txt index 56d0a683..4afad904 100644 --- a/skills/alicloud-ros-agent/requirements-code.txt +++ b/skills/alicloud-ros-agent/requirements-code.txt @@ -1,2 +1,3 @@ alibabacloud-credentials>=1.0.8,<2 -aliyun-python-sdk-core>=2.16,<3 +alibabacloud-tea-openapi>=0.4.4,<0.5 +requests>=2.31,<3 diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py index aa816e3d..7805e237 100644 --- a/skills/alicloud-ros-agent/scripts/ros_agent.py +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Bounded Alibaba Cloud ROS Agent bridge using Alibaba Cloud CLI.""" +"""Bounded Alibaba Cloud ROS Agent bridge using signed StartChat RPCs.""" import argparse import contextlib @@ -18,6 +18,7 @@ import tempfile import time import urllib.error +import urllib.parse import urllib.request import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -653,7 +654,6 @@ def build_command( resolve_aliyun(args.aliyun_path), "ros", "StartChat", - "--force", "--method", "POST", "--endpoint", @@ -686,7 +686,6 @@ def build_stop_command(job: Dict[str, Any], session_id: str) -> List[str]: resolve_aliyun(str(job.get("aliyunPath") or "aliyun")), "ros", "StopChat", - "--force", "--method", "POST", "--endpoint", @@ -718,17 +717,9 @@ def _load_code_sdk() -> Dict[str, Any]: 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"), + "DaraRequest": getattr(importlib.import_module("darabonba.request"), "DaraRequest"), + "OpenApiUtils": getattr(importlib.import_module("alibabacloud_tea_openapi.utils"), "Utils"), + "requests": importlib.import_module("requests"), } except (ImportError, AttributeError) as exc: raise BridgeError( @@ -917,7 +908,7 @@ def _code_credentials( profile: Optional[str], region_id: Optional[str], credential_source: Optional[str] = None, -) -> Any: +) -> Tuple[str, str, Optional[str]]: 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() @@ -944,9 +935,57 @@ def _code_credentials( security_token = credentials.get_security_token() if not access_key_id or not access_key_secret: raise ValueError("empty credentials") + return access_key_id, access_key_secret, security_token or None + + +def _canonical_query_string(parameters: Dict[str, str]) -> str: + return "&".join( + "{}={}".format(name, urllib.parse.quote(value, safe="~", encoding="utf-8")) + for name, value in sorted(parameters.items()) + ) + + +def _build_v3_request( + sdk: Dict[str, Any], + operation: str, + parameters: Dict[str, str], + endpoint: str, + credentials: Tuple[str, str, Optional[str]], +) -> Tuple[str, Dict[str, str]]: + access_key_id, access_key_secret, security_token = credentials + signature_algorithm = "ACS3-HMAC-SHA256" + utils = sdk["OpenApiUtils"] + payload_hash = utils.hash(b"", signature_algorithm).hex() + headers = { + "accept": "text/event-stream" if operation == "StartChat" else "application/json", + "accept-encoding": "identity", + "host": endpoint, + "user-agent": USER_AGENT, + "x-acs-action": operation, + "x-acs-content-sha256": payload_hash, + "x-acs-date": utils.get_timestamp(), + "x-acs-signature-nonce": utils.get_nonce(), + "x-acs-version": "2019-09-10", + } if security_token: - return sdk["StsTokenCredential"](access_key_id, access_key_secret, security_token) - return sdk["AccessKeyCredential"](access_key_id, access_key_secret) + headers["x-acs-accesskey-id"] = access_key_id + headers["x-acs-security-token"] = security_token + + request = sdk["DaraRequest"]() + request.protocol = "https" + request.method = "POST" + request.pathname = "/" + request.query = dict(parameters) + request.headers = headers + headers["Authorization"] = utils.get_authorization( + request, + signature_algorithm, + payload_hash, + access_key_id, + access_key_secret, + ) + query = _canonical_query_string(parameters) + return "https://{}/{}".format(endpoint, "?{}".format(query) if query else ""), headers class _CodeHttpResponse: @@ -983,27 +1022,8 @@ def _open_code_request( ) -> 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) + credentials = _code_credentials(sdk, aliyun_path, profile, region_id, credential_source) + url, headers = _build_v3_request(sdk, operation, parameters, endpoint, credentials) except BridgeError: raise except Exception as exc: @@ -1016,10 +1036,10 @@ def _open_code_request( 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(), + method="POST", + url=url, + data=None, + headers=headers, timeout=(connect_timeout, read_timeout), allow_redirects=False, verify=_endpoint_kind(endpoint) != "loopback", diff --git a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py index 3c664a33..b0123f3b 100644 --- a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py +++ b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py @@ -127,7 +127,7 @@ def test_bridge_parses_as_python_38_and_uses_only_standard_library_imports() -> assert "access-key-secret" not in source.lower() -def test_build_command_forces_post_rpc_without_explicit_version_or_credentials(monkeypatch) -> None: +def test_build_command_uses_published_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( @@ -149,7 +149,7 @@ def test_build_command_forces_post_rpc_without_explicit_version_or_credentials(m ) assert command[:3] == ["/usr/local/bin/aliyun", "ros", "StartChat"] - assert "--force" in command + assert "--force" not in command assert command[command.index("--method") + 1] == "POST" assert command[command.index("--Mode") + 1] == "IaCCodePipeline" assert "--PipelineName" not in command @@ -208,6 +208,7 @@ def test_build_stop_command_uses_only_published_stop_chat_inputs(monkeypatch) -> 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 "--force" not in command assert "--secure" in command assert "--skip-secure-verify" in command assert "--Query" not in command @@ -1658,58 +1659,6 @@ def __init__(self, profile_name=None): 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 @@ -1734,16 +1683,11 @@ def request(self, **values): 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), - } + sdk = bridge._load_code_sdk() + sdk["CLIProfileCredentialsProvider"] = FakeProvider + sdk["requests"] = SimpleNamespace(Session=FakeSession) + monkeypatch.setattr(sdk["OpenApiUtils"], "get_timestamp", staticmethod(lambda: "2026-08-26T03:00:00Z")) + monkeypatch.setattr(sdk["OpenApiUtils"], "get_nonce", staticmethod(lambda: "fixed-nonce")) monkeypatch.setattr(bridge, "_load_code_sdk", lambda: sdk) monkeypatch.setattr(bridge, "_selected_cli_profile", lambda profile: (profile, "AK")) @@ -1759,18 +1703,20 @@ def close(self): ) 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"]["url"] == "https://127.0.0.1:56124/?AgentVersion=V2&Query=hello" + assert captured["http"]["method"] == "POST" assert captured["http"]["stream"] is True assert captured["http"]["verify"] is False + headers = captured["http"]["headers"] + assert headers["x-acs-action"] == "StartChat" + assert headers["x-acs-version"] == "2019-09-10" + assert headers["x-acs-date"] == "2026-08-26T03:00:00Z" + assert headers["x-acs-signature-nonce"] == "fixed-nonce" + assert headers["x-acs-security-token"] == "fake-token" + assert headers["user-agent"] == bridge.USER_AGENT + assert headers["Authorization"].startswith("ACS3-HMAC-SHA256 Credential=fake-ak,SignedHeaders=") + assert "SignatureVersion" not in captured["http"]["url"] + assert "HMAC-SHA1" not in headers["Authorization"] response.close() assert captured["responseClosed"] is True assert captured["sessionClosed"] is True @@ -1781,23 +1727,14 @@ def test_code_credentials_use_environment_before_cli_profile(monkeypatch) -> Non 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") + credentials = 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") + assert credentials == ("fake-env-ak", "fake-env-secret", "fake-env-token") def test_code_credentials_with_profile_source_do_not_fall_back_to_environment(monkeypatch) -> None: @@ -1823,18 +1760,12 @@ def __init__(self, profile_name=None): 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( + credentials = bridge._code_credentials( sdk, "aliyun", "fixed-profile", @@ -1842,16 +1773,14 @@ def __init__(self, *values): "profile", ) - assert isinstance(credential, FakeCredential) assert captured["profile"] == "fixed-profile" - assert captured["values"] == ("fake-profile-ak", "fake-profile-secret") + assert credentials == ("fake-profile-ak", "fake-profile-secret", None) 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( { @@ -1894,23 +1823,16 @@ def fake_run(command, **kwargs): ) 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") + credentials = 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 credentials == ("fake-refreshed-ak", "fake-refreshed-secret", "fake-refreshed-token") assert len(commands) == 1 refresh_command, refresh_options = commands[0] assert "--dryrun" in refresh_command @@ -1942,24 +1864,15 @@ def test_code_credentials_reuse_unexpired_oauth_sts_without_starting_cli(monkeyp ), 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") + credentials = bridge._code_credentials(sdk, "aliyun", None, "cn-hangzhou") - assert isinstance(credential, FakeCredential) - assert captured["values"] == ("fake-cached-ak", "fake-cached-secret", "fake-cached-token") + assert credentials == ("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: diff --git a/tests/skill_bridge/test_start_chat_relay.py b/tests/skill_bridge/test_start_chat_relay.py index c8f348cd..6c446313 100644 --- a/tests/skill_bridge/test_start_chat_relay.py +++ b/tests/skill_bridge/test_start_chat_relay.py @@ -359,6 +359,23 @@ def _aliyun_start_chat_command( return command +def _require_aliyun_apis(aliyun: str, *operations: str) -> None: + for operation in operations: + completed = subprocess.run( + [aliyun, "ros", operation, "--help"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + check=False, + ) + if completed.returncode == 2 and "is not a valid api" in completed.stderr: + pytest.skip("Installed Alibaba Cloud CLI metadata does not include {} yet".format(operation)) + + def _aliyun_start_chat( aliyun: str, endpoint: str, @@ -385,6 +402,8 @@ def _aliyun_start_chat( timeout=20, check=False, ) + if completed.returncode == 2 and "is not a valid api" in completed.stderr: + pytest.skip("Installed Alibaba Cloud CLI metadata does not include StartChat yet") assert completed.returncode == 0, bridge.sanitize_text(completed.stderr, 1000) return completed.stdout, completed.stderr @@ -422,6 +441,8 @@ def _aliyun_stop_chat(aliyun: str, endpoint: str, session_id: str) -> dict: timeout=20, check=False, ) + if completed.returncode == 2 and "is not a valid api" in completed.stderr: + pytest.skip("Installed Alibaba Cloud CLI metadata does not include StopChat yet") assert completed.returncode == 0, bridge.sanitize_text(completed.stderr, 1000) value = json.loads(completed.stdout) assert isinstance(value, dict) @@ -607,6 +628,7 @@ def test_stop_chat_round_trip_through_real_aliyun_cli( aliyun = shutil.which("aliyun") if aliyun is None: pytest.skip("Alibaba Cloud CLI is not installed") + _require_aliyun_apis(aliyun, "StopChat") metrics_path = tmp_path / "relay-metrics.json" relay_server = relay.StartChatRelay( ("127.0.0.1", 0), @@ -673,6 +695,7 @@ def test_stop_chat_cancels_live_a2a_stream_through_real_aliyun_cli( aliyun = shutil.which("aliyun") if aliyun is None: pytest.skip("Alibaba Cloud CLI is not installed") + _require_aliyun_apis(aliyun, "StartChat", "StopChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) @@ -785,6 +808,7 @@ def test_normal_permission_round_trip_through_real_aliyun_cli_and_a2a( aliyun = shutil.which("aliyun") if aliyun is None: pytest.skip("Alibaba Cloud CLI is not installed") + _require_aliyun_apis(aliyun, "StartChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) @@ -894,6 +918,7 @@ def test_normal_consecutive_permissions_return_at_each_serial_boundary( aliyun = shutil.which("aliyun") if aliyun is None: pytest.skip("Alibaba Cloud CLI is not installed") + _require_aliyun_apis(aliyun, "StartChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) @@ -1009,6 +1034,7 @@ def test_top_pipeline_permission_ends_parent_start_chat_and_continues_on_reply_s aliyun = shutil.which("aliyun") if aliyun is None: pytest.skip("Alibaba Cloud CLI is not installed") + _require_aliyun_apis(aliyun, "StartChat") 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 ( @@ -1137,6 +1163,7 @@ def test_sub_pipeline_permissions_round_trip_through_real_aliyun_cli_and_a2a( aliyun = shutil.which("aliyun") if aliyun is None: pytest.skip("Alibaba Cloud CLI is not installed") + _require_aliyun_apis(aliyun, "StartChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config"))