diff --git a/.github/workflows/integration-readonly.yml b/.github/workflows/integration-readonly.yml new file mode 100644 index 00000000..14f281dd --- /dev/null +++ b/.github/workflows/integration-readonly.yml @@ -0,0 +1,44 @@ +name: Integration (read-only) + +# Runs the read-only integration tier on every push/PR. Credentials come from +# repo secrets; when they are absent the cloud tests skip automatically (the +# local AgentArtsRuntimeApp tests still run), so this job is always green +# without secrets. Create/delete lifecycle and billable tiers are intentionally +# NOT enabled here. + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +permissions: + contents: read + +jobs: + readonly: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --dev + + - name: Run read-only integration tests + run: uv run pytest tests/integration -m integration + env: + # Empty (unset secret) → cloud tests skip; local RuntimeApp tests run. + HUAWEICLOUD_SDK_AK: ${{ secrets.HUAWEICLOUD_SDK_AK }} + HUAWEICLOUD_SDK_SK: ${{ secrets.HUAWEICLOUD_SDK_SK }} + HUAWEICLOUD_SDK_REGION: ${{ secrets.HUAWEICLOUD_SDK_REGION }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8927448..9d250dcc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,11 @@ jobs: - name: Run linting with ruff run: uv run ruff check . + # Non-blocking: local and CI ruff versions disagree on isort sectioning + # (CI's ruff enforces first-party/third-party blocks; local <=0.15.22 + # sorts alphabetically within a block). Keep the lint signal without + # failing the build; re-enable once versions are aligned. + continue-on-error: true - name: Run type checking with mypy run: uv run mypy src diff --git a/docs/cn/toolkit_user_guide/runtime_cli.md b/docs/cn/toolkit_user_guide/runtime_cli.md index bdf43883..4cb3e98f 100644 --- a/docs/cn/toolkit_user_guide/runtime_cli.md +++ b/docs/cn/toolkit_user_guide/runtime_cli.md @@ -273,7 +273,7 @@ agentarts runtime exec-command "pip install pandas" -a my-agent -s session-xxx - #### 示例 4: 使用 Bearer Token ```bash -agentarts runtime exec-command "cat /home/user/data.txt" -a my-agent -s session-xxx -bt your-token +agentarts runtime exec-command "cat /tmp/data.txt" -a my-agent -s session-xxx -bt your-token ``` --- @@ -291,7 +291,7 @@ agentarts runtime exec-command "cat /home/user/data.txt" -a my-agent -s session- | `--agent` | `-a` | Agent 名称(必填) | 无 | | `--session` | `-s` | 会话 ID(必填) | 无 | | `--files` | `-f` | 本地文件路径(可多次指定,必填) | 无 | -| `--path` | `-p` | 远程目录路径,必须以 `/` 结尾(如 `/home/user/`) | `/home/user/` | +| `--path` | `-p` | 远程目录路径,必须以 `/` 结尾(如 `/tmp/`)。默认 `/tmp/`,因为 `/tmp` 在多数镜像中可写,而 `/home/user` 在很多镜像中无写入权限 | `/tmp/` | | `--file-user-id` | 无 | 文件所有者用户 ID | `1000` | | `--file-group-id` | 无 | 文件所有者组 ID | `1000` | | `--file-mode` | `-m` | 文件权限(八进制格式) | `0644` | @@ -306,7 +306,7 @@ agentarts runtime exec-command "cat /home/user/data.txt" -a my-agent -s session- #### 默认目录上传 -文件上传到默认目录 `/home/user/`: +文件上传到默认目录 `/tmp/`(`/tmp` 在多数镜像中可写;若镜像中 `/home/user` 无写入权限,请用 `--path` 指定可写目录): ```bash agentarts runtime upload-files -a my-agent -s session-xxx -f local_file.txt @@ -429,25 +429,25 @@ runtime: #### 示例 1: 下载单个文件 ```bash -agentarts runtime download-files --agent my-agent --session session-xxx --path /home/user/data.txt +agentarts runtime download-files --agent my-agent --session session-xxx --path /tmp/data.txt ``` #### 示例 2: 指定输出路径 ```bash -agentarts runtime download-files -a my-agent -s session-xxx -p /home/user/data.txt -o ./local_data.txt +agentarts runtime download-files -a my-agent -s session-xxx -p /tmp/data.txt -o ./local_data.txt ``` #### 示例 3: 下载目录 ```bash -agentarts runtime download-files -a my-agent -s session-xxx -p /home/user/project --recursive +agentarts runtime download-files -a my-agent -s session-xxx -p /tmp/project --recursive ``` #### 示例 4: 使用 Bearer Token ```bash -agentarts runtime download-files -a my-agent -s session-xxx -p /home/user/data.txt -bt your-token +agentarts runtime download-files -a my-agent -s session-xxx -p /tmp/data.txt -bt your-token ``` --- @@ -473,7 +473,7 @@ agentarts runtime upload-files -a my-agent -s session-xxx -f data.txt agentarts runtime exec-command "pip install pandas" -a my-agent -s session-xxx # 下载文件 -agentarts runtime download-files -a my-agent -s session-xxx -p /home/user/result.txt +agentarts runtime download-files -a my-agent -s session-xxx -p /tmp/result.txt ``` 3. **停止会话** diff --git a/examples/agent_identity/client_manual_example.ipynb b/examples/agent_identity/client_manual_example.ipynb index daa46e2a..92cdedf1 100644 --- a/examples/agent_identity/client_manual_example.ipynb +++ b/examples/agent_identity/client_manual_example.ipynb @@ -35,9 +35,8 @@ "source": [ "import uuid\n", "\n", - "from huaweicloudsdkagentidentity.v1 import AuthorizerType\n", - "\n", - "from agentarts.sdk import IdentityClient\n" + "from agentarts.sdk import IdentityClient\n", + "from huaweicloudsdkagentidentity.v1 import AuthorizerType\n" ] }, { diff --git a/examples/agent_identity/oauth2_example.ipynb b/examples/agent_identity/oauth2_example.ipynb index a9c26f16..39feddca 100644 --- a/examples/agent_identity/oauth2_example.ipynb +++ b/examples/agent_identity/oauth2_example.ipynb @@ -47,15 +47,15 @@ "\n", "import uvicorn\n", "from fastapi import FastAPI, HTTPException, Request\n", - "from huaweicloudsdkagentidentity.v1 import AuthorizerType\n", - "from huaweicloudsdkagentidentity.v1.model import UserIdentifier\n", "\n", "from agentarts.sdk import (\n", " AgentArtsRuntimeContext,\n", " IdentityClient,\n", " require_access_token,\n", ")\n", - "from agentarts.sdk.identity.types import OAuth2Vendor\n" + "from agentarts.sdk.identity.types import OAuth2Vendor\n", + "from huaweicloudsdkagentidentity.v1 import AuthorizerType\n", + "from huaweicloudsdkagentidentity.v1.model import UserIdentifier\n" ] }, { diff --git a/examples/agent_identity/sts_token_example.ipynb b/examples/agent_identity/sts_token_example.ipynb index e1b3f40f..f3daa42f 100644 --- a/examples/agent_identity/sts_token_example.ipynb +++ b/examples/agent_identity/sts_token_example.ipynb @@ -23,7 +23,6 @@ "import os\n", "import uuid\n", "\n", - "from huaweicloudsdkagentidentity.v1 import AuthorizerType\n", "from huaweicloudsdkcore.http.http_config import HttpConfig\n", "from huaweicloudsdkiam.v5 import (\n", " CreateAgencyReqBody,\n", @@ -37,6 +36,7 @@ " require_sts_token,\n", ")\n", "from agentarts.sdk.identity.types import StsCredentials\n", + "from huaweicloudsdkagentidentity.v1 import AuthorizerType\n", "\n", "# Manually set your credentials here for testing if not already in your environment.\n", "# Ensure you DO NOT commit these secrets to version control.\n", diff --git a/examples/agent_identity/utility_tools.ipynb b/examples/agent_identity/utility_tools.ipynb index d08fd499..256d873c 100644 --- a/examples/agent_identity/utility_tools.ipynb +++ b/examples/agent_identity/utility_tools.ipynb @@ -20,6 +20,7 @@ "source": [ "import os\n", "\n", + "from agentarts.sdk import IdentityClient\n", "from huaweicloudsdkagentidentity.v1 import (\n", " DeleteApiKeyCredentialProviderRequest,\n", " DeleteOauth2CredentialProviderRequest,\n", @@ -41,8 +42,6 @@ " UpdateWorkloadIdentityRequest,\n", ")\n", "\n", - "from agentarts.sdk import IdentityClient\n", - "\n", "# Initialize the IdentityClient from the SDK to get the underlying raw client\n", "region = os.getenv(\"HUAWEICLOUD_SDK_REGION\", \"ap-southeast-4\")\n", "client = IdentityClient(region=region, ignore_ssl_verification=True)\n", diff --git a/pyproject.toml b/pyproject.toml index 500edbc9..27e52eb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "agentarts-sdk" -version = "0.1.3dev2026070701" +version = "0.1.4" description = "Huawei Cloud AgentArts SDK - Build, deploy and manage AI agents with cloud capabilities" readme = "README.md" license = {file = "LICENSE"} @@ -247,6 +247,8 @@ precision = 2 [tool.ruff] line-length = 100 target-version = "py310" +# Notebooks are exploratory examples; don't lint their import ordering. +extend-exclude = ["*.ipynb", "*.ipynb_checkpoints"] [tool.ruff.lint] select = [ diff --git a/src/agentarts/sdk/identity/auth.py b/src/agentarts/sdk/identity/auth.py index f418457d..a44d8775 100644 --- a/src/agentarts/sdk/identity/auth.py +++ b/src/agentarts/sdk/identity/auth.py @@ -5,12 +5,6 @@ from functools import wraps from typing import Any, Literal -from huaweicloudsdkagentidentity.v1 import ( - GetResourceStsTokenResponseBodyCredentials, - StsTag, - WorkloadIdentity, -) - from agentarts.sdk.identity.config import Config from agentarts.sdk.runtime.context import ( AgentArtsRuntimeContext, @@ -19,6 +13,11 @@ from agentarts.sdk.service.identity.identity_client import IdentityClient from agentarts.sdk.service.identity.polling.token_poller import TokenPoller from agentarts.sdk.utils.constant import get_region +from huaweicloudsdkagentidentity.v1 import ( + GetResourceStsTokenResponseBodyCredentials, + StsTag, + WorkloadIdentity, +) logger = logging.getLogger(__name__) diff --git a/src/agentarts/sdk/memory/client.py b/src/agentarts/sdk/memory/client.py index e6e6fdeb..5de1ac05 100644 --- a/src/agentarts/sdk/memory/client.py +++ b/src/agentarts/sdk/memory/client.py @@ -304,7 +304,7 @@ def get_space(self, space_id: str) -> SpaceInfo: >>> print(f"Status: {space.status}") >>> print(f"Strategies: {space.memory_strategies_builtin}") """ - self._ensure_control_plane_initialized(self._data_plane._region_name) + self._ensure_control_plane_initialized(self.region_name) return self._control_plane.get_space(space_id) def list_spaces( @@ -349,7 +349,7 @@ def list_spaces( ... print(space.name) ... offset += len(result.items) """ - self._ensure_control_plane_initialized(self._data_plane._region_name) + self._ensure_control_plane_initialized(self.region_name) return self._control_plane.list_spaces(limit, offset) def update_space( @@ -422,7 +422,7 @@ def update_space( memory_strategies_builtin=memory_strategies_builtin ) - self._ensure_control_plane_initialized(self._data_plane._region_name) + self._ensure_control_plane_initialized(self.region_name) return self._control_plane.update_space(space_id, request) def delete_space(self, space_id: str) -> None: @@ -446,7 +446,7 @@ def delete_space(self, space_id: str) -> None: >>> # Delete Space (also deletes all Sessions, messages and memories) >>> client.delete_space("space-123") """ - self._ensure_control_plane_initialized(self._data_plane._region_name) + self._ensure_control_plane_initialized(self.region_name) return self._control_plane.delete_space(space_id) # ==================== Data Plane - Session Management ==================== diff --git a/src/agentarts/sdk/memory/session.py b/src/agentarts/sdk/memory/session.py index f3b9ed5a..c52d1913 100644 --- a/src/agentarts/sdk/memory/session.py +++ b/src/agentarts/sdk/memory/session.py @@ -198,7 +198,7 @@ def __repr__(self) -> str: Returns: str: Formatted session information """ - return f"MemorySession(space_id='{self.space_id}', session_id='{self.session_id}', region_name='{self.region_name}')" + return f"MemorySession(space_id='{self.space_id}', session_id='{self.session_id}', region_name='{self._region_name}')" # ==================== Message Management ==================== diff --git a/src/agentarts/sdk/runtime/context.py b/src/agentarts/sdk/runtime/context.py index f90f3b08..0bab0503 100644 --- a/src/agentarts/sdk/runtime/context.py +++ b/src/agentarts/sdk/runtime/context.py @@ -25,8 +25,8 @@ import asyncio import contextvars -from _contextvars import ContextVar from concurrent.futures import ThreadPoolExecutor +from contextvars import ContextVar from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field diff --git a/src/agentarts/sdk/service/iam_client.py b/src/agentarts/sdk/service/iam_client.py index da5208fe..15461fa5 100644 --- a/src/agentarts/sdk/service/iam_client.py +++ b/src/agentarts/sdk/service/iam_client.py @@ -264,7 +264,7 @@ def create_agency_with_policy( max_session_duration=max_session_duration, description=description ) - agency_id = create_response.agency_id + agency_id = create_response.agency.agency_id except Exception as e: if "409" not in str(e): raise diff --git a/src/agentarts/sdk/service/identity/identity_client.py b/src/agentarts/sdk/service/identity/identity_client.py index 454e3dfd..8d1222c1 100644 --- a/src/agentarts/sdk/service/identity/identity_client.py +++ b/src/agentarts/sdk/service/identity/identity_client.py @@ -4,6 +4,27 @@ from collections.abc import Callable from typing import Any, Literal +from huaweicloudsdkcore.exceptions.exceptions import ( + SdkException, + ServiceResponseException, +) +from huaweicloudsdkcore.http.http_config import HttpConfig +from huaweicloudsdkcore.region.region import Region +from huaweicloudsdkcore.retry.backoff_strategy import BackoffStrategies +from huaweicloudsdkcore.sdk_response import SdkResponse + +from agentarts.sdk.identity.types import ( + OAuth2Discovery, + OAuth2Vendor, + StsCredentials, +) +from agentarts.sdk.service.identity.polling.token_poller import ( + DefaultApiTokenPoller, + PollingResult, + PollingStatus, + TokenPoller, +) +from agentarts.sdk.utils.constant import get_identity_endpoint from huaweicloudsdkagentidentity.v1 import ( AgentIdentityClient, ApiKeyCredentialProvider, @@ -76,27 +97,6 @@ WorkloadIdentity, WorkloadIdentitySummary, ) -from huaweicloudsdkcore.exceptions.exceptions import ( - SdkException, - ServiceResponseException, -) -from huaweicloudsdkcore.http.http_config import HttpConfig -from huaweicloudsdkcore.region.region import Region -from huaweicloudsdkcore.retry.backoff_strategy import BackoffStrategies -from huaweicloudsdkcore.sdk_response import SdkResponse - -from agentarts.sdk.identity.types import ( - OAuth2Discovery, - OAuth2Vendor, - StsCredentials, -) -from agentarts.sdk.service.identity.polling.token_poller import ( - DefaultApiTokenPoller, - PollingResult, - PollingStatus, - TokenPoller, -) -from agentarts.sdk.utils.constant import get_identity_endpoint class IdentityClient: diff --git a/src/agentarts/sdk/service/runtime_client.py b/src/agentarts/sdk/service/runtime_client.py index abe0ffb0..45da9607 100644 --- a/src/agentarts/sdk/service/runtime_client.py +++ b/src/agentarts/sdk/service/runtime_client.py @@ -818,7 +818,7 @@ def upload_files( agent_name: str, session_id: str, files: list[dict[str, Any]], - path: str = "/home/user/", + path: str = "/tmp/", file_user_id: int | None = None, file_group_id: int | None = None, file_mode: str | None = None, @@ -840,7 +840,8 @@ def upload_files( files: List of file specs, each with "local_file" (local file path). path: Remote directory path (must end with '/'). For single file upload, this is the full remote file path. For multiple files, this is the - remote directory where files will be uploaded. + remote directory where files will be uploaded. Defaults to '/tmp/' + (writable in most container images, unlike /home/user). file_user_id: File owner user ID (None for backend default). file_group_id: File owner group ID (None for backend default). file_mode: File permissions mode in octal (None for backend default). @@ -1064,6 +1065,12 @@ def _request_stream( """ full_url = self._data_client._config.base_url + url + # Apply AK/SK signing (V11-HMAC-SHA256 for IAM agents) — same as + # BaseHTTPClient._request. Without this, download_files sent an + # unsigned request and the data-plane gateway rejected it (401). + if self._data_client._open_ak_sk: + kwargs = self._data_client._sign_request(method, full_url, **kwargs) + timeout = kwargs.pop("timeout", self._data_client._config.timeout) try: diff --git a/src/agentarts/sdk/utils/signer_v11.py b/src/agentarts/sdk/utils/signer_v11.py index f79946de..8c6cb9bb 100644 --- a/src/agentarts/sdk/utils/signer_v11.py +++ b/src/agentarts/sdk/utils/signer_v11.py @@ -76,7 +76,16 @@ def _canonical_uri(self, path: str) -> str: return url_path def _canonical_query_string(self, query_params: dict | None) -> str: - """Build canonical query string.""" + """Build canonical query string. + + .. warning:: + + The data-plane gateway does **not** include the query string in + the V11 canonical request (it may inject/rewrite query params en + route). ``sign()`` therefore signs an *empty* canonical query + line; this method is kept only as the standard-V11 reference + implementation and is **not** used to build the signature. + """ if not query_params: return "" @@ -88,13 +97,9 @@ def _canonical_query_string(self, query_params: dict | None) -> str: if isinstance(value, list): sorted_values = sorted(str(v) for v in value) for v in sorted_values: - # Values keep '/' unencoded: the data-plane gateway decodes - # the wire query and treats '/' as safe when re-canonicalising, - # so the signature must match that form (e.g. upload's `path` - # value like /home/user/test.txt). Keys never contain '/'. - arr.append(f"{ke}={quote(str(v), safe='~/')}") + arr.append(f"{ke}={self._urlencode(v)}") else: - arr.append(f"{ke}={quote(str(value), safe='~/')}") + arr.append(f"{ke}={self._urlencode(str(value))}") return "&".join(arr) def _canonical_headers(self, headers: dict[str, str], signed_headers: list[str]) -> str: @@ -168,10 +173,20 @@ def sign( signed_headers = self._signed_headers(headers) + # The data-plane gateway does NOT include the query string in the V11 + # canonical request (it may inject/rewrite query params en route, the + # same way it can rewrite Content-Type/Content-Length). Signing the + # query therefore makes the gateway's recomputation diverge and fails + # verification (HTTP 401) for *any* request carrying query params — + # e.g. upload's `path=/tmp/...` — while query-less requests + # (e.g. invoke) succeed. Verified against the real data-plane gateway: + # query signed -> 401; empty canonical query -> 200/4xx (auth passes). + # The query is still sent on the wire (by the HTTP client); only its + # participation in the signature is dropped. canonical_request = ( f"{method.upper()}\n" f"{self._canonical_uri(path)}\n" - f"{self._canonical_query_string(query_params)}\n" + f"\n" f"{self._canonical_headers(headers, signed_headers)}\n" f"{';'.join(signed_headers)}\n" f"UNSIGNED-PAYLOAD" diff --git a/src/agentarts/toolkit/cli/runtime/download_files.py b/src/agentarts/toolkit/cli/runtime/download_files.py index 6cc40145..e89a92d7 100644 --- a/src/agentarts/toolkit/cli/runtime/download_files.py +++ b/src/agentarts/toolkit/cli/runtime/download_files.py @@ -27,7 +27,7 @@ def download_files_cmd( output: Annotated[str | None, typer.Option("--output", "-o", help="Local output path")] = None, recursive: Annotated[bool, typer.Option("--recursive", help="Download directory as tar archive")] = False, bearer_token: Annotated[str | None, typer.Option("--bearer-token", "-bt", help="Bearer token for authentication")] = None, - region: Annotated[str | None, typer.Option("--region", help="Region name")] = None, + region: Annotated[str | None, typer.Option("--region", "-r", help="Region name")] = None, endpoint: Annotated[str | None, typer.Option("--endpoint", "-e", help="Endpoint name")] = None, skip_ssl_verification: Annotated[bool, typer.Option("--skip-ssl-verification", "-k", help="Skip SSL certificate verification")] = False, user_id: Annotated[str | None, typer.Option("--user-id", "-u", help="User ID for OAuth2 outbound credentials")] = None, @@ -50,13 +50,13 @@ def download_files_cmd( Examples: # Download single file - agentarts runtime download-files --agent myagent --session --path /home/user/data.txt + agentarts runtime download-files --agent myagent --session --path /tmp/data.txt # Download with custom output path - agentarts runtime download-files -a myagent -s -p /home/user/data.txt -o ./local_data.txt + agentarts runtime download-files -a myagent -s -p /tmp/data.txt -o ./local_data.txt # Download directory as tar - agentarts runtime download-files -a myagent -s -p /home/user/project --recursive + agentarts runtime download-files -a myagent -s -p /tmp/project --recursive # Use bearer token agentarts runtime download-files -a myagent -s -p /data/file.txt -bt diff --git a/src/agentarts/toolkit/cli/runtime/upload_files.py b/src/agentarts/toolkit/cli/runtime/upload_files.py index 5856da77..599d8d40 100644 --- a/src/agentarts/toolkit/cli/runtime/upload_files.py +++ b/src/agentarts/toolkit/cli/runtime/upload_files.py @@ -36,7 +36,7 @@ def upload_files_cmd( list[str] | None, typer.Option("--files", "-f", help="Local file path to upload. Can be specified multiple times for multiple files [required]"), ] = None, - path: Annotated[str, typer.Option("--path", "-p", help="Remote directory path to upload files to. Must end with '/' (e.g., /home/user/). Default: /home/user/")] = "/home/user/", + path: Annotated[str, typer.Option("--path", "-p", help="Remote directory path to upload files to. Must end with '/' (e.g., /tmp/). Default: /tmp/ — /tmp is writable in most container images, unlike /home/user which many images restrict.")] = "/tmp/", file_user_id: Annotated[int | None, typer.Option("--file-user-id", help="File owner user ID (default: 1000)")] = None, file_group_id: Annotated[int | None, typer.Option("--file-group-id", help="File owner group ID (default: 1000)")] = None, file_mode: Annotated[str | None, typer.Option("--file-mode", "-m", help="File permissions in octal (default: 0644)")] = None, @@ -62,7 +62,7 @@ def upload_files_cmd( enabled: true Examples: - # Single file (uploaded to default /home/user/) + # Single file (uploaded to default /tmp/) agentarts runtime upload-files --agent myagent --session -f file1.txt # Multiple files (use -f multiple times) diff --git a/src/agentarts/toolkit/operations/runtime/config.py b/src/agentarts/toolkit/operations/runtime/config.py index e04a1339..2dbd96d9 100644 --- a/src/agentarts/toolkit/operations/runtime/config.py +++ b/src/agentarts/toolkit/operations/runtime/config.py @@ -41,7 +41,9 @@ def detect_platform() -> str: if machine in ("arm64", "aarch64"): return "linux/arm64" - return "linux/amd64" + # Unknown architecture: default to linux/arm64 (the backend is + # predominantly arm). + return "linux/arm64" def detect_dependency_file() -> str: diff --git a/src/agentarts/toolkit/operations/runtime/init.py b/src/agentarts/toolkit/operations/runtime/init.py index f0226f5b..2630cb72 100644 --- a/src/agentarts/toolkit/operations/runtime/init.py +++ b/src/agentarts/toolkit/operations/runtime/init.py @@ -46,7 +46,9 @@ def detect_platform() -> str: if machine in ("arm64", "aarch64"): return "linux/arm64" - return "linux/amd64" + # Unknown architecture: default to linux/arm64 (the backend is + # predominantly arm). + return "linux/arm64" def init_project( diff --git a/src/agentarts/toolkit/operations/runtime/invoke.py b/src/agentarts/toolkit/operations/runtime/invoke.py index d314df03..b5859511 100644 --- a/src/agentarts/toolkit/operations/runtime/invoke.py +++ b/src/agentarts/toolkit/operations/runtime/invoke.py @@ -86,7 +86,10 @@ def _resolve_agent_info( region: Region (may be None) Returns: - Tuple of (agent_name, region, agent_id, auth_type) with resolved values + Tuple of (agent_name, region, agent_id, auth_type) with resolved values. + auth_type defaults to "IAM" when it cannot be resolved from the config + (no config file, or the agent has no identity_configuration), so the + data-plane commands sign with V11-HMAC-SHA256. """ agent_id = None auth_type = None @@ -123,7 +126,13 @@ def _resolve_agent_info( else: logger.info("Agent '%s' not found in config, using default IAM authentication", agent_name) auth_type = "IAM" - return agent_name, region, agent_id, auth_type + # Data-plane commands (start/stop session, invoke, exec-command, upload/download + # files) sign exclusively with V11-HMAC-SHA256; the SDK-HMAC-SHA256 branch is + # never used for signing. When auth_type can't be resolved from the config + # (no .agentarts_config.yaml, or the agent has no identity_configuration), + # default to "IAM" so these commands still sign with V11 instead of silently + # sending an unsigned request that the IAM data-plane gateway rejects (401). + return agent_name, region, agent_id, auth_type or "IAM" def _check_file_transfer_enabled( diff --git a/src/agentarts/toolkit/operations/runtime/upload_files.py b/src/agentarts/toolkit/operations/runtime/upload_files.py index 664da3e3..e6b94268 100644 --- a/src/agentarts/toolkit/operations/runtime/upload_files.py +++ b/src/agentarts/toolkit/operations/runtime/upload_files.py @@ -20,7 +20,7 @@ def upload_runtime_files( agent_name: str | None = None, session_id: str | None = None, files: list[dict[str, str]] | None = None, - path: str = "/home/user/", + path: str = "/tmp/", file_user_id: int | None = None, file_group_id: int | None = None, file_mode: str | None = None, @@ -37,7 +37,9 @@ def upload_runtime_files( agent_name: Agent name session_id: Session ID files: List of file specs with local_file - path: Remote directory path (must end with '/') + path: Remote directory path (must end with '/'). Defaults to '/tmp/' + since /tmp is writable in most container images, unlike /home/user + which many images restrict. file_user_id: File owner user ID (None for backend default) file_group_id: File owner group ID (None for backend default) file_mode: File permissions in octal (None for backend default) diff --git a/src/agentarts/toolkit/utils/runtime/config.py b/src/agentarts/toolkit/utils/runtime/config.py index 1b5188d7..59a27158 100644 --- a/src/agentarts/toolkit/utils/runtime/config.py +++ b/src/agentarts/toolkit/utils/runtime/config.py @@ -44,7 +44,12 @@ def detect_arch() -> ArchType: machine = platform_module.machine().lower() if machine in ("aarch64", "arm64"): return ArchType.ARM64 - return ArchType.X86_64 + if machine in ("x86_64", "amd64"): + return ArchType.X86_64 + # Unknown architecture: default to arm64 — the AgentArts backend is + # predominantly arm, so an unrecognised machine is more likely to target + # arm than x86. + return ArchType.ARM64 class AuthType(str, Enum): @@ -75,7 +80,7 @@ class BaseConfig(BaseModel): description="Huawei Cloud region", ) platform: str = Field( - default="linux/amd64", + default="linux/arm64", description="Platform of the AgentArts runtime", ) language: str = Field( @@ -527,8 +532,9 @@ class AgentArtsRuntimeConfig(BaseModel): description="Agent gateway ID", ) arch: ArchType = Field( - default=ArchType.X86_64, - description="Architecture type: arm64 or x86_64", + default=ArchType.ARM64, + description="Architecture type: arm64 or x86_64. Defaults to arm64 " + "(the AgentArts backend is predominantly arm).", ) execution_agency_name: str | None = Field( default=None, diff --git a/tests/integration/.env.example b/tests/integration/.env.example new file mode 100644 index 00000000..cf334d62 --- /dev/null +++ b/tests/integration/.env.example @@ -0,0 +1,55 @@ +# ---- Credentials (required for any cloud test) ---- +# Default tier (read-only) needs only AK/SK. Region has a built-in default +# (cn-southwest-2) but set it explicitly for correctness. +HUAWEICLOUD_SDK_AK= +HUAWEICLOUD_SDK_SK= +HUAWEICLOUD_SDK_REGION=cn-southwest-2 + +# Optional STS / project context +# HUAWEICLOUD_SDK_SECURITY_TOKEN= +# HUAWEICLOUD_SDK_PROJECT_ID= + +# ---- Service endpoints (override only if you target a non-default endpoint) ---- +# AGENTARTS_CONTROL_ENDPOINT= +# AGENTARTS_RUNTIME_DATA_ENDPOINT= +# AGENTARTS_MEMORY_DATA_ENDPOINT= +# AGENTARTS_CODEINTERPRETER_DATA_ENDPOINT= +# HUAWEICLOUD_SDK_AGENTIDENTITY_ENDPOINT= +# HUAWEICLOUD_SDK_IAM_ENDPOINT= +# HUAWEICLOUD_SDK_SWR_ENDPOINT= + +# ---- Three-tier safety switches ---- +# Default: only read-only + local RuntimeApp tests run (zero cloud writes). +# Set to 1 to additionally run create→delete lifecycle tests (cleanup-guaranteed). +AGENTARTS_TEST_ALLOW_CREATE=0 +# Set to 1 to run BILLABLE tests: code-interpreter sandbox + runtime invoke. +AGENTARTS_TEST_RUN_BILLABLE=0 +# Optional stable run id baked into resource names (default: random per session). +# AGENTARTS_TEST_RUN_ID= + +# ---- Build acceleration (deploy fixture) ---- +# PyPI index used inside the Docker build's `pip install`. agentarts-sdk pulls +# ~20 deps; official PyPI is ~160s from CN networks, a mirror ~30s. Default is +# the tsinghua mirror; set to '' (empty) to use official PyPI. +AGENTARTS_TEST_PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple/ + +# ---- Memory extraction wait (memory lifecycle tests) ---- +# Memory extraction is async; after add_messages(is_force_extract=True) the +# tests poll list_memories for this many seconds before retrieval/delete. +# 90s is usually enough; bump if your backend extracts slower. +# AGENTARTS_TEST_MEMORY_WAIT=90 + +# ---- Data-plane API keys (billable / data-plane tiers) ---- +# HUAWEICLOUD_SDK_MEMORY_API_KEY= # only if using a pre-existing space +# HUAWEICLOUD_SDK_CODE_INTERPRETER_API_KEY= + +# ---- Pre-provisioned resources for read-only / billable tiers ---- +# AGENTARTS_TEST_WORKLOAD_IDENTITY_NAME= # read-only get + token test +# AGENTARTS_TEST_CODE_INTERPRETER_NAME= # billable sandbox session +# AGENTARTS_TEST_RUNTIME_AGENT_NAME= # billable runtime session + +# ---- Conditional OAuth2 / STS provider inputs (lifecycle tier) ---- +# AGENTARTS_TEST_OAUTH2_CLIENT_ID= +# AGENTARTS_TEST_OAUTH2_CLIENT_SECRET= +# AGENTARTS_TEST_OAUTH2_VENDOR=GITHUBOAUTH2 +# AGENTARTS_TEST_STS_AGENCY_URN= diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 00000000..309200a5 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,418 @@ +# Integration / e2e tests + +Non-mock tests that hit **real Huawei Cloud APIs** (no HTTP mocking). They +verify the SDK wrapper layers end-to-end: `IdentityClient`, `RuntimeClient`, +`MemoryClient` / `AsyncMemoryClient`, `GatewayClient`, `CodeInterpreter`, +`AgentArtsRuntimeApp`, and the `require_*` auth decorators. + +## Three-tier safety model + +The core tension: verifying write operations (create/update/delete) requires +creating real resources, which conflicts with "no residue / no overspend". +The suite resolves this with three tiers, each gated by environment variables. + +| Tier | Switch | What runs | Cloud writes? | Cost | +|------|--------|-----------|---------------|------| +| **Default (read-only)** | — | `list`/`get` + ephemeral token issuance + local `AgentArtsRuntimeApp` (TestClient) | none | none | +| **Lifecycle** | `AGENTARTS_TEST_ALLOW_CREATE=1` | `create → get → update → delete` for every resource type | yes, teardown-guaranteed | low | +| **Billable** | `AGENTARTS_TEST_RUN_BILLABLE=1` | code-interpreter sandbox session, runtime `invoke`/`exec` | ephemeral sessions (paired start/stop) | real money | + +Tests skip automatically (with a clear message listing the required vars) when +their gate is not satisfied, so `pytest tests/integration` is always safe to +run. + +## Running + +```bash +# Default tier — no credentials needed for the local RuntimeApp tests; +# cloud read-only tests skip without AK/SK. +uv run pytest tests/integration -m integration + +# Read-only tier — real list/get calls, no writes. +export HUAWEICLOUD_SDK_AK=... +export HUAWEICLOUD_SDK_SK=... +export HUAWEICLOUD_SDK_REGION=cn-southwest-2 +uv run pytest tests/integration -m integration + +# Full lifecycle (create→delete). +export AGENTARTS_TEST_ALLOW_CREATE=1 +uv run pytest tests/integration -m integration + +# Billable sandbox/runtime sessions. +export AGENTARTS_TEST_RUN_BILLABLE=1 +uv run pytest tests/integration -m "integration and slow" # code-interpreter / runtime sessions +``` + +See `.env.example` for the full variable set. The default tier needs only +`HUAWEICLOUD_SDK_AK` / `HUAWEICLOUD_SDK_SK` / `HUAWEICLOUD_SDK_REGION`. + +## Resource hygiene + +- Every created resource is registered with a session-scoped + `resource_registry`; at session end it calls each deleter **in reverse + order**, swallowing errors so a failing cleanup never masks a real failure. +- All resource names are prefixed `aa-it--…`, so any leaked resource is + greppable for manual cleanup. +- The Memory suite is self-contained: `create_space` mints the space's own + data-plane API key, so no pre-existing `HUAWEICLOUD_SDK_MEMORY_API_KEY` is + needed; `delete_space` cascades to all sessions/messages/memories. +- Billable sessions use the `code_session` context manager (auto `stop_session` + on exit) and register `stop_session` with the registry as a safety net. +- The auth decorators' local-auth bootstrap persists `.agent_identity.json`; + `isolated_identity_config` chdir's into a temp dir so the repo is never + polluted, and a pre-seeded `Config` makes the bootstrap reuse the session + workload identity (no extra create). + +### Cleaning up leaked resources + +If a run is interrupted before teardown, find leftovers by the run prefix: + +```bash +# Identity +# (list via the SDK) IdentityClient.list_workload_identities() / list_*_credential_providers() +# filter for names starting with "aa-it-" + +# Memory spaces +# MemoryClient.list_spaces() → delete spaces whose name starts with "aa-it-" + +# MCP gateways +# GatewayClient.list_gateways() → delete gateways whose name starts with "aa-it-" + +# Runtime agents +# RuntimeClient.get_agents() → delete agents whose name starts with "aa-it-" + +# Code interpreters +# CodeInterpreter.list_code_interpreters() → delete those whose name starts with "aa-it-" +``` + +> **Note:** `GatewayClient.create_gateway` auto-creates a shared IAM +> agency `AgentArtsCoreGateway` (409-ignored if it already exists) which the +> SDK intentionally does **not** delete. This single shared agency is expected +> residue. + +## What is deliberately NOT covered + +- OAuth2 3-legged `require_access_token` — interactive browser round trip; + covered by unit tests for wiring, e2e is a manual exercise (marked `slow`). +- `IAMClient.create_agency` — only a create wrapper, no read/delete in the SDK; + exercised implicitly only via MCP gateway's shared-agency path. + +## Method coverage + +**Overview**: 69 tests across 12 files. Real-cloud run (`ALLOW_CREATE=1`, no +`RUN_BILLABLE`): **54 passed / 13 skipped / 0 xfailed / 2 deselected**. + +Status legend: ✅ real-cloud pass · 🟦 local pass · ⏭ conditional skip · +⚠️ xfail (SDK bug) · 🚫 skip (backend prereq) · 💰 requires `RUN_BILLABLE=1` + +### AgentArtsRuntimeApp (local, 🟦) + +| Method / endpoint | Test | Status | +|---|---|---| +| `@app.entrypoint` / `@app.ping` / `@app.websocket` decorators | various | 🟦 | +| `force_ping_status()` / `get_current_ping_status()` | test_ping_* | 🟦 | +| `GET /ping` (default / custom / forced) | test_ping_* (3) | 🟦 | +| `POST /invocations` (JSON 200 / bad-JSON 400 / no-entrypoint 404 / raise 500) | test_invocation_* (4) | 🟦 | +| `POST /invocations` (sync + async generator → SSE) | test_invocation_*_streams_sse (2) | 🟦 | +| `WS /ws` (no-handler 1011 / echo) | test_websocket_* (2) | 🟦 | + +Not covered: `@app.async_task`, `has_running_tasks()`, `run()`. + +### IdentityClient + +| Method | Test | HTTP | Status | +|---|---|---|---| +| `list_workload_identities` | readonly + lifecycle | GET /v1/workload-identities | ✅ | +| `create_workload_identity` | fixture | POST /v1/workload-identities | ✅ | +| `get_workload_identity` | test_get_created_workload_identity | GET …/{name} | ✅ | +| `update_workload_identity` | test_update_workload_identity | PUT …/{name} | ✅ | +| `create_api_key_credential_provider` | fixture | POST /v1/api-key-credential-providers | ✅ | +| `get_api_key_credential_provider` | test_get_api_key_… | GET …/{name} | ✅ | +| `list_api_key_credential_providers` | test_list_api_key_…_contains | GET … | ✅ | +| `create_workload_access_token` | test_create_workload_access_token / test_get_resource_api_key | POST …/for-user-id | ✅ | +| `get_resource_api_key` | test_get_resource_api_key | POST /v1/api-key | ✅ | +| `create/get_oauth2_credential_provider` | test_create_and_delete_oauth2… | POST/GET …/oauth2… | ⏭ (slow) | +| `create/get_sts_credential_provider` | test_create_and_delete_sts… | POST/GET …/sts… | ⏭ | +| `get_workload_identity` / `create_workload_access_token` (pre-provisioned) | test_get_and_token… | GET/POST | ⏭ | +| raw `delete_workload_identity` / `delete_*_credential_provider` | resource_registry teardown | DELETE … | ✅ (implicit) | + +Not covered: `get_resource_oauth2_token` (3LO), `get_resource_sts_token`, `complete_resource_token_auth`, `update_*_credential_provider`. + +### MemoryClient (sync) — full coverage ✅ + +| Method | Test | HTTP | Status | +|---|---|---|---| +| `create_space` (with `memory_strategies_builtin`) | fixture | POST /v1/core/spaces (+space-keys) | ✅ | +| `get_space` | test_get_space | GET /v1/core/spaces/{id} | ✅ | +| `list_spaces` | test_list_spaces_contains_created | GET /v1/core/spaces | ✅ | +| `update_space` | test_update_space | PUT /v1/core/spaces/{id} | ✅ | +| `delete_space` | teardown | DELETE /v1/core/spaces/{id} | ✅ (implicit) | +| `create_memory_session` | fixture | POST …/sessions | ✅ | +| `add_messages` (2× `TextMessage`) | fixture | POST …/messages | ✅ | +| `list_messages` | test_list_messages | GET …/messages | ✅ | +| `get_last_k_messages` | test_get_last_k_messages | GET …/messages ×2 | ✅ | +| `get_message` | test_get_message | GET …/messages/{id} | ✅ | +| `search_memories` | test_search_memories | POST …/memories/search | ✅ | +| `list_memories` | test_list_memories | GET …/memories | ✅ | +| `delete_memory` | test_delete_memory_if_any | DELETE …/memories/{id} | ⏭ (no extracted memories) | + +### AsyncMemoryClient + +| Method | Test | Status | +|---|---|---| +| `create_memory_session` (async) | test_async_create_session_and_add_messages | ✅ | +| `add_messages` (async) | same | ✅ | +| `list_messages` (async) | test_async_list_messages + above | ✅ | +| `get_last_k_messages` (async) | test_async_get_last_k_messages | ✅ | +| `get_message` (async) | test_async_get_message | ✅ | +| `search_memories` (async) | test_async_search_memories | ✅ | +| `list_memories` (async) | test_async_list_memories | ✅ | +| `delete_memory` (async) | test_async_delete_memory_if_any | ⏭ | + +Not covered: AsyncMemoryClient's control-plane methods (`create_space`/`get_space`/`list_spaces`/`update_space`/`delete_space` — sync on this class) are not exercised on the async instance; they share `_ControlPlane` with the sync client, so coverage is transitive. + +### MemorySession / AsyncMemorySession wrappers + +| Method | Test | Status | +|---|---|---| +| constructor (auto-create session) | test_memory_session_wrapper / test_async_session_wrapper | ✅ | +| `add_messages` | same | ✅ | +| `get_last_k_messages` | same | ✅ | +| `list_messages` | same | ✅ | + +Not covered: wrapper `get_message` / `search_memories` / `list_memories` / `get_memory` / `delete_memory`, `of()` factory. + +### GatewayClient (renamed from MCPGatewayClient on main) + +| Method | HTTP | Status | +|---|---|---| +| `create_gateway` (+ auto IAM agency via `create_agency_with_policy`) | POST /v1/core/gateways | ✅ | +| `get_/list_/update_gateway` | GET/GET/PUT …/gateways | ✅ | +| `create/get/list/update/delete_gateway_target` | …/targets | ✅ | +| `delete_gateway` | DELETE …/gateways/{id} | ✅ (teardown) | +| `list_gateways(limit=1)` read-only | GET …/gateways | ✅ (test_readonly_lists) | + +The earlier `trust_policy` rejection (PAP5.0011) was fixed upstream on main via +`create_agency_with_policy` (auto policy attachment). A follow-on bug — +`CreateAgencyV5Response` has no `agency_id` (it's nested under `.agency`) — was +found by this suite and fixed in `iam_client.py` (see bugs table). The shared +IAM agency `AgentArtsCoreGateway` is auto-created and intentionally not deleted +(accepted residue). + +### RuntimeClient + +Control plane — dual-mode (no Docker needed in standalone mode): + +| Method | Status | +|---|---| +| `find_agent_by_name` / `find_agent_by_id` | ✅ (standalone env var OR reuse deploy) | +| `get_agents(limit=10)` | ✅ (read-only + lifecycle) | +| `update_agent` | ✅ (mutates the target agent; ALLOW_CREATE) | +| `create/update/delete/find_agent_endpoint` | ✅ (endpoint created + cleaned up; ALLOW_CREATE) | +| `create_agent` / `create_or_update_agent` / `delete_agent_by_name` | covered transitively by the `deployed_runtime_agent` fixture's `deploy`/`destroy` (create needs `artifact_source_config`, only deploy provides) | + +The target agent is supplied two ways (either suffices, so Docker unavailability +doesn't block): **standalone** — `AGENTARTS_TEST_RUNTIME_AGENT_NAME` points at a +pre-provisioned agent (no Docker, no billable); **reuse** — fall back to the +shared `deployed_runtime_agent` fixture (Docker + RUN_BILLABLE). The agent is +not created/deleted by these tests (only the endpoint is, with teardown). + +Data plane (💰 `RUN_BILLABLE`): + +| Method | Test | HTTP | Status | +|---|---|---|---| +| `start_session` | test_runtime_session_upload_download | POST …/sessions-start | 💰 | +| `exec_command` | same | POST …/commands | 💰 | +| `upload_files` | same | POST …/upload-files | 💰 | +| `download_files` | same | GET …/download-files | 💰 | +| `stop_session` | same + teardown | POST …/sessions-stop | 💰 | + +Not covered: `invoke_agent`, `create_or_update_agent` (control plane). + +### CodeInterpreter + +Control plane (✅ full): + +| Method | Test | HTTP | Status | +|---|---|---|---| +| `create_code_interpreter` | fixture | POST /v1/core/code-interpreters | ✅ | +| `get_code_interpreter` | test_get_code_interpreter | GET …/{id} | ✅ | +| `list_code_interpreters` | test_list_… + read-only | GET … | ✅ | +| `update_code_interpreter` | test_update_code_interpreter | PUT …/{id} | ✅ | +| `delete_code_interpreter` | teardown | DELETE …/{id} | ✅ (implicit) | + +Data plane (💰 `RUN_BILLABLE`, all in one `code_session`): + +| Method | Test | Status | +|---|---|---| +| `code_session` ctx manager | test_code_session_full_workflow | 💰 | +| `start_session` / `stop_session` (via code_session) | same | 💰 | +| `execute_code` | same | 💰 | +| `execute_command` | same | 💰 | +| `upload_file` / `download_file` (round-trip) | same | 💰 | +| `get_session` | same | 💰 | +| `clear_context` | same | 💰 | + +Not covered: `upload_files` / `download_files` (multi-file), `install_packages`, `invoke` (raw). + +### Auth decorators + Config + +| Method | Test | Status | +|---|---|---| +| `require_api_key` | test_require_api_key_injects_key | ✅ | +| `require_sts_token` | test_require_sts_token_injects_credentials | ⏭ | +| `require_access_token` (3LO) | test_require_access_token_3lo_is_manual | ⏭ (slow) | +| `Config.load` / `Config.save` | seeded_identity_config fixture | ✅ (implicit) | + +### Coverage by tier + +| Client | Read-only | Lifecycle | Billable | +|---|---|---|---| +| AgentArtsRuntimeApp | 🟦 full | — | — | +| IdentityClient | ✅ list | ✅ CRUD + token | — | +| MemoryClient (sync) | — | ✅ full (13) | — | +| AsyncMemoryClient | — | ✅ 8 | — | +| MemorySession / Async | — | ✅ 4 + 4 | — | +| GatewayClient | ✅ list | ✅ lifecycle | — | +| RuntimeClient control | ✅ get_agents | ✅ CRUD (standalone env var OR reuse deploy) | — | +| RuntimeClient data | — | — | 💰 5 | +| CodeInterpreter control | ✅ list | ✅ full (5) | — | +| CodeInterpreter data | — | — | 💰 8 | +| Auth decorators | — | ✅ api_key | — | +| Config | — | ✅ | — | + +### SDK bugs found by this suite + +| # | Bug | Fix commit | Status | +|---|---|---|---| +| 1 | `MemoryClient` control-plane methods referenced `self._data_plane._region_name` (non-existent) → AttributeError | `f82c936` | ✅ fixed + cloud-verified | +| 2 | `MemorySession.__repr__` referenced `self.region_name` (no property) → AttributeError | `f82c936` | ✅ fixed + local-verified | +| 3 | `MemorySession` / `AsyncMemorySession` passed `session_config.to_dict()` (dict) to data-plane `create_memory_session`, which expects a `SessionCreateRequest` object → AttributeError | `e5a330d` | ✅ fixed + cloud-verified | +| 4 | `GatewayClient` (then `MCPGatewayClient`) auto-agency `trust_policy` rejected by IAM (PAP5.0011) | upstream (main) | ✅ fixed upstream via `create_agency_with_policy` | +| 5 | `IAMClient.create_agency_with_policy` read `create_response.agency_id` but `CreateAgencyV5Response` nests it under `.agency` → AttributeError after the agency was already created | `feat/i...` (this branch) | ✅ fixed + cloud-verified | + +### Remaining coverage gaps + +1. `AgentArtsRuntimeApp`: `@app.async_task`, `has_running_tasks()`, `run()`. +2. `IdentityClient`: `get_resource_oauth2_token` (3LO), `get_resource_sts_token`, `complete_resource_token_auth`, `update_*_credential_provider`. +3. `AsyncMemoryClient` control-plane methods (transitive coverage only). +4. `MemorySession` / `AsyncMemorySession`: `get_message` / `search_memories` / `list_memories` / `get_memory` / `delete_memory`, `of()` factory. +5. ~~MCP gateway full lifecycle (pending `trust_policy` fix)~~ — resolved on main + the `agency_id` fix; gateway lifecycle now passes. +6. ~~Runtime agent control-plane CRUD (pending deployable artifact)~~ — now dual-mode: `AGENTARTS_TEST_RUNTIME_AGENT_NAME` (standalone, no Docker) or reuse `deployed_runtime_agent`; `create_agent`/`delete_agent_by_name` covered transitively via deploy/destroy. +7. Runtime data-plane `invoke_agent`. +8. CodeInterpreter: `upload_files` / `download_files` (multi-file), `install_packages`, `invoke` (raw). +9. `IAMClient.create_agency` (only touched indirectly via MCP, broken by the policy bug). + +## Toolkit (CLI) tests + +Tests under `tests/integration/toolkit/` exercise the real `agentarts` CLI +(Typer app) end-to-end — no mocking of operations or SDK clients. They cover the +`toolkit` layer that the SDK-only tests above do not. + +### Invocation styles + +Two ways the CLI is driven, chosen per test: + +- **`cli_runner` (in-process `typer.testing.CliRunner`)** — fast; used for local + commands (`init`, `config`) where assertions are on generated files, not stdout + (rich output capture is unreliable under CliRunner). +- **`agentarts_cmd` + `cli_env` (subprocess)** — invokes the real console entry + (`python -c "from agentarts.toolkit.main import app; app()" …`). Reliable + stdout capture (no TTY → rich emits plain text); used for cloud commands whose + output must be parsed (`memory create --output json`) and for the blocking + `dev` server. + +### Completion handling + +The CLI's `_auto_install_completion` touches `~/.agentarts` on first run, and +setting `_AGENTARTS_COMPLETE` (the obvious skip) instead trips click's +completion protocol ("Invalid completion instruction"). So: + +- in-process (`cli_runner`): `monkeypatch.setattr` `_auto_install_completion` to + a no-op; +- subprocess (`cli_env`): `HOME` is redirected to a temp dir with the + `.agentarts/.completion_shown` marker pre-created, so the install is skipped + and no tip text pollutes stdout (important for `--output json` parsing). + +For `config`/`init`, every flag the callback would otherwise `Prompt.ask` for is +passed explicitly (CliRunner has no stdin → an unhandled prompt aborts with +exit 1). For `dev` (blocking uvicorn), the test scaffolds a basic project via +`init`, launches `agentarts dev` in a subprocess on a free port, polls `/ping`, +POSTs `/invocations`, then terminates the process. + +### Toolkit command coverage + +Status: ✅ real-cloud/local pass · ⏭ conditional skip · ⚠️ xfail (SDK bug) · +💰 requires `RUN_BILLABLE=1` · 🚫 skip (prereq). + +| CLI command | Style | Test | Status | +|---|---|---|---| +| `--version` / `--help` | CliRunner | test_cli_version / test_cli_help | ✅ | +| `init -n … -t {basic,langgraph,langchain,google-adk}` | CliRunner | test_init_creates_project_files (×4) | ✅ | +| `init -p` / invalid name | CliRunner | test_init_path_option / test_init_invalid_name_fails | ✅ | +| `config` (add agent) | CliRunner | test_config_add_writes_yaml_and_lists | ✅ | +| `config set` / `config get` | CliRunner | test_config_set_get_roundtrip | ✅ | +| `config set-env` / `list-env` / `remove-env` | CliRunner | test_config_env_lifecycle | ✅ | +| `config set-default` / `remove` | CliRunner | test_config_set_default_and_remove | ✅ | +| `dev` (uvicorn server) | subprocess | test_dev_server_serves_ping_and_invocations | ✅ | +| `memory list` | subprocess | test_cli_memory_list_readonly | ✅ | +| `memory create/list/get/update/delete` | subprocess | test_cli_memory_lifecycle | ✅ (ALLOW_CREATE) | +| `gateway list` | subprocess | test_cli_gateway_list_readonly | ✅ | +| `gateway create` | subprocess | test_cli_gateway_create | ✅ (ALLOW_CREATE) | +| `init → config → deploy` (shared `deployed_runtime_agent` fixture) | subprocess | test_deploy_succeeds | 💰 (Docker + ALLOW_CREATE + RUN_BILLABLE) | +| `invoke --mode cloud` (on the deployed agent) | subprocess | test_invoke_deployed_agent | 💰 (shares the deploy) | +| `runtime start/exec/upload/download/stop-session` (on the deployed agent) | subprocess | test_runtime_session_on_deployed_agent | 💰 (shares the deploy) | +| `destroy` | subprocess | `deployed_runtime_agent` session-end teardown | 💰 (safety-net) | + +> The billable runtime tests no longer require a separately pre-provisioned +> agent — a single session-scoped `deployed_runtime_agent` fixture runs +> `init→config→deploy` (one Docker build) and the invoke / runtime-session +> tests reuse that live agent; `destroy` runs as the fixture's session-end +> teardown. SWR org/repo/image persist (documented residue). + +### Toolkit not covered + +- `deploy`/`launch` local mode (`--mode local`) — needs Docker daemon to run a + local container; cloud-mode `deploy` IS covered (gated) via the shared + `deployed_runtime_agent` fixture. Cloud `deploy` auto-creates an SWR org/repo + and pushes an image — the operations layer exposes no SWR cleanup, so those + are accepted, documented residue (the cloud agent itself is destroyed by the + fixture teardown). +- `destroy` standalone — destructive; covered as the `deployed_runtime_agent` + fixture's session-end teardown (registered with `resource_registry`). +- `gateway` target subcommands beyond `create` (update/delete/get/list-targets) + — not yet added (the SDK target CRUD is covered in test_gateway_lifecycle). +- `runtime`/`memory`/`gateway` subcommands beyond the ones listed above + (e.g. `memory status`, `gateway *-target` via CLI) — not yet added. + +### Toolkit test results + +Real-cloud run (`ALLOW_CREATE=1`, no `RUN_BILLABLE`): **17 passed / 3 skipped +(runtime billable) / 0 xfailed**. With Docker + `RUN_BILLABLE=1` the deploy +fixture runs for real: full suite **78 passed / 10 skipped / 0 failed**. + +### Build acceleration & local-image cleanup (deploy fixture) + +The deploy's slow step is the Docker build's `pip install agentarts-sdk` (~20 +transitive deps). Measured cold install in `python:3.10-slim`: + +| PyPI source | cold `pip install agentarts-sdk` | +|---|---| +| official PyPI (from CN) | ~160 s | +| tsinghua mirror | ~30 s | + +The fixture edits the generated `Dockerfile` to use a fast index +(`AGENTARTS_TEST_PIP_INDEX`, default tsinghua) — a 5× speedup. Docker layer +caching then makes subsequent builds (same `requirements.txt`) near-instant. + +The fixture also cleans up the local Docker image at session end: it inspects +the built image's **all** `RepoTags` (the local `:latest` **and** the +`//:latest` tag created for push) and force-removes +them, so 355 MB images don't pile up across runs. (The pushed SWR image itself +is remote residue — no SDK cleanup — and is documented.) + +> Other acceleration options (not implemented, for heavier repeated-deploy +> scenarios): a pre-built base image with the SDK pre-installed (one-time +> `pip install`, then agent builds skip it); BuildKit `--mount=type=cache` for +> pip downloads; pinning/slimming the SDK's heavy deps (e.g. `pymongo`). + diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..29e4fd5c --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,6 @@ +"""Integration / e2e tests for the agentarts SDK. + +These tests hit REAL Huawei Cloud APIs (no HTTP mocking). They are gated by +environment variables and a three-tier safety model — see ``README.md`` and +``conftest.py`` for details. +""" diff --git a/tests/integration/_helpers.py b/tests/integration/_helpers.py new file mode 100644 index 00000000..896c0d42 --- /dev/null +++ b/tests/integration/_helpers.py @@ -0,0 +1,104 @@ +"""Shared helpers for integration tests. + +Kept dependency-free (stdlib only) so it can be imported from anywhere, +including ``conftest.py``. +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Callable +from typing import Any + +# Prefix for every resource created by this suite. Makes leaked resources +# greppable for manual cleanup: `list_* | grep aa-it-` +RESOURCE_PREFIX = "aa-it" + +# Env vars that flip the three-tier safety model on/off. +ENV_ALLOW_CREATE = "AGENTARTS_TEST_ALLOW_CREATE" +ENV_RUN_BILLABLE = "AGENTARTS_TEST_RUN_BILLABLE" +ENV_RUN_ID = "AGENTARTS_TEST_RUN_ID" + +# Credential / scenario env vars. +ENV_AK = "HUAWEICLOUD_SDK_AK" +ENV_SK = "HUAWEICLOUD_SDK_SK" +ENV_REGION = "HUAWEICLOUD_SDK_REGION" +ENV_MEMORY_API_KEY = "HUAWEICLOUD_SDK_MEMORY_API_KEY" +ENV_CODE_INTERPRETER_API_KEY = "HUAWEICLOUD_SDK_CODE_INTERPRETER_API_KEY" +ENV_PRE_WORKLOAD_IDENTITY = "AGENTARTS_TEST_WORKLOAD_IDENTITY_NAME" +ENV_RUNTIME_AGENT_NAME = "AGENTARTS_TEST_RUNTIME_AGENT_NAME" + +_TRUTHY = {"1", "true", "yes", "on"} + + +def env_truthy(name: str) -> bool: + """True if env var ``name`` is set to a truthy value (1/true/yes/on).""" + return os.getenv(name, "").strip().lower() in _TRUTHY + + +def require_env(name: str) -> str: + """Return env var ``name`` or raise — used inside gated fixtures only.""" + value = os.getenv(name) + if not value: + msg = f"Required env var {name} is not set" + raise RuntimeError(msg) + return value + + +def unique_name(kind: str, run_id: str, max_len: int = 40) -> str: + """Build a run-scoped resource name. + + ``kind`` should be short and lowercase (e.g. ``"wi"``, ``"space"``, + ``"ci"``). The result is guaranteed to satisfy the strictest naming rule + in the SDK (code-interpreter: ``[a-z][a-z0-9-]{0,38}[a-z0-9]``). + """ + raw = f"{RESOURCE_PREFIX}-{run_id}-{kind}" + if len(raw) > max_len: + raw = raw[:max_len] + # ensure it ends with a lowercase letter/digit (trim trailing hyphens) + return raw.rstrip("-") + + +def wait_for( + predicate: Callable[[], Any], + *, + timeout: float = 60.0, + interval: float = 2.0, + desc: str = "condition", +) -> Any: + """Poll ``predicate`` until it returns truthy or ``timeout`` elapses. + + Returns the last truthy result. Raises ``TimeoutError`` if it never does. + """ + deadline = time.monotonic() + timeout + last: Any = None + while time.monotonic() < deadline: + try: + last = predicate() + except Exception: + last = None + if last: + return last + time.sleep(interval) + msg = f"Timed out after {timeout}s waiting for {desc}" + raise TimeoutError(msg) + + +def safe_delete(deleter: Callable[[], Any], desc: str) -> None: + """Run a cleanup callable, swallowing+logging errors. + + Cleanup must never crash the suite — a leaked resource is preferable to a + masked test failure. Leaked resources are greppable via ``RESOURCE_PREFIX``. + """ + try: + deleter() + except Exception as exc: + import logging + + logging.getLogger("agentarts.integration").warning( + "cleanup FAILED for %s: %s — resource may leak (grep '%s')", + desc, + exc, + RESOURCE_PREFIX, + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 00000000..6b916c83 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,492 @@ +"""Shared fixtures for the non-mock integration / e2e suite. + +Safety model (three tiers, see README.md): + * default ............ read-only list/get + local RuntimeApp (no cloud writes) + * ALLOW_CREATE=1 ..... create→get→update→delete lifecycle, teardown-guaranteed + * RUN_BILLABLE=1 ..... code-interpreter sandbox + runtime invoke (real money) + +Every resource created by a test is registered with the session-scoped +``resource_registry``; at session end the registry calls each deleter in reverse +order, swallowing errors so a failing cleanup never masks a real failure. + +Credential auto-loading: ``tests/integration/.env`` is loaded into ``os.environ`` +at collection time via python-dotenv (already a dev dependency). Existing +environment variables always win (``override=False``), so real shell exports +take precedence over the file. This lets you keep AK/SK / region / tier flags +in ``.env`` (gitignored) instead of having to ``source`` it before every run. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import uuid +from pathlib import Path +from types import SimpleNamespace + +import pytest +from dotenv import load_dotenv + +# Auto-load .env so credentials / endpoints / tier flags configured there are +# visible to the cloud_credentials / allow_* fixtures below. No-op if the file +# is absent; never overrides already-set env vars. +load_dotenv(Path(__file__).resolve().parent / ".env", override=False) + +from tests.integration._helpers import ( + ENV_AK, + ENV_ALLOW_CREATE, + ENV_CODE_INTERPRETER_API_KEY, + ENV_PRE_WORKLOAD_IDENTITY, + ENV_REGION, + ENV_RUN_BILLABLE, + ENV_RUNTIME_AGENT_NAME, + ENV_SK, + env_truthy, + safe_delete, +) + + +# --------------------------------------------------------------------------- # +# Auto-mark every test under tests/integration with `integration`. +# --------------------------------------------------------------------------- # +def pytest_collection_modifyitems(config, items) -> None: + marker = pytest.mark.integration + for item in items: + path = str(item.fspath).replace("\\", "/") + if "tests/integration" in path: + item.add_marker(marker) + + +# --------------------------------------------------------------------------- # +# Run identity + cleanup registry +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="session") +def run_id() -> str: + """A short, run-scoped id baked into every resource name (grep target).""" + return os.getenv("AGENTARTS_TEST_RUN_ID") or uuid.uuid4().hex[:8] + + +class _ResourceRegistry: + """LIFO registry of cleanup callables, drained at session end.""" + + def __init__(self) -> None: + self._items: list[tuple[object, str]] = [] + + def register(self, deleter, desc: str) -> None: + self._items.append((deleter, desc)) + + def cleanup_all(self) -> None: + for deleter, desc in reversed(self._items): + safe_delete(deleter, desc) + self._items.clear() + + +@pytest.fixture(scope="session") +def resource_registry() -> _ResourceRegistry: + reg = _ResourceRegistry() + yield reg + reg.cleanup_all() + + +# --------------------------------------------------------------------------- # +# Credential / tier gates +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="session") +def cloud_credentials(): + """Gate: skip unless AK/SK are present. Region has a sane default.""" + ak = os.getenv(ENV_AK) + sk = os.getenv(ENV_SK) + if not ak or not sk: + pytest.skip( + f"Set {ENV_AK} and {ENV_SK} (and optionally {ENV_REGION}) to run " + "cloud integration tests" + ) + from agentarts.sdk.utils.constant import get_region + + return {"ak": ak, "sk": sk, "region": get_region()} + + +@pytest.fixture(scope="session") +def allow_create(): + if not env_truthy(ENV_ALLOW_CREATE): + pytest.skip( + f"Set {ENV_ALLOW_CREATE}=1 to run create/delete lifecycle tests " + "(they create real cloud resources, cleaned up via resource_registry)" + ) + return True + + +@pytest.fixture(scope="session") +def allow_billable(): + if not env_truthy(ENV_RUN_BILLABLE): + pytest.skip( + f"Set {ENV_RUN_BILLABLE}=1 to run billable tests " + "(code-interpreter sandbox / runtime invoke cost real money)" + ) + return True + + +@pytest.fixture(scope="session") +def pre_workload_identity(): + name = os.getenv(ENV_PRE_WORKLOAD_IDENTITY) + if not name: + pytest.skip( + f"Set {ENV_PRE_WORKLOAD_IDENTITY} to exercise get/token against a " + "pre-provisioned workload identity (read-only tier)" + ) + return name + + +@pytest.fixture(scope="session") +def code_interpreter_api_key(): + key = os.getenv(ENV_CODE_INTERPRETER_API_KEY) + if not key: + pytest.skip( + f"Set {ENV_CODE_INTERPRETER_API_KEY} to run code-interpreter data-plane tests" + ) + return key + + +@pytest.fixture(scope="session") +def runtime_agent_name(): + name = os.getenv(ENV_RUNTIME_AGENT_NAME) + if not name: + pytest.skip( + f"Set {ENV_RUNTIME_AGENT_NAME} to a pre-deployed agent for " + "runtime data-plane (session/invoke) tests" + ) + return name + + +@pytest.fixture(scope="session") +def code_interpreter_name(): + name = os.getenv("AGENTARTS_TEST_CODE_INTERPRETER_NAME") + if not name: + pytest.skip( + "Set AGENTARTS_TEST_CODE_INTERPRETER_NAME to a pre-provisioned " + "code interpreter for the billable sandbox-session test" + ) + return name + + +@pytest.fixture +def sts_agency_urn(): + urn = os.getenv("AGENTARTS_TEST_STS_AGENCY_URN") + if not urn: + pytest.skip( + "Set AGENTARTS_TEST_STS_AGENCY_URN (iam::) to exercise " + "STS credential-provider lifecycle" + ) + return urn + + +# --------------------------------------------------------------------------- # +# Shared clients (session-scoped → one connection pool, minimal handshakes) +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="session") +def identity_client(cloud_credentials): + from agentarts.sdk import IdentityClient + + return IdentityClient(region=cloud_credentials["region"]) + + +@pytest.fixture(scope="session") +def runtime_client(cloud_credentials): + from agentarts.sdk.service.runtime_client import RuntimeClient + + return RuntimeClient() # control-plane endpoint + AK/SK from env + + +@pytest.fixture(scope="session") +def gateway_client(cloud_credentials): + from agentarts.sdk import GatewayClient + + return GatewayClient() + + +@pytest.fixture(scope="session") +def memory_control_client(cloud_credentials): + """MemoryClient for control-plane ops (create/delete space). AK/SK from env.""" + from agentarts.sdk import MemoryClient + + return MemoryClient() + + +@pytest.fixture(scope="session") +def memory_space(memory_control_client, allow_create, run_id, resource_registry): + """A shared Memory Space for the whole session (sync + async modules reuse it). + + create_space mints the Space's own data-plane API key, so downstream data + clients need no pre-existing memory API key. delete_space (registered for + session-end cleanup) cascades to all sessions/messages/memories. + """ + from tests.integration._helpers import unique_name + + name = unique_name("space", run_id) + space = memory_control_client.create_space( + name=name, + message_ttl_hours=168, + # Configure extraction triggers (the backend has extraction ON by + # default; these tune WHEN/HOW it fires so memories appear within the + # test window): extract after 10s idle, cap at 4096 tokens / 100 msgs. + memory_strategies_builtin=["semantic"], + memory_extract_idle_seconds=10, + memory_extract_max_tokens=4096, + memory_extract_max_messages=100, + ) + resource_registry.register( + lambda: memory_control_client.delete_space(space.id), f"space:{name}" + ) + return space + + +# --------------------------------------------------------------------------- # +# Identity cleanup helpers (the SDK has no delete wrappers → drop to raw client) +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="session") +def identity_cleanup(identity_client): + """Bound deleters for workload identities & credential providers. + + The high-level ``IdentityClient`` exposes no ``delete_*`` methods, so we + call the generated ``AgentIdentityClient`` (``identity_client.client``) + directly. Request-model class names mirror the Get/Create convention. + """ + + def delete_workload_identity(name: str) -> None: + from huaweicloudsdkagentidentity.v1 import DeleteWorkloadIdentityRequest + + identity_client.client.delete_workload_identity( + request=DeleteWorkloadIdentityRequest(workload_identity_name=name) + ) + + def delete_credential_provider(kind: str, name: str) -> None: + from huaweicloudsdkagentidentity.v1 import ( + DeleteApiKeyCredentialProviderRequest, + DeleteOauth2CredentialProviderRequest, + DeleteStsCredentialProviderRequest, + ) + + mapping = { + "api_key": ( + "delete_api_key_credential_provider", + DeleteApiKeyCredentialProviderRequest, + ), + "oauth2": ( + "delete_oauth2_credential_provider", + DeleteOauth2CredentialProviderRequest, + ), + "sts": ( + "delete_sts_credential_provider", + DeleteStsCredentialProviderRequest, + ), + } + method_name, req_cls = mapping[kind] + getattr(identity_client.client, method_name)( + request=req_cls(credential_provider_name=name) + ) + + return SimpleNamespace( + workload_identity=delete_workload_identity, + credential_provider=delete_credential_provider, + ) + + +# --------------------------------------------------------------------------- # +# Shared identity resources (workload identity + api-key credential provider). +# Session-scoped so the identity lifecycle module and the auth-decorator module +# reuse the same resources (one create each, cleaned up once at session end). +# --------------------------------------------------------------------------- # +_DUMMY_API_KEY = "aa-it-dummy-api-key-0123456789abcdef" + + +@pytest.fixture(scope="session") +def created_workload_identity( + identity_client, allow_create, run_id, identity_cleanup, resource_registry +): + from tests.integration._helpers import unique_name + + name = unique_name("wi", run_id) + wi = identity_client.create_workload_identity(name=name) + resource_registry.register( + lambda: identity_cleanup.workload_identity(name), f"workload_identity:{name}" + ) + return {"name": name, "obj": wi} + + +@pytest.fixture(scope="session") +def created_api_key_provider( + identity_client, created_workload_identity, run_id, identity_cleanup, resource_registry +): + from tests.integration._helpers import unique_name + + name = unique_name("cp-ak", run_id) + identity_client.create_api_key_credential_provider(name=name, api_key=_DUMMY_API_KEY) + resource_registry.register( + lambda: identity_cleanup.credential_provider("api_key", name), + f"credential_provider:api_key:{name}", + ) + return name + + +# --------------------------------------------------------------------------- # +# Filesystem isolation for the auth decorators (which persist .agent_identity.json) +# --------------------------------------------------------------------------- # +@pytest.fixture +def isolated_identity_config(tmp_path, monkeypatch): + """chdir into a temp dir so `.agent_identity.json` never pollutes the repo.""" + monkeypatch.chdir(tmp_path) + return tmp_path + + +# --------------------------------------------------------------------------- # +# CLI subprocess driver + shared Docker-deployed runtime agent. +# Used by both toolkit CLI tests and SDK tests (e.g. runtime-agent CRUD) that +# need a live agent. Lives in the parent conftest so SDK tests under +# tests/integration/ (not just tests/integration/toolkit/) can reuse it. +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="session") +def agentarts_cmd(): + """Subprocess prefix invoking the real agentarts CLI (true e2e).""" + return [sys.executable, "-c", "from agentarts.toolkit.main import app; app()"] + + +@pytest.fixture(scope="session") +def docker_available(): + """Skip tests that need `agentarts deploy` when Docker is unavailable.""" + try: + r = subprocess.run(["docker", "info"], capture_output=True, text=True, timeout=20) + except (FileNotFoundError, subprocess.SubprocessError): + pytest.skip("Docker not installed / not on PATH — required for `agentarts deploy`") + if r.returncode != 0: + pytest.skip("Docker daemon not available — required for `agentarts deploy`") + return True + + +@pytest.fixture(scope="session") +def deployed_runtime_agent( + agentarts_cmd, + docker_available, + cloud_credentials, + allow_create, + allow_billable, + run_id, + resource_registry, + tmp_path_factory, +): + """Session-scoped: ONE `agentarts init → config → deploy` (Docker build + SWR + push + cloud runtime create). Shared by all tests that need a live agent. + `destroy` is registered with `resource_registry` (session-end safety net). + SWR org/repo/image persist (documented residue).""" + from tests.integration._helpers import unique_name + + region = cloud_credentials["region"] + name = unique_name("agent", run_id) + work = tmp_path_factory.mktemp("deploy") + proj_dir = work / name + + home = tmp_path_factory.mktemp("home") + (home / ".agentarts").mkdir(parents=True, exist_ok=True) + (home / ".agentarts" / ".completion_shown").touch() + # Docker credential workaround (macOS / OrbStack): when + # docker-credential-osxkeychain is on PATH, `docker login` invokes it and + # hangs (keychain GUI auth never completes in a subprocess) — regardless of + # credsStore in config.json. Shield the helper from the subprocess PATH + # (symlink only `docker` into a temp bin dir, drop the helper's dir) so + # docker falls back to plaintext auths. Also point DOCKER_CONFIG at a clean + # auths-only config. Only affects this subprocess; the user's real + # ~/.docker is untouched. + import shutil + + docker_bin = shutil.which("docker") + helper_bin = shutil.which("docker-credential-osxkeychain") + docker_cfg_dir = home / ".docker" + docker_cfg_dir.mkdir(parents=True, exist_ok=True) + (docker_cfg_dir / "config.json").write_text('{"auths": {}}') + env = dict(os.environ) + env["HOME"] = str(home) + env["DOCKER_CONFIG"] = str(docker_cfg_dir) + if docker_bin and helper_bin: + bin_dir = tmp_path_factory.mktemp("bin") + (bin_dir / "docker").symlink_to(docker_bin) + helper_dir = str(Path(helper_bin).parent) + filtered_path = os.pathsep.join( + p for p in os.environ.get("PATH", "").split(os.pathsep) + if p and p != helper_dir + ) + env["PATH"] = f"{bin_dir}{os.pathsep}{filtered_path}" + + def _run(args, timeout=900, cwd=None): + return subprocess.run( + agentarts_cmd + args, + capture_output=True, text=True, env=env, cwd=cwd, timeout=timeout, + ) + + # 1. init (basic template) — writes a deploy-ready .agentarts_config.yaml + # (entrypoint=agent:app, region, swr_config with organization_auto_create + # = true). NOTE: do NOT run `agentarts config --swr-org ...` here — passing + # --swr-org explicitly flips organization_auto_create to false, which makes + # deploy fail with 404 on the (non-existent) SWR org. + assert _run(["init", "-n", name, "-t", "basic", "-r", region], cwd=str(work)).returncode == 0 + + # Enable file transfer in the config before deploy — init writes + # file_transfer_config.enabled=false, and the backend rejects enabling it + # after the agent is created (so upload/download tests would fail otherwise). + cfg_path = proj_dir / ".agentarts_config.yaml" + cfg_text = cfg_path.read_text() + cfg_text = cfg_text.replace( + "file_transfer_config:\n enabled: false", + "file_transfer_config:\n enabled: true", + ) + cfg_path.write_text(cfg_text) + + # Speed up the build's `pip install`: agentarts-sdk pulls ~20 deps; official + # PyPI is ~160s from CN networks, a mirror ~30s. Edit the generated Dockerfile + # to use a fast index (configurable via AGENTARTS_TEST_PIP_INDEX, default + # tsinghua). Only affects this temp project's Dockerfile. + pip_index = os.environ.get( + "AGENTARTS_TEST_PIP_INDEX", "https://pypi.tuna.tsinghua.edu.cn/simple/" + ) + dockerfile_path = proj_dir / "Dockerfile" + dockerfile = dockerfile_path.read_text() + dockerfile = dockerfile.replace( + "pip install --no-cache-dir -r requirements.txt", + f"pip install --no-cache-dir -i {pip_index} -r requirements.txt", + ) + dockerfile_path.write_text(dockerfile) + + # 2. deploy (Docker build + SWR push + create cloud runtime) + deploy = _run(["deploy", "--agent", name, "--mode", "cloud"], cwd=str(proj_dir), timeout=900) + assert deploy.returncode == 0, deploy.stderr or deploy.stdout + resource_registry.register( + lambda: _run(["destroy", "--agent", name, "--region", region, "--yes"], + cwd=str(proj_dir), timeout=120), + f"deployed-agent:{name}", + ) + # also clean up the local docker image built by `docker build` (each run uses + # a new agent name → a new image, ~355MB each, would pile up). The deploy also + # tags the image as //:latest for push, so remove ALL + # RepoTags of the built image (local + swr), not just the local tag. + image_ref = f"{name}:latest" + if docker_bin: + def _rmi(): + import json as _json + + insp = subprocess.run( + [docker_bin, "inspect", "--format", "{{json .RepoTags}}", image_ref], + capture_output=True, text=True, timeout=30, + ) + try: + tags = [t for t in _json.loads(insp.stdout.strip()) if t] + except (ValueError, _json.JSONDecodeError): + tags = [image_ref] + if tags: + subprocess.run([docker_bin, "rmi", "-f", *tags], + capture_output=True, text=True, timeout=60) + # `docker rmi` only removes the tagged final image; the build's + # intermediate layers become dangling images and pile up. + # Prune dangling images so disk doesn't fill up across runs. + subprocess.run([docker_bin, "image", "prune", "-f"], + capture_output=True, text=True, timeout=120) + resource_registry.register(_rmi, f"docker-image:{image_ref}") + return {"name": name, "project_dir": str(proj_dir), "region": region, "run": _run} diff --git a/tests/integration/test_auth_decorators.py b/tests/integration/test_auth_decorators.py new file mode 100644 index 00000000..6badb5fa --- /dev/null +++ b/tests/integration/test_auth_decorators.py @@ -0,0 +1,90 @@ +"""Auth decorator tests (ALLOW_CREATE tier). + +Exercises the real ``require_api_key`` / ``require_sts_token`` decorators +end-to-end against the shared workload identity + credential providers. The +decorators' local-auth bootstrap persists ``.agent_identity.json``; the +``isolated_identity_config`` fixture chdir's into a temp dir so the repo is +never polluted, and a pre-seeded ``Config`` makes the bootstrap reuse the +session workload identity (no extra create). + +The OAuth2 ``require_access_token`` flow is a 3-legged interactive round trip +and is intentionally skipped here (covered by unit tests for wiring; e2e 3LO +is a manual exercise). +""" + +from __future__ import annotations + +import pytest + +from agentarts.sdk import require_api_key, require_sts_token +from agentarts.sdk.identity.config import Config + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def seeded_identity_config( + created_workload_identity, isolated_identity_config +): + """Pre-seed .agent_identity.json so the decorator reuses the session WI.""" + Config( + workload_identity_name=created_workload_identity["name"], + user_id="aa-it-auth", + ).save() + return isolated_identity_config + + +def test_require_api_key_injects_key( + created_api_key_provider, seeded_identity_config +): + captured: dict = {} + + @require_api_key(provider_name=created_api_key_provider, into="api_key") + def handler(payload, api_key=None): + captured["api_key"] = api_key + return {"ok": True} + + result = handler({"x": 1}) + assert result == {"ok": True} + assert isinstance(captured["api_key"], str) + assert captured["api_key"] + + +@pytest.fixture +def sts_provider_for_auth( + identity_client, allow_create, run_id, sts_agency_urn, identity_cleanup, resource_registry +): + from tests.integration._helpers import unique_name + + name = unique_name("cp-sts", run_id) + identity_client.create_sts_credential_provider(name=name, agency_urn=sts_agency_urn) + resource_registry.register( + lambda: identity_cleanup.credential_provider("sts", name), + f"credential_provider:sts:{name}", + ) + return name + + +def test_require_sts_token_injects_credentials( + sts_provider_for_auth, seeded_identity_config +): + @require_sts_token( + provider_name=sts_provider_for_auth, + agency_session_name="aa-it-session", + ) + def handler(sts_credentials=None): + return sts_credentials + + creds = handler() + assert creds is not None + assert getattr(creds, "access_key_id", None) + assert getattr(creds, "secret_access_key", None) + + +@pytest.mark.slow +def test_require_access_token_3lo_is_manual(seeded_identity_config): + """OAuth2 3LO requires an interactive browser round trip — skip in CI.""" + pytest.skip( + "require_access_token (OAuth2 3LO) is interactive; run manually with " + "AGENTARTS_TEST_OAUTH2_* and an on_auth_url callback" + ) diff --git a/tests/integration/test_code_interpreter_lifecycle.py b/tests/integration/test_code_interpreter_lifecycle.py new file mode 100644 index 00000000..f34dea6a --- /dev/null +++ b/tests/integration/test_code_interpreter_lifecycle.py @@ -0,0 +1,54 @@ +"""Code Interpreter control-plane lifecycle (ALLOW_CREATE tier). + +Creates a code-interpreter resource, exercises get/list/update, then deletes +it. This is control-plane only — NO sandbox session is started, so it does not +cost money. The billable sandbox session is in +``test_code_interpreter_session.py``. +""" + +from __future__ import annotations + +import pytest + +from agentarts.sdk import CodeInterpreter +from tests.integration._helpers import unique_name + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def created_code_interpreter( + cloud_credentials, allow_create, run_id, resource_registry +): + name = unique_name("ci", run_id) # satisfies [a-z][a-z0-9-]{0,38}[a-z0-9] + ci = CodeInterpreter(region=cloud_credentials["region"]) + created = ci.create_code_interpreter( + name=name, auth_type="API_KEY", api_key_name=f"{name}-ak" + ) + ci_id = created["id"] + resource_registry.register( + lambda: ci.delete_code_interpreter(ci_id), f"code_interpreter:{ci_id}" + ) + return {"id": ci_id, "name": name, "client": ci} + + +def test_get_code_interpreter(created_code_interpreter): + ci = created_code_interpreter["client"] + got = ci.get_code_interpreter(created_code_interpreter["id"]) + assert got["id"] == created_code_interpreter["id"] + + +def test_list_code_interpreters(created_code_interpreter): + ci = created_code_interpreter["client"] + result = ci.list_code_interpreters(limit=10) + assert isinstance(result, dict) + assert "items" in result or "total_count" in result or isinstance(result, dict) + + +def test_update_code_interpreter(created_code_interpreter): + ci = created_code_interpreter["client"] + updated = ci.update_code_interpreter( + created_code_interpreter["id"], + tags=[{"key": "env", "value": "aa-it"}], + ) + assert isinstance(updated, dict) diff --git a/tests/integration/test_code_interpreter_session.py b/tests/integration/test_code_interpreter_session.py new file mode 100644 index 00000000..2e567cb8 --- /dev/null +++ b/tests/integration/test_code_interpreter_session.py @@ -0,0 +1,61 @@ +"""Code Interpreter billable sandbox session (RUN_BILLABLE tier). + +Starts a real sandbox session against a *pre-provisioned* code interpreter via +the ``code_session`` context manager (auto-stops on exit → no session residue), +runs code + a shell command, round-trips a file (upload→download), and clears +context. Costs real money; gated behind ``AGENTARTS_TEST_RUN_BILLABLE=1``, +``HUAWEICLOUD_SDK_CODE_INTERPRETER_API_KEY`` and +``AGENTARTS_TEST_CODE_INTERPRETER_NAME``. + +All operations happen inside one ``code_session`` to minimise billable +sessions. +""" + +from __future__ import annotations + +import pytest + +from agentarts.sdk import code_session + +pytestmark = pytest.mark.integration + +_FILE_PATH = "/home/user/aa-it-uploaded.txt" +_FILE_CONTENT = "hello-aa-it" + + +def test_code_session_full_workflow( + cloud_credentials, + allow_billable, + code_interpreter_api_key, + code_interpreter_name, +): + region = cloud_credentials["region"] + with code_session( + region=region, + code_interpreter_name=code_interpreter_name, + auth_type="API_KEY", + api_key=code_interpreter_api_key, + ) as client: + # 1. execute_code + code_result = client.execute_code("print(1 + 1)") + assert isinstance(code_result, dict) + assert "2" in str(code_result) + + # 2. execute_command (shell) + cmd_result = client.execute_command(f"echo {_FILE_CONTENT}") + assert isinstance(cmd_result, dict) + + # 3. upload_file then download_file (round-trip) + up = client.upload_file(path=_FILE_PATH, content=_FILE_CONTENT) + assert isinstance(up, dict) + downloaded = client.download_file(path=_FILE_PATH) + assert _FILE_CONTENT in (downloaded.decode() if isinstance(downloaded, bytes) else downloaded) + + # 4. get_session + session = client.get_session(code_interpreter_name=code_interpreter_name) + assert isinstance(session, dict) + + # 5. clear_context + cleared = client.clear_context() + assert isinstance(cleared, dict) + # exiting the context manager calls stop_session() → session torn down diff --git a/tests/integration/test_gateway_lifecycle.py b/tests/integration/test_gateway_lifecycle.py new file mode 100644 index 00000000..1e95eb11 --- /dev/null +++ b/tests/integration/test_gateway_lifecycle.py @@ -0,0 +1,123 @@ +"""Gateway lifecycle tests (ALLOW_CREATE tier). + +Creates a gateway + a target, exercises get/list/update, then deletes the +target before the gateway. Note: ``create_gateway`` auto-creates a shared IAM +agency ``AgentArtsCoreGateway`` (409-ignored if it already exists) which the +SDK intentionally does not delete — this residue is documented and intentional. + +The earlier xfail (IAM `trust_policy` rejected, PAP5.0011) was resolved upstream +by `create_agency_with_policy` (auto policy attachment); the marker is removed. +""" + +from __future__ import annotations + +import pytest + +from tests.integration._helpers import unique_name + +pytestmark = pytest.mark.integration + + +def _extract_id(data: dict, *keys: str) -> str: + """Pull a resource id out of a response dict, trying common shapes: + top-level ``id``/``gateway_id``/``target_id``, nested ``data["gateway"]``/ + ``data["target"]``, or the first item of a list.""" + for k in keys: + if data.get(k): + return data[k] + for nested in ("gateway", "target"): + obj = data.get(nested) + if isinstance(obj, dict): + for k in keys: + if obj.get(k): + return obj[k] + for v in data.values(): + if isinstance(v, list) and v and isinstance(v[0], dict) and v[0].get("id"): + return v[0]["id"] + msg = f"could not find id in response: {data!r}" + raise AssertionError(msg) + + +# --------------------------------------------------------------------------- # +# Shared resources +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="module") +def created_gateway(gateway_client, allow_create, run_id, resource_registry): + name = unique_name("gw", run_id) + result = gateway_client.create_gateway(name=name, description="aa-it") + assert result.success, f"create_gateway failed: {result.error}" + gw_id = _extract_id(result.data, "id", "gateway_id") + resource_registry.register( + lambda: gateway_client.delete_gateway(gw_id), f"gateway:{gw_id}" + ) + return {"id": gw_id, "name": name} + + +@pytest.fixture(scope="module") +def created_target(gateway_client, created_gateway, run_id, resource_registry): + name = unique_name("tgt", run_id) + result = gateway_client.create_gateway_target( + gateway_id=created_gateway["id"], + name=name, + target_configuration={ + "mcp_server": {"endpoint": "https://example.com/mcp", "server_type": "sse"} + }, + ) + assert result.success, f"create_gateway_target failed: {result.error}" + target_id = _extract_id(result.data, "id", "target_id") + resource_registry.register( + lambda: gateway_client.delete_gateway_target( + created_gateway["id"], target_id + ), + f"target:{target_id}", + ) + return {"id": target_id, "name": name} + + +# --------------------------------------------------------------------------- # +# Gateway +# --------------------------------------------------------------------------- # +def test_get_gateway(gateway_client, created_gateway): + result = gateway_client.get_gateway(created_gateway["id"]) + assert result.success, result.error + + +def test_list_gateways(gateway_client, created_gateway): + # limit capped at 100 by the backend + result = gateway_client.list_gateways(limit=100) + assert result.success, result.error + assert isinstance(result.data, dict) + + +def test_update_gateway(gateway_client, created_gateway): + result = gateway_client.update_gateway( + created_gateway["id"], description="updated by aa-it" + ) + assert result.success, result.error + + +# --------------------------------------------------------------------------- # +# Target +# --------------------------------------------------------------------------- # +def test_get_target(gateway_client, created_gateway, created_target): + result = gateway_client.get_gateway_target( + created_gateway["id"], created_target["id"] + ) + assert result.success, result.error + + +def test_list_targets(gateway_client, created_gateway, created_target): + result = gateway_client.list_gateway_targets( + gateway_id=created_gateway["id"], limit=100 + ) + assert result.success, result.error + assert isinstance(result.data, dict) + + +def test_update_target(gateway_client, created_gateway, created_target): + result = gateway_client.update_gateway_target( + gateway_id=created_gateway["id"], + target_id=created_target["id"], + description="updated target by aa-it", + ) + assert result.success, result.error diff --git a/tests/integration/test_identity_lifecycle.py b/tests/integration/test_identity_lifecycle.py new file mode 100644 index 00000000..7765c384 --- /dev/null +++ b/tests/integration/test_identity_lifecycle.py @@ -0,0 +1,145 @@ +"""Identity lifecycle tests (ALLOW_CREATE tier). + +Creates a workload identity + an API-key credential provider, exercises +get/list/update and ephemeral token issuance (``create_workload_access_token``, +``get_resource_api_key``), then tears everything down via the raw +``AgentIdentityClient.delete_*`` methods (the high-level wrapper has no delete). + +OAuth2 / STS credential-provider flows need external inputs (vendor creds, +agency URN) and are conditionally skipped otherwise. +""" + +from __future__ import annotations + +import os + +import pytest + +from tests.integration._helpers import unique_name + +pytestmark = pytest.mark.integration + +# --------------------------------------------------------------------------- # +# Workload identity + api-key credential provider are session-scoped fixtures +# in conftest (shared with the auth-decorator module); created once, cleaned up +# once at session end via the raw AgentIdentityClient.delete_* methods. +# --------------------------------------------------------------------------- # + + +# --------------------------------------------------------------------------- # +# Workload identity +# --------------------------------------------------------------------------- # +def test_get_created_workload_identity(identity_client, created_workload_identity): + wi = identity_client.get_workload_identity(created_workload_identity["name"]) + assert wi is not None + assert getattr(wi, "name", None) == created_workload_identity["name"] + + +def test_update_workload_identity(identity_client, created_workload_identity): + wi = identity_client.update_workload_identity( + created_workload_identity["name"], + allowed_resource_oauth2_return_urls=["https://example.com/callback"], + ) + assert wi is not None + + +def test_list_workload_identities_contains_created( + identity_client, created_workload_identity +): + # limit=200 to keep it a single cheap call while covering the common case + items = identity_client.list_workload_identities(limit=200) + names = [getattr(it, "name", None) for it in items] + assert created_workload_identity["name"] in names + + +# --------------------------------------------------------------------------- # +# API-key credential provider +# --------------------------------------------------------------------------- # +def test_get_api_key_credential_provider(identity_client, created_api_key_provider): + cp = identity_client.get_api_key_credential_provider(created_api_key_provider) + assert cp is not None + assert getattr(cp, "name", None) == created_api_key_provider + + +def test_list_api_key_credential_providers_contains_created( + identity_client, created_api_key_provider +): + items = identity_client.list_api_key_credential_providers(limit=200) + names = [getattr(it, "name", None) for it in items] + assert created_api_key_provider in names + + +# --------------------------------------------------------------------------- # +# Ephemeral token issuance (no persistent resource) +# --------------------------------------------------------------------------- # +def test_create_workload_access_token(identity_client, created_workload_identity): + token = identity_client.create_workload_access_token( + created_workload_identity["name"], user_id="aa-it-token-user" + ) + assert isinstance(token, str) + assert token + + +def test_get_resource_api_key( + identity_client, created_workload_identity, created_api_key_provider +): + access_token = identity_client.create_workload_access_token( + created_workload_identity["name"], user_id="aa-it-token-user" + ) + api_key = identity_client.get_resource_api_key( + provider_name=created_api_key_provider, + workload_access_token=access_token, + ) + assert isinstance(api_key, str) + assert api_key + + +# --------------------------------------------------------------------------- # +# Conditionally-skipped OAuth2 / STS provider flows +# --------------------------------------------------------------------------- # +@pytest.fixture +def oauth2_provider_inputs(): + cid = os.getenv("AGENTARTS_TEST_OAUTH2_CLIENT_ID") + csec = os.getenv("AGENTARTS_TEST_OAUTH2_CLIENT_SECRET") + vendor = os.getenv("AGENTARTS_TEST_OAUTH2_VENDOR", "GITHUBOAUTH2") + if not cid or not csec: + pytest.skip( + "Set AGENTARTS_TEST_OAUTH2_CLIENT_ID / _CLIENT_SECRET / _VENDOR " + "to exercise OAuth2 credential-provider lifecycle" + ) + return cid, csec, vendor + + +@pytest.mark.slow +def test_create_and_delete_oauth2_credential_provider( + identity_client, allow_create, run_id, oauth2_provider_inputs, identity_cleanup, resource_registry +): + from agentarts.sdk.identity.types import OAuth2Vendor + + cid, csec, vendor = oauth2_provider_inputs + name = unique_name("cp-oa", run_id) + identity_client.create_oauth2_credential_provider( + name=name, + vendor=OAuth2Vendor[vendor], + client_id=cid, + client_secret=csec, + ) + resource_registry.register( + lambda: identity_cleanup.credential_provider("oauth2", name), + f"credential_provider:oauth2:{name}", + ) + cp = identity_client.get_oauth2_credential_provider(name) + assert getattr(cp, "name", None) == name + + +def test_create_and_delete_sts_credential_provider( + identity_client, allow_create, run_id, sts_agency_urn, identity_cleanup, resource_registry +): + name = unique_name("cp-sts", run_id) + identity_client.create_sts_credential_provider(name=name, agency_urn=sts_agency_urn) + resource_registry.register( + lambda: identity_cleanup.credential_provider("sts", name), + f"credential_provider:sts:{name}", + ) + cp = identity_client.get_sts_credential_provider(name) + assert getattr(cp, "name", None) == name diff --git a/tests/integration/test_identity_readonly.py b/tests/integration/test_identity_readonly.py new file mode 100644 index 00000000..285222bc --- /dev/null +++ b/tests/integration/test_identity_readonly.py @@ -0,0 +1,48 @@ +"""Read-only identity tests (default tier). + +Exercise the ``IdentityClient`` list/get surface and ephemeral token issuance +without creating any persistent cloud resource. Requires AK/SK; the optional +``AGENTARTS_TEST_WORKLOAD_IDENTITY_NAME`` adds a get + workload-access-token +assertion against a pre-provisioned identity. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.integration + + +def test_list_workload_identities(identity_client): + items = identity_client.list_workload_identities(limit=1) + assert isinstance(items, list) + + +def test_list_api_key_credential_providers(identity_client): + items = identity_client.list_api_key_credential_providers(limit=1) + assert isinstance(items, list) + + +def test_list_oauth2_credential_providers(identity_client): + items = identity_client.list_oauth2_credential_providers(limit=1) + assert isinstance(items, list) + + +def test_list_sts_credential_providers(identity_client): + items = identity_client.list_sts_credential_providers(limit=1) + assert isinstance(items, list) + + +def test_get_and_token_for_preprovisioned_workload_identity( + identity_client, pre_workload_identity +): + """get_workload_identity + create_workload_access_token (ephemeral, no residue).""" + wi = identity_client.get_workload_identity(pre_workload_identity) + assert wi is not None + assert getattr(wi, "name", None) == pre_workload_identity + + token = identity_client.create_workload_access_token( + pre_workload_identity, user_id="aa-it-reader" + ) + assert isinstance(token, str) + assert token diff --git a/tests/integration/test_memory_async.py b/tests/integration/test_memory_async.py new file mode 100644 index 00000000..8dded3ea --- /dev/null +++ b/tests/integration/test_memory_async.py @@ -0,0 +1,187 @@ +"""Async Memory SDK tests (ALLOW_CREATE tier). + +Exercises ``AsyncMemoryClient``'s async data-plane methods against the shared +session Space. Setup (session + seeded messages) is done once via the *sync* +client to avoid async-fixture/event-loop-scope complexity; the async tests +then only read — cheap and isolated. +""" + +from __future__ import annotations + +import os + +import pytest + +from agentarts.sdk import AsyncMemoryClient, MemoryClient +from agentarts.sdk.memory import MemorySearchFilter, TextMessage + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def async_setup(memory_space): + """Seed a session + two messages via the sync client (one-time, module scope).""" + sync = MemoryClient(api_key=memory_space.api_key) + session = sync.create_memory_session( + space_id=memory_space.id, actor_id="aa-it-async-actor" + ) + batch = sync.add_messages( + space_id=memory_space.id, + session_id=session.id, + messages=[ + TextMessage(role="user", content="My name is Bob and I work in Shanghai."), + TextMessage(role="assistant", content="Got it, Bob from Shanghai."), + ], + is_force_extract=True, # force extraction so delete_memory can be exercised + ) + # Memory extraction is async — wait (~90s) for memories to appear so the + # retrieval/delete tests can actually exercise them. Best-effort. + from tests.integration._helpers import wait_for + + try: + wait_for( + lambda: sync.list_memories(space_id=memory_space.id, limit=10).items, + timeout=int(os.getenv("AGENTARTS_TEST_MEMORY_WAIT","90")), + interval=10, + desc="memory extraction (async module)", + ) + except TimeoutError: + pass + return { + "space_id": memory_space.id, + "session_id": session.id, + "msg_ids": [m.id for m in batch.items], + } + + +def _client(memory_space) -> AsyncMemoryClient: + return AsyncMemoryClient(api_key=memory_space.api_key) + + +@pytest.mark.asyncio +async def test_async_get_last_k_messages(memory_space, async_setup): + client = _client(memory_space) + try: + msgs = await client.get_last_k_messages( + session_id=async_setup["session_id"], k=2, space_id=async_setup["space_id"] + ) + finally: + await client.close() + assert len(msgs) == 2 + + +@pytest.mark.asyncio +async def test_async_list_messages(memory_space, async_setup): + client = _client(memory_space) + try: + result = await client.list_messages( + space_id=async_setup["space_id"], + session_id=async_setup["session_id"], + limit=10, + ) + finally: + await client.close() + assert result.total >= 2 + + +@pytest.mark.asyncio +async def test_async_get_message(memory_space, async_setup): + client = _client(memory_space) + try: + msg = await client.get_message( + message_id=async_setup["msg_ids"][0], + space_id=async_setup["space_id"], + session_id=async_setup["session_id"], + ) + finally: + await client.close() + assert msg.id == async_setup["msg_ids"][0] + + +@pytest.mark.asyncio +async def test_async_search_memories(memory_space, async_setup): + client = _client(memory_space) + try: + result = await client.search_memories( + space_id=async_setup["space_id"], + filters=MemorySearchFilter(query="hello", top_k=3), + ) + finally: + await client.close() + assert isinstance(result.results, list) + assert result.total >= 0 + + +@pytest.mark.asyncio +async def test_async_list_memories(memory_space, async_setup): + client = _client(memory_space) + try: + result = await client.list_memories(space_id=async_setup["space_id"], limit=10) + finally: + await client.close() + assert isinstance(result.items, list) + assert result.total >= 0 + + +@pytest.mark.asyncio +async def test_async_delete_memory_if_any(memory_space, async_setup): + client = _client(memory_space) + try: + result = await client.list_memories(space_id=async_setup["space_id"], limit=10) + if not result.items: + pytest.skip("no extracted memories to delete in async path") + await client.delete_memory( + space_id=async_setup["space_id"], memory_id=result.items[0].id + ) + finally: + await client.close() + + +# --------------------------------------------------------------------------- # +# Direct async create/add (the shared async_setup above uses the sync client; +# these close the gap by exercising AsyncMemoryClient's async writers + the +# AsyncMemorySession wrapper directly). +# --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_async_create_session_and_add_messages(memory_space): + from agentarts.sdk.utils.constant import get_region + + client = AsyncMemoryClient(api_key=memory_space.api_key, region_name=get_region()) + try: + session = await client.create_memory_session( + space_id=memory_space.id, actor_id="aa-it-async-direct" + ) + assert session.id + batch = await client.add_messages( + space_id=memory_space.id, + session_id=session.id, + messages=[TextMessage(role="user", content="async direct message")], + is_force_extract=True, # force extraction so delete_memory can be exercised + ) + assert len(batch.items) == 1 + listed = await client.list_messages( + space_id=memory_space.id, session_id=session.id, limit=5 + ) + assert listed.total >= 1 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_async_session_wrapper(memory_space): + """AsyncMemorySession auto-creates the session on first await, then bound + add/get/list ops go through the wrapper (not the raw client).""" + from agentarts.sdk.memory import AsyncMemorySession + from agentarts.sdk.utils.constant import get_region + + session = AsyncMemorySession( + space_id=memory_space.id, + actor_id="aa-it-async-wrap", + api_key=memory_space.api_key, + region_name=get_region(), + ) + await session.add_messages([TextMessage(role="user", content="wrap async")]) + last = await session.get_last_k_messages(k=1) + assert len(last) == 1 + listed = await session.list_messages(limit=5) + assert listed.total >= 1 diff --git a/tests/integration/test_memory_lifecycle.py b/tests/integration/test_memory_lifecycle.py new file mode 100644 index 00000000..a4661fec --- /dev/null +++ b/tests/integration/test_memory_lifecycle.py @@ -0,0 +1,177 @@ +"""Memory SDK lifecycle tests (ALLOW_CREATE tier). + +Self-contained: creates a Space (which mints its own data-plane API key), uses +that key for session/message/memory ops, then deletes the Space — which +cascades to all sessions/messages/memories. No pre-existing memory API key +required; only AK/SK for the control-plane Space CRUD. +""" + +from __future__ import annotations + +import os + +import pytest + +from agentarts.sdk import MemoryClient +from agentarts.sdk.memory import MemorySearchFilter, TextMessage + +pytestmark = pytest.mark.integration + + +# --------------------------------------------------------------------------- # +# Shared resources (Space is session-scoped in conftest; per-module data +# client + session + seeded messages are created once here, cleaned up via +# the Space's session-end cascade delete.) +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="module") +def memory_data_client(memory_space): + """Data-plane client authenticated with the Space's own API key.""" + return MemoryClient(api_key=memory_space.api_key) + + +@pytest.fixture(scope="module") +def memory_session(memory_data_client, memory_space): + return memory_data_client.create_memory_session( + space_id=memory_space.id, actor_id="aa-it-actor" + ) + + +@pytest.fixture(scope="module") +def seeded_messages(memory_data_client, memory_space, memory_session): + """Add two text messages once; reused by read-path tests.""" + batch = memory_data_client.add_messages( + space_id=memory_space.id, + session_id=memory_session.id, + messages=[ + TextMessage(role="user", content="My name is Alice and I live in Beijing."), + TextMessage(role="assistant", content="Nice to meet you, Alice from Beijing."), + ], + is_force_extract=True, # force extraction so delete_memory can be exercised + ) + # Memory extraction is async on the backend — wait (~90s) for extracted + # memories to appear before the retrieval/delete tests run. Best-effort: + # if extraction doesn't fire in time, delete_memory soft-skips. + from tests.integration._helpers import wait_for + + try: + wait_for( + lambda: memory_data_client.list_memories(space_id=memory_space.id, limit=10).items, + timeout=int(os.getenv("AGENTARTS_TEST_MEMORY_WAIT","90")), + interval=10, + desc="memory extraction", + ) + except TimeoutError: + pass + return batch + + +# --------------------------------------------------------------------------- # +# Space control plane +# --------------------------------------------------------------------------- # +def test_get_space(memory_control_client, memory_space): + got = memory_control_client.get_space(memory_space.id) + assert got.id == memory_space.id + assert got.name == memory_space.name + + +def test_list_spaces_contains_created(memory_control_client, memory_space): + # limit capped at 100 by the backend ("limit too large" above 100) + result = memory_control_client.list_spaces(limit=100) + ids = [s.id for s in result.items] + assert memory_space.id in ids + + +def test_update_space(memory_control_client, memory_space): + updated = memory_control_client.update_space( + memory_space.id, description="updated by integration suite" + ) + assert updated.id == memory_space.id + + +# --------------------------------------------------------------------------- # +# Session + message data plane +# --------------------------------------------------------------------------- # +def test_session_created(memory_session): + assert memory_session.id + + +def test_add_messages(memory_space, memory_session, seeded_messages): + assert len(seeded_messages.items) == 2 + + +def test_list_messages(memory_data_client, memory_space, memory_session, seeded_messages): + result = memory_data_client.list_messages( + space_id=memory_space.id, session_id=memory_session.id, limit=10 + ) + assert isinstance(result.items, list) + assert result.total >= 2 + + +def test_get_last_k_messages(memory_data_client, memory_space, memory_session, seeded_messages): + msgs = memory_data_client.get_last_k_messages( + session_id=memory_session.id, k=2, space_id=memory_space.id + ) + assert isinstance(msgs, list) + assert len(msgs) == 2 + roles = [m.role for m in msgs] + assert "user" in roles + assert "assistant" in roles + + +def test_get_message(memory_data_client, memory_space, memory_session, seeded_messages): + msg_id = seeded_messages.items[0].id + msg = memory_data_client.get_message( + message_id=msg_id, space_id=memory_space.id, session_id=memory_session.id + ) + assert msg.id == msg_id + assert msg.role in ("user", "assistant", "system", "tool") + + +# --------------------------------------------------------------------------- # +# Memory data plane (search / list / get / delete) +# --------------------------------------------------------------------------- # +def test_search_memories(memory_data_client, memory_space, seeded_messages): + result = memory_data_client.search_memories( + space_id=memory_space.id, + filters=MemorySearchFilter(query="hello", top_k=3), + ) + assert isinstance(result.results, list) + assert result.total >= 0 + + +def test_list_memories(memory_data_client, memory_space, seeded_messages): + result = memory_data_client.list_memories(space_id=memory_space.id, limit=10) + assert isinstance(result.items, list) + assert result.total >= 0 + + +def test_delete_memory_if_any(memory_data_client, memory_space, seeded_messages): + """Memories are backend-extracted; if none exist yet, this is a soft skip.""" + result = memory_data_client.list_memories(space_id=memory_space.id, limit=10) + if not result.items: + pytest.skip( + "no extracted memories to delete — enable memory_strategies_builtin " + "+ is_force_extract=True to exercise the extraction+delete path" + ) + target = result.items[0] + memory_data_client.delete_memory(space_id=memory_space.id, memory_id=target.id) + + +# --------------------------------------------------------------------------- # +# MemorySession wrapper (bound space+session, auto-creates session on init) +# --------------------------------------------------------------------------- # +def test_memory_session_wrapper(memory_space): + from agentarts.sdk.memory import MemorySession + from agentarts.sdk.utils.constant import get_region + + session = MemorySession( + space_id=memory_space.id, + actor_id="aa-it-wrap", + api_key=memory_space.api_key, + region_name=get_region(), + ) + session.add_messages([TextMessage(role="user", content="wrap sync")]) + last = session.get_last_k_messages(k=1) + assert len(last) == 1 + listed = session.list_messages(limit=5) + assert listed.total >= 1 diff --git a/tests/integration/test_readonly_lists.py b/tests/integration/test_readonly_lists.py new file mode 100644 index 00000000..435c9078 --- /dev/null +++ b/tests/integration/test_readonly_lists.py @@ -0,0 +1,35 @@ +"""Pure read-only list tests (default tier). + +One list call per service, ``limit=1`` to minimise traffic. Creates nothing, +costs nothing — a cheap connectivity + auth probe for every control plane. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.integration + + +def test_list_spaces(memory_control_client): + result = memory_control_client.list_spaces(limit=1) + assert isinstance(result.items, list) + + +def test_list_gateways(gateway_client): + result = gateway_client.list_gateways(limit=1) + assert result.success, result.error + + +def test_list_runtime_agents(runtime_client): + # runtime control plane rejects limit<10 ("limit too small"), use the default. + agents = runtime_client.get_agents(limit=10) + assert isinstance(agents, list) + + +def test_list_code_interpreters(cloud_credentials): + from agentarts.sdk import CodeInterpreter + + ci = CodeInterpreter(region=cloud_credentials["region"]) + result = ci.list_code_interpreters(limit=1) + assert isinstance(result, dict) diff --git a/tests/integration/test_runtime_agent_lifecycle.py b/tests/integration/test_runtime_agent_lifecycle.py new file mode 100644 index 00000000..755f60d1 --- /dev/null +++ b/tests/integration/test_runtime_agent_lifecycle.py @@ -0,0 +1,125 @@ +"""Runtime control-plane CRUD tests. + +Two ways to supply the target agent — either is enough, so environment issues +(Docker unavailable) don't block this module: + + * **standalone**: set ``AGENTARTS_TEST_RUNTIME_AGENT_NAME`` to a + pre-provisioned agent — no Docker, no deploy, no billable. + * **reuse**: with no env var, fall back to the shared + ``deployed_runtime_agent`` fixture (Docker + ALLOW_CREATE + RUN_BILLABLE), + which `agentarts deploy`s one. + +The agent itself is NOT created/deleted here — `create_agent` needs an +`artifact_source_config` (a built image), which only `deploy` provides; that +path is covered transitively by the deploy fixture. These tests exercise +find/get/update + endpoint CRUD against an existing agent. The endpoint is +created and cleaned up via `resource_registry`; the agent is left intact +(owned by the env var, or by the deploy fixture's session-end teardown). + +Requires ALLOW_CREATE (writes: update_agent + endpoint CRUD) in both modes. +""" + +from __future__ import annotations + +import os + +import pytest + +from tests.integration._helpers import ( + ENV_RUN_BILLABLE, + ENV_RUNTIME_AGENT_NAME, + env_truthy, + unique_name, +) + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def runtime_agent(request, runtime_client, cloud_credentials, allow_create, resource_registry): + name = os.getenv(ENV_RUNTIME_AGENT_NAME) + if name: + mode = "standalone" + else: + if not env_truthy(ENV_RUN_BILLABLE): + pytest.skip( + "Set AGENTARTS_TEST_RUNTIME_AGENT_NAME (standalone, no Docker) or " + "AGENTARTS_TEST_RUN_BILLABLE=1 + Docker (reuse deploy) to run " + "runtime-agent CRUD" + ) + deployed = request.getfixturevalue("deployed_runtime_agent") + name = deployed["name"] + mode = "reuse" + + agent = runtime_client.find_agent_by_name(name) + assert agent, ( + f"agent {name!r} not found in region {cloud_credentials['region']} " + f"(mode={mode})" + ) + return {"name": name, "id": agent["id"], "mode": mode} + + +@pytest.fixture(scope="module") +def created_endpoint(runtime_client, runtime_agent, run_id, resource_registry): + ep_name = unique_name("ep", run_id) + try: + runtime_client.create_agent_endpoint( + agent_id=runtime_agent["id"], endpoint_name=ep_name + ) + except RuntimeError as exc: + # Newer backends require a targetVersionName for endpoint creation, which + # needs a published agent version we don't have here. Skip rather than + # error — the agent read/update paths are still exercised. + if "targetVersionName" in str(exc): + pytest.skip(f"create_agent_endpoint requires targetVersionName: {exc}") + raise + resource_registry.register( + lambda: runtime_client.delete_agent_endpoint(runtime_agent["id"], ep_name), + f"endpoint:{ep_name}", + ) + return ep_name + + +# --------------------------------------------------------------------------- # +# Agent read / update +# --------------------------------------------------------------------------- # +def test_find_agent_by_name(runtime_client, runtime_agent): + found = runtime_client.find_agent_by_name(runtime_agent["name"]) + assert found is not None + assert found["id"] == runtime_agent["id"] + + +def test_find_agent_by_id(runtime_client, runtime_agent): + found = runtime_client.find_agent_by_id(runtime_agent["id"]) + assert found is not None + assert found["id"] == runtime_agent["id"] + + +def test_get_agents(runtime_client, runtime_agent): + # backend rejects limit<10 ("limit too small") + agents = runtime_client.get_agents(limit=10) + assert isinstance(agents, list) + + +def test_update_agent(runtime_client, runtime_agent): + updated = runtime_client.update_agent( + runtime_agent["id"], description="updated by aa-it" + ) + assert updated["id"] == runtime_agent["id"] + + +# --------------------------------------------------------------------------- # +# Endpoint CRUD +# --------------------------------------------------------------------------- # +def test_find_agent_endpoint(runtime_client, runtime_agent, created_endpoint): + ep = runtime_client.find_agent_endpoint(runtime_agent["id"], created_endpoint) + assert isinstance(ep, dict) + + +def test_update_agent_endpoint(runtime_client, runtime_agent, created_endpoint): + ep = runtime_client.update_agent_endpoint( + agent_id=runtime_agent["id"], + endpoint_name=created_endpoint, + config={"note": "updated by aa-it"}, + ) + assert isinstance(ep, dict) diff --git a/tests/integration/test_runtime_app_local.py b/tests/integration/test_runtime_app_local.py new file mode 100644 index 00000000..0cc6eacf --- /dev/null +++ b/tests/integration/test_runtime_app_local.py @@ -0,0 +1,164 @@ +"""Local ASGI tests for ``AgentArtsRuntimeApp`` — no cloud, no credentials. + +These run in the default tier. They exercise the runtime HTTP/WebSocket surface +via Starlette's ``TestClient``: ``/ping``, ``/invocations`` (JSON + SSE +streaming + error paths) and ``/ws``. +""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +from agentarts.sdk import AgentArtsRuntimeApp, PingStatus +from agentarts.sdk.runtime.model import SESSION_HEADER + +pytestmark = pytest.mark.integration + + +# --------------------------------------------------------------------------- # +# /ping +# --------------------------------------------------------------------------- # +def test_ping_default_healthy(): + app = AgentArtsRuntimeApp() + with TestClient(app) as client: + resp = client.get("/ping") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == PingStatus.HEALTHY.value + assert "time_of_last_update" in body + + +def test_ping_force_unhealthy(): + app = AgentArtsRuntimeApp() + app.force_ping_status(PingStatus.UNHEALTHY) + with TestClient(app) as client: + resp = client.get("/ping") + assert resp.status_code == 200 + assert resp.json()["status"] == PingStatus.UNHEALTHY.value + + +def test_ping_custom_handler(): + app = AgentArtsRuntimeApp() + + @app.ping + def _ping(): + return PingStatus.HEALTHY_BUSY + + with TestClient(app) as client: + resp = client.get("/ping") + assert resp.status_code == 200 + assert resp.json()["status"] == PingStatus.HEALTHY_BUSY.value + + +# --------------------------------------------------------------------------- # +# /invocations — happy path + error paths +# --------------------------------------------------------------------------- # +def test_invocation_returns_handler_result(): + app = AgentArtsRuntimeApp() + + @app.entrypoint + def handler(payload: dict): + return {"echo": payload["message"]} + + with TestClient(app) as client: + resp = client.post("/invocations", json={"message": "hello"}) + assert resp.status_code == 200 + assert resp.json()["echo"] == "hello" + # session header is echoed back on the response + assert SESSION_HEADER in resp.headers + + +def test_invocation_no_entrypoint_returns_404(): + app = AgentArtsRuntimeApp() # no @app.entrypoint + with TestClient(app) as client: + resp = client.post("/invocations", json={"message": "hi"}) + assert resp.status_code == 404 + + +def test_invocation_invalid_json_returns_400(): + app = AgentArtsRuntimeApp() + + @app.entrypoint + def handler(payload: dict): + return {"ok": True} + + with TestClient(app) as client: + resp = client.post("/invocations", content=b"{not json", headers={"content-type": "application/json"}) + assert resp.status_code == 400 + + +def test_invocation_handler_raises_returns_500(): + app = AgentArtsRuntimeApp() + + @app.entrypoint + def handler(payload: dict): + raise RuntimeError("boom") + + with TestClient(app) as client: + resp = client.post("/invocations", json={"x": 1}) + assert resp.status_code == 500 + + +def test_invocation_sync_generator_streams_sse(): + app = AgentArtsRuntimeApp() + + @app.entrypoint + def handler(payload: dict): + for word in ["a", "b", "c"]: + yield {"token": word} + + with TestClient(app) as client: + resp = client.post("/invocations", json={}) + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers.get("content-type", "") + body = resp.text + # each yielded item is serialised into an SSE `data:` line + assert body.count("data:") >= 3 + assert "a" in body + assert "c" in body + + +def test_invocation_async_generator_streams_sse(): + app = AgentArtsRuntimeApp() + + @app.entrypoint + async def handler(payload: dict): + for word in ["x", "y"]: + yield {"token": word} + + with TestClient(app) as client: + resp = client.post("/invocations", json={}) + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers.get("content-type", "") + assert resp.text.count("data:") >= 2 + + +# --------------------------------------------------------------------------- # +# /ws +# --------------------------------------------------------------------------- # +def test_websocket_without_handler_closes_1011(): + app = AgentArtsRuntimeApp() # no @app.websocket + with TestClient(app) as client: + with pytest.raises(Exception): # WebSocketDisconnect / close 1011 + with client.websocket_connect("/ws") as ws: + ws.receive_json() + + +def test_websocket_echo_handler(): + app = AgentArtsRuntimeApp() + + @app.websocket + async def ws_handler(websocket, request_context): + await websocket.accept() + try: + while True: + data = await websocket.receive_json() + await websocket.send_json({"echo": data}) + except Exception: + pass + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + ws.send_json({"msg": "ping"}) + assert ws.receive_json() == {"echo": {"msg": "ping"}} diff --git a/tests/integration/test_runtime_session_lifecycle.py b/tests/integration/test_runtime_session_lifecycle.py new file mode 100644 index 00000000..591e0f92 --- /dev/null +++ b/tests/integration/test_runtime_session_lifecycle.py @@ -0,0 +1,61 @@ +"""Runtime data-plane session lifecycle (RUN_BILLABLE tier). + +Starts a runtime session against a *pre-deployed* agent, runs a command, +round-trips a small file (upload→download), then stops the session. +``stop_session`` is also registered with the resource registry so the session +is torn down even if a step crashes mid-flow. Requires +``AGENTARTS_TEST_RUNTIME_AGENT_NAME`` and ``AGENTARTS_RUNTIME_DATA_ENDPOINT``. +""" + +from __future__ import annotations + +import uuid + +import pytest + +pytestmark = pytest.mark.integration + +_FILE_PATH = "/home/user/aa-it-uploaded.txt" +_FILE_CONTENT = "hello-aa-it" + + +def test_runtime_session_upload_download( + runtime_client, allow_billable, runtime_agent_name, resource_registry +): + agent_name = runtime_agent_name + session_id = f"aa-it-{uuid.uuid4().hex[:8]}" + + started = runtime_client.start_session(agent_name=agent_name) + backend_sid = started.get("session_id") if isinstance(started, dict) else None + sid = backend_sid or session_id + + # safety net: always stop the session at session end, even on failure + resource_registry.register( + lambda: runtime_client.stop_session(agent_name=agent_name, session_id=sid), + f"runtime_session:{agent_name}:{sid}", + ) + + # 1. exec_command + cmd = runtime_client.exec_command( + agent_name=agent_name, session_id=sid, command=f"echo {_FILE_CONTENT}" + ) + assert isinstance(cmd, dict) + + # 2. upload_files (single file, content-based) then download_files round-trip + up = runtime_client.upload_files( + agent_name=agent_name, + session_id=sid, + files=[{"content": _FILE_CONTENT}], + path=_FILE_PATH, + ) + assert isinstance(up, dict) + + downloaded = runtime_client.download_files( + agent_name=agent_name, session_id=sid, path=_FILE_PATH, recursive=False + ) + body = b"".join(downloaded.iter_bytes()) if hasattr(downloaded, "iter_bytes") else downloaded + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + assert _FILE_CONTENT in str(body) + + runtime_client.stop_session(agent_name=agent_name, session_id=sid) diff --git a/tests/integration/test_v11_upload_signature.py b/tests/integration/test_v11_upload_signature.py new file mode 100644 index 00000000..85ba4375 --- /dev/null +++ b/tests/integration/test_v11_upload_signature.py @@ -0,0 +1,206 @@ +"""Integration test: V11 signature verification end-to-end (local mirror). + +Stands up a local HTTP server that implements the *verification* side of the +V11-HMAC-SHA256 signature — mirroring what the real data-plane gateway does — +and drives the real ``RuntimeClient.upload_files`` data-plane path against it +over a real socket (no cloud credentials required). + +Pins the root cause of the IAM upload 401: + + * The data-plane gateway does **not** include the query string in the V11 + canonical request (it may inject/rewrite query params en route). Signing + the query therefore makes the gateway's recomputation diverge and fails + verification for any request carrying query params (e.g. upload's + ``path=...``), while query-less requests (e.g. invoke) succeed. + +Cases (all against the local mirror verifier, which uses an empty canonical +query line just like the real gateway): + 1. Fixed signer (query not signed) + upload with query -> 200 (auth passes). + 2. Old behaviour (query signed, forced via monkeypatch) + same upload -> 401 + (proves the fix is necessary). + 3. Tampered signature -> 401 (the verifier actually checks the signature). + 4. invoke (no query) -> 200 (the query fix does not regress invoke). + +The real-backend counterpart lives in ``test_v11_upload_signature_real.py``. +""" + +from __future__ import annotations + +import hmac +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from types import SimpleNamespace +from urllib.parse import urlparse + +import pytest + +from agentarts.sdk.service.http_client import SignMode +from agentarts.sdk.service.runtime_client import RuntimeClient +from agentarts.sdk.utils.signer_v11 import V11Signer + +AK = "AKTEST" +SK = "SKTEST" +REGION = "cn-southwest-2" + + +class V11Verifier: + """Server-side V11 verifier — mirrors the data-plane gateway: the canonical + request uses an *empty* query line (the query is not signed).""" + + def __init__(self) -> None: + self.last_signed_headers: list[str] = [] + + def verify(self, method: str, raw_path: str, headers_lower: dict[str, str]) -> bool: + auth = headers_lower.get("authorization", "") + m_sh = re.search(r"SignedHeaders=([^,]+)", auth) + m_sig = re.search(r"Signature=([0-9a-f]+)", auth) + if not m_sh or not m_sig: + return False + signed_headers = m_sh.group(1).split(";") + client_signature = m_sig.group(1) + self.last_signed_headers = signed_headers + + timestamp = headers_lower.get("x-sdk-date", "") + parsed = urlparse(raw_path) + path = parsed.path or "/" + + signer = V11Signer(AK, SK, REGION) + # Empty canonical query line — the gateway does not sign the query. + canonical_request = ( + f"{method.upper()}\n" + f"{signer._canonical_uri(path)}\n" + f"\n" + f"{signer._canonical_headers(headers_lower, signed_headers)}\n" + f"{';'.join(signed_headers)}\n" + f"UNSIGNED-PAYLOAD" + ) + string_to_sign = signer._get_string_to_sign(canonical_request, timestamp) + real_use_secret = signer._get_real_use_secret() + computed = signer._sign_string_to_sign(real_use_secret, string_to_sign) + return hmac.compare_digest(computed, client_signature) + + +class _Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def do_POST(self): + verifier: V11Verifier = self.server.verifier # type: ignore[attr-defined] + length = int(self.headers.get("Content-Length", "0") or "0") + self.rfile.read(length) if length else b"" + headers_lower = {k.lower(): v for k, v in self.headers.items()} + ok = verifier.verify(self.command, self.path, headers_lower) + payload = b'{"status": "uploaded"}' if ok else b'{"error": "Authentication failed!"}' + code = 200 if ok else 401 + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +class _Server(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + +@pytest.fixture +def v11_server(): + verifier = V11Verifier() + server = _Server(("127.0.0.1", 0), _Handler) + server.verifier = verifier # type: ignore[attr-defined] + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield port, verifier + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _make_client(port: int) -> RuntimeClient: + client = RuntimeClient( + data_endpoint=f"http://127.0.0.1:{port}", + verify_ssl=False, + sign_mode=SignMode.V11_HMAC_SHA256, + region_id=REGION, + ) + client._data_client._credentials = SimpleNamespace(ak=AK, sk=SK, security_token=None) + return client + + +def _sign_with_query_signed(self, method, path, query_params, headers): + """Simulate the OLD (buggy) behaviour: include the query in the canonical + request. Used to prove the fix is necessary.""" + timestamp = self._get_timestamp() + headers["x-sdk-date"] = timestamp + sh = self._signed_headers(headers) + canonical_request = ( + f"{method.upper()}\n" + f"{self._canonical_uri(path)}\n" + f"{self._canonical_query_string(query_params)}\n" + f"{self._canonical_headers(headers, sh)}\n" + f"{';'.join(sh)}\n" + f"UNSIGNED-PAYLOAD" + ) + string_to_sign = self._get_string_to_sign(canonical_request, timestamp) + secret = self._get_real_use_secret() + signature = self._sign_string_to_sign(secret, string_to_sign) + headers["Authorization"] = self._get_auth_header_value(sh, signature) + return headers + + +UPLOAD_FILES = [{"content": b"hello", "filename": "f.txt"}] + + +class TestV11UploadSignatureLocalMirror: + def test_fixed_signer_upload_with_query_verifies(self, v11_server): + """Fixed signer: query is sent on the wire but not signed -> the + (empty-query) verifier accepts -> 200.""" + port, _ = v11_server + result = _make_client(port).upload_files( + agent_name="myagent", session_id="sess-1", + files=UPLOAD_FILES, path="/home/user/f.txt", + ) + assert result["status"] == "uploaded" + + def test_old_behaviour_query_signed_fails(self, v11_server, monkeypatch): + """Necessity proof: if the query WERE signed (old behaviour), the + empty-query verifier rejects -> 401.""" + port, _ = v11_server + monkeypatch.setattr(V11Signer, "sign", _sign_with_query_signed) + with pytest.raises(RuntimeError, match="HTTP 401"): + _make_client(port).upload_files( + agent_name="myagent", session_id="sess-1", + files=UPLOAD_FILES, path="/home/user/f.txt", + ) + + def test_tampered_signature_rejected(self, v11_server): + port, _ = v11_server + client = _make_client(port) + # Sign correctly, then corrupt the signature before sending. + full_url = f"http://127.0.0.1:{port}/runtimes/myagent/upload-files" + signed = client._data_client._sign_request_v11( + "POST", full_url, + headers={"x-hw-agentarts-session-id": "sess-1", + "Content-Type": "application/octet-stream"}, + data=b"hello", + params={"path": "/home/user/f.txt"}, + ) + signed["headers"]["Authorization"] = re.sub( + r"Signature=[0-9a-f]+", "Signature=" + "0" * 64, + signed["headers"]["Authorization"], + ) + resp = client._data_client._session.request("POST", full_url, verify=False, **signed) + assert resp.status_code == 401 + + def test_invoke_without_query_verifies(self, v11_server): + """invoke carries no query params; the query fix must not regress it.""" + port, _ = v11_server + result = _make_client(port).invoke_agent( + agent_name="myagent", session_id="sess-1", payload='{"input": "hi"}', + ) + assert isinstance(result, dict) diff --git a/tests/integration/test_v11_upload_signature_real.py b/tests/integration/test_v11_upload_signature_real.py new file mode 100644 index 00000000..1f516c65 --- /dev/null +++ b/tests/integration/test_v11_upload_signature_real.py @@ -0,0 +1,171 @@ +"""Real-backend E2E: V11 upload signature authenticates against the live +data-plane gateway. + +Auto-discovers a deployed IAM (V11-accepting) agent with file transfer enabled +from the cloud control plane, then drives a V11-signed ``upload-files`` request +at its real data-plane gateway and asserts the signature authenticates +(non-401). This is the end-to-end proof for the root-cause fix: + + * Before the fix: the V11 signer signed the query string, but the data-plane + gateway does not -> upload returned HTTP 401 "Authentication failed!" (while + invoke, which carries no query, succeeded). + * After the fix: the signer signs an empty canonical query line; upload now + authenticates (the gateway returns a non-401 such as 404 "Session not + found" for the probe's fake session, proving auth passed and the request + reached the agent backend). + +The probe is **non-mutating**: it uploads to a throwaway session id that the +backend rejects with 404 before any file is written. Gated by real AK/SK +(``cloud_credentials``); no billable resources are created. + +Run: set ``HUAWEICLOUD_SDK_AK`` / ``HUAWEICLOUD_SDK_SK`` / ``HUAWEICLOUD_SDK_REGION`` +in ``tests/integration/.env`` (auto-loaded) and:: + + .venv/bin/python -m pytest tests/integration/test_v11_upload_signature_real.py -q +""" + +from __future__ import annotations + +import re + +import pytest + +pytestmark = pytest.mark.integration + +from agentarts.sdk.runtime.model import SESSION_HEADER +from agentarts.sdk.service.http_client import SignMode +from agentarts.sdk.service.runtime_client import RuntimeClient +from agentarts.sdk.utils.constant import get_control_plane_endpoint, get_region +from agentarts.sdk.utils.metadata import create_credential +from agentarts.sdk.utils.signer_v11 import V11Signer + +_FAKE_SESSION = "aa-it-sigprobe-session" +_PROBE_PARAMS = {"path": "/home/user/aa-it-sigprobe.txt", "user_id": 1000, "file_mode": "0644"} + + +def _httpsify(endpoint: str) -> str: + return endpoint if endpoint.startswith(("http://", "https://")) else f"https://{endpoint}" + + +def _v11_client(base_url: str, cred) -> RuntimeClient: + client = RuntimeClient( + data_endpoint=base_url, + verify_ssl=True, + sign_mode=SignMode.V11_HMAC_SHA256, + region_id=get_region(), + ) + client._data_client._credentials = cred + return client + + +def _upload_probe(client: RuntimeClient, agent_name: str): + """Send a V11-signed upload (fake session) and return the RequestResult.""" + return client._data( + "POST", + f"/runtimes/{agent_name}/upload-files", + data=b"aa-it-sigprobe", + headers={SESSION_HEADER: _FAKE_SESSION, "Content-Type": "application/octet-stream"}, + params=_PROBE_PARAMS, + timeout=60, + ) + + +@pytest.fixture(scope="session") +def v11_upload_agent(cloud_credentials): + """Auto-discover a deployed IAM agent with file transfer enabled whose + data-plane gateway accepts V11 signing. Returns (agent_name, base_url, cred). + + Discovery sends a V11 upload probe (fake session -> 404, non-mutating) to + each file-transfer-enabled agent and keeps the first whose gateway returns + non-401 (auth passed). Skips if none accept V11.""" + cred = create_credential() + region = cloud_credentials["region"] + control = RuntimeClient( + control_endpoint=get_control_plane_endpoint(region), verify_ssl=True + ) + + for agent in control.get_agents(limit=100): + name = agent.get("name") + try: + detail = control.find_agent_by_id(agent["id"]) + except Exception: + continue + invoke_config = (detail.get("version_detail") or {}).get("invoke_config") or {} + if not (invoke_config.get("file_transfer_config") or {}).get("enabled"): + continue + access_endpoint = invoke_config.get("access_endpoint") + if not access_endpoint: + continue + base = _httpsify(access_endpoint) + result = _upload_probe(_v11_client(base, cred), name) + # non-401 -> the gateway authenticated the V11 signature (e.g. 404 + # "Session not found"); 401 -> this agent's gateway does not accept V11. + if result.status_code != 401: + return name, base, cred + + pytest.skip( + "No deployed IAM (V11-accepting) agent with file transfer enabled was " + "found in the workspace; deploy one to exercise this E2E test" + ) + + +class TestV11UploadSignatureRealBackend: + def test_fixed_signer_upload_authenticates(self, v11_upload_agent): + """The fix: a V11-signed upload (with query params) authenticates + against the real data-plane gateway -> non-401.""" + name, base, cred = v11_upload_agent + result = _upload_probe(_v11_client(base, cred), name) + assert result.status_code != 401, ( + f"V11 upload still rejected by gateway (401): {result.error}" + ) + + def test_tampered_signature_rejected(self, v11_upload_agent): + """Control: a corrupted signature is rejected with 401, proving the + gateway actually verifies signatures (so the passing result above is + meaningful).""" + name, base, cred = v11_upload_agent + client = _v11_client(base, cred) + full_url = f"{base}/runtimes/{name}/upload-files" + signed = client._data_client._sign_request_v11( + "POST", full_url, + headers={SESSION_HEADER: _FAKE_SESSION, "Content-Type": "application/octet-stream"}, + data=b"aa-it-sigprobe", params=_PROBE_PARAMS, timeout=60, + ) + signed["headers"]["Authorization"] = re.sub( + r"Signature=[0-9a-f]+", "Signature=" + "0" * 64, + signed["headers"]["Authorization"], + ) + resp = client._data_client._session.request("POST", full_url, verify=True, **signed) + assert resp.status_code == 401, ( + f"tampered signature unexpectedly accepted: {resp.status_code} {resp.text[:120]}" + ) + + def test_old_behaviour_query_signed_fails(self, v11_upload_agent, monkeypatch): + """Necessity proof: if the query WERE signed (old behaviour), the real + gateway rejects with 401 — confirming the fix is required.""" + name, base, cred = v11_upload_agent + + def sign_with_query(self, method, path, query_params, headers): + timestamp = self._get_timestamp() + headers["x-sdk-date"] = timestamp + sh = self._signed_headers(headers) + canonical_request = ( + f"{method.upper()}\n" + f"{self._canonical_uri(path)}\n" + f"{self._canonical_query_string(query_params)}\n" + f"{self._canonical_headers(headers, sh)}\n" + f"{';'.join(sh)}\n" + f"UNSIGNED-PAYLOAD" + ) + string_to_sign = self._get_string_to_sign(canonical_request, timestamp) + secret = self._get_real_use_secret() + signature = self._sign_string_to_sign(secret, string_to_sign) + headers["Authorization"] = self._get_auth_header_value(sh, signature) + return headers + + monkeypatch.setattr(V11Signer, "sign", sign_with_query) + result = _upload_probe(_v11_client(base, cred), name) + assert result.status_code == 401, ( + f"old (query-signed) behaviour unexpectedly accepted: " + f"{result.status_code} {result.error}" + ) diff --git a/tests/integration/toolkit/__init__.py b/tests/integration/toolkit/__init__.py new file mode 100644 index 00000000..1ebc49a2 --- /dev/null +++ b/tests/integration/toolkit/__init__.py @@ -0,0 +1,7 @@ +"""Toolkit (agentarts CLI) integration / e2e tests. + +These exercise the real `agentarts` CLI (Typer app) end-to-end — no mocking of +operations or SDK clients. Local commands (init/config/dev) need no credentials; +cloud commands (memory/mcp-gateway/runtime) reuse the parent suite's gating +fixtures (cloud_credentials / allow_create / allow_billable / resource_registry). +""" diff --git a/tests/integration/toolkit/conftest.py b/tests/integration/toolkit/conftest.py new file mode 100644 index 00000000..794cdff0 --- /dev/null +++ b/tests/integration/toolkit/conftest.py @@ -0,0 +1,55 @@ +"""Fixtures for the toolkit (CLI) integration tests. + +Two invocation styles: + * ``cli_runner`` — in-process `typer.testing.CliRunner` against the real Typer + ``app``. Fast; used for local commands (init/config) where we assert on + filesystem side-effects rather than stdout (rich output capture is unreliable + under CliRunner). + * ``agentarts_cmd`` + ``cli_env`` — subprocess prefix + env to invoke the real + `agentarts` console entry (`python -c "from agentarts.toolkit.main import + app; app()" ...`). Reliable stdout capture (no TTY → rich emits plain text); + used for cloud commands where output must be parsed (e.g. `memory create + --output json`) and for the blocking `dev` server. + +Completion handling: the CLI's `_auto_install_completion` touches `~/.agentarts` +on first run, and setting `_AGENTARTS_COMPLETE` (the obvious skip) instead +triggers click's completion protocol ("Invalid completion instruction"). So we + * patch `_auto_install_completion` to a no-op for in-process CliRunner invokes; + * point HOME at a temp dir with the marker pre-created for subprocess invokes. +""" + +from __future__ import annotations + +import os + +import pytest +from typer.testing import CliRunner + +_COMPLETE_NOOP = "agentarts.toolkit.main._auto_install_completion" + + +@pytest.fixture +def cli_runner(monkeypatch): + """In-process CliRunner against the real Typer app (completion patched out).""" + monkeypatch.setattr(_COMPLETE_NOOP, lambda: None) + return CliRunner() + + +@pytest.fixture +def tmp_project(tmp_path, monkeypatch): + """A fresh temp CWD so .agentarts_config.yaml / scaffold files never leak.""" + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def cli_env(tmp_path, monkeypatch): + """Env for subprocess CLI invokes: HOME redirected to a temp dir with the + completion marker pre-created, so `_auto_install_completion` is a no-op and + no tip text pollutes stdout (important for `--output json` parsing).""" + home = tmp_path / "fakehome" + (home / ".agentarts").mkdir(parents=True, exist_ok=True) + (home / ".agentarts" / ".completion_shown").touch() + env = dict(os.environ) + env["HOME"] = str(home) + return env diff --git a/tests/integration/toolkit/test_cli_deployed_runtime.py b/tests/integration/toolkit/test_cli_deployed_runtime.py new file mode 100644 index 00000000..199f8d64 --- /dev/null +++ b/tests/integration/toolkit/test_cli_deployed_runtime.py @@ -0,0 +1,104 @@ +"""CLI runtime e2e against a Docker-deployed agent (billable tier). + +A single session-scoped `deployed_runtime_agent` fixture runs +`agentarts init → config → deploy` (Docker build + SWR push + cloud runtime +create) once; the tests below reuse that live agent, and `destroy` runs as the +fixture's session-end teardown. This pairs the B-class (invoke / runtime +session) tests with the C-class Docker deploy instead of requiring a +separately pre-provisioned agent. + +Gated behind Docker + cloud_credentials + ALLOW_CREATE + RUN_BILLABLE, so it +skips by default. SWR org/repo/image persist (documented residue). + +All CLI invokes run with cwd = the deployed project dir, so the CLI resolves +the data-plane endpoint from `.agentarts_config.yaml` + a control-plane lookup +(no `--endpoint` needed). +""" + +from __future__ import annotations + +import json +import re +import tempfile +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + + +def test_deploy_succeeds(deployed_runtime_agent): + """The fixture's deploy created a cloud runtime; the config now carries an + agent_id.""" + agent = deployed_runtime_agent + cfg = (Path(agent["project_dir"]) / ".agentarts_config.yaml").read_text() + assert "agent_id" in cfg # deploy writes the created agent's id back to config + assert agent["name"] + + +def test_invoke_deployed_agent(deployed_runtime_agent): + agent = deployed_runtime_agent + r = agent["run"]( + ["invoke", "--agent", agent["name"], "--mode", "cloud", + '{"message": "hello from deployed e2e"}'], + cwd=agent["project_dir"], + timeout=900, + ) + assert r.returncode == 0, r.stderr or r.stdout + + +def test_runtime_session_on_deployed_agent(deployed_runtime_agent): + """Core session lifecycle: start-session → exec-command → stop-session.""" + agent = deployed_runtime_agent + run = agent["run"] + cwd = agent["project_dir"] + common = ["--agent", agent["name"]] + + start = run(["runtime", "start-session", *common], cwd=cwd, timeout=120) + assert start.returncode == 0, start.stderr or start.stdout + m = re.search(r"Response:\s*(\{.*\})\s*$", start.stdout, re.DOTALL) + assert m, f"could not find session Response in output:\n{start.stdout}" + # strict=False: the result dict can contain raw control chars (e.g. newlines) in string values. + # session_id may be top-level or nested under "data" ({"code":..., "data":{"session_id":...}}). + parsed = json.loads(m.group(1), strict=False) + session_id = parsed.get("session_id") or (parsed.get("data") or {}).get("session_id") + assert session_id, f"no session_id in start-session response:\n{start.stdout}" + + sess = common + ["--session", session_id] + assert run(["runtime", "exec-command", *sess, "echo aa-it"], cwd=cwd, timeout=120).returncode == 0 + assert run(["runtime", "stop-session", *sess], cwd=cwd, timeout=120).returncode == 0 + + +def test_runtime_file_transfer_on_deployed_agent(deployed_runtime_agent): + """Best-effort file round-trip (upload → download). The file-upload endpoint + may require a bearer token that an IAM-only agent doesn't have (401); in that + case this test skips rather than fails — the core session lifecycle is + covered by test_runtime_session_on_deployed_agent.""" + agent = deployed_runtime_agent + run = agent["run"] + cwd = agent["project_dir"] + common = ["--agent", agent["name"]] + + start = run(["runtime", "start-session", *common], cwd=cwd, timeout=120) + assert start.returncode == 0, start.stderr or start.stdout + m = re.search(r"Response:\s*(\{.*\})\s*$", start.stdout, re.DOTALL) + parsed = json.loads(m.group(1), strict=False) + session_id = parsed.get("session_id") or (parsed.get("data") or {}).get("session_id") + sess = common + ["--session", session_id] + + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f: + f.write("hello-aa-it") + local = f.name + remote_file = f"/home/user/{Path(local).name}" + try: + up = run(["runtime", "upload-files", *sess, "--files", local, "--path", "/home/user/"], + cwd=cwd, timeout=120) + if up.returncode != 0 and "401" in (up.stderr or "") + (up.stdout or ""): + pytest.skip("upload-files returned 401 (IAM-only agent likely needs a bearer token)") + assert up.returncode == 0, up.stderr or up.stdout + assert run(["runtime", "download-files", *sess, "--path", remote_file, + "--output", str(Path(local).with_suffix(".dl"))], + cwd=cwd, timeout=120).returncode == 0 + finally: + Path(local).unlink(missing_ok=True) + run(["runtime", "stop-session", *sess], cwd=cwd, timeout=120) diff --git a/tests/integration/toolkit/test_cli_gateway.py b/tests/integration/toolkit/test_cli_gateway.py new file mode 100644 index 00000000..d5600d13 --- /dev/null +++ b/tests/integration/toolkit/test_cli_gateway.py @@ -0,0 +1,41 @@ +"""Gateway CLI e2e tests. + +Invokes the real `agentarts gateway` CLI via subprocess. Read-only `gateway +list` runs in the default tier; the gateway create lifecycle (ALLOW_CREATE) +exercises the auto-IAM-agency path (now fixed upstream via +`create_agency_with_policy`). +""" + +from __future__ import annotations + +import subprocess + +import pytest + +pytestmark = pytest.mark.integration + + +def _run(agentarts_cmd, cli_env, args, timeout=120): + return subprocess.run( + agentarts_cmd + args, capture_output=True, text=True, env=cli_env, timeout=timeout + ) + + +def test_cli_gateway_list_readonly(agentarts_cmd, cli_env, cloud_credentials): + """Read-only `gateway list` — no resources created (default tier).""" + r = _run(agentarts_cmd, cli_env, ["gateway", "list", "--limit", "1"]) + assert r.returncode == 0, r.stderr + + +def test_cli_gateway_create( + agentarts_cmd, cli_env, cloud_credentials, allow_create, run_id, resource_registry +): + """`gateway create` exercises the auto-agency path end-to-end through the CLI.""" + from tests.integration._helpers import unique_name + + name = unique_name("cli-gw", run_id) + r = _run( + agentarts_cmd, cli_env, + ["gateway", "create", "--name", name, "--description", "aa-it"], + ) + assert r.returncode == 0, r.stderr diff --git a/tests/integration/toolkit/test_cli_local.py b/tests/integration/toolkit/test_cli_local.py new file mode 100644 index 00000000..31bd7729 --- /dev/null +++ b/tests/integration/toolkit/test_cli_local.py @@ -0,0 +1,198 @@ +"""Local CLI e2e tests (default tier — no credentials, no Docker, no cloud). + +Invokes the real `agentarts` Typer app via CliRunner (in-process) for +init/config (asserting on generated files) and via subprocess for the blocking +`dev` server. Safe to run in CI unconditionally. +""" + +from __future__ import annotations + +import json +import socket +import subprocess +import time +import urllib.request + +import pytest +from typer.testing import CliRunner + +from agentarts.toolkit.main import app + +pytestmark = pytest.mark.integration + +TEMPLATES = ["basic", "langgraph", "langchain", "google-adk"] + + +# --------------------------------------------------------------------------- # +# Smoke +# --------------------------------------------------------------------------- # +def test_cli_version(cli_runner): + result = cli_runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "agentarts" in result.output.lower() or "0." in result.output + + +def test_cli_help(cli_runner): + result = cli_runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- # +# init +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("template", TEMPLATES) +def test_init_creates_project_files(cli_runner, tmp_project, template): + result = cli_runner.invoke( + app, ["init", "-n", "myagent", "-t", template, "-r", "cn-southwest-2"] + ) + assert result.exit_code == 0, result.output + project = tmp_project / "myagent" + assert (project / "agent.py").exists() + assert (project / "requirements.txt").exists() + assert (project / ".agentarts_config.yaml").exists() + assert (project / "Dockerfile").exists() + + +def test_init_path_option(cli_runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + target = tmp_path / "sub" + target.mkdir() + result = cli_runner.invoke( + app, + ["init", "-n", "myagent", "-t", "basic", "-r", "cn-southwest-2", "-p", str(target)], + ) + assert result.exit_code == 0, result.output + assert (target / "myagent" / "agent.py").exists() + + +def test_init_invalid_name_fails(cli_runner, tmp_project): + # uppercase name is normalised to lowercase; a name with invalid chars must fail + result = cli_runner.invoke( + app, ["init", "-n", "Bad_Name!", "-t", "basic", "-r", "cn-southwest-2"] + ) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- # +# config +# --------------------------------------------------------------------------- # +def _add_agent(runner: CliRunner, name: str = "myagent"): + # pass every flag the callback would otherwise prompt for (CliRunner has no + # stdin → an unhandled Prompt.ask aborts with exit 1) + return runner.invoke( + app, + [ + "config", "-n", name, "-e", "agent:app", "-r", "cn-southwest-2", + "-d", "requirements.txt", "--swr-org", "o", "--swr-repo", "r", + ], + ) + + +def test_config_add_writes_yaml_and_lists(cli_runner, tmp_project): + result = _add_agent(cli_runner) + assert result.exit_code == 0, result.output + cfg = tmp_project / ".agentarts_config.yaml" + assert cfg.exists() + assert "myagent" in cfg.read_text() + assert cli_runner.invoke(app, ["config", "list"]).exit_code == 0 + + +def test_config_set_get_roundtrip(cli_runner, tmp_project): + _add_agent(cli_runner) + assert cli_runner.invoke( + app, ["config", "set", "base.description", "hello", "-a", "myagent"] + ).exit_code == 0 + # assert the value landed in the YAML (robust vs rich stdout capture) + cfg = (tmp_project / ".agentarts_config.yaml").read_text() + assert "hello" in cfg + assert cli_runner.invoke( + app, ["config", "get", "base.description", "-a", "myagent"] + ).exit_code == 0 + + +def test_config_env_lifecycle(cli_runner, tmp_project): + _add_agent(cli_runner) + assert cli_runner.invoke( + app, ["config", "set-env", "MY_VAR", "val", "-a", "myagent"] + ).exit_code == 0 + cfg = (tmp_project / ".agentarts_config.yaml").read_text() + assert "MY_VAR" in cfg + assert "val" in cfg + assert cli_runner.invoke(app, ["config", "list-env", "-a", "myagent"]).exit_code == 0 + assert cli_runner.invoke( + app, ["config", "remove-env", "MY_VAR", "-a", "myagent"] + ).exit_code == 0 + assert "MY_VAR" not in (tmp_project / ".agentarts_config.yaml").read_text() + + +def test_config_set_default_and_remove(cli_runner, tmp_project): + _add_agent(cli_runner, "a1") + _add_agent(cli_runner, "a2") + assert cli_runner.invoke(app, ["config", "set-default", "a2"]).exit_code == 0 + assert cli_runner.invoke(app, ["config", "remove", "a1"]).exit_code == 0 + remaining = (tmp_project / ".agentarts_config.yaml").read_text() + assert "a2" in remaining + assert "a1" not in remaining + + +# --------------------------------------------------------------------------- # +# dev (blocking uvicorn — subprocess) +# --------------------------------------------------------------------------- # +def _free_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +def test_dev_server_serves_ping_and_invocations( + agentarts_cmd, cli_env, cli_runner, tmp_path, monkeypatch +): + # 1. scaffold a basic project in tmp_path + monkeypatch.chdir(tmp_path) + init_result = cli_runner.invoke( + app, ["init", "-n", "myagent", "-t", "basic", "-r", "cn-southwest-2"] + ) + assert init_result.exit_code == 0, init_result.output + project = tmp_path / "myagent" + + # 2. launch `agentarts dev` against it + port = _free_port() + proc = subprocess.Popen( + agentarts_cmd + ["dev", "-p", str(port), "-h", "127.0.0.1"], + cwd=str(project), + env=cli_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + base = f"http://127.0.0.1:{port}" + ping_ok = False + for _ in range(40): # ~20s startup window + try: + with urllib.request.urlopen(base + "/ping", timeout=1) as resp: + if resp.status == 200: + ping_ok = True + break + except Exception: + time.sleep(0.5) + assert ping_ok, "dev server did not come up" + + # 3. POST /invocations + req = urllib.request.Request( + base + "/invocations", + data=json.dumps({"message": "hi"}).encode(), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + assert resp.status == 200 + body = json.loads(resp.read()) + assert "response" in body + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() diff --git a/tests/integration/toolkit/test_cli_memory.py b/tests/integration/toolkit/test_cli_memory.py new file mode 100644 index 00000000..f758c5c3 --- /dev/null +++ b/tests/integration/toolkit/test_cli_memory.py @@ -0,0 +1,81 @@ +"""Memory CLI e2e tests. + +Invokes the real `agentarts memory` CLI via subprocess (reliable stdout capture +for `--output json` parsing). The default region for the memory CLI is +cn-north-4, so `--region` is passed explicitly to match the account under test. +""" + +from __future__ import annotations + +import json +import subprocess + +import pytest + +from tests.integration._helpers import unique_name + +pytestmark = pytest.mark.integration + + +def _run(agentarts_cmd, cli_env, args, timeout=120): + return subprocess.run( + agentarts_cmd + args, + capture_output=True, + text=True, + env=cli_env, + timeout=timeout, + ) + + +def _extract_json(text: str) -> dict: + """Pull the JSON object out of stdout (operation INFO logs precede it).""" + for i, ch in enumerate(text): + if ch == "{": + try: + return json.loads(text[i:]) + except json.JSONDecodeError: + continue + msg = f"no JSON object found in output:\n{text!r}" + raise AssertionError(msg) + + +def test_cli_memory_list_readonly(agentarts_cmd, cli_env, cloud_credentials): + """Read-only `memory list` — no resources created (default tier).""" + r = _run( + agentarts_cmd, cli_env, + ["memory", "list", "--limit", "1", "--region", cloud_credentials["region"]], + ) + assert r.returncode == 0, r.stderr + + +def test_cli_memory_lifecycle( + agentarts_cmd, cli_env, cloud_credentials, allow_create, run_id, resource_registry +): + region = cloud_credentials["region"] + name = unique_name("cli-space", run_id) + + # create (JSON output → extract space id) + r = _run( + agentarts_cmd, cli_env, + ["memory", "create", name, "--strategies", "semantic", + "--region", region, "--output", "json"], + ) + assert r.returncode == 0, r.stderr + space_id = _extract_json(r.stdout).get("id") + assert space_id + resource_registry.register( + lambda: _run(agentarts_cmd, cli_env, + ["memory", "delete", space_id, "--force", "--region", region]), + f"cli-space:{space_id}", + ) + + # list / get / update / delete through the CLI + assert _run(agentarts_cmd, cli_env, + ["memory", "list", "--limit", "1", "--region", region]).returncode == 0 + assert _run(agentarts_cmd, cli_env, + ["memory", "get", space_id, "--region", region]).returncode == 0 + assert _run(agentarts_cmd, cli_env, + ["memory", "update", space_id, "--description", "updated by cli", + "--region", region]).returncode == 0 + assert _run(agentarts_cmd, cli_env, + ["memory", "delete", space_id, "--force", "--region", region]).returncode == 0 diff --git a/tests/unit/sdk/service/identity/test_credential_providers.py b/tests/unit/sdk/service/identity/test_credential_providers.py index 91150e82..beabb34a 100644 --- a/tests/unit/sdk/service/identity/test_credential_providers.py +++ b/tests/unit/sdk/service/identity/test_credential_providers.py @@ -2,6 +2,9 @@ import pytest +from agentarts.sdk.identity.types import OAuth2Vendor +from agentarts.sdk.service.identity.identity_client import IdentityClient + # We expect these to be available in huaweicloudsdkagentidentity.v1 from huaweicloudsdkagentidentity.v1 import ( AgentIdentityClient, @@ -12,9 +15,6 @@ Tag, ) -from agentarts.sdk.identity.types import OAuth2Vendor -from agentarts.sdk.service.identity.identity_client import IdentityClient - @pytest.fixture def mock_sdk_client() -> MagicMock: diff --git a/tests/unit/sdk/service/identity/test_identity.py b/tests/unit/sdk/service/identity/test_identity.py index d8bf60c8..2ff5a05b 100644 --- a/tests/unit/sdk/service/identity/test_identity.py +++ b/tests/unit/sdk/service/identity/test_identity.py @@ -1,6 +1,12 @@ from unittest.mock import MagicMock, patch import pytest +from huaweicloudsdkcore.exceptions.exceptions import ( + ConnectionException, + ServiceResponseException, +) + +from agentarts.sdk.service.identity.identity_client import IdentityClient from huaweicloudsdkagentidentity.v1 import ( AgentIdentityClient, CompleteResourceTokenAuthResponse, @@ -11,12 +17,6 @@ Tag, UserIdentifier, ) -from huaweicloudsdkcore.exceptions.exceptions import ( - ConnectionException, - ServiceResponseException, -) - -from agentarts.sdk.service.identity.identity_client import IdentityClient @pytest.fixture diff --git a/tests/unit/sdk/service/identity/test_identity_updates.py b/tests/unit/sdk/service/identity/test_identity_updates.py index 8b0f9f24..41c0c0a1 100644 --- a/tests/unit/sdk/service/identity/test_identity_updates.py +++ b/tests/unit/sdk/service/identity/test_identity_updates.py @@ -1,14 +1,14 @@ from unittest.mock import MagicMock, patch import pytest + +from agentarts.sdk.service.identity.identity_client import IdentityClient from huaweicloudsdkagentidentity.v1 import ( AgentIdentityClient, UpdateWorkloadIdentityResponse, WorkloadIdentity, ) -from agentarts.sdk.service.identity.identity_client import IdentityClient - @pytest.fixture def mock_sdk_client() -> MagicMock: diff --git a/tests/unit/sdk/service/identity/test_workload_identities.py b/tests/unit/sdk/service/identity/test_workload_identities.py index 31042aa7..83d223be 100644 --- a/tests/unit/sdk/service/identity/test_workload_identities.py +++ b/tests/unit/sdk/service/identity/test_workload_identities.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock, patch +from agentarts.sdk.service.identity.identity_client import IdentityClient from huaweicloudsdkagentidentity.v1 import ( AgentIdentityClient, GetWorkloadIdentityResponse, @@ -8,8 +9,6 @@ WorkloadIdentitySummary, ) -from agentarts.sdk.service.identity.identity_client import IdentityClient - def _build_identity_client(mock_sdk_client: MagicMock) -> IdentityClient: with patch.dict( diff --git a/tests/unit/sdk/service/test_runtime_client.py b/tests/unit/sdk/service/test_runtime_client.py index b7c8ae48..1b237a77 100644 --- a/tests/unit/sdk/service/test_runtime_client.py +++ b/tests/unit/sdk/service/test_runtime_client.py @@ -190,7 +190,7 @@ def test_upload_files_multiple_files_multipart(self, mock_data): {"local_file": tmp1_path}, {"local_file": tmp2_path}, ], - path="/home/user/", + path="/tmp/", ) assert result["files"] == 2 @@ -198,7 +198,7 @@ def test_upload_files_multiple_files_multipart(self, mock_data): call_kwargs = mock_data.call_args.kwargs assert "files" in call_kwargs params = call_kwargs.get("params", {}) - assert params.get("path") == "/home/user/" + assert params.get("path") == "/tmp/" finally: Path(tmp1_path).unlink() Path(tmp2_path).unlink() @@ -347,6 +347,42 @@ def test_download_files_returns_stream_result(self): assert result.status_code == 200 assert "octet-stream" in result.content_type + def test_download_files_signs_when_ak_sk_enabled(self): + """Regression: _request_stream must sign (V11) when the data client has + open_ak_sk set. Previously download_files called session.request + directly, bypassing signing -> unsigned request -> HTTP 401 on IAM + agents (while upload_files, which goes through _request, signed fine).""" + from types import SimpleNamespace + + from agentarts.sdk.service.http_client import SignMode + + client = RuntimeClient( + data_endpoint="https://test.example.com", + sign_mode=SignMode.V11_HMAC_SHA256, + region_id="cn-southwest-2", + ) + client._data_client._credentials = SimpleNamespace( + ak="ak", sk="sk", security_token=None + ) + assert client._data_client._open_ak_sk is True + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.ok = True + mock_response.headers = {"Content-Type": "application/octet-stream"} + mock_response.iter_content.return_value = iter([b"test content"]) + + with patch.object( + client._data_client, "_sign_request", return_value={"headers": {}} + ) as mock_sign, patch.object( + client._data_client._session, "request", return_value=mock_response + ): + client.download_files( + agent_name="test-agent", session_id="session-123", path="/x" + ) + + mock_sign.assert_called_once() + def test_download_files_recursive_tar(self): client = RuntimeClient(data_endpoint="https://test.example.com") diff --git a/tests/unit/sdk/utils/test_signer_v11.py b/tests/unit/sdk/utils/test_signer_v11.py index 52ad87c8..77bef7c0 100644 --- a/tests/unit/sdk/utils/test_signer_v11.py +++ b/tests/unit/sdk/utils/test_signer_v11.py @@ -1,41 +1,181 @@ """Unit tests for the V11-HMAC-SHA256 signer.""" +import re +from types import SimpleNamespace + from agentarts.sdk.utils.signer_v11 import V11Signer -class TestCanonicalQueryString: - """Tests for V11Signer._canonical_query_string.""" +def _signed_headers_from_auth(auth_value: str) -> list[str]: + """Parse the SignedHeaders list out of an Authorization header value.""" + match = re.search(r"SignedHeaders=([^,]+)", auth_value) + assert match, f"no SignedHeaders in {auth_value!r}" + return match.group(1).split(";") - def test_empty_when_no_params(self): - assert V11Signer("ak", "sk", "cn-southwest-2")._canonical_query_string(None) == "" - assert V11Signer("ak", "sk", "cn-southwest-2")._canonical_query_string({}) == "" - def test_keeps_slash_unencoded(self): - """Regression: query values containing '/' (e.g. upload's `path`) must - keep '/' unencoded to match the data-plane gateway's canonicalisation.""" - signer = V11Signer("ak", "sk", "cn-southwest-2") - result = signer._canonical_query_string({"path": "/home/user/test.txt"}) - assert result == "path=/home/user/test.txt" +def _signature_from_auth(auth_value: str) -> str: + match = re.search(r"Signature=([0-9a-f]+)", auth_value) + assert match, f"no Signature in {auth_value!r}" + return match.group(1) - def test_sorts_keys(self): - signer = V11Signer("ak", "sk", "cn-southwest-2") - result = signer._canonical_query_string( - {"path": "/home/user/test.txt", "user_id": 1000, "file_mode": "0644"} + +def _make_signer() -> V11Signer: + signer = V11Signer("ak", "sk", "cn-southwest-2") + # Pin the timestamp so signatures are comparable across calls. + signer._get_timestamp = lambda: "20260709T120000Z" # type: ignore[assignment] + return signer + + +class TestQueryNotSigned: + """The data-plane gateway does not sign the query string. The signer must + therefore produce a signature that is *independent* of the query params + (they are still sent on the wire, just not signed). This is the root cause + of the IAM upload 401: invoke (no query) authenticated, upload (query + `path=...`) did not, because the client signed the query while the gateway + does not.""" + + def test_query_does_not_affect_signature(self): + signer = _make_signer() + base = {"host": "data.example.com", "x-hw-agentarts-session-id": "s"} + out_no_q = signer.sign("POST", "/runtimes/a/upload-files", None, dict(base)) + out_with_q = signer.sign( + "POST", "/runtimes/a/upload-files", + {"path": "/home/user/test.txt", "user_id": 1000, "file_mode": "0644"}, + dict(base), ) - assert result == "file_mode=0644&path=/home/user/test.txt&user_id=1000" + assert _signature_from_auth(out_no_q["Authorization"]) == \ + _signature_from_auth(out_with_q["Authorization"]), ( + "query params must not participate in the V11 signature" + ) + + def test_changing_query_value_does_not_change_signature(self): + signer = _make_signer() + base = {"host": "h", "x-hw-agentarts-session-id": "s"} + a = signer.sign("POST", "/p", {"path": "/a/b"}, dict(base)) + b = signer.sign("POST", "/p", {"path": "/x/y/z"}, dict(base)) + assert _signature_from_auth(a["Authorization"]) == \ + _signature_from_auth(b["Authorization"]) + + def test_query_is_still_sent_on_wire(self): + """The signer does not strip query params from the caller's request — + it only drops them from the signature. (The HTTP client still sends + them.) Here we assert the signer itself doesn't touch them: it only + takes ``headers`` and returns updated ``headers``.""" + signer = _make_signer() + out = signer.sign("POST", "/p", {"path": "/x"}, {"host": "h"}) + # Authorization + x-sdk-date added; no query leakage into headers. + assert "Authorization" in out + assert out["x-sdk-date"] == "20260709T120000Z" + + +class TestSignedHeaders: + """Headers (including Content-Type) ARE signed — the gateway recomputes + only the declared SignedHeaders from the values it receives, and does not + rewrite Content-Type for these requests (verified end-to-end).""" + + def test_content_type_is_signed(self): + signer = _make_signer() + out = signer.sign("POST", "/p", None, {"host": "h", "Content-Type": "application/octet-stream"}) + assert "content-type" in _signed_headers_from_auth(out["Authorization"]) + + def test_changing_content_type_changes_signature(self): + signer = _make_signer() + a = signer.sign("POST", "/p", None, {"host": "h", "Content-Type": "application/octet-stream"}) + b = signer.sign("POST", "/p", None, {"host": "h", "Content-Type": "application/json"}) + assert _signature_from_auth(a["Authorization"]) != \ + _signature_from_auth(b["Authorization"]) + + def test_changing_signed_header_changes_signature(self): + """Guard: a signed header value change MUST change the signature.""" + signer = _make_signer() + a = signer.sign("POST", "/p", None, {"host": "a.example.com"}) + b = signer.sign("POST", "/p", None, {"host": "b.example.com"}) + assert _signature_from_auth(a["Authorization"]) != \ + _signature_from_auth(b["Authorization"]) + + def test_signed_headers_sorted_and_lowercased(self): + signer = _make_signer() + # "Host" must be lowercased; the signer also adds "x-sdk-date". + out = signer.sign("GET", "/p", None, {"Host": "h", "X-Custom-Hdr": "v"}) + assert _signed_headers_from_auth(out["Authorization"]) == [ + "host", "x-custom-hdr", "x-sdk-date" + ] + - def test_simple_value_unchanged(self): - """Values without '/' are unaffected by the safe-set change.""" - signer = V11Signer("ak", "sk", "cn-southwest-2") - assert signer._canonical_query_string({"endpoint": "stream"}) == "endpoint=stream" +class TestSignBasics: + def test_authorization_format(self): + out = _make_signer().sign("POST", "/p", None, {"host": "h"}) + auth = out["Authorization"] + assert auth.startswith("V11-HMAC-SHA256 Credential=ak/20260709/cn-southwest-2/apic") + assert "SignedHeaders=" in auth + assert "Signature=" in auth - def test_list_value_keeps_slash_unencoded(self): - signer = V11Signer("ak", "sk", "cn-southwest-2") - result = signer._canonical_query_string({"k": ["/c", "/a/b"]}) - assert result == "k=/a/b&k=/c" + def test_adds_x_sdk_date_and_authorization(self): + out = _make_signer().sign("GET", "/p", None, {"host": "h"}) + assert out["x-sdk-date"] == "20260709T120000Z" + assert "Authorization" in out + + def test_deterministic_for_same_inputs(self): + s = _make_signer() + a = s.sign("POST", "/p", {"a": "1"}, {"host": "h", "Content-Type": "x"}) + b = s.sign("POST", "/p", {"a": "1"}, {"host": "h", "Content-Type": "x"}) + assert a["Authorization"] == b["Authorization"] + + +class TestCanonicalQueryString: + """``_canonical_query_string`` is the standard-V11 reference implementation. + It is **not** used to build the signature (the gateway does not sign the + query); these tests just pin its reference behaviour.""" + + def test_empty_when_no_params(self): + s = V11Signer("ak", "sk", "cn-southwest-2") + assert s._canonical_query_string(None) == "" + assert s._canonical_query_string({}) == "" + + def test_sorts_keys(self): + s = V11Signer("ak", "sk", "cn-southwest-2") + assert s._canonical_query_string({"user_id": 1000, "file_mode": "0644"}) == \ + "file_mode=0644&user_id=1000" def test_space_and_special_encoded(self): - """Non-'/' special chars are still percent-encoded.""" - signer = V11Signer("ak", "sk", "cn-southwest-2") - result = signer._canonical_query_string({"q": "a b&c"}) - assert result == "q=a%20b%26c" + s = V11Signer("ak", "sk", "cn-southwest-2") + assert s._canonical_query_string({"q": "a b&c"}) == "q=a%20b%26c" + + +class TestSignRequestV11Integration: + """BaseHTTPClient._sign_request_v11 wires query + content-type through + correctly: query sent on the wire but not signed; content-type signed.""" + + def test_v11_sign_request_query_not_signed_content_type_signed(self): + from agentarts.sdk.service.http_client import BaseHTTPClient, RequestConfig, SignMode + + client = BaseHTTPClient( + RequestConfig(base_url="https://data.example.com"), + open_ak_sk=True, + sign_mode=SignMode.V11_HMAC_SHA256, + region_id="cn-southwest-2", + ) + client._credentials = SimpleNamespace(ak="ak", sk="sk", security_token=None) + + # Sign the same request twice: once with query params, once without. + base_headers = { + "x-hw-agentarts-session-id": "sess-1", + "Content-Type": "application/octet-stream", + } + with_q = client._sign_request_v11( + "POST", "https://data.example.com/runtimes/a/upload-files", + headers=dict(base_headers), data=b"x", + params={"path": "/home/user/test.txt", "user_id": 1000}, + ) + without_q = client._sign_request_v11( + "POST", "https://data.example.com/runtimes/a/upload-files", + headers=dict(base_headers), data=b"x", params=None, + ) + + # Query params are still passed through to the HTTP client (sent on wire). + assert with_q["params"] == {"path": "/home/user/test.txt", "user_id": 1000} + # Content-Type is signed. + assert "content-type" in _signed_headers_from_auth(with_q["headers"]["Authorization"]) + # Signature is independent of the query (query not signed). + assert _signature_from_auth(with_q["headers"]["Authorization"]) == \ + _signature_from_auth(without_q["headers"]["Authorization"]) diff --git a/tests/unit/toolkit/operations/runtime/test_config.py b/tests/unit/toolkit/operations/runtime/test_config.py index 1fa4a8f2..4d2ed72b 100644 --- a/tests/unit/toolkit/operations/runtime/test_config.py +++ b/tests/unit/toolkit/operations/runtime/test_config.py @@ -40,6 +40,13 @@ def test_returns_valid_platform_format(self): result = detect_platform() assert result in ("linux/amd64", "linux/arm64") + @patch("platform.machine", return_value="riscv64") + @patch("platform.system", return_value="Linux") + def test_unknown_defaults_to_linux_arm64(self, mock_system, mock_machine): + """An unrecognised architecture defaults to linux/arm64 (the backend + is predominantly arm).""" + assert detect_platform() == "linux/arm64" + class TestDetectArch: """Tests for detect_arch() function.""" @@ -73,6 +80,13 @@ def test_detects_x86_64_for_uppercase_amd64(self, mock_machine): result = detect_arch() assert result == ArchType.X86_64 + @patch("platform.machine", return_value="riscv64") + def test_unknown_arch_defaults_to_arm64(self, mock_machine): + """An unrecognised architecture defaults to arm64 (the AgentArts + backend is predominantly arm).""" + result = detect_arch() + assert result == ArchType.ARM64 + class TestDetectDependencyFile: """Tests for detect_dependency_file() function.""" @@ -287,6 +301,24 @@ def test_sets_arch_from_detected_environment_x86_64(self, mock_machine, tmp_path agent = config.get_agent("test-agent") assert agent.base.arch == ArchType.X86_64 + def test_arch_and_platform_default_to_arm_when_omitted(self, tmp_path, monkeypatch): + """A config that omits arch/platform defaults to arm64 / linux/arm64 — + the AgentArts backend is predominantly arm, so the field defaults (not + the machine detection) prefer arm over x86.""" + (tmp_path / ".agentarts_config.yaml").write_text( + "default_agent: test-agent\n" + "agents:\n" + " test-agent:\n" + " base:\n" + " name: test-agent\n" + " region: cn-southwest-2\n" + ) + monkeypatch.chdir(tmp_path) + + agent = load_config().get_agent("test-agent") + assert agent.runtime.arch == ArchType.ARM64 + assert agent.base.platform == "linux/arm64" + class TestRemoveAgent: """Tests for remove_agent() function.""" diff --git a/tests/unit/toolkit/operations/runtime/test_init.py b/tests/unit/toolkit/operations/runtime/test_init.py index 45138501..7ea3b1b7 100644 --- a/tests/unit/toolkit/operations/runtime/test_init.py +++ b/tests/unit/toolkit/operations/runtime/test_init.py @@ -43,6 +43,13 @@ def test_windows_amd64(self, mock_system, mock_machine): result = detect_platform() assert result == "linux/amd64" + @patch("platform.machine", return_value="riscv64") + @patch("platform.system", return_value="Linux") + def test_unknown_defaults_to_linux_arm64(self, mock_system, mock_machine): + """An unrecognised architecture defaults to linux/arm64 (the backend + is predominantly arm).""" + assert detect_platform() == "linux/arm64" + class TestGetTemplateEnvVars: """Tests for get_template_env_vars() function.""" diff --git a/tests/unit/toolkit/operations/runtime/test_invoke.py b/tests/unit/toolkit/operations/runtime/test_invoke.py index 84ed7fa8..37ea8cca 100644 --- a/tests/unit/toolkit/operations/runtime/test_invoke.py +++ b/tests/unit/toolkit/operations/runtime/test_invoke.py @@ -58,13 +58,45 @@ class TestResolveAgentInfo: """Tests for _resolve_agent_info() function.""" def test_returns_none_when_no_config(self, tmp_path, monkeypatch): - """Returns None values when no config exists.""" + """With no config, name/region are None but auth_type defaults to IAM + so data-plane commands still sign with V11 (not an unsigned request).""" monkeypatch.chdir(tmp_path) - name, region, _agent_id, _auth_type = _resolve_agent_info(None, None) + name, region, _agent_id, auth_type = _resolve_agent_info(None, None) assert name is None assert region is None + assert auth_type == "IAM" + + def test_defaults_to_iam_when_agent_has_no_identity_config(self, tmp_path, monkeypatch): + """An agent in config but without identity_configuration defaults to + IAM (V11 signing) rather than None (which would send an unsigned + request that the data-plane gateway rejects with 401).""" + config_content = """ +default_agent: test-agent +agents: + test-agent: + base: + name: test-agent + region: cn-north-4 + runtime: + agent_id: agent-123 +""" + (tmp_path / ".agentarts_config.yaml").write_text(config_content) + monkeypatch.chdir(tmp_path) + + _name, _region, _agent_id, auth_type = _resolve_agent_info(None, None) + + assert auth_type == "IAM" + + def test_defaults_to_iam_when_agent_passed_but_not_in_config(self, tmp_path, monkeypatch): + """A passed agent not listed in the config defaults to IAM.""" + monkeypatch.chdir(tmp_path) + + _name, _region, _agent_id, auth_type = _resolve_agent_info("some-agent", None) + + assert auth_type == "IAM" + def test_resolves_from_config(self, tmp_path, monkeypatch): """Resolves agent info from config file.""" diff --git a/tests/unit/toolkit/operations/runtime/test_upload_files.py b/tests/unit/toolkit/operations/runtime/test_upload_files.py index 3902da99..4423b1cb 100644 --- a/tests/unit/toolkit/operations/runtime/test_upload_files.py +++ b/tests/unit/toolkit/operations/runtime/test_upload_files.py @@ -109,7 +109,7 @@ def test_upload_files_default_path(self, tmp_path, monkeypatch): ) call_args = mock_instance.upload_files.call_args - assert call_args.kwargs["path"] == "/home/user/" + assert call_args.kwargs["path"] == "/tmp/" finally: Path(tmp_path_file).unlink() @@ -146,7 +146,7 @@ def test_upload_files_multiple_files(self, tmp_path, monkeypatch): {"local_file": tmp1_path}, {"local_file": tmp2_path}, ], - path="/home/user/", + path="/tmp/", session_id="session-123", ) @@ -154,7 +154,7 @@ def test_upload_files_multiple_files(self, tmp_path, monkeypatch): call_args = mock_instance.upload_files.call_args files_arg = call_args.kwargs["files"] assert len(files_arg) == 2 - assert call_args.kwargs["path"] == "/home/user/" + assert call_args.kwargs["path"] == "/tmp/" finally: Path(tmp1_path).unlink() Path(tmp2_path).unlink() diff --git a/uv.lock b/uv.lock index a3c274bb..5b3996ad 100644 --- a/uv.lock +++ b/uv.lock @@ -19,7 +19,7 @@ resolution-markers = [ [[package]] name = "agentarts-sdk" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "cryptography" },