diff --git a/skills/alicloud-ros-agent/SKILL.md b/skills/alicloud-ros-agent/SKILL.md index 6da22a04..d8c82edb 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 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. +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 dependency-free transport invokes the ROS CLI plugin's `start-chat` and `stop-chat` commands. Run the bridge with `python3` on macOS/Linux or `py -3` on Windows. ## Required interaction contract @@ -27,13 +27,15 @@ While a Pipeline has `wireState: TASK_STATE_WORKING`, `follow` is the only obser ## 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: +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. Its local execution mode requires the installed CLI and a ROS plugin that provides `start-chat` and `stop-chat`; its remote execution mode expects the host's same-name `aliyun` command to forward those API invocations to a cloud CLI sandbox. 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 bounded JSON result includes the effective `transport`, `aliyunCLIExecutionMode`, endpoint, Agent modes, Thinking policy, configured Profile policy, effective region when locally available, and only non-secret credential metadata. `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. In local CLI mode, `rosPluginReady`, `pluginAutoInstallEnabled`, and `pluginInstallRequired` describe plugin readiness. If and only if `pluginInstallRequired` is true, visibly report that the required ROS CLI plugin is being installed, run exactly `aliyun plugin install --name ros`, and then rerun `check`; never add a version, package URL, mirror, or source override. If the plugin is absent but CLI automatic plugin installation is enabled, `pluginInstallRequired` is false and the first `start-chat` invocation may install it. In remote CLI mode, `check` deliberately does not run CLI management commands or inspect local Profiles/plugins. + +Use the check result as the sole readiness source. Except for the one local-mode plugin install command directed by `pluginInstallRequired`, never run `aliyun configure`, `aliyun plugin`, or other discovery/management commands, 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. @@ -54,12 +56,15 @@ The bridge reads an optional `config.json` beside this `SKILL.md`. If it is abse } ``` -- `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. +When `transport` is `aliyun_cli`, add `"aliyunCLIExecutionMode": "local"` or `"remote"`; do not add that field to a `code` transport configuration. + +- `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` is the dependency-free path and invokes only the ROS plugin's validated `start-chat` and `stop-chat` operations. 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. +- `aliyunCLIExecutionMode` accepts exactly `local` or `remote`, defaults to `local`, and is valid only with `transport: "aliyun_cli"`. `local` uses the native local CLI, Profile, and plugin installation. `remote` treats the same-name `aliyun` executable as a cloud-sandbox forwarder: the bridge may invoke only `aliyun ros start-chat` and `aliyun ros stop-chat`, never CLI management or another API operation. Remote mode does not read or pass a local Profile, does not infer a local region, rejects `aliyunCLIProfile`, and requires a public `*.aliyuncs.com` endpoint. All prompt and response payloads are passed inline; never pass a file-backed CLI parameter to the remote command. - `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`. +- `aliyunCLIProfile` is an empty or exact CLI Profile name and defaults to empty. Empty preserves code-mode environment-AK precedence and otherwise selects the local CLI's effective current Profile. A non-empty value pins that Profile for code transport or local CLI execution, ignores environment AK/Profile selectors, and fails instead of falling back when the Profile is unavailable. It is invalid in remote CLI execution. 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. @@ -96,7 +101,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 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`. +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 uses the installed/remote ROS plugin's published `start-chat` and `stop-chat` commands and does not bypass plugin validation. Both transports identify every StartChat and StopChat request with the user-agent segment `AlibabaCloud-Agent-Skills/alibabacloud-ros-agent`. ## Architecture before deployment confirmation @@ -114,7 +119,7 @@ Present the deployment summary, fenced Mermaid block, and confirmation question ## Optional context and images -Use `--client-context-file ` for a JSON object accepted by StartChat. Keep this file inside the workspace and exclude secrets. +With the `code` transport only, use `--client-context-file ` for a JSON object accepted by StartChat. Keep this file inside the workspace and exclude secrets. The ROS CLI plugin does not expose ClientContext, so the bridge rejects this option for both local and remote `aliyun_cli` execution. Use `--attachments-file ` for up to five OSS-backed images. The file must be a JSON array such as: diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py index 7805e237..7c768d89 100644 --- a/skills/alicloud-ros-agent/scripts/ros_agent.py +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -28,6 +28,7 @@ MAX_CONTEXT_BYTES = 64 * 1024 MAX_CONFIG_BYTES = 16 * 1024 MAX_CLI_CONFIG_BYTES = 2 * 1024 * 1024 +MAX_PLUGIN_MANIFEST_BYTES = 2 * 1024 * 1024 MAX_SSE_LINE_BYTES = 16 * 1024 * 1024 MAX_SSE_EVENT_BYTES = 16 * 1024 * 1024 MAX_FINAL_TEXT_BYTES = 10 * 1024 @@ -56,6 +57,9 @@ SUPPORTED_AGENT_MODES = {"normal", "pipeline"} DEFAULT_TRANSPORT = "code" SUPPORTED_TRANSPORTS = {"code", "aliyun_cli"} +DEFAULT_ALIYUN_CLI_EXECUTION_MODE = "local" +SUPPORTED_ALIYUN_CLI_EXECUTION_MODES = {"local", "remote"} +ROS_PLUGIN_COMMANDS = {"start-chat", "stop-chat"} USER_AGENT = "AlibabaCloud-Agent-Skills/alibabacloud-ros-agent" ACCESS_KEY_ID_ENV_NAMES = ( "ALIBABA_CLOUD_ACCESS_KEY_ID", @@ -352,6 +356,7 @@ def load_skill_config(path: Optional[pathlib.Path] = None) -> Dict[str, Any]: "allowedAgentModes", "managerIdleSeconds", "transport", + "aliyunCLIExecutionMode", "enableThinking", "aliyunCLIProfile", } @@ -365,6 +370,12 @@ def load_skill_config(path: Optional[pathlib.Path] = None) -> Dict[str, Any]: raise BridgeError("invalid_config", "transport must be code or aliyun_cli.") result["transport"] = transport + if "aliyunCLIExecutionMode" in value: + execution_mode = value["aliyunCLIExecutionMode"] + if not isinstance(execution_mode, str) or execution_mode not in SUPPORTED_ALIYUN_CLI_EXECUTION_MODES: + raise BridgeError("invalid_config", "aliyunCLIExecutionMode must be local or remote.") + result["aliyunCLIExecutionMode"] = execution_mode + if "endpoint" in value: endpoint = value["endpoint"] if not isinstance(endpoint, str) or not endpoint.strip() or endpoint != endpoint.strip(): @@ -414,6 +425,26 @@ def load_skill_config(path: Optional[pathlib.Path] = None) -> Dict[str, Any]: "aliyunCLIProfile must be an empty or non-padded Profile name of at most 200 bytes.", ) result["aliyunCLIProfile"] = profile + + execution_mode = result.get("aliyunCLIExecutionMode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE) + transport = result.get("transport", DEFAULT_TRANSPORT) + if "aliyunCLIExecutionMode" in result and transport != "aliyun_cli": + raise BridgeError( + "invalid_config", + "aliyunCLIExecutionMode may be configured only when transport is aliyun_cli.", + ) + if execution_mode == "remote": + if result.get("aliyunCLIProfile"): + raise BridgeError( + "invalid_config", + "aliyunCLIProfile is not available when aliyunCLIExecutionMode is remote.", + ) + endpoint = result.get("endpoint") + if isinstance(endpoint, str) and _endpoint_kind(endpoint, "invalid_config") != "aliyun": + raise BridgeError( + "invalid_config", + "Remote aliyun CLI execution requires a public aliyuncs.com endpoint.", + ) return result @@ -421,11 +452,13 @@ 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) + cli_execution_mode = config.get("aliyunCLIExecutionMode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE) 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.aliyun_cli_execution_mode = cli_execution_mode args.profile_pinned = bool(configured_profile) if args.command == "check": args.endpoint = configured_endpoint or DEFAULT_ENDPOINT @@ -440,13 +473,19 @@ def apply_skill_config(args: argparse.Namespace, config: Dict[str, Any]) -> None 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") + endpoint_kind = _endpoint_kind(args.endpoint, "invalid_config" if configured_endpoint else "invalid_input") + if transport == "aliyun_cli" and cli_execution_mode == "remote" and endpoint_kind != "aliyun": + raise BridgeError("invalid_input", "Remote aliyun CLI execution requires a public aliyuncs.com endpoint.") 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 transport == "aliyun_cli" and cli_execution_mode == "remote" and requested_profile: + raise BridgeError("config_conflict", "--profile is not available with remote aliyun CLI execution.") 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 transport == "aliyun_cli" and getattr(args, "client_context_file", None): + raise BridgeError("unsupported_input", "The ROS CLI plugin does not support ClientContext.") 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 @@ -649,17 +688,20 @@ def build_command( client_context: Optional[str], attachments: List[Dict[str, str]], ) -> List[str]: + if client_context is not None: + raise BridgeError("unsupported_input", "The ROS CLI plugin does not support ClientContext.") endpoint_kind = _endpoint_kind(args.endpoint or "") + execution_mode = getattr(args, "aliyun_cli_execution_mode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE) + if execution_mode == "remote" and endpoint_kind != "aliyun": + raise BridgeError("invalid_input", "Remote aliyun CLI execution requires a public aliyuncs.com endpoint.") + if execution_mode == "remote" and args.profile: + raise BridgeError("invalid_input", "Remote aliyun CLI execution does not accept a local Profile.") command = [ resolve_aliyun(args.aliyun_path), "ros", - "StartChat", - "--method", - "POST", + "start-chat", "--endpoint", args.endpoint, - "--header", - "Accept=text/event-stream", "--connect-timeout", str(args.connect_timeout), "--read-timeout", @@ -674,20 +716,45 @@ def build_command( 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]) + command.extend( + [ + "--query", + prompt, + "--agent-version", + "V2", + "--enable-partial-message", + "true", + "--enable-thinking", + "false" if args.no_thinking else "true", + "--biz-mode", + "IaCCodePipeline" if args.mode == "pipeline" else "IaCCodeNormal", + ] + ) + if args.session_id: + command.extend(["--session-id", args.session_id]) + if args.region_id: + command.extend(["--biz-region-id", args.region_id]) + for attachment in attachments: + values = [] + for field in ("Type", "MimeType", "Name", "OssObjectKey"): + if field in attachment: + values.append("{}={}".format(field, attachment[field])) + command.extend(["--attachments", *values]) 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) + execution_mode = job.get("aliyunCLIExecutionMode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE) + if execution_mode == "remote" and endpoint_kind != "aliyun": + raise BridgeError("invalid_input", "Remote aliyun CLI execution requires a public aliyuncs.com endpoint.") + if execution_mode == "remote" and job.get("profile"): + raise BridgeError("invalid_input", "Remote aliyun CLI execution does not accept a local Profile.") command = [ resolve_aliyun(str(job.get("aliyunPath") or "aliyun")), "ros", - "StopChat", - "--method", - "POST", + "stop-chat", "--endpoint", endpoint, "--connect-timeout", @@ -706,7 +773,7 @@ def build_stop_command(job: Dict[str, Any], session_id: str) -> List[str]: 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]) + command.extend(["--agent-version", "V2", "--session-id", session_id]) return command @@ -780,6 +847,52 @@ def _read_cli_configuration() -> Dict[str, Any]: return value +def _local_ros_plugin_status() -> Dict[str, Any]: + configured_root = os.environ.get("ALIBABA_CLOUD_CLI_PLUGINS_DIR") + root = ( + pathlib.Path(os.path.expanduser(configured_root)) + if configured_root + else pathlib.Path.home() / ".aliyun" / "plugins" + ) + manifest_path = root / "manifest.json" + try: + with manifest_path.open("rb") as handle: + raw = handle.read(MAX_PLUGIN_MANIFEST_BYTES + 1) + except FileNotFoundError: + return {"installed": False, "ready": False} + except OSError as exc: + raise BridgeError("cli_check_failed", "The Alibaba Cloud CLI plugin manifest could not be read.") from exc + if len(raw) > MAX_PLUGIN_MANIFEST_BYTES: + raise BridgeError("cli_check_failed", "The Alibaba Cloud CLI plugin manifest is too large.") + try: + manifest = json.loads(raw.decode("utf-8")) + except (UnicodeError, ValueError) as exc: + raise BridgeError("cli_check_failed", "The Alibaba Cloud CLI plugin manifest is invalid.") from exc + plugins = manifest.get("plugins") if isinstance(manifest, dict) else None + plugin = plugins.get("aliyun-cli-ros") if isinstance(plugins, dict) else None + if not isinstance(plugin, dict): + return {"installed": False, "ready": False} + + commands = plugin.get("cmdNames") + command_names = {value for value in commands if isinstance(value, str)} if isinstance(commands, list) else set() + raw_path = plugin.get("path") + executable_exists = False + if isinstance(raw_path, str) and raw_path: + plugin_root = pathlib.Path(os.path.expanduser(raw_path)) + candidates = (plugin_root / "aliyun-cli-ros", plugin_root / "aliyun-cli-ros.exe") + executable_exists = any( + candidate.is_file() and (os.name == "nt" or os.access(str(candidate), os.X_OK)) for candidate in candidates + ) + result: Dict[str, Any] = { + "installed": True, + "ready": executable_exists and ROS_PLUGIN_COMMANDS.issubset(command_names), + } + version = plugin.get("version") + if isinstance(version, str) and version: + result["version"] = sanitize_text(version, 80) + return result + + 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") @@ -792,13 +905,17 @@ def _selected_cli_profile_record(profile: Optional[str]) -> Dict[str, Any]: 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] + assert isinstance(selected, dict) + result: Dict[str, Any] = {"name": profile_name, "mode": mode} 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) + result["autoPluginInstall"] = bool(selected.get("auto_plugin_install")) or ( + os.environ.get("ALIBABA_CLOUD_CLI_PLUGIN_AUTO_INSTALL") == "true" + ) return result @@ -808,6 +925,13 @@ def _selected_cli_profile(profile: Optional[str]) -> Tuple[str, str]: def _resolve_start_identity(args: argparse.Namespace) -> None: + if ( + args.transport == "aliyun_cli" + and getattr(args, "aliyun_cli_execution_mode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE) == "remote" + ): + args.profile = None + args.credential_source = "remote" + return 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): @@ -1110,6 +1234,29 @@ def decode(data: List[str], raw: List[str]) -> Tuple[Optional[Dict[str, Any]], s yield decode(data_lines, raw_lines) +def _cli_plugin_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + data = payload.get("data") + return data if isinstance(data, dict) else payload + + +def iter_cli_plugin_payloads(lines: Iterable[str]) -> Iterator[Tuple[Optional[Dict[str, Any]], str]]: + for raw_line in lines: + if len(raw_line.encode("utf-8")) > MAX_SSE_LINE_BYTES: + raise BridgeError("stream_failed", "A StartChat CLI output line exceeded the bridge limit.") + payload_text = raw_line.strip() + if not payload_text: + continue + try: + value = json.loads(payload_text) + except ValueError: + yield None, payload_text + continue + if not isinstance(value, dict): + yield None, payload_text + continue + yield _cli_plugin_payload(value), payload_text + + def _event_payload(result: Any) -> Any: if not isinstance(result, dict): return result @@ -3080,7 +3227,7 @@ def _consume_start_chat( 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): + for payload, raw in iter_cli_plugin_payloads(process.stdout): if payload is None: summary.malformed_event_count += 1 if raw: @@ -3208,6 +3355,7 @@ def _request_from_job(job: Dict[str, Any], prompt: str) -> Dict[str, Any]: "endpoint": job["endpoint"], # Jobs created before transport selection existed used the native CLI. "transport": job.get("transport", "aliyun_cli"), + "aliyunCLIExecutionMode": job.get("aliyunCLIExecutionMode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE), "sessionId": job.get("sessionId"), "regionId": job.get("regionId"), "profile": job.get("profile"), @@ -3227,6 +3375,7 @@ def _start_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: mode = payload.get("mode") endpoint = payload.get("endpoint") transport = payload.get("transport", DEFAULT_TRANSPORT) + cli_execution_mode = payload.get("aliyunCLIExecutionMode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE) 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: @@ -3236,6 +3385,17 @@ def _start_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: _endpoint_kind(endpoint) if transport not in SUPPORTED_TRANSPORTS: raise BridgeError("invalid_input", "The ROS transport is invalid.") + if cli_execution_mode not in SUPPORTED_ALIYUN_CLI_EXECUTION_MODES: + raise BridgeError("invalid_input", "The aliyun CLI execution mode is invalid.") + if transport != "aliyun_cli" and cli_execution_mode != DEFAULT_ALIYUN_CLI_EXECUTION_MODE: + raise BridgeError("invalid_input", "The aliyun CLI execution mode requires the aliyun_cli transport.") + if transport == "aliyun_cli" and cli_execution_mode == "remote": + if _endpoint_kind(endpoint) != "aliyun": + raise BridgeError("invalid_input", "Remote aliyun CLI execution requires a public aliyuncs.com endpoint.") + if payload.get("profile"): + raise BridgeError("invalid_input", "Remote aliyun CLI execution does not accept a local Profile.") + if payload.get("clientContext") is not None: + raise BridgeError("unsupported_input", "The ROS CLI plugin does not support ClientContext.") aliyun_path = str(payload.get("aliyunPath") or "aliyun") if transport == "aliyun_cli": resolve_aliyun(aliyun_path) @@ -3254,6 +3414,7 @@ def _start_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: "mode": mode, "endpoint": endpoint, "transport": transport, + "aliyunCLIExecutionMode": cli_execution_mode, "regionId": payload.get("regionId"), "profile": payload.get("profile"), "credentialSource": payload.get("credentialSource"), @@ -3668,6 +3829,7 @@ def fail_worker(error: BridgeError) -> None: args = argparse.Namespace( aliyun_path=request.get("aliyunPath", "aliyun"), transport=request.get("transport", "aliyun_cli"), + aliyun_cli_execution_mode=request.get("aliyunCLIExecutionMode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE), endpoint=request.get("endpoint"), connect_timeout=int(request.get("connectTimeout") or 10), read_timeout=int(request.get("readTimeout") or DEFAULT_READ_TIMEOUT_SECONDS), @@ -4085,12 +4247,20 @@ def _parse_profile_fields(output: bytes) -> Dict[str, str]: 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]]] + cli_execution_mode = getattr(args, "aliyun_cli_execution_mode", DEFAULT_ALIYUN_CLI_EXECUTION_MODE) if args.transport == "code": sdk = _load_code_sdk() if not args.profile_pinned: environment_credentials = _environment_credentials() - if environment_credentials is not None: + plugin_status = None # type: Optional[Dict[str, Any]] + plugin_auto_install = None # type: Optional[bool] + if args.transport == "aliyun_cli" and cli_execution_mode == "remote": + resolve_aliyun(args.aliyun_path) + current_profile = {"configured": True, "mode": "RemoteSandbox"} + cli = "aliyun" + version = None + elif 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 @@ -4122,19 +4292,31 @@ def run_check(args: argparse.Namespace) -> Dict[str, Any]: assert version_result is not None cli = "aliyun" version = sanitize_text((version_result.stdout or b"").decode("utf-8", "replace"), 200) + plugin_status = _local_ros_plugin_status() + plugin_auto_install = bool(selected.get("autoPluginInstall")) - return { + result = { "ok": True, "cli": cli, "version": version, "transport": args.transport, + "aliyunCLIExecutionMode": cli_execution_mode, "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, - } + } # type: Dict[str, Any] + if plugin_status is not None: + result["rosPluginReady"] = plugin_status["ready"] + result["pluginAutoInstallEnabled"] = plugin_auto_install + result["pluginInstallRequired"] = bool(plugin_status["installed"] and not plugin_status["ready"]) or bool( + not plugin_status["installed"] and not plugin_auto_install + ) + if plugin_status.get("version"): + result["rosPluginVersion"] = plugin_status["version"] + return result def _follow_after_command(args: argparse.Namespace, result: Dict[str, Any]) -> Dict[str, Any]: @@ -4167,6 +4349,7 @@ def run_start_job(args: argparse.Namespace) -> Dict[str, Any]: "prompt": prompt, "mode": args.mode, "transport": args.transport, + "aliyunCLIExecutionMode": args.aliyun_cli_execution_mode, "endpoint": args.endpoint, "regionId": args.region_id, "profile": args.profile, diff --git a/tests/mcp/test_oauth.py b/tests/mcp/test_oauth.py index a22bcea5..41485189 100644 --- a/tests/mcp/test_oauth.py +++ b/tests/mcp/test_oauth.py @@ -1363,17 +1363,16 @@ def wait_for_barrier(name: str) -> None: time.sleep(0.01) class BarrierStorage(MCPSecretStorage): - _blob_barrier_fired = False + _blob_reads = 0 def get_secret(self, key: str) -> str | None: value = super().get_secret(key) # 所有 OAuth 字段现在合并进单个 blob 条目;两个进程都读到过期旧状态后, # 用一次性栅栏让它们同时进入刷新竞争,验证粗粒度 CAS 锁只放行一次网络刷新。 - if not self._blob_barrier_fired and key == oauth_module.oauth_storage_key( - config, scope=MCPConfigScope.USER - ): - self._blob_barrier_fired = True - wait_for_barrier("oauth-blob-read") + if key == oauth_module.oauth_storage_key(config, scope=MCPConfigScope.USER): + self._blob_reads += 1 + if self._blob_reads == 4: + wait_for_barrier("oauth-blob-read") return value oauth_module.discover_oauth_metadata = lambda _config: oauth_module.OAuthMetadata( diff --git a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py index b0123f3b..069cb057 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_uses_published_post_rpc_without_explicit_version_or_credentials(monkeypatch) -> None: +def test_build_command_uses_ros_plugin_without_explicit_credentials(monkeypatch) -> None: monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") command = bridge.build_command( _chat_args( @@ -137,7 +137,7 @@ def test_build_command_uses_published_post_rpc_without_explicit_version_or_crede no_thinking=True, ), "创建 VPC", - '{"preferredLanguage":"zh"}', + None, [ { "Type": "image", @@ -148,16 +148,21 @@ def test_build_command_uses_published_post_rpc_without_explicit_version_or_crede ], ) - assert command[:3] == ["/usr/local/bin/aliyun", "ros", "StartChat"] + assert command[:3] == ["/usr/local/bin/aliyun", "ros", "start-chat"] assert "--force" not 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 "--method" not in command + assert command[command.index("--biz-mode") + 1] == "IaCCodePipeline" + assert command[command.index("--session-id") + 1] == "session-1" + assert command[command.index("--enable-partial-message") + 1] == "true" + assert command[command.index("--enable-thinking") + 1] == "false" + attachment_index = command.index("--attachments") + assert command[attachment_index + 1 : attachment_index + 5] == [ + "Type=image", + "MimeType=image/png", + "Name=diagram.png", + "OssObjectKey=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) @@ -188,6 +193,24 @@ def test_build_command_supports_loopback_endpoint_through_native_cli(monkeypatch assert "--skip-secure-verify" in command +def test_build_command_rejects_remote_profile_and_loopback(monkeypatch) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/remote/bin/aliyun") + with pytest.raises(bridge.BridgeError, match="local Profile"): + bridge.build_command( + _chat_args(aliyun_cli_execution_mode="remote", profile="local-profile"), + "hello", + None, + [], + ) + with pytest.raises(bridge.BridgeError, match="public aliyuncs.com"): + bridge.build_command( + _chat_args(aliyun_cli_execution_mode="remote", endpoint="127.0.0.1:56124"), + "hello", + None, + [], + ) + + 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( @@ -201,18 +224,18 @@ def test_build_stop_command_uses_only_published_stop_chat_inputs(monkeypatch) -> "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[:3] == ["/usr/local/bin/aliyun", "ros", "stop-chat"] + assert "--method" not in command + assert command[command.index("--agent-version") + 1] == "V2" + assert command[command.index("--session-id") + 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 "--force" not in command assert "--secure" in command assert "--skip-secure-verify" in command - assert "--Query" not in command - assert "--Mode" not in command + assert "--query" not in command + assert "--biz-mode" not in command assert not any("access-key" in value.lower() for value in command) @@ -225,6 +248,7 @@ def test_optional_skill_config_defaults_and_applies_endpoint_and_mode_policy(tmp json.dumps( { "transport": "aliyun_cli", + "aliyunCLIExecutionMode": "local", "endpoint": "127.0.0.1:56124", "allowedAgentModes": ["normal"], "managerIdleSeconds": 45, @@ -240,6 +264,7 @@ def test_optional_skill_config_defaults_and_applies_endpoint_and_mode_policy(tmp assert args.endpoint == "127.0.0.1:56124" assert args.transport == "aliyun_cli" + assert args.aliyun_cli_execution_mode == "local" assert config["allowedAgentModes"] == ["normal"] assert args.manager_idle_seconds == 45 assert args.no_thinking is True @@ -300,6 +325,10 @@ def test_profile_and_thinking_fixed_by_config_reject_conflicting_start_flags() - {"unknown": True}, {"transport": "unsupported"}, {"transport": True}, + {"aliyunCLIExecutionMode": "unsupported"}, + {"aliyunCLIExecutionMode": "remote"}, + {"transport": "aliyun_cli", "aliyunCLIExecutionMode": "remote", "aliyunCLIProfile": "profile"}, + {"transport": "aliyun_cli", "aliyunCLIExecutionMode": "remote", "endpoint": "127.0.0.1:56124"}, {"endpoint": "https://127.0.0.1:56124"}, {"endpoint": "attacker.example"}, {"allowedAgentModes": []}, @@ -445,6 +474,7 @@ def test_check_returns_safe_current_profile_and_effective_skill_policy(monkeypat "cli": None, "version": None, "transport": "code", + "aliyunCLIExecutionMode": "local", "endpoint": "127.0.0.1:56124", "allowedAgentModes": ["normal"], "managerIdleSeconds": bridge.MANAGER_IDLE_SECONDS, @@ -606,7 +636,17 @@ def fake_run(command, **_kwargs): monkeypatch.setattr( bridge, "_selected_cli_profile_record", - lambda profile: {"name": profile or "default", "mode": "AK", "regionId": "cn-hangzhou"}, + lambda profile: { + "name": profile or "default", + "mode": "AK", + "regionId": "cn-hangzhou", + "autoPluginInstall": False, + }, + ) + monkeypatch.setattr( + bridge, + "_local_ros_plugin_status", + lambda: {"installed": True, "ready": True, "version": "0.7.2"}, ) monkeypatch.setattr(bridge.subprocess, "run", fake_run) monkeypatch.setattr(bridge, "_load_code_sdk", lambda: pytest.fail("CLI transport must not load SDK packages")) @@ -617,6 +657,185 @@ def fake_run(command, **_kwargs): assert result["ok"] is True assert result["transport"] == "aliyun_cli" + assert result["aliyunCLIExecutionMode"] == "local" + assert result["rosPluginReady"] is True + assert result["pluginInstallRequired"] is False + assert result["pluginAutoInstallEnabled"] is False + assert result["rosPluginVersion"] == "0.7.2" + + +@pytest.mark.parametrize( + ("plugin_status", "auto_install", "install_required"), + [ + ({"installed": False, "ready": False}, False, True), + ({"installed": False, "ready": False}, True, False), + ({"installed": True, "ready": False, "version": "0.7.1"}, True, True), + ], +) +def test_local_cli_check_reports_when_skill_must_install_ros_plugin( + monkeypatch, plugin_status, auto_install: bool, install_required: bool +) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/usr/local/bin/aliyun") + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda _profile: { + "name": "default", + "mode": "AK", + "regionId": "cn-hangzhou", + "autoPluginInstall": auto_install, + }, + ) + monkeypatch.setattr(bridge, "_local_ros_plugin_status", lambda: plugin_status) + monkeypatch.setattr( + bridge, + "_run_check_command", + lambda _command, required: SimpleNamespace(returncode=0, stdout=b"3.4.11\n", stderr=b""), + ) + args = argparse.Namespace(command="check", aliyun_path="aliyun") + bridge.apply_skill_config(args, {"transport": "aliyun_cli"}) + + result = bridge.run_check(args) + + assert result["rosPluginReady"] is False + assert result["pluginAutoInstallEnabled"] is auto_install + assert result["pluginInstallRequired"] is install_required + + +def test_remote_aliyun_cli_check_does_not_run_cli_or_read_local_configuration(monkeypatch) -> None: + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/remote/bin/aliyun") + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda _profile: pytest.fail("remote CLI mode must not read a local Profile"), + ) + monkeypatch.setattr( + bridge, + "_local_ros_plugin_status", + lambda: pytest.fail("remote CLI mode must not inspect local plugins"), + ) + monkeypatch.setattr( + bridge.subprocess, + "run", + lambda *_args, **_kwargs: pytest.fail("remote CLI check must not invoke a CLI command"), + ) + args = argparse.Namespace(command="check", aliyun_path="aliyun") + bridge.apply_skill_config( + args, + { + "transport": "aliyun_cli", + "aliyunCLIExecutionMode": "remote", + "endpoint": "ros-pre.aliyuncs.com", + }, + ) + + result = bridge.run_check(args) + + assert result["ok"] is True + assert result["cli"] == "aliyun" + assert result["version"] is None + assert result["aliyunCLIExecutionMode"] == "remote" + assert result["currentProfile"] == {"configured": True, "mode": "RemoteSandbox"} + assert "rosPluginReady" not in result + assert "pluginInstallRequired" not in result + + +def test_remote_aliyun_cli_identity_does_not_read_local_credentials_or_region(monkeypatch) -> None: + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda _profile: pytest.fail("remote CLI mode must not read a local Profile"), + ) + monkeypatch.setattr( + bridge, + "_environment_region", + lambda: pytest.fail("remote CLI mode must not read a local region"), + ) + args = SimpleNamespace( + transport="aliyun_cli", + aliyun_cli_execution_mode="remote", + profile=None, + profile_pinned=False, + region_id=None, + ) + + bridge._resolve_start_identity(args) + + assert args.profile is None + assert args.credential_source == "remote" + assert args.region_id is None + + +def test_local_ros_plugin_status_requires_binary_and_start_stop_commands(monkeypatch, tmp_path: Path) -> None: + plugin_root = tmp_path / "aliyun-cli-ros" + plugin_root.mkdir() + executable = plugin_root / ("aliyun-cli-ros.exe" if os.name == "nt" else "aliyun-cli-ros") + executable.write_bytes(b"plugin") + if os.name != "nt": + executable.chmod(0o700) + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "plugins": { + "aliyun-cli-ros": { + "version": "0.7.2", + "path": str(plugin_root), + "cmdNames": ["start-chat", "stop-chat"], + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("ALIBABA_CLOUD_CLI_PLUGINS_DIR", str(tmp_path)) + + assert bridge._local_ros_plugin_status() == {"installed": True, "ready": True, "version": "0.7.2"} + + manifest.write_text( + json.dumps( + { + "plugins": { + "aliyun-cli-ros": { + "version": "0.7.1", + "path": str(plugin_root), + "cmdNames": ["describe-regions"], + } + } + } + ), + encoding="utf-8", + ) + assert bridge._local_ros_plugin_status() == {"installed": True, "ready": False, "version": "0.7.1"} + + +def test_cli_transport_rejects_client_context_and_remote_profile() -> None: + local = argparse.Namespace( + command="start", + endpoint=None, + mode="normal", + profile=None, + no_thinking=False, + client_context_file="context.json", + ) + with pytest.raises(bridge.BridgeError) as context_error: + bridge.apply_skill_config(local, {"transport": "aliyun_cli"}) + assert context_error.value.code == "unsupported_input" + + remote = argparse.Namespace( + command="start", + endpoint=None, + mode="normal", + profile="local-profile", + no_thinking=False, + client_context_file=None, + ) + with pytest.raises(bridge.BridgeError) as profile_error: + bridge.apply_skill_config( + remote, + {"transport": "aliyun_cli", "aliyunCLIExecutionMode": "remote"}, + ) + assert profile_error.value.code == "config_conflict" def test_workspace_json_inputs_validate_context_and_flatten_attachments(tmp_path: Path) -> None: @@ -749,6 +968,22 @@ def test_sse_parser_handles_heartbeats_multiline_data_and_raw_json() -> None: ] +def test_cli_plugin_parser_streams_and_unwraps_each_json_line() -> None: + first = {"result": {"statusUpdate": {"status": {"state": "TASK_STATE_WORKING"}}}} + second = {"result": {"statusUpdate": {"status": {"state": "TASK_STATE_COMPLETED"}}}} + + events = list( + bridge.iter_cli_plugin_payloads( + [ + json.dumps({"data": first}) + "\n", + json.dumps({"data": second}) + "\n", + ] + ) + ) + + assert events == [(first, json.dumps({"data": first})), (second, json.dumps({"data": second}))] + + def test_sse_parser_rejects_an_unterminated_event_as_soon_as_its_cumulative_limit_is_exceeded( monkeypatch, ) -> None: @@ -1496,18 +1731,17 @@ def test_cli_failure_redacts_secrets_from_error() -> None: 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( + output = json.dumps( + { + "data": _status_event( state="TASK_STATE_INPUT_REQUIRED", text="done", metadata={"assistantFinal": {"complete": True}}, - ), - separators=(",", ":"), - ) - + "\n\n" + ) + }, + separators=(",", ":"), ) + output += "\n" captured = {} class FakeProcess: @@ -1556,7 +1790,7 @@ def fake_popen(command, **kwargs): assert result["state"] == "turn-completed" assert result["finalText"] == "done" assert os.path.normcase(captured["cwd"]) == os.path.normcase(str(tmp_path)) - assert captured["command"][captured["command"].index("--Query") + 1] == "hello" + 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: @@ -1943,10 +2177,9 @@ def test_run_respond_sends_json_as_the_only_start_chat_control_payload(monkeypat ), encoding="utf-8", ) - output = ( - "data: " - + json.dumps( - { + output = json.dumps( + { + "data": { "result": { "messageId": "permission-ack-1", "taskId": "task-1", @@ -1965,12 +2198,12 @@ def test_run_respond_sends_json_as_the_only_start_chat_control_payload(monkeypat }, } ], - } - }, - separators=(",", ":"), - ) - + "\n\n" + }, + } + }, + separators=(",", ":"), ) + output += "\n" captured = {} class FakeProcess: @@ -2013,16 +2246,16 @@ def fake_popen(command, **kwargs): result = bridge.run_respond(args) command = captured["command"] - query_text = command[command.index("--Query") + 1] + 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) + assert command[command.index("--enable-thinking") + 1] == "false" + assert "--client-context" not in command + assert "--attachments" not in command def _wait_for_pid_exit(pid: int, timeout: float = 4.0) -> None: @@ -2065,16 +2298,19 @@ 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 = _write_fake_aliyun( - tmp_path, + # Invoke the current interpreter as the fake CLI and let it execute the + # positional ``ros`` script from the worker cwd. This avoids depending on + # Windows batch-file launch behavior in a manager lifecycle test. + fake_cli = Path(sys.executable) + (workspace / "ros").write_text( "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", + + "print(json.dumps({'data': event}), flush=True)\n", + encoding="utf-8", ) # Leave enough scheduling headroom for a loaded Windows xdist runner; this @@ -2136,8 +2372,7 @@ def test_managed_worker_outlives_start_and_follow_returns_step_start_before_fina tmp_path, "import json, time\n" + "def emit(value):\n" - + " print('data: ' + json.dumps(value), flush=True)\n" - + " print('', flush=True)\n" + + " print(json.dumps({'data': value}), flush=True)\n" + "def status(state, text='', metadata=None):\n" + " body = {'state': state}\n" + " if text:\n" @@ -3227,6 +3462,45 @@ def test_request_from_legacy_job_uses_current_stream_read_timeout_default() -> N assert request["readTimeout"] == bridge.DEFAULT_READ_TIMEOUT_SECONDS == 1800 assert request["transport"] == "aliyun_cli" + assert request["aliyunCLIExecutionMode"] == "local" + + +def test_remote_cli_job_persists_execution_mode_without_local_identity(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv(bridge.STATE_DIR_ENV, str(tmp_path / "state")) + workspace = tmp_path / "workspace" + workspace.mkdir() + captured = {} + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: "/remote/bin/aliyun") + + def fake_spawn(job_id, request): + captured["jobId"] = job_id + captured["request"] = request + return 123 + + monkeypatch.setattr(bridge, "_spawn_worker", fake_spawn) + + started = bridge._start_job_local( + { + "workspace": str(workspace), + "prompt": "create a VPC", + "mode": "normal", + "transport": "aliyun_cli", + "aliyunCLIExecutionMode": "remote", + "endpoint": "ros-pre.aliyuncs.com", + "regionId": None, + "profile": None, + "credentialSource": "remote", + "aliyunPath": "aliyun", + } + ) + + _root, job_path, _spool = bridge._job_paths(started["jobId"]) + job = bridge._load_state_json(job_path) + assert job["aliyunCLIExecutionMode"] == "remote" + assert job["profile"] is None + assert job["regionId"] is None + assert captured["request"]["aliyunCLIExecutionMode"] == "remote" + assert captured["request"]["credentialSource"] == "remote" @pytest.mark.parametrize( diff --git a/tests/skill_bridge/test_start_chat_relay.py b/tests/skill_bridge/test_start_chat_relay.py index 6c446313..f090d969 100644 --- a/tests/skill_bridge/test_start_chat_relay.py +++ b/tests/skill_bridge/test_start_chat_relay.py @@ -28,6 +28,7 @@ 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" +REAL_ALIYUN_PLUGINS_DIR = Path.home() / ".aliyun" / "plugins" def _load_module(name: str, path: Path): @@ -344,7 +345,7 @@ def _aliyun_start_chat_command( [], ) command[command.index("--endpoint") + 1] = endpoint - query_index = command.index("--Query") + query_index = command.index("--query") command[query_index:query_index] = [ "--skip-secure-verify", "--mode", @@ -359,21 +360,12 @@ 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 _require_aliyun_apis(monkeypatch: pytest.MonkeyPatch, aliyun: str, *operations: str) -> None: + del aliyun, operations + monkeypatch.setenv("ALIBABA_CLOUD_CLI_PLUGINS_DIR", str(REAL_ALIYUN_PLUGINS_DIR)) + status = bridge._local_ros_plugin_status() + if not status["ready"]: + pytest.skip("Installed ROS CLI plugin does not provide start-chat and stop-chat") def _aliyun_start_chat( @@ -419,7 +411,7 @@ def _aliyun_stop_chat(aliyun: str, endpoint: str, session_id: str) -> dict: }, session_id, ) - input_index = command.index("--AgentVersion") + input_index = command.index("--agent-version") command[input_index:input_index] = [ "--mode", "AK", @@ -458,7 +450,7 @@ def _summarize_sse( ) -> dict: summary = bridge.StreamSummary(session_id, mode=mode) diagnostics = [] - for payload, raw in bridge.iter_sse_payloads(stdout.splitlines(keepends=True)): + for payload, raw in bridge.iter_cli_plugin_payloads(stdout.splitlines(keepends=True)): if payload is None: summary.malformed_event_count += 1 diagnostics.append(raw) @@ -628,7 +620,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") + _require_aliyun_apis(monkeypatch, aliyun, "StopChat") metrics_path = tmp_path / "relay-metrics.json" relay_server = relay.StartChatRelay( ("127.0.0.1", 0), @@ -695,7 +687,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") + _require_aliyun_apis(monkeypatch, aliyun, "StartChat", "StopChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) @@ -747,7 +739,7 @@ def runtime_factory(options): None, [], ) - query_index = command.index("--Query") + query_index = command.index("--query") command[query_index:query_index] = [ "--mode", "AK", @@ -808,7 +800,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") + _require_aliyun_apis(monkeypatch, aliyun, "StartChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) @@ -918,7 +910,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") + _require_aliyun_apis(monkeypatch, aliyun, "StartChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) @@ -1034,7 +1026,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") + _require_aliyun_apis(monkeypatch, 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 ( @@ -1163,7 +1155,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") + _require_aliyun_apis(monkeypatch, aliyun, "StartChat") workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config"))