diff --git a/skill-runtime/package_skill.py b/skill-runtime/package_skill.py index 1f0a05d6..c2a8a7b4 100644 --- a/skill-runtime/package_skill.py +++ b/skill-runtime/package_skill.py @@ -12,12 +12,12 @@ import zipfile from pathlib import Path +from skill_profiles import PROFILES, SkillProfile, render_profile, skill_profile + ROOT = Path(__file__).resolve().parents[1] -SKILL_SOURCE = ROOT / "skills/iac-code" PUBLIC_ORIGIN = "https://ros-public-tools.oss-cn-beijing.aliyuncs.com" PRODUCT_PREFIX = "github-releases/aliyun/iac-code" RUNTIME_PYTHON = "cp312" -SKILL_FILES = ("SKILL.md", "agents/openai.yaml", "scripts/iac_code.py") _SEMVER_PATTERN = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") _TAG_PATTERN = re.compile(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") _CANDIDATE_PATTERN = re.compile(r"candidate-[0-9]{8}T[0-9]{6}Z-([0-9a-f]{12})") @@ -44,26 +44,36 @@ def _candidate(value: str | None, source_commit: str, name: str) -> str | None: def _validate_args(args: argparse.Namespace) -> None: + profile = skill_profile(args.profile) for name in ("source_commit", "publisher_commit"): if _COMMIT_PATTERN.fullmatch(getattr(args, name)) is None: raise SystemExit("--{} must be a lowercase 40-character Git commit".format(name.replace("_", "-"))) if _RFC3339_PATTERN.fullmatch(args.published_at) is None: raise SystemExit("--published-at must use UTC RFC3339 form YYYY-MM-DDTHH:MM:SSZ") - if _DIGEST_PATTERN.fullmatch(args.manifest_sha256) is None or set(args.manifest_sha256) == {"0"}: + if args.skill_version and _SEMVER_PATTERN.fullmatch(args.skill_version) is None: + raise SystemExit("--skill-version must use canonical X.Y.Z") + _candidate(args.skill_candidate_id, args.source_commit, "--skill-candidate-id") + if not profile.requires_runtime: + runtime_values = (args.runtime_tag, args.runtime_candidate_id, args.iac_code_version, args.manifest_sha256) + if any(value is not None for value in runtime_values) or args.manifest_size is not None: + raise SystemExit("{} does not accept Runtime arguments".format(profile.name)) + return + if args.manifest_sha256 is None or _DIGEST_PATTERN.fullmatch(args.manifest_sha256) is None: + raise SystemExit("--manifest-sha256 must be a non-placeholder lowercase SHA-256") + if set(args.manifest_sha256) == {"0"}: raise SystemExit("--manifest-sha256 must be a non-placeholder lowercase SHA-256") - if args.manifest_size <= 0: + if args.manifest_size is None or args.manifest_size <= 0: raise SystemExit("--manifest-size must be positive") if args.runtime_python != RUNTIME_PYTHON: raise SystemExit("the initial Skill contract requires runtime Python cp312") - if args.skill_version and _SEMVER_PATTERN.fullmatch(args.skill_version) is None: - raise SystemExit("--skill-version must use canonical X.Y.Z") if args.runtime_tag: match = _TAG_PATTERN.fullmatch(args.runtime_tag) if match is None or args.runtime_tag != "v{}".format(args.iac_code_version): raise SystemExit("--runtime-tag must be the canonical tag for --iac-code-version") - if _SEMVER_PATTERN.fullmatch(args.iac_code_version) is None: + if args.runtime_tag is None and args.runtime_candidate_id is None: + raise SystemExit("runtime-backed Skill requires --runtime-tag or --runtime-candidate-id") + if args.iac_code_version is None or _SEMVER_PATTERN.fullmatch(args.iac_code_version) is None: raise SystemExit("--iac-code-version must use canonical X.Y.Z") - _candidate(args.skill_candidate_id, args.source_commit, "--skill-candidate-id") runtime_source = args.runtime_source_commit or args.source_commit if _COMMIT_PATTERN.fullmatch(runtime_source) is None: raise SystemExit("--runtime-source-commit must be a lowercase 40-character Git commit") @@ -80,11 +90,20 @@ def runtime_manifest_url(args: argparse.Namespace) -> str: return "/".join((PUBLIC_ORIGIN, PRODUCT_PREFIX, suffix)) -def skill_public_url(args: argparse.Namespace) -> str: - if args.skill_version: - suffix = "skill/releases/{}/iac-code-skill-{}.zip".format(args.skill_version, args.skill_version) +def skill_public_url(args: argparse.Namespace, profile: SkillProfile) -> str: + if profile.name == "iac-code": + if args.skill_version: + suffix = "skill/releases/{}/iac-code-skill-{}.zip".format(args.skill_version, args.skill_version) + else: + suffix = "skill/candidates/{}/iac-code-skill.zip".format(args.skill_candidate_id) + elif args.skill_version: + suffix = "skills/{}/releases/{}/{}-skill-{}.zip".format( + profile.name, args.skill_version, profile.name, args.skill_version + ) else: - suffix = "skill/candidates/{}/iac-code-skill.zip".format(args.skill_candidate_id) + suffix = "skills/{}/candidates/{}/{}-skill.zip".format( + profile.name, args.skill_candidate_id, profile.name + ) return "/".join((PUBLIC_ORIGIN, PRODUCT_PREFIX, suffix)) @@ -98,15 +117,11 @@ def _replace_constant(source: str, name: str, value: str) -> str: def _stage(args: argparse.Namespace, root: Path) -> Path: - skill_root = root / "iac-code" - for relative in SKILL_FILES: - source = SKILL_SOURCE / relative - if not source.is_file(): - raise SystemExit("Skill source is missing {}".format(relative)) - destination = skill_root / relative - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(source.read_bytes()) - bridge_path = skill_root / "scripts/iac_code.py" + profile = skill_profile(args.profile) + skill_root = render_profile(profile.name, root / profile.archive_root) + if not profile.requires_runtime: + return skill_root + bridge_path = skill_root / profile.bridge_path bridge = bridge_path.read_text(encoding="utf-8") runtime_identity = args.runtime_tag or args.runtime_candidate_id skill_identity = args.skill_version or args.skill_candidate_id @@ -122,18 +137,15 @@ def _stage(args: argparse.Namespace, root: Path) -> Path: bridge = _replace_constant(bridge, name, value) ast.parse(bridge, filename=str(bridge_path), feature_version=(3, 8)) bridge_path.write_text(bridge, encoding="utf-8", newline="\n") - actual = sorted(path.relative_to(skill_root).as_posix() for path in skill_root.rglob("*") if path.is_file()) - if actual != sorted(SKILL_FILES): - raise SystemExit("Skill staging contains files outside the package whitelist") return skill_root -def deterministic_zip(skill_root: Path, output: Path) -> None: +def deterministic_zip(skill_root: Path, profile: SkillProfile, output: Path) -> None: output.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - for relative in SKILL_FILES: + for relative in profile.output_files: source = skill_root / relative - info = zipfile.ZipInfo("iac-code/" + relative, date_time=(1980, 1, 1, 0, 0, 0)) + info = zipfile.ZipInfo(profile.archive_root + "/" + relative, date_time=(1980, 1, 1, 0, 0, 0)) info.create_system = 3 mode = 0o755 if relative == "scripts/iac_code.py" else 0o644 info.external_attr = (0o100000 | mode) << 16 @@ -141,28 +153,29 @@ def deterministic_zip(skill_root: Path, output: Path) -> None: archive.writestr(info, source.read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) -def release_manifest(args: argparse.Namespace, archive: Path) -> dict[str, object]: - runtime_identity_name = "runtimeTag" if args.runtime_tag else "runtimeCandidateId" - runtime_identity = args.runtime_tag or args.runtime_candidate_id +def release_manifest(args: argparse.Namespace, profile: SkillProfile, archive: Path) -> dict[str, object]: value: dict[str, object] = { "schemaVersion": 1, - "kind": "iac-code-skill-release" if args.skill_version else "iac-code-skill-candidate", + "kind": "{}-skill-{}".format(profile.name, "release" if args.skill_version else "candidate"), + "skillName": profile.name, "skillSourceCommit": args.source_commit, "publisherCommit": args.publisher_commit, "publishedAt": args.published_at, - runtime_identity_name: runtime_identity, - "runtimeManifest": { - "url": runtime_manifest_url(args), - "size": args.manifest_size, - "sha256": args.manifest_sha256, - }, "skill": { "name": archive.name, - "url": skill_public_url(args), + "url": skill_public_url(args, profile), "size": archive.stat().st_size, "sha256": sha256(archive), }, } + if profile.requires_runtime: + runtime_identity_name = "runtimeTag" if args.runtime_tag else "runtimeCandidateId" + value[runtime_identity_name] = args.runtime_tag or args.runtime_candidate_id + value["runtimeManifest"] = { + "url": runtime_manifest_url(args), + "size": args.manifest_size, + "sha256": args.manifest_sha256, + } if args.skill_version: value["skillVersion"] = args.skill_version else: @@ -172,16 +185,17 @@ def release_manifest(args: argparse.Namespace, archive: Path) -> dict[str, objec def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() + parser.add_argument("--profile", choices=sorted(PROFILES), default="iac-code") skill = parser.add_mutually_exclusive_group(required=True) skill.add_argument("--skill-version") skill.add_argument("--skill-candidate-id") - runtime = parser.add_mutually_exclusive_group(required=True) + runtime = parser.add_mutually_exclusive_group() runtime.add_argument("--runtime-tag") runtime.add_argument("--runtime-candidate-id") - parser.add_argument("--iac-code-version", required=True) + parser.add_argument("--iac-code-version") parser.add_argument("--runtime-python", default=RUNTIME_PYTHON) - parser.add_argument("--manifest-sha256", required=True) - parser.add_argument("--manifest-size", type=int, required=True) + parser.add_argument("--manifest-sha256") + parser.add_argument("--manifest-size", type=int) parser.add_argument("--source-commit", required=True) parser.add_argument("--runtime-source-commit") parser.add_argument("--publisher-commit", required=True) @@ -194,10 +208,11 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() _validate_args(args) + profile = skill_profile(args.profile) with tempfile.TemporaryDirectory(prefix="iac-code-skill-package-") as temporary: skill_root = _stage(args, Path(temporary)) - deterministic_zip(skill_root, args.output) - manifest = release_manifest(args, args.output) + deterministic_zip(skill_root, profile, args.output) + manifest = release_manifest(args, profile, args.output) args.manifest_output.parent.mkdir(parents=True, exist_ok=True) encoded = json.dumps(manifest, indent=2, sort_keys=True) + "\n" args.manifest_output.write_text(encoded, encoding="utf-8", newline="\n") diff --git a/skill-runtime/skill-package-contract.json b/skill-runtime/skill-package-contract.json index b4919767..43505c38 100644 --- a/skill-runtime/skill-package-contract.json +++ b/skill-runtime/skill-package-contract.json @@ -9,6 +9,40 @@ "hostPythonMinimum": "3.8", "kind": "iac-code-skill-package-contract", "packageScript": "skill-runtime/package_skill.py", + "profileScript": "skill-runtime/skill_profiles.py", + "profiles": { + "alibabacloud-iac-code": { + "archiveRoot": "alibabacloud-iac-code", + "files": [ + "SKILL.md", + "references/ram-policies.md", + "scripts/iac_code.py" + ], + "requiresRuntime": true, + "sourceDirectory": "skills/iac-code" + }, + "alibabacloud-ros-agent": { + "archiveRoot": "alibabacloud-ros-agent", + "files": [ + "SKILL.md", + "references/ram-policies.md", + "scripts/requirements.txt", + "scripts/ros_agent.py" + ], + "requiresRuntime": false, + "sourceDirectory": "skills/alicloud-ros-agent" + }, + "iac-code": { + "archiveRoot": "iac-code", + "files": [ + "SKILL.md", + "agents/openai.yaml", + "scripts/iac_code.py" + ], + "requiresRuntime": true, + "sourceDirectory": "skills/iac-code" + } + }, "runtimePython": "cp312", "schemaVersion": 1, "sourceDirectory": "skills/iac-code" diff --git a/skill-runtime/skill_profiles.py b/skill-runtime/skill_profiles.py new file mode 100644 index 00000000..3269395f --- /dev/null +++ b/skill-runtime/skill_profiles.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Render the external Skill sources for one immutable publication profile.""" + +from __future__ import annotations + +import argparse +import ast +import json +import re +import shutil +from dataclasses import dataclass, replace +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +_CONDITIONAL_PATTERN = re.compile(r"\{\{#(?P[A-Z0-9_]+)\}\}(?P.*?)\{\{/(?P=name)\}\}", re.DOTALL) +_PLACEHOLDER_PATTERN = re.compile(r"\{\{[A-Z0-9_]+\}\}") + + +@dataclass(frozen=True) +class SkillProfile: + name: str + source_directory: str + archive_root: str + title: str + requires_runtime: bool + agenthub: bool + files: tuple[tuple[str, str], ...] + bridge_path: str + user_agent_template: str + requirements_file: str = "" + + @property + def output_files(self) -> tuple[str, ...]: + return tuple(destination for _source, destination in self.files) + + +PROFILES = { + "iac-code": SkillProfile( + name="iac-code", + source_directory="skills/iac-code", + archive_root="iac-code", + title="iac-code", + requires_runtime=True, + agenthub=False, + files=( + ("SKILL.md.template", "SKILL.md"), + ("agents/openai.yaml", "agents/openai.yaml"), + ("scripts/iac_code.py", "scripts/iac_code.py"), + ), + bridge_path="scripts/iac_code.py", + user_agent_template="iac-code-skill/1", + ), + "alibabacloud-iac-code": SkillProfile( + name="alibabacloud-iac-code", + source_directory="skills/iac-code", + archive_root="alibabacloud-iac-code", + title="alibabacloud-iac-code", + requires_runtime=True, + agenthub=True, + files=( + ("SKILL.md.template", "SKILL.md"), + ("references/ram-policies.md", "references/ram-policies.md"), + ("scripts/iac_code.py", "scripts/iac_code.py"), + ), + bridge_path="scripts/iac_code.py", + user_agent_template="AlibabaCloud-Agent-Skills/alibabacloud-iac-code/{session-id}", + ), + "alibabacloud-ros-agent": SkillProfile( + name="alibabacloud-ros-agent", + source_directory="skills/alicloud-ros-agent", + archive_root="alibabacloud-ros-agent", + title="Alibaba Cloud ROS Agent", + requires_runtime=False, + agenthub=True, + files=( + ("SKILL.md.template", "SKILL.md"), + ("references/ram-policies.md", "references/ram-policies.md"), + ("requirements-code.txt", "scripts/requirements.txt"), + ("scripts/ros_agent.py", "scripts/ros_agent.py"), + ), + bridge_path="scripts/ros_agent.py", + user_agent_template="AlibabaCloud-Agent-Skills/alibabacloud-ros-agent/{session-id}", + requirements_file="scripts/requirements.txt", + ), +} + +SOURCE_DEFAULTS = ( + PROFILES["iac-code"], + replace( + PROFILES["alibabacloud-ros-agent"], + name="alicloud-ros-agent", + agenthub=False, + user_agent_template="AlibabaCloud-Agent-Skills/alicloud-ros-agent", + requirements_file="requirements-code.txt", + ), +) + + +def skill_profile(name: str) -> SkillProfile: + try: + return PROFILES[str(name)] + except KeyError as error: + raise SystemExit("unsupported Skill profile: {}".format(name)) from error + + +def render_markdown(source: str, profile: SkillProfile) -> str: + flags = {"AGENTHUB": profile.agenthub, "PUBLIC": not profile.agenthub} + + def conditional(match: re.Match[str]) -> str: + return match.group("body") if flags.get(match.group("name"), False) else "" + + rendered = _CONDITIONAL_PATTERN.sub(conditional, source) + rendered = rendered.replace("{{SKILL_NAME}}", profile.name).replace("{{SKILL_TITLE}}", profile.title) + if profile.requirements_file: + rendered = rendered.replace("{{REQUIREMENTS_FILE}}", profile.requirements_file) + unresolved = _PLACEHOLDER_PATTERN.findall(rendered) + if unresolved: + raise SystemExit( + "Skill template contains unresolved placeholders: {}".format(", ".join(sorted(set(unresolved)))) + ) + return rendered.rstrip() + "\n" + + +def _replace_constant(source: str, name: str, value: str) -> str: + pattern = re.compile(r'^{} = "[^"]*"$'.format(re.escape(name)), re.MULTILINE) + updated, count = pattern.subn("{} = {}".format(name, json.dumps(value)), source) + if count != 1: + raise SystemExit("Skill bridge must contain exactly one {} constant".format(name)) + return updated + + +def render_profile(profile_name: str, destination: Path) -> Path: + profile = skill_profile(profile_name) + source_root = ROOT / profile.source_directory + destination.mkdir(parents=True, exist_ok=True) + for source_name, destination_name in profile.files: + source = source_root / source_name + target = destination / destination_name + if not source.is_file() or source.is_symlink(): + raise SystemExit("Skill profile source is missing or invalid: {}".format(source_name)) + target.parent.mkdir(parents=True, exist_ok=True) + if source_name == "SKILL.md.template": + target.write_text( + render_markdown(source.read_text(encoding="utf-8"), profile), + encoding="utf-8", + newline="\n", + ) + else: + shutil.copyfile(source, target) + + bridge = destination / profile.bridge_path + bridge_text = bridge.read_text(encoding="utf-8") + bridge_text = _replace_constant(bridge_text, "SKILL_DISTRIBUTION", "agenthub" if profile.agenthub else "public") + bridge_text = _replace_constant(bridge_text, "SKILL_NAME", profile.name) + bridge_text = _replace_constant(bridge_text, "USER_AGENT_TEMPLATE", profile.user_agent_template) + if profile.requirements_file: + bridge_text = _replace_constant(bridge_text, "REQUIREMENTS_FILE", profile.requirements_file) + ast.parse(bridge_text, filename=str(bridge), feature_version=(3, 8)) + bridge.write_text(bridge_text, encoding="utf-8", newline="\n") + + actual = sorted(path.relative_to(destination).as_posix() for path in destination.rglob("*") if path.is_file()) + if actual != sorted(profile.output_files): + raise SystemExit("Skill profile output contains files outside the profile whitelist") + return destination + + +def sync_source_markdown(*, check: bool) -> None: + for profile in SOURCE_DEFAULTS: + source_root = ROOT / profile.source_directory + template = source_root / "SKILL.md.template" + destination = source_root / "SKILL.md" + rendered = render_markdown(template.read_text(encoding="utf-8"), profile) + if check: + if not destination.is_file() or destination.read_text(encoding="utf-8") != rendered: + raise SystemExit("generated Skill source is stale: {}".format(destination.relative_to(ROOT))) + else: + destination.write_text(rendered, encoding="utf-8", newline="\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + operation = parser.add_mutually_exclusive_group(required=True) + operation.add_argument("--profile", choices=sorted(PROFILES)) + operation.add_argument("--sync-defaults", action="store_true") + operation.add_argument("--check-defaults", action="store_true") + parser.add_argument("--output", type=Path) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.sync_defaults or args.check_defaults: + if args.output is not None: + raise SystemExit("--output is not valid with source synchronization") + sync_source_markdown(check=args.check_defaults) + return 0 + if args.output is None: + raise SystemExit("--output is required with --profile") + render_profile(args.profile, args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/alicloud-ros-agent/SKILL.md b/skills/alicloud-ros-agent/SKILL.md index d8c82edb..9a6acf11 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 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. +Use the bridge at `scripts/ros_agent.py`. Its default code transport uses the Alibaba Cloud Credentials SDK default credential chain and Tea OpenAPI V3 signing to send ROS RPCs directly and consume StartChat SSE incrementally. The bridge never reads credential environment variables or credential values from Profile files itself. A Profile explicitly pinned by local policy is delegated to the Credentials SDK Profile provider and is exclusive: it never falls back to the default chain or another Profile. Credentials exist only inside the SDK-backed 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,13 @@ 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. 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: +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. The Credentials SDK default chain or explicitly pinned Profile provider resolves the effective identity without bridge-level credential parsing. 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`, `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. +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 unpinned code mode, `mode: DefaultCredentialChain` means the Credentials SDK resolved the identity without bridge-level credential parsing. 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. @@ -43,7 +43,7 @@ Add `--aliyun-path ` to `check` or `start` only when the effective credent ## 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: +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 Credentials SDK default chain selects the effective identity, and the temporary loopback manager exits 60 seconds after the last SSE worker and manager request become idle. The file accepts these settings: ```json { @@ -58,13 +58,13 @@ The bridge reads an optional `config.json` beside this `SKILL.md`. If it is abse 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. +- `transport` accepts exactly `code` or `aliyun_cli`. `code` is the default: it delegates identity resolution to the Credentials SDK default chain unless a Profile is explicitly pinned, in which case the SDK Profile provider owns resolution and refresh. 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 or from a pinned Profile to the default chain. - `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 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`. +- `aliyunCLIProfile` is an empty or exact CLI Profile name and defaults to empty. In code mode, empty uses the Credentials SDK default chain. A non-empty value pins that Profile for code transport or local CLI execution 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. @@ -77,7 +77,7 @@ Unknown fields, invalid values, and duplicate modes fail closed. Never edit `con 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. + Pass `--region-id` only when the user explicitly supplied a region. Otherwise the bridge uses the first supported region environment variable, then an explicitly pinned 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: @@ -119,7 +119,7 @@ Present the deployment summary, fenced Mermaid block, and confirmation question ## Optional context and images -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. +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 bridge rejects common credential-bearing keys at any nesting depth; if rejected, remove the sensitive fields and never retry with copied Profile or credential data. 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/SKILL.md.template b/skills/alicloud-ros-agent/SKILL.md.template new file mode 100644 index 00000000..e6a066ca --- /dev/null +++ b/skills/alicloud-ros-agent/SKILL.md.template @@ -0,0 +1,250 @@ +--- +name: {{SKILL_NAME}} +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. +--- + +# {{SKILL_TITLE}} + +Use the bridge at `scripts/ros_agent.py`. Its default code transport uses the Alibaba Cloud Credentials SDK default credential chain and Tea OpenAPI V3 signing to send ROS RPCs directly and consume StartChat SSE incrementally. The bridge never reads credential environment variables or credential values from Profile files itself. A Profile explicitly pinned by local policy is delegated to the Credentials SDK Profile provider and is exclusive: it never falls back to the default chain or another Profile. Credentials exist only inside the SDK-backed 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 + +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_FILE}}` to be installed for the Python interpreter that runs the bridge. The Credentials SDK default chain or explicitly pinned Profile provider resolves the effective identity without bridge-level credential parsing. 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`, `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 unpinned code mode, `mode: DefaultCredentialChain` means the Credentials SDK resolved the identity without bridge-level credential parsing. 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. + +{{#AGENTHUB}}The returned `transport` is installation policy, not an Agent choice. In an AgentHub-managed ephemeral runtime, if `check` returns `sdk_not_installed`, use the same Python interpreter to install only the exact bundled dependencies from `{{REQUIREMENTS_FILE}}` into that ephemeral runtime, without `sudo` or system changes, and then rerun `check` once. This readiness repair does not authorize a transport change, alternative tooling, or credential access. If installation, the repeated `check`, or any other readiness check fails, report the exact error 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.{{/AGENTHUB}}{{#PUBLIC}}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.{{/PUBLIC}} + +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 Credentials SDK default chain selects the effective identity, 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": "" +} +``` + +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 delegates identity resolution to the Credentials SDK default chain unless a Profile is explicitly pinned, in which case the SDK Profile provider owns resolution and refresh. 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 or from a pinned Profile to the default chain. +- `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. In code mode, empty uses the Credentials SDK default chain. A non-empty value pins that Profile for code transport or local CLI execution 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. + +## 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 an explicitly pinned 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 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 + +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 + +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 bridge rejects common credential-bearing keys at any nesting depth; if rejected, remove the sensitive fields and never retry with copied Profile or credential data. 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: + +```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. + +{{#AGENTHUB}} +## Input/output examples + +Input: "Review this ROS template, explain validation errors, and propose a corrected version." + +Expected output: the bridge returns the authoritative ROS Agent response and preserves the same session for follow-up questions, permissions, and final artifacts. + +## Edge cases + +If credentials, endpoint metadata, or permissions are unavailable, stop with the returned actionable error. Reuse the returned session ID for follow-ups and StopChat. If a stream is interrupted, resume the frozen job instead of silently creating a second cloud session. + +## RAM permissions + +Before the first ROS API request, read [references/ram-policies.md](references/ram-policies.md) and verify that +the selected credential has only the exact ROS actions required for the requested conversation and cancellation path. + +## Observability + +All outbound HTTP requests made by this AgentHub Skill carry this `User-Agent` template: + +```text +AlibabaCloud-Agent-Skills/{{SKILL_NAME}}/{session-id} +``` + +- `{{SKILL_NAME}}` is the fixed AgentHub Skill identifier and matches the frontmatter `name`. +- The session ID must be a 32-character lowercase hexadecimal string generated exactly once per session. + It must be reused unchanged for every outbound HTTP request in that session. The bridge reads `SKILL_SESSION_ID` + after validation; if it is absent or invalid, the bridge generates the session ID with `uuid.uuid4().hex` and stores + it for that session. +{{/AGENTHUB}} diff --git a/skills/alicloud-ros-agent/references/ram-policies.md b/skills/alicloud-ros-agent/references/ram-policies.md new file mode 100644 index 00000000..a91242fc --- /dev/null +++ b/skills/alicloud-ros-agent/references/ram-policies.md @@ -0,0 +1,21 @@ +# RAM permissions + +## Required actions + +The selected credential needs only the ROS API actions used by this bridge: + +| Action | Required when | Access | +|---|---|---| +| `ros:StartChat` | Every ROS Agent conversation, continuation, or permission response | Write | +| `ros:StopChat` | The user explicitly cancels an active ROS Agent conversation | Write | + +Do not grant `ros:*` or a product-wide `FullAccess` policy. If the RAM service supports resource-level scoping for +the selected ROS API, restrict `Resource` to the applicable account and region; otherwise use the API's documented +resource scope. The remote ROS Agent's downstream permissions are managed by the service and are not a reason to +broaden the caller credential used by this Skill. + +## Failure handling + +On `Forbidden`, `Forbidden.RAM`, `NoPermission`, or another authorization response, return the sanitized bridge +error and request ID when available. Do not enumerate credentials, switch identities, edit CLI configuration, or +fall back to a direct API or CLI invocation. diff --git a/skills/alicloud-ros-agent/scripts/ros_agent.py b/skills/alicloud-ros-agent/scripts/ros_agent.py index 62ca3bd1..35c81449 100644 --- a/skills/alicloud-ros-agent/scripts/ros_agent.py +++ b/skills/alicloud-ros-agent/scripts/ros_agent.py @@ -60,25 +60,23 @@ 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", - "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", -) +SKILL_DISTRIBUTION = "public" +SKILL_NAME = "alicloud-ros-agent" +USER_AGENT_TEMPLATE = "AlibabaCloud-Agent-Skills/alicloud-ros-agent" +REQUIREMENTS_FILE = "requirements-code.txt" + + +def _skill_user_agent() -> str: + if SKILL_DISTRIBUTION != "agenthub": + return USER_AGENT_TEMPLATE + value = os.environ.get("SKILL_SESSION_ID", "").strip().lower() + if re.fullmatch(r"[0-9a-f]{32}", value) is None: + value = uuid.uuid4().hex + os.environ["SKILL_SESSION_ID"] = value + return USER_AGENT_TEMPLATE.replace("{session-id}", value) + + +USER_AGENT = _skill_user_agent() PROFILE_ENV_NAMES = ( "ALIBABACLOUD_PROFILE", "ALIBABA_CLOUD_PROFILE", @@ -129,6 +127,17 @@ r"(?i)((?:[\"']?)(?:access[-_ ]?key(?:[-_ ]?id|[-_ ]?secret)?|security[-_ ]?token|signature|" r"authorization)(?:[\"']?)\s*[:=]\s*(?:[\"']?)(?:bearer\s+)?)([^\"'\s,;&}]+)" ) +SENSITIVE_CLIENT_CONTEXT_KEY_PARTS = ( + "accesskey", + "authorization", + "cookie", + "credential", + "password", + "profile", + "secret", + "signature", + "token", +) class BridgeError(Exception): @@ -519,6 +528,15 @@ def _workspace(raw_path: Optional[str] = None) -> pathlib.Path: return path +def _trusted_manager_workspace(raw_path: str) -> pathlib.Path: + """Resolve a manager workspace under the same user-owned roots as the CLI.""" + + path = _resolve_user_owned_path(raw_path, "invalid_input", "The workspace") + 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: workspace_path = os.path.normcase(os.path.realpath(str(workspace))) resolved_path = os.path.normcase(os.path.realpath(os.path.expanduser(raw_path))) @@ -552,12 +570,28 @@ def _load_json_file(workspace: pathlib.Path, raw_path: str, maximum: int, label: raise BridgeError("invalid_input", "{} must contain valid JSON.".format(label)) from exc +def _contains_sensitive_client_context_key(value: Any) -> bool: + if isinstance(value, dict): + for key, item in value.items(): + normalized = re.sub(r"[^a-z0-9]", "", str(key).lower()) + if any(part in normalized for part in SENSITIVE_CLIENT_CONTEXT_KEY_PARTS): + return True + if _contains_sensitive_client_context_key(item): + return True + return False + if isinstance(value, list): + return any(_contains_sensitive_client_context_key(item) for item in value) + return False + + 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.") + if _contains_sensitive_client_context_key(value): + raise BridgeError("invalid_input", "The client context must not contain credential or secret fields.") 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.") @@ -780,6 +814,7 @@ def build_stop_command(job: Dict[str, Any], session_id: str) -> List[str]: def _load_code_sdk() -> Dict[str, Any]: try: return { + "CredentialClient": getattr(importlib.import_module("alibabacloud_credentials.client"), "Client"), "CLIProfileCredentialsProvider": getattr( importlib.import_module("alibabacloud_credentials.provider.cli_profile"), "CLIProfileCredentialsProvider", @@ -791,8 +826,8 @@ def _load_code_sdk() -> Dict[str, Any]: 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.", + "The configured code transport requires the packages listed in {} for the Python interpreter running " + "this bridge. Do not switch transports; install them and run check again.".format(REQUIREMENTS_FILE), ) from exc @@ -804,20 +839,6 @@ def _first_nonempty_env(names: Tuple[str, ...]) -> Optional[str]: 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): @@ -919,11 +940,6 @@ def _selected_cli_profile_record(profile: Optional[str]) -> Dict[str, Any]: 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: if ( args.transport == "aliyun_cli" @@ -932,17 +948,14 @@ def _resolve_start_identity(args: argparse.Namespace) -> None: 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): - environment = _environment_credentials() - if args.transport == "aliyun_cli" or environment is None: + args.profile = None + args.credential_source = None + else: 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() @@ -952,80 +965,6 @@ def _resolve_start_identity(args: argparse.Namespace) -> None: 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, @@ -1033,32 +972,20 @@ def _code_credentials( region_id: Optional[str], credential_source: Optional[str] = None, ) -> Tuple[str, str, Optional[str]]: - if credential_source not in {None, "environment", "profile"}: + if credential_source not in {None, "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 + if credential_source == "profile": + selected = _selected_cli_profile_record(profile) + provider = sdk["CLIProfileCredentialsProvider"](profile_name=selected["name"]) + client = sdk["CredentialClient"](provider=provider) 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") + client = sdk["CredentialClient"]() + credential = client.get_credential() + access_key_id = credential.access_key_id + access_key_secret = credential.access_key_secret + security_token = credential.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 @@ -2860,7 +2787,7 @@ def _format_conclusion(summary: Any, language: str) -> str: if requirement: parts.append(requirement) if region: - parts.append(("地域 " if language == "zh" else "region ") + region) + parts.append(("\u5730\u57df " if language == "zh" else "region ") + region) resources = summary.get("resources") if isinstance(resources, list): names = [] @@ -2870,13 +2797,16 @@ def _format_conclusion(summary: Any, language: str) -> str: 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 - ) + action = { + "create": "\u65b0\u5efa", + "use_existing": "\u590d\u7528", + "reference": "\u5f15\u7528", + "forbid": "\u7981\u6b62", + }.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)) + parts.append(("\u8d44\u6e90 " if language == "zh" else "resources ") + "\u3001".join(names)) candidates = summary.get("candidates") if isinstance(candidates, list): names = [] @@ -2889,9 +2819,13 @@ def _format_conclusion(summary: Any, language: str) -> str: 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) + prefix = ( + "{} \u4e2a\u5019\u9009\u65b9\u6848 ".format(count) + if language == "zh" + else "{} candidates ".format(count) + ) + parts.append(prefix + "\u3001".join(names)) + return sanitize_text(("\uff1b" if language == "zh" else "; ").join(parts), 520) def _format_user_update(milestone: Dict[str, Any], language: str) -> str: @@ -2899,12 +2833,12 @@ def _format_user_update(milestone: Dict[str, Any], language: str) -> str: 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": "候选步骤失败", + "step_started": "\u6b65\u9aa4\u5f00\u59cb", + "step_completed": "\u6b65\u9aa4\u5b8c\u6210", + "step_failed": "\u6b65\u9aa4\u5931\u8d25", + "candidate_step_started": "\u5019\u9009\u6b65\u9aa4\u5f00\u59cb", + "candidate_step_completed": "\u5019\u9009\u6b65\u9aa4\u5b8c\u6210", + "candidate_step_failed": "\u5019\u9009\u6b65\u9aa4\u5931\u8d25", }, "en": { "step_started": "Step started", @@ -2916,10 +2850,14 @@ def _format_user_update(milestone: Dict[str, Any], language: str) -> str: }, } label = labels.get(language, labels["en"]).get(str(event_type), sanitize_text(str(event_type), 80)) - separator = ":" if language == "zh" else ": " + separator = "\uff1a" if language == "zh" else ": " conclusion = _format_conclusion(milestone.get("conclusionSummary"), language) if conclusion: - detail = "{}{}{}".format(detail, ";结论:" if language == "zh" else "; conclusion: ", conclusion) + detail = "{}{}{}".format( + detail, + "\uff1b\u7ed3\u8bba\uff1a" if language == "zh" else "; conclusion: ", + conclusion, + ) return sanitize_text(label + (separator + detail if detail else ""), 720) @@ -3041,7 +2979,7 @@ def _job_result( 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) + "ROS Agent \u4ecd\u5728\u5904\u7406\u4e2d\uff08{} \u79d2\uff09\u3002".format(elapsed) if result["preferredLanguage"] == "zh" else "ROS Agent is still working ({}s).".format(elapsed) ) @@ -3370,7 +3308,7 @@ def _request_from_job(job: Dict[str, Any], prompt: str) -> Dict[str, Any]: def _start_job_local(payload: Dict[str, Any]) -> Dict[str, Any]: - workspace = _workspace(str(payload.get("workspace") or "")) + workspace = _trusted_manager_workspace(str(payload.get("workspace") or "")) prompt = payload.get("prompt") mode = payload.get("mode") endpoint = payload.get("endpoint") @@ -3697,7 +3635,7 @@ def _run_stop_chat(job: Dict[str, Any], session_id: str) -> Dict[str, Any]: 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 + job.get("credentialSource") if job.get("credentialSource") == "profile" else None ), error_code="stop_chat_failed", ) @@ -3844,7 +3782,7 @@ def fail_worker(error: BridgeError) -> None: if not isinstance(prompt, str): fail_worker(BridgeError("invalid_input", "The worker prompt is invalid.")) return 1 - workspace = _workspace(str(request.get("workspace") or "")) + workspace = _trusted_manager_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 @@ -4253,12 +4191,9 @@ 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() plugin_status = None # type: Optional[Dict[str, Any]] plugin_auto_install = None # type: Optional[bool] @@ -4267,9 +4202,24 @@ def run_check(args: argparse.Namespace) -> Dict[str, Any]: 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" + elif args.transport == "code" and not args.profile_pinned: + assert sdk is not None + region_id = _environment_region() or "cn-hangzhou" + try: + _code_credentials(sdk, args.aliyun_path, None, region_id, None) + except BridgeError: + raise + except Exception as exc: + raise BridgeError( + "credential_failed", + "Alibaba Cloud SDK default credential chain could not resolve credentials.", + True, + ) from exc + current_profile = { + "configured": True, + "mode": "DefaultCredentialChain", + "regionId": region_id, + } # type: Dict[str, Any] cli = None version = None else: diff --git a/skills/iac-code/SKILL.md b/skills/iac-code/SKILL.md index c01ca6d0..674c1530 100644 --- a/skills/iac-code/SKILL.md +++ b/skills/iac-code/SKILL.md @@ -1,12 +1,18 @@ --- name: iac-code -description: Use the packaged iac-code agent for Alibaba Cloud infrastructure tasks, including designing, provisioning, changing, or deploying resources; generating, reviewing, converting, validating, or troubleshooting ROS and Terraform templates; selecting existing cloud resources; estimating costs; operating ROS stacks; and inspecting or explicitly cleaning downloaded iac-code Skill Runtime caches. Trigger for Alibaba Cloud infrastructure work even when the user does not mention iac-code, ROS, Terraform, or this Skill, and for requests to inspect or clean the iac-code Runtime cache. Do not trigger for general Alibaba Cloud questions or unrelated application code. Run through the local authenticated A2A runtime without pip or headless mode. +description: Use the packaged iac-code agent for Alibaba Cloud infrastructure tasks, including designing, provisioning, changing, or deploying resources; generating, reviewing, converting, validating, or troubleshooting ROS and Terraform templates; selecting existing cloud resources; estimating costs; operating ROS stacks; and inspecting or explicitly cleaning downloaded iac-code Skill Runtime caches. Trigger for Alibaba Cloud infrastructure work even when the user does not mention iac-code, ROS, Terraform, or this Skill, and for requests to inspect or clean the iac-code Runtime cache. Do not trigger for general Alibaba Cloud questions or unrelated application code. For matched requests, invoke the packaged bridge before any alternative tool and fail closed on bridge errors. Run through the local authenticated A2A runtime without pip or headless mode. --- # iac-code Use the single standard-library entry point at `scripts/iac_code.py`. Never install `iac-code` with pip and never invoke a headless command. Run every command below with `python3` on macOS/Linux. On Windows, replace `python3` with `py -3`; use `python` only after confirming it is CPython 3.8–3.14. Resolve the launcher once and reuse it for the whole job. +## Mandatory routing and fail-closed behavior + +For every infrastructure request covered by this Skill, the first operational command must invoke the packaged bridge with `scripts/iac_code.py start`. Do not inspect the bridge source, reconstruct its behavior, write a replacement script, call Alibaba Cloud APIs directly, or install an alternative CLI or runtime before that invocation. Runtime-cache requests are the only exception: their first operational command must be `scripts/iac_code.py cache list`. + +Treat a bridge error returned before job creation as the authoritative outcome for that invocation. In particular, when the bridge returns `incompatible_host`, report its error code, message, retryability, and any available host/runtime-baseline facts, then stop. Do not install Terraform, pip packages, another Runtime, or other substitute tools; do not bypass the bridge with direct cloud calls; do not ask for deployment inputs; and do not continue the infrastructure workflow or claim success. A later attempt is allowed only after the host compatibility problem has actually been corrected. + ## Workflow 1. Put the complete user request in a UTF-8 prompt file inside the workspace. diff --git a/skills/iac-code/SKILL.md.template b/skills/iac-code/SKILL.md.template new file mode 100644 index 00000000..fbaab4eb --- /dev/null +++ b/skills/iac-code/SKILL.md.template @@ -0,0 +1,160 @@ +--- +name: {{SKILL_NAME}} +description: Use the packaged iac-code agent for Alibaba Cloud infrastructure tasks, including designing, provisioning, changing, or deploying resources; generating, reviewing, converting, validating, or troubleshooting ROS and Terraform templates; selecting existing cloud resources; estimating costs; operating ROS stacks; and inspecting or explicitly cleaning downloaded iac-code Skill Runtime caches. Trigger for Alibaba Cloud infrastructure work even when the user does not mention iac-code, ROS, Terraform, or this Skill, and for requests to inspect or clean the iac-code Runtime cache. Do not trigger for general Alibaba Cloud questions or unrelated application code. For matched requests, invoke the packaged bridge before any alternative tool and fail closed on bridge errors. Run through the local authenticated A2A runtime without pip or headless mode. +--- + +# {{SKILL_TITLE}} + +Use the single standard-library entry point at `scripts/iac_code.py`. Never install `iac-code` with pip and never invoke a headless command. Run every command below with `python3` on macOS/Linux. On Windows, replace `python3` with `py -3`; use `python` only after confirming it is CPython 3.8–3.14. Resolve the launcher once and reuse it for the whole job. + +## Mandatory routing and fail-closed behavior + +For every infrastructure request covered by this Skill, the first operational command must invoke the packaged bridge with `scripts/iac_code.py start`. Do not inspect the bridge source, reconstruct its behavior, write a replacement script, call Alibaba Cloud APIs directly, or install an alternative CLI or runtime before that invocation. Runtime-cache requests are the only exception: their first operational command must be `scripts/iac_code.py cache list`. + +Treat a bridge error returned before job creation as the authoritative outcome for that invocation. In particular, when the bridge returns `incompatible_host`, report its error code, message, retryability, and any available host/runtime-baseline facts, then stop. Do not install Terraform, pip packages, another Runtime, or other substitute tools; do not bypass the bridge with direct cloud calls; do not ask for deployment inputs; and do not continue the infrastructure workflow or claim success. A later attempt is allowed only after the host compatibility problem has actually been corrected. + +## Workflow + +1. Put the complete user request in a UTF-8 prompt file inside the workspace. +2. Start a job with an explicit absolute workspace: + + ```text + python3 scripts/iac_code.py start --mode normal --cwd --prompt-file --language --follow + ``` + + 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`: + + ```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. +3. `--follow` consumes the event stream until the next parent/candidate step boundary, permission, user question, candidate selection, `turn_completed`, or terminal state. It writes every parent `step_started`/`step_completed`/`step_failed` and candidate `candidate_step_started`/`candidate_step_completed`/`candidate_step_failed` boundary plus low-frequency bounded heartbeats to stderr; stdout contains one bounded JSON result. A boundary result sets `boundaryReached: true`, `presentationRequired: true`, and provides ready-to-display localized strings in `userUpdates`. Before invoking another tool, emit every `userUpdates` string in a user-visible assistant text block, including the Step 1/2 conclusion already embedded in completed-step updates. Never leave these updates only in reasoning, Bash output, a tool description, or the final summary. After that visible text block, immediately call `follow` again with the returned cursor. Do not treat `boundaryReached` as completion. Do not expand this into raw tool-event or token-delta output. + While it is running, do not independently answer the infrastructure task or ask a parallel business question. Only ask the user when the current result contains `inputRequired`. +4. If follow reaches its bounded wait window, call the diagnostic follow command again with the returned cursor: + + ```text + python3 scripts/iac_code.py follow --job-id --cursor --wait-seconds 60 + ``` + + The recommended wait is 60 seconds and the bridge enforces a 120-second maximum even if a larger value is supplied. + +5. When `state` is `turn_completed`, treat `finalText` and `artifacts` as the authoritative normal-turn result. When a Pipeline reaches any terminal state, including `completed`, `failed`, `canceled`, or `rejected`, treat `pipelineResult` and `artifacts` as its authoritative result and present its success or failure details directly. If rollback cleanup is pending, the bridge automatically runs a cleanup-only normal task in the same context before returning the Pipeline result; keep following it and handle any returned permission normally. If cleanup is `failed` or `unavailable`, report that manual inspection or retry is required and do not claim it succeeded. Never send a synthetic cleanup prompt or a follow-up merely to retrieve or summarize an existing result. Never recover an answer from Session files, spool files, logs, or raw tool-result files. +6. To send the next natural-language message in the same normal conversation, or after a completed Pipeline has handed the same conversation to normal mode, write it to another workspace prompt file and continue the existing job: + + ```text + python3 scripts/iac_code.py continue --job-id --prompt-file --follow + ``` + + Keep the same `jobId` and `contextId`. A new `taskId` per normal turn is expected. Never call `start --mode normal` to continue a completed Pipeline, and never call `start` merely because a normal turn completed. + +Use `poll` only for diagnosis or recovery when follow cannot be used: + +```text +python3 scripts/iac_code.py poll --job-id --cursor --wait-seconds 5 +``` + +## User input + +When `inputRequired` is present, preserve every correlation field in the response. Never reuse an answer file from another request. + +- For `permission`, apply the outer Agent's own equivalent permission policy. If the same operation would proceed without asking when invoked directly by the outer Agent, respond `allow_once`; if that policy would deny, respond `deny`; otherwise ask the user. iac-code has already applied its own allow/deny rules, and the outer Agent must not override an iac-code denial. Base the decision on `title`, `purpose`, `effect`, `target`, `isReadOnly`, `deploymentSummary`, and `safeSummary`; do not infer safety from the internal `toolName` alone. When asking about deployment, show the provided plan, region, stack, template, total price, and per-resource prices without exposing raw tool input. +- For `ask_user_question`, present the current prompt and options without inventing a second question. Accept a listed option. Accept free text only when `allowFreeText` is `true`; when present, show `freeTextPrompt` with the input. +- For `candidate_selection`, first present every option's `summary`, render `architectureDiagram` as Mermaid when present, and show `totalMonthlyCost` plus `costItems`. Do not invent missing details or replace these prices with a rough estimate. Then return the selected candidate ID/index requested by the envelope. +- Bind every user answer only to the current `kind`, `inputId`, `requestTaskId`, and `contextId`. Never reinterpret a resource selection as deployment confirmation or reuse it for a later input. + +For an automatically decided permission, respond in one tool call while preserving the current input and tool identities: + +```text +python3 scripts/iac_code.py respond --job-id --input-id --tool-use-id --decision allow_once --follow +``` + +Use `deny` when the outer Agent's policy denies it. For a user question, candidate selection, or permission that was explicitly shown to the user, write the correlated answer as JSON to a UTF-8 file and resume the same job: + +- Permission: `{"kind":"permission","requestTaskId":"","contextId":"","inputId":"","toolUseId":"","decision":"allow_once"}` or use `deny`. +- Question: `{"kind":"ask_user_question","requestTaskId":"","contextId":"","inputId":"","answer":""}`. +- Candidate: `{"kind":"candidate_selection","requestTaskId":"","contextId":"","inputId":"","answer":""}`. + +```text +python3 scripts/iac_code.py respond --job-id --input-file --follow +``` + +If the user cancels the whole operation, call: + +```text +python3 scripts/iac_code.py cancel --job-id +``` + +Do not turn task cancellation into a permission denial. + +## Runtime cache maintenance + +Only inspect or clean downloaded Runtime packages when the user explicitly asks about iac-code Skill Runtime storage or cleanup. This does not require starting an A2A job. + +First list the installed packages and show each Runtime tag, target, size, and whether it is current or active, plus the total size: + +```text +python3 scripts/iac_code.py cache list +``` + +Before deleting anything, show what will be removed and obtain explicit user confirmation. Then clean either one listed tag or historical Candidate packages: + +```text +python3 scripts/iac_code.py cache clean --runtime-tag --confirm +python3 scripts/iac_code.py cache clean --candidates --confirm +``` + +The current pinned Runtime and packages used by a live A2A process are protected and reported under `skipped`. Never treat an ordinary infrastructure request as cleanup consent. These commands remove only downloaded Runtime packages; they do not remove sessions, jobs, server state, artifacts, credentials, or user configuration. + +## Output discipline + +- Treat the script's stdout as its stable JSON protocol; diagnostics and cold-install progress are written to stderr. +- Keep only the current job identity, newest cursor, current input envelope, and authoritative boundary result in working context. Follow and poll outputs are bounded, redacted projections. +- Treat live step-boundary records as transient user-visible progress. Show them when received, but do not copy the full history back into later prompts or repeat all of it in the final answer. +- Use `latestText` only as running progress. Use `pipelineResult` from a terminal Pipeline as its success or failure result. Only `finalText` from a `turn_completed` result or a returned result artifact is a normal-turn answer. +- Do not expose runtime tokens, local state files, credentials, environment values, or raw tool inputs/results. +- If an error code is returned, report the concise message and suggested retry. Do not fall back to pip installation or another ABI artifact. + +{{#AGENTHUB}} +## Input/output examples + +Input: "Create and validate a ROS template for a VPC and two subnets in cn-hangzhou." + +Expected output: the bridge returns the authoritative iac-code result, including the generated or validated template, progress boundaries, any permission request, and actionable errors without inventing cloud state. + +## Edge cases + +If the packaged Runtime cannot be downloaded or verified, stop and report the verification error; never install an unverified fallback. If credentials are incomplete, report the preflight result and do not claim that cloud operations ran. Continue an existing job with its job ID instead of starting a replacement job. + +## RAM permissions + +Before a task that reads or changes Alibaba Cloud resources, read +[references/ram-policies.md](references/ram-policies.md). Grant only the exact actions required by the selected +workflow; template-only and Runtime-cache workflows require no Alibaba Cloud RAM permission. + +## Observability + +All outbound HTTP requests made by this AgentHub Skill carry this `User-Agent` template: + +```text +AlibabaCloud-Agent-Skills/{{SKILL_NAME}}/{session-id} +``` + +- `{{SKILL_NAME}}` is the fixed AgentHub Skill identifier and matches the frontmatter `name`. +- The session ID must be a 32-character lowercase hexadecimal string generated exactly once per session. + It must be reused unchanged for every outbound HTTP request in that session. The bridge reads `SKILL_SESSION_ID` + after validation; if it is absent or invalid, the bridge generates the session ID with `uuid.uuid4().hex` and stores + it for that session. +{{/AGENTHUB}} diff --git a/skills/iac-code/agents/openai.yaml b/skills/iac-code/agents/openai.yaml index 2780f155..9e9a023f 100644 --- a/skills/iac-code/agents/openai.yaml +++ b/skills/iac-code/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "iac-code" short_description: "Design and operate Alibaba Cloud infrastructure" - default_prompt: "Use $iac-code for this Alibaba Cloud infrastructure task and follow one job through real interaction boundaries. Default to normal; use selling only when explicitly requested or when candidate architectures, cost comparison, plan confirmation, and deployment are all needed." + default_prompt: "Use $iac-code for this Alibaba Cloud infrastructure task and follow one job through real interaction boundaries. Invoke the packaged bridge before any alternative tool and fail closed if the bridge rejects the host. Default to normal; use selling only when explicitly requested or when candidate architectures, cost comparison, plan confirmation, and deployment are all needed." diff --git a/skills/iac-code/references/ram-policies.md b/skills/iac-code/references/ram-policies.md new file mode 100644 index 00000000..e9ae939d --- /dev/null +++ b/skills/iac-code/references/ram-policies.md @@ -0,0 +1,34 @@ +# RAM permissions + +## Permission model + +The packaged bridge and Runtime-cache commands do not call Alibaba Cloud APIs and require no RAM permission. +Infrastructure jobs run through the authenticated iac-code Runtime. Grant the Runtime credential only the exact +Alibaba Cloud actions needed by the user's approved task. Template generation, conversion, and offline validation +also require no cloud permission. + +Do not attach a product-wide `FullAccess` policy or use an action wildcard. Read-only discovery should use exact +`Describe`, `List`, or `Get` actions. Create, update, and delete actions must be added only after the corresponding +plan and cleanup scope have been approved. Where an API supports resource-level authorization, scope `Resource` to +the target account, region, stack, or resource ARN rather than all resources. + +## Common workflow actions + +These are workflow-specific examples, not one policy to grant in full. + +| Workflow | Read actions | Write actions when explicitly approved | +|---|---|---| +| Caller preflight | `sts:GetCallerIdentity` | None | +| ROS template and stack operations | `ros:ValidateTemplate`, `ros:GetTemplateParameterConstraints`, `ros:PreviewStack`, `ros:GetStack`, `ros:ListStackResources` | `ros:CreateStack`, `ros:UpdateStack`, `ros:DeleteStack` | +| VPC and vSwitch operations | `vpc:DescribeVpcs`, `vpc:DescribeVSwitches`, `vpc:DescribeVpcAttribute`, `vpc:DescribeVSwitchAttributes` | `vpc:CreateVpc`, `vpc:CreateVSwitch`, `vpc:DeleteVSwitch`, `vpc:DeleteVpc` | +| Security-group operations | `ecs:DescribeZones`, `ecs:DescribeSecurityGroups`, `ecs:DescribeSecurityGroupAttribute` | `ecs:CreateSecurityGroup`, `ecs:AuthorizeSecurityGroup`, `ecs:AuthorizeSecurityGroupEgress`, `ecs:DeleteSecurityGroup` | + +Other resource types require their own exact product/action pairs. Never infer that the actions above authorize ECS +instances, databases, public IP addresses, gateways, load balancers, disks, or any other unrelated resource. + +## Failure handling + +If the Runtime returns `Forbidden`, `Forbidden.RAM`, `NoPermission`, or a similar authorization error, report the +exact denied action and request ID when available. Do not retry with broader credentials, print credentials, or +bypass the bridge with a direct CLI call. Ask for the narrow missing action only when continuing the approved task +requires it. diff --git a/skills/iac-code/scripts/iac_code.py b/skills/iac-code/scripts/iac_code.py index 9d4dcf1a..61d05275 100644 --- a/skills/iac-code/scripts/iac_code.py +++ b/skills/iac-code/scripts/iac_code.py @@ -34,6 +34,9 @@ RUNTIME_TAG = "v0.12.0" IAC_CODE_VERSION = "0.12.0" RUNTIME_PYTHON = "cp312" +SKILL_DISTRIBUTION = "public" +SKILL_NAME = "iac-code" +USER_AGENT_TEMPLATE = "iac-code-skill/1" MANIFEST_URL = "https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/iac-code/skill-runtime/releases/v0.12.0/runtime-manifest.json" # Replaced in a temporary staging directory by skill-runtime/package_skill.py. MANIFEST_SHA256 = "0000000000000000000000000000000000000000000000000000000000000000" @@ -104,6 +107,19 @@ ) +def _skill_user_agent(): + if SKILL_DISTRIBUTION != "agenthub": + return USER_AGENT_TEMPLATE + value = os.environ.get("SKILL_SESSION_ID", "").strip().lower() + if re.fullmatch(r"[0-9a-f]{32}", value) is None: + value = uuid.uuid4().hex + os.environ["SKILL_SESSION_ID"] = value + return USER_AGENT_TEMPLATE.replace("{session-id}", value) + + +_SKILL_USER_AGENT = _skill_user_agent() + + class BridgeError(Exception): def __init__(self, code, message, retryable=False, details=None): Exception.__init__(self, message) @@ -448,7 +464,7 @@ def __exit__(self, _type, _value, _traceback): def _download(url, destination, maximum, expected_size=None): - request = urllib.request.Request(url, headers={"User-Agent": "iac-code-skill/1"}) + request = urllib.request.Request(url, headers={"User-Agent": _SKILL_USER_AGENT}) downloaded = 0 try: with urllib.request.urlopen(request, timeout=30) as response, destination.open("wb") as output: @@ -825,7 +841,7 @@ def _pid_alive(pid): def _http_json(url, token=None, method="GET", payload=None, timeout=10): data = _json_bytes(payload) if payload is not None else None - headers = {"Accept": "application/json", "A2A-Version": "1.0"} + headers = {"Accept": "application/json", "A2A-Version": "1.0", "User-Agent": _SKILL_USER_AGENT} if payload is not None: headers["Content-Type"] = "application/json" if token: @@ -887,7 +903,8 @@ def _runtime_configuration_readiness(record, require_cloud): readiness["cloud"]["requiredForStart"] = bool(require_cloud) if not readiness["llm"]["ready"]: message = ( - "iac-code 的 LLM 配置不完整,请先在 iac-code 中配置模型提供商和 API Key。" + "iac-code \u7684 LLM \u914d\u7f6e\u4e0d\u5b8c\u6574\uff0c" + "\u8bf7\u5148\u5728 iac-code \u4e2d\u914d\u7f6e\u6a21\u578b\u63d0\u4f9b\u5546\u548c API Key\u3002" if _ACTIVE_LANGUAGE == "zh" else "iac-code's LLM configuration is incomplete. Configure its model provider and API key first." ) @@ -899,7 +916,8 @@ def _runtime_configuration_readiness(record, require_cloud): ) if require_cloud and not readiness["cloud"]["ready"]: message = ( - "该 Pipeline 需要阿里云凭证,请先在 iac-code 中完成云凭证配置。" + "\u8be5 Pipeline \u9700\u8981\u963f\u91cc\u4e91\u51ed\u8bc1\uff0c" + "\u8bf7\u5148\u5728 iac-code \u4e2d\u5b8c\u6210\u4e91\u51ed\u8bc1\u914d\u7f6e\u3002" if _ACTIVE_LANGUAGE == "zh" else "This Pipeline requires Alibaba Cloud credentials. Configure them in iac-code first." ) @@ -2205,6 +2223,7 @@ def _stream_jsonrpc(record, payload): "Content-Type": "application/json", "Accept": "text/event-stream", "A2A-Version": "1.0", + "User-Agent": _SKILL_USER_AGENT, "Authorization": "Bearer " + record["token"], }, method="POST", @@ -2909,7 +2928,7 @@ def _job_result( ): elapsed = max(0, int(time.time()) - int(job.get("turnStartedAt", job.get("createdAt", time.time())))) result["heartbeat"] = ( - "iac-code 仍在处理中({} 秒)。".format(elapsed) + "iac-code \u4ecd\u5728\u5904\u7406\u4e2d\uff08{} \u79d2\uff09\u3002".format(elapsed) if _ACTIVE_LANGUAGE == "zh" else "iac-code is still working ({}s).".format(elapsed) ) diff --git a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py index 35c73950..f0cdcb7a 100644 --- a/tests/skill_bridge/test_alicloud_ros_agent_bridge.py +++ b/tests/skill_bridge/test_alicloud_ros_agent_bridge.py @@ -46,7 +46,20 @@ def _write_fake_aliyun(tmp_path: Path, source: str) -> Path: 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: + for name in ( + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABACLOUD_ACCESS_KEY_ID", + "ALICLOUD_ACCESS_KEY_ID", + "ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + "ALIBABACLOUD_ACCESS_KEY_SECRET", + "ALICLOUD_ACCESS_KEY_SECRET", + "ACCESS_KEY_SECRET", + "ALIBABA_CLOUD_SECURITY_TOKEN", + "ALIBABACLOUD_SECURITY_TOKEN", + "ALICLOUD_SECURITY_TOKEN", + "SECURITY_TOKEN", + ): monkeypatch.delenv(name, raising=False) @@ -405,7 +418,7 @@ def fake_run_start(args): 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: +def test_managed_start_persists_default_chain_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") @@ -429,23 +442,18 @@ def fake_manager_request(record, path, payload, timeout): assert result["ok"] is True assert captured["payload"]["profile"] is None - assert captured["payload"]["credentialSource"] == "environment" + assert captured["payload"]["credentialSource"] is None 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: +def test_check_returns_safe_default_chain_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", - }, + lambda _profile: pytest.fail("the default credential chain must not inspect CLI Profiles"), ) monkeypatch.setattr(bridge, "_load_code_sdk", lambda: {"sdk": True}) monkeypatch.setattr( @@ -480,20 +488,14 @@ def test_check_returns_safe_current_profile_and_effective_skill_policy(monkeypat "managerIdleSeconds": bridge.MANAGER_IDLE_SECONDS, "enableThinking": True, "aliyunCLIProfile": "", - "currentProfile": { - "configured": True, - "name": "test-profile", - "mode": "OAuth", - "language": "zh", - "regionId": "cn-hangzhou", - }, + "currentProfile": {"configured": True, "mode": "DefaultCredentialChain", "regionId": "cn-hangzhou"}, } assert captured == { "sdk": {"sdk": True}, "aliyunPath": "aliyun", - "profile": "test-profile", + "profile": None, "regionId": "cn-hangzhou", - "credentialSource": "profile", + "credentialSource": None, } @@ -508,21 +510,34 @@ def test_check_rejects_an_unavailable_selected_profile(monkeypatch) -> None: ), ) args = argparse.Namespace(command="check", aliyun_path="aliyun") - bridge.apply_skill_config(args, {}) + bridge.apply_skill_config(args, {"aliyunCLIProfile": "missing-profile"}) 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: +def test_code_check_delegates_identity_resolution_to_default_credential_chain(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: {}) + monkeypatch.setattr(bridge, "resolve_aliyun", lambda _path: pytest.fail("default chain must not require CLI")) + captured = [] + + class FakeCredentialClient: + def __init__(self): + captured.append("constructed-without-config") + + def get_credential(self): + return SimpleNamespace( + access_key_id="fake-chain-ak", + access_key_secret="fake-chain-secret", + security_token="fake-chain-token", + ) + + monkeypatch.setattr(bridge, "_load_code_sdk", lambda: {"CredentialClient": FakeCredentialClient}) args = argparse.Namespace(command="check", aliyun_path="aliyun") bridge.apply_skill_config(args, {}) @@ -530,32 +545,31 @@ def test_code_check_prefers_cli_compatible_environment_credentials(monkeypatch) assert result["currentProfile"] == { "configured": True, - "mode": "Environment", + "mode": "DefaultCredentialChain", "regionId": "cn-shanghai", } + assert captured == ["constructed-without-config"] 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_bridge_does_not_parse_credential_environment_variables() -> None: + source = BRIDGE_PATH.read_text(encoding="utf-8") + for name in ( + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABACLOUD_ACCESS_KEY_ID", + "ALICLOUD_ACCESS_KEY_ID", + "ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + "ALIBABACLOUD_ACCESS_KEY_SECRET", + "ALICLOUD_ACCESS_KEY_SECRET", + "ACCESS_KEY_SECRET", + ): + assert name not in source -def test_code_start_identity_uses_environment_region_without_requiring_cli(monkeypatch) -> None: +def test_code_start_identity_uses_environment_region_without_inspecting_credentials_or_cli(monkeypatch) -> None: _clear_code_credential_env(monkeypatch) _clear_region_and_profile_env(monkeypatch) monkeypatch.setenv("ALIBABACLOUD_ACCESS_KEY_ID", "fake-env-ak") @@ -564,7 +578,7 @@ def test_code_start_identity_uses_environment_region_without_requiring_cli(monke monkeypatch.setattr( bridge, "_selected_cli_profile_record", - lambda _profile: pytest.fail("environment credentials must not require a CLI Profile"), + lambda _profile: pytest.fail("default credentials must not require a CLI Profile"), ) args = SimpleNamespace( transport="code", @@ -576,11 +590,11 @@ def test_code_start_identity_uses_environment_region_without_requiring_cli(monke bridge._resolve_start_identity(args) assert args.profile is None - assert args.credential_source == "environment" + assert args.credential_source is None assert args.region_id == "cn-shanghai" -def test_code_start_identity_defaults_environment_region_to_hangzhou(monkeypatch) -> None: +def test_code_start_identity_defaults_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") @@ -594,7 +608,7 @@ def test_code_start_identity_defaults_environment_region_to_hangzhou(monkeypatch bridge._resolve_start_identity(args) - assert args.credential_source == "environment" + assert args.credential_source is None assert args.region_id == "cn-hangzhou" @@ -867,6 +881,25 @@ def test_workspace_json_inputs_validate_context_and_flatten_attachments(tmp_path ] +def test_client_context_rejects_sensitive_keys_at_any_depth(tmp_path: Path) -> None: + context = tmp_path / "context.json" + context.write_text( + json.dumps( + { + "region": "cn-hangzhou", + "page": {"metadata": [{"AccessKeyId": "must-not-be-sent"}]}, + } + ), + encoding="utf-8", + ) + + with pytest.raises(bridge.BridgeError) as error: + bridge.load_client_context(tmp_path, str(context)) + + assert error.value.code == "invalid_input" + assert "credential or secret fields" in error.value.message + + def test_permission_query_projects_only_correlated_control_fields(tmp_path: Path) -> None: permission_file = tmp_path / "permission.json" permission_file.write_text( @@ -950,6 +983,21 @@ def test_prompt_file_must_be_utf8_nonempty_and_inside_workspace(tmp_path: Path) bridge.read_prompt(workspace, str(empty)) +def test_authenticated_manager_accepts_existing_workspace_inside_user_owned_roots(tmp_path: Path) -> None: + workspace = tmp_path / "agenthub-workspace" + workspace.mkdir() + + assert bridge._trusted_manager_workspace(str(workspace)) == workspace.resolve() + + with pytest.raises(bridge.BridgeError, match="existing directory"): + bridge._trusted_manager_workspace(str(workspace / "missing")) + + +def test_authenticated_manager_rejects_workspace_outside_user_owned_roots() -> None: + with pytest.raises(bridge.BridgeError, match="home or temporary directory"): + bridge._trusted_manager_workspace(os.path.abspath(os.sep)) + + def test_sse_parser_handles_heartbeats_multiline_data_and_raw_json() -> None: lines = [ ": comment\n", @@ -1876,22 +1924,20 @@ def test_open_code_request_loads_cli_profile_and_streams_with_sdk_signing(monkey _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 FakeCredentialClient: + def __init__(self, provider=None): + captured["provider"] = provider + + def get_credential(self): + return SimpleNamespace( + access_key_id="fake-ak", + access_key_secret="fake-secret", + security_token="fake-token", + ) class FakeRaw: def read(self, _maximum, decode_content=False): @@ -1918,12 +1964,17 @@ def close(self): captured["sessionClosed"] = True sdk = bridge._load_code_sdk() + sdk["CredentialClient"] = FakeCredentialClient 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")) + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda profile: {"name": profile, "mode": "AK"}, + ) response = bridge._open_code_request( "StartChat", @@ -1934,6 +1985,7 @@ def close(self): "aliyun", 10, 600, + "profile", ) assert captured["profile"] == "skill-profile" @@ -1956,19 +2008,38 @@ def close(self): assert captured["sessionClosed"] is True -def test_code_credentials_use_environment_before_cli_profile(monkeypatch) -> None: +def test_code_credentials_use_default_sdk_chain_without_profile_inspection(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 FakeCredentialClient: + def __init__(self): + captured.append("constructed-without-config") + + def get_credential(self): + return SimpleNamespace( + access_key_id="fake-chain-ak", + access_key_secret="fake-chain-secret", + security_token="fake-chain-token", + ) + sdk = { - "CLIProfileCredentialsProvider": lambda **_kwargs: pytest.fail("environment credentials must win"), + "CredentialClient": FakeCredentialClient, + "CLIProfileCredentialsProvider": lambda **_kwargs: pytest.fail("default chain must not pin a profile"), } - monkeypatch.setattr(bridge, "_selected_cli_profile", lambda *_args: pytest.fail("must not inspect Profile")) + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda *_args: pytest.fail("default chain must not inspect Profile"), + ) credentials = bridge._code_credentials(sdk, "aliyun", "ignored-profile", "cn-hangzhou") - assert credentials == ("fake-env-ak", "fake-env-secret", "fake-env-token") + assert captured == ["constructed-without-config"] + assert credentials == ("fake-chain-ak", "fake-chain-secret", "fake-chain-token") def test_code_credentials_with_profile_source_do_not_fall_back_to_environment(monkeypatch) -> None: @@ -1977,27 +2048,30 @@ def test_code_credentials_with_profile_source_do_not_fall_back_to_environment(mo 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 FakeCredentialClient: + def __init__(self, provider=None): + captured["provider"] = provider + + def get_credential(self): + return SimpleNamespace( + access_key_id="fake-profile-ak", + access_key_secret="fake-profile-secret", + security_token=None, + ) sdk = { + "CredentialClient": FakeCredentialClient, "CLIProfileCredentialsProvider": FakeProvider, } - monkeypatch.setattr(bridge, "_selected_cli_profile", lambda profile: (profile, "AK")) + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda profile: {"name": profile, "mode": "AK"}, + ) credentials = bridge._code_credentials( sdk, @@ -2011,102 +2085,39 @@ def get_credentials(self): 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 = [] - 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"") - - sdk = { - "CLIProfileCredentialsProvider": lambda **_kwargs: pytest.fail("OAuth must be refreshed by native CLI"), - } - 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) +def test_code_credentials_delegate_oauth_profile_resolution_and_refresh_to_sdk(monkeypatch) -> None: + captured = {} - credentials = bridge._code_credentials(sdk, "aliyun", "oauth-profile", "cn-hangzhou") + class FakeProvider: + def __init__(self, profile_name=None): + captured["profile"] = profile_name - 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 - 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 + class FakeCredentialClient: + def __init__(self, provider=None): + captured["provider"] = provider + def get_credential(self): + return SimpleNamespace( + access_key_id="fake-sdk-ak", + access_key_secret="fake-sdk-secret", + security_token="fake-sdk-token", + ) -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", + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda profile: {"name": profile, "mode": "OAuth"}, ) sdk = { - "CLIProfileCredentialsProvider": lambda **_kwargs: pytest.fail("OAuth must not use SDK refresh"), + "CredentialClient": FakeCredentialClient, + "CLIProfileCredentialsProvider": FakeProvider, } - monkeypatch.setattr(bridge, "_cli_config_path", lambda: config_path) - monkeypatch.setattr(bridge.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("CLI must not start")) - credentials = bridge._code_credentials(sdk, "aliyun", None, "cn-hangzhou") + credentials = bridge._code_credentials(sdk, "aliyun", "oauth-profile", "cn-hangzhou", "profile") - assert credentials == ("fake-cached-ak", "fake-cached-secret", "fake-cached-token") + assert captured["profile"] == "oauth-profile" + assert isinstance(captured["provider"], FakeProvider) + assert credentials == ("fake-sdk-ak", "fake-sdk-secret", "fake-sdk-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_iac_code_bridge.py b/tests/skill_bridge/test_iac_code_bridge.py index c1dfb7e4..256ab33e 100644 --- a/tests/skill_bridge/test_iac_code_bridge.py +++ b/tests/skill_bridge/test_iac_code_bridge.py @@ -2624,6 +2624,9 @@ def test_skill_contract_uses_implicit_trigger_normal_default_and_follow() -> Non skill = (ROOT / "skills/iac-code/SKILL.md").read_text(encoding="utf-8") agent_metadata = (ROOT / "skills/iac-code/agents/openai.yaml").read_text(encoding="utf-8") assert "even when the user does not mention iac-code, ROS, Terraform" in skill + assert "the first operational command must invoke the packaged bridge" in skill + assert "when the bridge returns `incompatible_host`" in skill + assert "do not bypass the bridge with direct cloud calls" in skill assert "Normal is the default" in skill assert "candidate-architecture, cost-comparison, plan-confirmation" in skill assert "start --mode normal" in skill and "--follow" in skill @@ -2657,6 +2660,7 @@ def test_skill_contract_uses_implicit_trigger_normal_default_and_follow() -> Non assert "session.jsonl" not in skill assert "`pip install" not in skill assert "Default to normal" in agent_metadata + assert "fail closed if the bridge rejects the host" in agent_metadata assert "candidate architectures, cost comparison, plan confirmation" in agent_metadata diff --git a/tests/skill_bridge/test_runtime_release.py b/tests/skill_bridge/test_runtime_release.py index 140ebb59..63e7336d 100644 --- a/tests/skill_bridge/test_runtime_release.py +++ b/tests/skill_bridge/test_runtime_release.py @@ -4,6 +4,7 @@ import hashlib import importlib.util import json +import re import subprocess import sys import tarfile @@ -18,9 +19,11 @@ BUILD_SCRIPT = ROOT / "skill-runtime/build_runtime.py" ASSEMBLE_SCRIPT = ROOT / "skill-runtime/assemble_manifest.py" PACKAGE_SCRIPT = ROOT / "skill-runtime/package_skill.py" +PROFILE_SCRIPT = ROOT / "skill-runtime/skill_profiles.py" SOURCE_COMMIT = "a" * 40 PUBLISHER_COMMIT = "b" * 40 PUBLISHED_AT = "2026-08-15T10:30:00Z" +NON_ENGLISH_SOURCE_PATTERN = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]") def _load_module(name: str, path: Path): @@ -230,6 +233,99 @@ def test_skill_package_is_deterministic_whitelisted_and_pinned(tmp_path: Path) - assert manifest["skill"]["sha256"] == hashlib.sha256(first.read_bytes()).hexdigest() +def test_skill_profiles_render_three_strict_product_shapes(tmp_path: Path) -> None: + subprocess.run([sys.executable, str(PROFILE_SCRIPT), "--check-defaults"], cwd=ROOT, check=True) + expected = { + "iac-code": ["SKILL.md", "agents/openai.yaml", "scripts/iac_code.py"], + "alibabacloud-iac-code": [ + "SKILL.md", + "references/ram-policies.md", + "scripts/iac_code.py", + ], + "alibabacloud-ros-agent": [ + "SKILL.md", + "references/ram-policies.md", + "scripts/requirements.txt", + "scripts/ros_agent.py", + ], + } + for name, files in expected.items(): + output = tmp_path / name + subprocess.run( + [sys.executable, str(PROFILE_SCRIPT), "--profile", name, "--output", str(output)], + cwd=ROOT, + check=True, + ) + actual = sorted(path.relative_to(output).as_posix() for path in output.rglob("*") if path.is_file()) + assert actual == files + skill = (output / "SKILL.md").read_text(encoding="utf-8") + assert "name: {}".format(name) in skill + bridge_name = "ros_agent.py" if name == "alibabacloud-ros-agent" else "iac_code.py" + bridge = next(output.rglob(bridge_name)).read_text(encoding="utf-8") + if name.startswith("alibabacloud-"): + assert "## Observability" in skill + assert "references/ram-policies.md" in skill + assert "32-character lowercase hexadecimal string" in skill + assert 'SKILL_DISTRIBUTION = "agenthub"' in bridge + assert "AlibabaCloud-Agent-Skills/{}/{{session-id}}".format(name) in bridge + if name == "alibabacloud-ros-agent": + assert "`scripts/requirements.txt`" in skill + assert "requirements-code.txt" not in skill + assert 'REQUIREMENTS_FILE = "scripts/requirements.txt"' in bridge + assert NON_ENGLISH_SOURCE_PATTERN.search(bridge) is None + else: + assert "## Observability" not in skill + assert 'SKILL_DISTRIBUTION = "public"' in bridge + + +def test_agenthub_profiles_package_as_independent_products(tmp_path: Path) -> None: + iac_archive = tmp_path / "alibabacloud-iac-code-skill-0.1.0.zip" + iac_manifest = tmp_path / "iac-manifest.json" + iac_command = _package_command(iac_archive, iac_manifest) + iac_command[2:2] = ["--profile", "alibabacloud-iac-code"] + subprocess.run(iac_command, cwd=ROOT, check=True) + with zipfile.ZipFile(iac_archive) as archive: + assert archive.namelist() == [ + "alibabacloud-iac-code/SKILL.md", + "alibabacloud-iac-code/references/ram-policies.md", + "alibabacloud-iac-code/scripts/iac_code.py", + ] + assert json.loads(iac_manifest.read_text(encoding="utf-8"))["skillName"] == "alibabacloud-iac-code" + + ros_archive = tmp_path / "alibabacloud-ros-agent-skill-0.1.0.zip" + ros_manifest = tmp_path / "ros-manifest.json" + subprocess.run( + [ + sys.executable, + str(PACKAGE_SCRIPT), + "--profile", + "alibabacloud-ros-agent", + "--skill-version", + "0.1.0", + "--source-commit", + SOURCE_COMMIT, + "--publisher-commit", + PUBLISHER_COMMIT, + "--published-at", + PUBLISHED_AT, + "--output", + str(ros_archive), + "--manifest-output", + str(ros_manifest), + ], + cwd=ROOT, + check=True, + ) + with zipfile.ZipFile(ros_archive) as archive: + assert archive.namelist() == [ + "alibabacloud-ros-agent/SKILL.md", + "alibabacloud-ros-agent/references/ram-policies.md", + "alibabacloud-ros-agent/scripts/requirements.txt", + "alibabacloud-ros-agent/scripts/ros_agent.py", + ] + assert json.loads(ros_manifest.read_text(encoding="utf-8"))["skillName"] == "alibabacloud-ros-agent" + + def test_formal_skill_package_rejects_runtime_candidate(tmp_path: Path) -> None: command = _package_command(tmp_path / "skill.zip", tmp_path / "manifest.json") tag_index = command.index("--runtime-tag") @@ -248,6 +344,23 @@ def test_publisher_contracts_are_explicit() -> None: assert runtime_contract["runtimePython"] == "cp312" assert len(runtime_contract["targets"]) == 3 assert skill_contract["files"] == ["SKILL.md", "agents/openai.yaml", "scripts/iac_code.py"] + assert skill_contract["profileScript"] == "skill-runtime/skill_profiles.py" + assert sorted(skill_contract["profiles"]) == [ + "alibabacloud-iac-code", + "alibabacloud-ros-agent", + "iac-code", + ] + assert skill_contract["profiles"]["alibabacloud-iac-code"]["files"] == [ + "SKILL.md", + "references/ram-policies.md", + "scripts/iac_code.py", + ] + assert skill_contract["profiles"]["alibabacloud-ros-agent"]["files"] == [ + "SKILL.md", + "references/ram-policies.md", + "scripts/requirements.txt", + "scripts/ros_agent.py", + ] def test_manifest_assembly_rejects_incomplete_target_matrix(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 f090d969..e2029d2a 100644 --- a/tests/skill_bridge/test_start_chat_relay.py +++ b/tests/skill_bridge/test_start_chat_relay.py @@ -45,7 +45,20 @@ def _load_module(name: str, path: 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: + for name in ( + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABACLOUD_ACCESS_KEY_ID", + "ALICLOUD_ACCESS_KEY_ID", + "ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + "ALIBABACLOUD_ACCESS_KEY_SECRET", + "ALICLOUD_ACCESS_KEY_SECRET", + "ACCESS_KEY_SECRET", + "ALIBABA_CLOUD_SECURITY_TOKEN", + "ALIBABACLOUD_SECURITY_TOKEN", + "ALICLOUD_SECURITY_TOKEN", + "SECURITY_TOKEN", + ): monkeypatch.delenv(name, raising=False) @@ -533,27 +546,30 @@ def start_a2a_call(session, parameters): 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() + class FakeCredentialClient: + def __init__(self, provider=None): + captured["provider"] = provider + + def get_credential(self): + return SimpleNamespace( + access_key_id="fake-access-key-id", + access_key_secret="fake-access-key-secret", + security_token="fake-security-token", + ) sdk = bridge._load_code_sdk() + sdk["CredentialClient"] = FakeCredentialClient sdk["CLIProfileCredentialsProvider"] = FakeProvider monkeypatch.setattr(bridge, "_load_code_sdk", lambda: sdk) - monkeypatch.setattr(bridge, "_selected_cli_profile", lambda profile: (profile, "AK")) + monkeypatch.setattr( + bridge, + "_selected_cli_profile_record", + lambda profile: {"name": profile, "mode": "AK"}, + ) args = SimpleNamespace( aliyun_path="not-used", transport="code", @@ -561,6 +577,7 @@ def get_credentials(self): connect_timeout=3, read_timeout=15, profile="sdk-profile", + credential_source="profile", region_id="cn-hangzhou", no_thinking=True, mode="normal",