diff --git a/docs/en/skill.md b/docs/en/skill.md index 62ffcbe..6f05f23 100644 --- a/docs/en/skill.md +++ b/docs/en/skill.md @@ -33,6 +33,13 @@ Data plane (local): Upload a local skill directory to the platform. +The directory is zipped and uploaded to an FC temporary OSS bucket, then +registered as a code-package tool (`createMethod=CODE_PACKAGE`, +`artifactType=Code`, `language=python3.12`, `command=python main.py`). If the +directory has no `main.py`, a placeholder entrypoint is injected so the package +is deployable. Inline code base64 fields such as `zipFile` and `zip_file` are +not accepted, including in `--from-file` payloads. + ``` ar skill create --name --code-dir [options] ``` diff --git a/docs/zh/skill.md b/docs/zh/skill.md index 01e9106..4cebbac 100644 --- a/docs/zh/skill.md +++ b/docs/zh/skill.md @@ -32,6 +32,8 @@ 把本地 Skill 目录打包上传到平台。 +目录会被打包并上传到 FC 临时 OSS bucket,然后注册为代码包工具(`createMethod=CODE_PACKAGE`、`artifactType=Code`、`language=python3.12`、`command=python main.py`)。若目录缺少 `main.py`,会注入占位入口文件以保证可部署。代码包不接受 `zipFile` / `zip_file` 等 inline base64 字段,`--from-file` 也会拒绝这些字段。 + ``` ar skill create --name --code-dir [options] ``` diff --git a/pyproject.toml b/pyproject.toml index 358e7c0..c0058cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ dependencies = [ "agentrun-sdk[core]>=0.0.37", + "oss2>=2.19.1", "pyyaml>=6.0", "questionary>=2.0", ] diff --git a/src/agentrun_cli/commands/skill_cmd.py b/src/agentrun_cli/commands/skill_cmd.py index 115793b..ba33943 100644 --- a/src/agentrun_cli/commands/skill_cmd.py +++ b/src/agentrun_cli/commands/skill_cmd.py @@ -18,10 +18,16 @@ """ import base64 +import email.utils +import hashlib +import hmac import io import json import os +import urllib.error +import urllib.request import zipfile +from dataclasses import dataclass import click @@ -58,16 +64,198 @@ def _serialize_tool(t) -> dict: } -def _zip_directory(dir_path: str) -> str: - """ZIP a directory and return base64-encoded content.""" +def _zip_skill_directory_bytes(dir_path: str) -> bytes: + """Package a Skill directory and inject a placeholder main.py when missing.""" buf = io.BytesIO() + has_main_py = False with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for root, _dirs, files in os.walk(dir_path): for fname in files: full_path = os.path.join(root, fname) arcname = os.path.relpath(full_path, dir_path) + if arcname == "main.py": + has_main_py = True zf.write(full_path, arcname) - return base64.b64encode(buf.getvalue()).decode("ascii") + if not has_main_py: + zf.writestr( + "main.py", + "def handler(event, context):\n" + " return {'status': 'ok'}\n\n" + "if __name__ == '__main__':\n" + " print('skill package placeholder')\n", + ) + return buf.getvalue() + + +def _reject_inline_code_fields(value: object) -> None: + """Reject legacy inline code fields in user-provided payloads.""" + if isinstance(value, dict): + for key, nested in value.items(): + if key in {"zipFile", "zip_file"}: + raise click.UsageError( + "Inline code base64 fields are not supported for Skill " + "creation; use --code-dir so the CLI uploads the package " + "to FC TempBucket OSS." + ) + _reject_inline_code_fields(nested) + elif isinstance(value, list): + for nested in value: + _reject_inline_code_fields(nested) + + +@dataclass(frozen=True) +class _CodePackageLocation: + oss_bucket_name: str + oss_object_name: str + + +def _upload_skill_archive_to_fc_temp_bucket( + zip_data: bytes, + *, + profile: str | None, + region: str | None, +) -> _CodePackageLocation: + """Upload a Skill ZIP to FC TempBucket OSS and return its code location.""" + import oss2 + + cfg = build_sdk_config(profile_name=profile, region=region) + ak = cfg.get_access_key_id() + sk = cfg.get_access_key_secret() + token = cfg.get_security_token() + try: + account_id = cfg.get_account_id() + except ValueError as exc: + raise click.ClickException( + "Creating a Skill requires access_key_id, access_key_secret, " + "account_id, and region." + ) from exc + region_id = cfg.get_region_id() + if not ak or not sk or not account_id or not region_id: + raise click.ClickException( + "Creating a Skill requires access_key_id, access_key_secret, " + "account_id, and region." + ) + + payload = _get_fc_temp_bucket_token(ak, sk, token, account_id, region_id) + oss_region = _required_temp_bucket_field(payload, "ossRegion") + oss_bucket = _required_temp_bucket_field(payload, "ossBucket") + object_name = _temp_bucket_object_name( + account_id, _required_temp_bucket_field(payload, "objectName") + ) + credentials = payload.get("credentials") or payload.get("Credentials") or {} + temp_ak = _required_temp_bucket_field(credentials, "accessKeyId") + temp_sk = _required_temp_bucket_field(credentials, "accessKeySecret") + temp_token = _required_temp_bucket_field(credentials, "securityToken") + + auth = oss2.StsAuth(temp_ak, temp_sk, temp_token) + bucket = oss2.Bucket(auth, _oss_endpoint_from_region(oss_region), oss_bucket) + bucket.put_object(object_name, zip_data) + return _CodePackageLocation(oss_bucket, object_name) + + +def _get_fc_temp_bucket_token( + access_key_id: str, + access_key_secret: str, + security_token: str | None, + account_id: str, + region_id: str, +) -> dict: + """Call the FC 2016 API to fetch temporary OSS credentials.""" + host = f"{account_id}.{region_id}.fc.aliyuncs.com" + path = "/2016-08-15/tempBucketToken" + date = email.utils.formatdate(usegmt=True) + headers = { + "Host": host, + "Accept": "application/json", + "Date": date, + "User-Agent": "agentrun-cli", + "X-Fc-Account-Id": account_id, + } + if security_token: + headers["X-Fc-Security-Token"] = security_token + headers["Authorization"] = _fc_authorization( + access_key_id, access_key_secret, "GET", headers, path + ) + request = urllib.request.Request( + f"https://{host}{path}", headers=headers, method="GET" + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 + raw = response.read() + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + message = f"Failed to get FC TempBucket token: {detail}" + raise click.ClickException(message) from exc + except urllib.error.URLError as exc: + message = f"Failed to get FC TempBucket token: {exc.reason}" + raise click.ClickException(message) from exc + try: + return json.loads(raw.decode("utf-8")) + except json.JSONDecodeError as exc: + preview = raw[:200].decode("utf-8", errors="replace") + message = f"Failed to parse FC TempBucket token response: {preview}" + raise click.ClickException(message) from exc + + +def _fc_authorization( + access_key_id: str, + access_key_secret: str, + method: str, + headers: dict[str, str], + resource: str, +) -> str: + """Build the Authorization header for FC 2016 API requests.""" + lower_headers = {key.lower(): value for key, value in headers.items()} + fc_headers = "" + for key in sorted(k for k in lower_headers if k.startswith("x-fc-")): + fc_headers += f"{key}:{lower_headers[key]}\n" + string_to_sign = "\n".join( + [ + method, + lower_headers.get("content-md5", ""), + lower_headers.get("content-type", ""), + lower_headers.get("date", ""), + f"{fc_headers}{resource}", + ] + ) + digest = hmac.new( + access_key_secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + hashlib.sha256, + ).digest() + signature = base64.b64encode(digest).decode("ascii") + return f"FC {access_key_id}:{signature}" + + +def _required_temp_bucket_field(data: dict, key: str) -> str: + """Read a required FC TempBucket field, accepting PascalCase variants.""" + value = data.get(key) + if value is None: + value = data.get(key[:1].upper() + key[1:]) + if not isinstance(value, str) or not value.strip(): + raise click.ClickException(f"FC TempBucket response is missing '{key}'") + return value.strip() + + +def _temp_bucket_object_name(account_id: str, object_name: str) -> str: + """Build the final FC TempBucket object name.""" + account_id = account_id.strip().strip("/") + object_name = object_name.strip().lstrip("/") + if not account_id: + return object_name + return f"{account_id}/{object_name}" + + +def _oss_endpoint_from_region(oss_region: str) -> str: + """Build an OSS endpoint from the FC-returned OSS region.""" + value = oss_region.strip() + if value.startswith(("http://", "https://")): + return value + if "." in value: + return f"https://{value}" + if value.startswith("oss-"): + return f"https://{value}.aliyuncs.com" + return f"https://oss-{value}.aliyuncs.com" def _load_json_option(raw: str | None) -> dict | None: @@ -119,6 +307,7 @@ def skill_create(ctx, skill_name, code_dir, description, credential_name, from_f if from_file: payload = _load_json_option(from_file) + _reject_inline_code_fields(payload) payload.setdefault("tool_type", "SKILL") inp = models.CreateToolInputV2(**payload) else: @@ -131,14 +320,24 @@ def skill_create(ctx, skill_name, code_dir, description, credential_name, from_f if not description: description = _extract_description(skill_md) - # ZIP and base64 encode - zip_b64 = _zip_directory(code_dir) + location = _upload_skill_archive_to_fc_temp_bucket( + _zip_skill_directory_bytes(code_dir), + profile=profile, + region=region, + ) - code_cfg = models.CodeConfiguration(zip_file=zip_b64) + code_cfg = models.CodeConfiguration( + oss_bucket_name=location.oss_bucket_name, + oss_object_name=location.oss_object_name, + language="python3.12", + command=["python", "main.py"], + ) inp = models.CreateToolInputV2( tool_name=skill_name, tool_type="SKILL", + create_method="CODE_PACKAGE", + artifact_type="Code", description=description, code_configuration=code_cfg, credential_name=credential_name, diff --git a/tests/integration/test_skill_cmd.py b/tests/integration/test_skill_cmd.py index 1750b82..ff2ebf5 100644 --- a/tests/integration/test_skill_cmd.py +++ b/tests/integration/test_skill_cmd.py @@ -7,6 +7,7 @@ from click.testing import CliRunner +from agentrun_cli.commands.skill_cmd import _CodePackageLocation from agentrun_cli.main import cli # --------------------------------------------------------------------------- @@ -24,6 +25,13 @@ def _mock_agentrun_models(): return mod +def _mock_agentrun_package(models_mod): + """Build mock alibabacloud_agentrun20250910 package.""" + pkg = MagicMock() + pkg.models = models_mod + return pkg + + def _make_tool_obj(**overrides): defaults = { "tool_id": "t-xxx", @@ -89,10 +97,14 @@ def test_create_skill(self): with ( _patch_inner_client(client), + patch( + "agentrun_cli.commands.skill_cmd._upload_skill_archive_to_fc_temp_bucket", + return_value=_CodePackageLocation("bucket", "149/object.zip"), + ) as mock_upload, patch.dict( "sys.modules", { - "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod), "alibabacloud_agentrun20250910.models": mock_mod, }, ), @@ -118,6 +130,12 @@ def test_create_skill(self): assert result.exit_code == 0, result.output out = json.loads(result.output) assert out["tool_name"] == "new-skill" + mock_upload.assert_called_once() + body = mock_mod.CreateToolRequest.call_args.kwargs["body"] + assert body.create_method == "CODE_PACKAGE" + assert body.artifact_type == "Code" + assert body.code_configuration.oss_bucket_name == "bucket" + assert body.code_configuration.oss_object_name == "149/object.zip" def test_create_from_file(self): mock_mod = _mock_agentrun_models() @@ -132,7 +150,7 @@ def test_create_from_file(self): patch.dict( "sys.modules", { - "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod), "alibabacloud_agentrun20250910.models": mock_mod, }, ), @@ -175,7 +193,7 @@ def test_list_skills(self): patch.dict( "sys.modules", { - "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod), "alibabacloud_agentrun20250910.models": mock_mod, }, ), @@ -198,7 +216,7 @@ def test_list_with_pagination(self): patch.dict( "sys.modules", { - "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod), "alibabacloud_agentrun20250910.models": mock_mod, }, ), diff --git a/tests/unit/test_skill_cmd.py b/tests/unit/test_skill_cmd.py index 80be993..2a2303e 100644 --- a/tests/unit/test_skill_cmd.py +++ b/tests/unit/test_skill_cmd.py @@ -1,6 +1,5 @@ """Unit tests for agentrun_cli.commands.skill_cmd — helpers and CLI commands.""" -import base64 import io import json import os @@ -8,15 +7,22 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import click import pytest from click.testing import CliRunner from agentrun_cli.commands.skill_cmd import ( + _CodePackageLocation, _ctx_cfg, _extract_description, + _fc_authorization, _load_json_option, + _oss_endpoint_from_region, + _reject_inline_code_fields, _serialize_tool, - _zip_directory, + _temp_bucket_object_name, + _upload_skill_archive_to_fc_temp_bucket, + _zip_skill_directory_bytes, skill_group, ) @@ -101,33 +107,282 @@ def test_fallback_last_updated_at(self): # --------------------------------------------------------------------------- -# Helper: _zip_directory +# Helper: _zip_skill_directory_bytes # --------------------------------------------------------------------------- class TestZipDirectory: - def test_zips_directory(self, tmp_path): - (tmp_path / "a.txt").write_text("hello") - sub = tmp_path / "sub" - sub.mkdir() - (sub / "b.txt").write_text("world") - - b64 = _zip_directory(str(tmp_path)) - raw = base64.b64decode(b64) - buf = io.BytesIO(raw) - with zipfile.ZipFile(buf, "r") as zf: - names = sorted(zf.namelist()) - assert "a.txt" in names - assert "sub/b.txt" in names - assert zf.read("a.txt") == b"hello" - assert zf.read("sub/b.txt") == b"world" - - def test_empty_directory(self, tmp_path): - b64 = _zip_directory(str(tmp_path)) - raw = base64.b64decode(b64) - buf = io.BytesIO(raw) - with zipfile.ZipFile(buf, "r") as zf: - assert zf.namelist() == [] + def test_skill_zip_adds_placeholder_main(self, tmp_path): + (tmp_path / "SKILL.md").write_text("# Skill\n") + raw = _zip_skill_directory_bytes(str(tmp_path)) + with zipfile.ZipFile(io.BytesIO(raw), "r") as zf: + assert "SKILL.md" in zf.namelist() + assert "main.py" in zf.namelist() + assert b"skill package placeholder" in zf.read("main.py") + + def test_skill_zip_keeps_existing_main(self, tmp_path): + (tmp_path / "SKILL.md").write_text("# Skill\n") + (tmp_path / "main.py").write_text("print('custom')\n") + raw = _zip_skill_directory_bytes(str(tmp_path)) + with zipfile.ZipFile(io.BytesIO(raw), "r") as zf: + assert zf.read("main.py") == b"print('custom')\n" + + def test_reject_inline_code_fields(self): + payload = {"codeConfiguration": {"zipFile": "abc"}} + with pytest.raises(click.UsageError, match="Inline code base64"): + _reject_inline_code_fields(payload) + + def test_reject_inline_code_fields_nested_snake_case(self): + payload = {"items": [{"code_configuration": {"zip_file": "abc"}}]} + with pytest.raises(click.UsageError, match="Inline code base64"): + _reject_inline_code_fields(payload) + + +class TestFCTempBucketHelpers: + def test_temp_bucket_object_name_prefixes_account(self): + assert _temp_bucket_object_name("149", "/abc") == "149/abc" + + def test_temp_bucket_object_name_without_account(self): + assert _temp_bucket_object_name("", "/abc") == "abc" + + def test_oss_endpoint_from_region(self): + assert _oss_endpoint_from_region("cn-hangzhou") == ( + "https://oss-cn-hangzhou.aliyuncs.com" + ) + assert _oss_endpoint_from_region("oss-cn-hangzhou") == ( + "https://oss-cn-hangzhou.aliyuncs.com" + ) + assert _oss_endpoint_from_region("oss-cn-hangzhou.aliyuncs.com") == ( + "https://oss-cn-hangzhou.aliyuncs.com" + ) + assert _oss_endpoint_from_region("https://example.com") == ( + "https://example.com" + ) + + def test_fc_authorization_uses_fc_signature(self): + headers = { + "Date": "Thu, 02 Jul 2026 12:00:00 GMT", + "X-Fc-Account-Id": "149", + "X-Fc-Security-Token": "sts-token", + } + + auth = _fc_authorization("ak", "sk", "GET", headers, "/2016-08-15/path") + + assert auth == "FC ak:yXJoM8Ao+2iKd1FetDoGXzA2BBNVqPQW1lnw1cRWxxc=" + assert "acs " not in auth + + def test_fc_authorization_date_header_is_case_insensitive(self): + headers = { + "date": "Thu, 02 Jul 2026 12:00:00 GMT", + "X-Fc-Account-Id": "149", + } + normalized_headers = { + "Date": "Thu, 02 Jul 2026 12:00:00 GMT", + "X-Fc-Account-Id": "149", + } + + assert _fc_authorization("ak", "sk", "GET", headers, "/path") == ( + _fc_authorization("ak", "sk", "GET", normalized_headers, "/path") + ) + + +def _fc_temp_bucket_payload(*, capitalized=False): + """Build an FC TempBucket payload; capitalized=True uses PascalCase keys.""" + if capitalized: + return { + "OssRegion": "cn-hangzhou", + "OssBucket": "fc-temp-bucket", + "ObjectName": "object.zip", + "Credentials": { + "AccessKeyId": "temp-ak", + "AccessKeySecret": "temp-sk", + "SecurityToken": "temp-token", + }, + } + return { + "ossRegion": "cn-hangzhou", + "ossBucket": "fc-temp-bucket", + "objectName": "object.zip", + "credentials": { + "accessKeyId": "temp-ak", + "accessKeySecret": "temp-sk", + "securityToken": "temp-token", + }, + } + + +class TestUploadSkillArchiveToFCTempBucket: + """Cover the FC token fetch and OSS upload path.""" + + @pytest.fixture + def mock_cfg(self): + cfg = MagicMock() + cfg.get_access_key_id.return_value = "ak" + cfg.get_access_key_secret.return_value = "sk" + cfg.get_security_token.return_value = "sts-token" + cfg.get_account_id.return_value = "149" + cfg.get_region_id.return_value = "cn-hangzhou" + return cfg + + @pytest.fixture + def installed_fake_deps(self): + """Inject a fake oss2 module to avoid real network dependencies.""" + fake_oss2 = MagicMock() + bucket = MagicMock() + fake_oss2.Bucket.return_value = bucket + fake_oss2.StsAuth.return_value = MagicMock(name="sts-auth") + + modules = { + "oss2": fake_oss2, + } + with patch.dict("sys.modules", modules): + yield fake_oss2, bucket + + def test_uploads_and_returns_location(self, mock_cfg, installed_fake_deps): + fake_oss2, bucket = installed_fake_deps + response = MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + _fc_temp_bucket_payload() + ).encode() + + with ( + patch( + "agentrun_cli.commands.skill_cmd.build_sdk_config", + return_value=mock_cfg, + ), + patch("agentrun_cli.commands.skill_cmd.urllib.request.urlopen") as urlopen, + ): + urlopen.return_value = response + location = _upload_skill_archive_to_fc_temp_bucket( + b"zip-bytes", profile="default", region="cn-hangzhou" + ) + + request = urlopen.call_args.args[0] + assert request.full_url == ( + "https://149.cn-hangzhou.fc.aliyuncs.com/2016-08-15/tempBucketToken" + ) + assert request.headers["Authorization"].startswith("FC ak:") + assert request.headers["X-fc-security-token"] == "sts-token" + assert location == _CodePackageLocation("fc-temp-bucket", "149/object.zip") + bucket.put_object.assert_called_once_with("149/object.zip", b"zip-bytes") + fake_oss2.StsAuth.assert_called_once_with("temp-ak", "temp-sk", "temp-token") + + def test_accepts_capitalized_payload(self, mock_cfg, installed_fake_deps): + """Parse FC responses that use PascalCase field names.""" + _fake_oss2, _bucket = installed_fake_deps + response = MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + _fc_temp_bucket_payload(capitalized=True) + ).encode() + + with ( + patch( + "agentrun_cli.commands.skill_cmd.build_sdk_config", + return_value=mock_cfg, + ), + patch( + "agentrun_cli.commands.skill_cmd.urllib.request.urlopen", + return_value=response, + ), + ): + location = _upload_skill_archive_to_fc_temp_bucket( + b"zip-bytes", profile=None, region=None + ) + + assert location.oss_bucket_name == "fc-temp-bucket" + assert location.oss_object_name == "149/object.zip" + + def test_missing_credentials_raises(self, mock_cfg, installed_fake_deps): + _fake_oss2, _bucket = installed_fake_deps + response = MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + { + "ossRegion": "cn-hangzhou", + "ossBucket": "fc-temp-bucket", + "objectName": "object.zip", + "credentials": { + "accessKeyId": "", + "accessKeySecret": "", + "securityToken": "", + }, + } + ).encode() + + with ( + patch( + "agentrun_cli.commands.skill_cmd.build_sdk_config", + return_value=mock_cfg, + ), + patch( + "agentrun_cli.commands.skill_cmd.urllib.request.urlopen", + return_value=response, + ), + pytest.raises(click.ClickException, match="accessKeyId"), + ): + _upload_skill_archive_to_fc_temp_bucket( + b"zip-bytes", profile=None, region=None + ) + + def test_missing_config_raises(self, installed_fake_deps): + cfg = MagicMock() + cfg.get_access_key_id.return_value = None + cfg.get_access_key_secret.return_value = None + cfg.get_security_token.return_value = None + cfg.get_account_id.return_value = None + cfg.get_region_id.return_value = None + + with ( + patch( + "agentrun_cli.commands.skill_cmd.build_sdk_config", + return_value=cfg, + ), + pytest.raises(click.ClickException, match="access_key_id"), + ): + _upload_skill_archive_to_fc_temp_bucket( + b"zip-bytes", profile=None, region=None + ) + + def test_missing_account_id_value_error_raises_click_exception( + self, installed_fake_deps + ): + cfg = MagicMock() + cfg.get_access_key_id.return_value = "ak" + cfg.get_access_key_secret.return_value = "sk" + cfg.get_security_token.return_value = None + cfg.get_account_id.side_effect = ValueError("account id is not set") + cfg.get_region_id.return_value = "cn-hangzhou" + + with ( + patch( + "agentrun_cli.commands.skill_cmd.build_sdk_config", + return_value=cfg, + ), + pytest.raises(click.ClickException, match="account_id"), + ): + _upload_skill_archive_to_fc_temp_bucket( + b"zip-bytes", profile=None, region=None + ) + + def test_invalid_temp_bucket_json_raises_click_exception( + self, mock_cfg, installed_fake_deps + ): + response = MagicMock() + response.__enter__.return_value.read.return_value = b"not-json-response" + + with ( + patch( + "agentrun_cli.commands.skill_cmd.build_sdk_config", + return_value=mock_cfg, + ), + patch( + "agentrun_cli.commands.skill_cmd.urllib.request.urlopen", + return_value=response, + ), + pytest.raises(click.ClickException, match="Failed to parse"), + ): + _upload_skill_archive_to_fc_temp_bucket( + b"zip-bytes", profile=None, region=None + ) # --------------------------------------------------------------------------- @@ -269,10 +524,12 @@ def test_create_missing_skill_md(self, mock_client_fn): assert result.exit_code != 0 assert "SKILL.md" in result.output + @patch("agentrun_cli.commands.skill_cmd._upload_skill_archive_to_fc_temp_bucket") @patch("agentrun_cli.commands.skill_cmd.get_agentrun_client") - def test_create_success(self, mock_client_fn): + def test_create_success(self, mock_client_fn, mock_upload): client = MagicMock() mock_client_fn.return_value = (client, {}, MagicMock()) + mock_upload.return_value = _CodePackageLocation("bucket", "149/object.zip") data = SimpleNamespace( tool_id="t-new", @@ -298,11 +555,21 @@ def test_create_success(self, mock_client_fn): assert result.exit_code == 0 client.create_tool_with_options.assert_called_once() - + request = client.create_tool_with_options.call_args.args[0] + body = request.body + assert body.create_method == "CODE_PACKAGE" + assert body.artifact_type == "Code" + assert body.code_configuration.oss_bucket_name == "bucket" + assert body.code_configuration.oss_object_name == "149/object.zip" + assert body.code_configuration.zip_file is None + mock_upload.assert_called_once() + + @patch("agentrun_cli.commands.skill_cmd._upload_skill_archive_to_fc_temp_bucket") @patch("agentrun_cli.commands.skill_cmd.get_agentrun_client") - def test_create_with_description_and_credential(self, mock_client_fn): + def test_create_with_description_and_credential(self, mock_client_fn, mock_upload): client = MagicMock() mock_client_fn.return_value = (client, {}, MagicMock()) + mock_upload.return_value = _CodePackageLocation("bucket", "149/object.zip") data = SimpleNamespace( tool_id="t-new", @@ -380,9 +647,46 @@ def test_create_from_file(self, mock_client_fn): assert result.exit_code == 0 @patch("agentrun_cli.commands.skill_cmd.get_agentrun_client") - def test_create_null_data_response(self, mock_client_fn): + def test_create_from_file_rejects_inline_code_base64(self, mock_client_fn): + client = MagicMock() + mock_client_fn.return_value = (client, {}, MagicMock()) + + runner = CliRunner() + with runner.isolated_filesystem(): + os.makedirs("my-skill") + with open("my-skill/SKILL.md", "w") as f: + f.write("# Hello\n") + with open("config.json", "w") as f: + json.dump( + { + "tool_name": "s", + "codeConfiguration": {"zipFile": "abc"}, + }, + f, + ) + result = runner.invoke( + skill_group, + [ + "create", + "--name", + "s", + "--code-dir", + "my-skill", + "--from-file", + "config.json", + ], + ) + + assert result.exit_code != 0 + assert "Inline code base64 fields are not supported" in result.output + client.create_tool_with_options.assert_not_called() + + @patch("agentrun_cli.commands.skill_cmd._upload_skill_archive_to_fc_temp_bucket") + @patch("agentrun_cli.commands.skill_cmd.get_agentrun_client") + def test_create_null_data_response(self, mock_client_fn, mock_upload): client = MagicMock() mock_client_fn.return_value = (client, {}, MagicMock()) + mock_upload.return_value = _CodePackageLocation("bucket", "149/object.zip") client.create_tool_with_options.return_value = SimpleNamespace( body=SimpleNamespace(data=None) )