diff --git a/agentkit/toolkit/cli/cli_list.py b/agentkit/toolkit/cli/cli_list.py index 74af8c4..4192693 100644 --- a/agentkit/toolkit/cli/cli_list.py +++ b/agentkit/toolkit/cli/cli_list.py @@ -46,6 +46,39 @@ def _is_harness_runtime(runtime: rt.AgentKitRuntimesForListRuntimes) -> bool: return False +# Fixed ADK app name the harness loader serves its agent under (the deployed +# HARNESS_NAME is irrelevant to the ADK app path). +_HARNESS_APP = "harness" + + +def _user_id_from_token(token: str) -> Optional[str]: + """Return the OIDC ``sub`` claim from a JWT bearer token, else ``None``. + + A custom_jwt harness is reached with an OIDC id_token whose ``sub`` is the + authenticated user's stable id. A key_auth token is an opaque api key (not a + JWT, no ``sub``), so this returns ``None`` and the caller must be given an + explicit ``--user-id``. + + NOTE: this mirrors ``cli_invoke._user_id_from_token``; consolidate the two + into one shared helper once the ``invoke harness`` run_sse work lands. + """ + import base64 + import binascii + import json + + parts = token.split(".") + if len(parts) != 3: + return None # not a JWT (e.g. a key_auth api key) + payload = parts[1] + payload += "=" * (-len(payload) % 4) # restore base64 padding + try: + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (ValueError, binascii.Error): + return None # malformed JWT payload + sub = claims.get("sub") + return sub if isinstance(sub, str) and sub else None + + @list_app.command("harness") def list_harness_command( region: Optional[str] = typer.Option( @@ -129,6 +162,173 @@ def build_request(next_token_val): local_console.print(table) +def _resolve_harness_endpoint(harness: str, directory: str, apikey: Optional[str]): + """Resolve a deployed harness's data-plane base URL and bearer token. + + Mirrors ``invoke harness``'s auth-type-aware credential selection: + 1. ``--apikey`` always wins (explicit override); + 2. a custom_jwt harness (no stored ``key``) → the ``agentkit login`` OIDC + id_token (auto-refreshed), whose ``sub`` identifies the caller; + 3. a key_auth harness → its static ``key`` (an opaque api key, not a JWT). + + Returns ``(base_url, token)``. Fast-fails when the harness is not in the + registry, or when a needed login id_token cannot be refreshed. + """ + from agentkit.toolkit.harness import load_harness_registry + + registry = load_harness_registry(directory) + entry = registry.get(harness) + if not isinstance(entry, dict) or not entry.get("url"): + from pathlib import Path + + registry_path = Path(directory).resolve() / "harness.json" + console.print( + f"[red]Error: harness '{harness}' not found in registry {registry_path}. " + f"Deploy it first with `agentkit deploy --harness {harness}`.[/red]" + ) + raise typer.Exit(1) + + base_url = entry["url"].rstrip("/") + + from agentkit.auth.errors import AuthError + from agentkit.auth.sso import load_session + + is_jwt_harness = entry.get("auth_type") == "custom_jwt" or not entry.get("key") + token = apikey or "" + if not token and is_jwt_harness: + try: + auth_session = load_session() + except Exception: + auth_session = None + if auth_session is not None: + try: + token = auth_session.valid_id_token() + except AuthError as e: + console.print(f"[red]❌ {e}[/red]") + if getattr(e, "hint", None): + console.print(f"[yellow]{e.hint}[/yellow]") + raise typer.Exit(1) + if not token: + token = entry.get("key") or "" + + return base_url, token + + +@list_app.command("sessions") +def list_sessions_command( + harness: str = typer.Option( + ..., "--harness", help="Harness name (resolved via the harness.json registry)." + ), + user_id: Optional[str] = typer.Option( + None, + "--user-id", + help=( + "user_id whose sessions to list. If omitted, it is taken from the " + "JWT `sub` of the harness credential; a key_auth harness has no JWT, " + "so --user-id is required there." + ), + ), + apikey: Optional[str] = typer.Option( + None, + "--apikey", + "-ak", + help="Bearer token (e.g. a custom_jwt harness's OIDC JWT) overriding the registry credential.", + ), + directory: str = typer.Option( + ".", "--directory", help="Directory containing harness.json." + ), + output: str = typer.Option( + "table", "--output", help="Output format: table|json|yaml" + ), + quiet: bool = typer.Option( + False, "--quiet", "-q", help="Print only session ids" + ), + no_color: bool = typer.Option( + False, "--no-color", "-nc", help="Disable colored output for tables/panels" + ), +): + """List a user's conversation sessions on a deployed harness runtime. + + Calls the harness runtime's ADK endpoint + ``GET /apps/harness/users/{user_id}/sessions``. ADK requires a user_id (there + is no cross-user listing), so it must be given explicitly via --user-id or be + derivable from the JWT `sub` of the harness credential. + """ + import json + from datetime import datetime + + import requests + from rich.table import Table + + local_console = console if not no_color else Console(no_color=True) + + base_url, token = _resolve_harness_endpoint(harness, directory, apikey) + + resolved_user_id = user_id or _user_id_from_token(token) + if not resolved_user_id: + local_console.print( + "[red]Error: cannot determine user_id — no --user-id given and the " + "harness credential is not a JWT with a `sub` claim (e.g. a key_auth " + "harness). Pass --user-id explicitly.[/red]" + ) + raise typer.Exit(1) + + url = f"{base_url}/apps/{_HARNESS_APP}/users/{resolved_user_id}/sessions" + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + with local_console.status("[cyan]Fetching sessions...[/cyan]", spinner="dots"): + try: + resp = requests.get(url, headers=headers, timeout=60) + except requests.RequestException as e: + local_console.print(f"[red]❌ List sessions request failed: {e}[/red]") + raise typer.Exit(1) + + if resp.status_code != 200: + local_console.print( + f"[red]❌ List sessions HTTP {resp.status_code}: {resp.text[:300]}[/red]" + ) + raise typer.Exit(1) + + sessions = resp.json() or [] + + if quiet: + for s in sessions: + local_console.print(s.get("id", "")) + return + + if output.lower() == "json": + local_console.print(json.dumps(sessions, indent=2, ensure_ascii=False)) + return + if output.lower() == "yaml": + import yaml + + local_console.print(yaml.safe_dump(sessions, sort_keys=False, allow_unicode=True)) + return + + table = Table( + title=f"Sessions for user '{resolved_user_id}' (Count: {len(sessions)})", + show_lines=False, + ) + table.add_column("SessionId", style="cyan") + table.add_column("Events", style="green") + table.add_column("LastUpdate", style="magenta") + for s in sessions: + ts = s.get("lastUpdateTime") + last_update = ( + datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") + if isinstance(ts, (int, float)) and ts + else "" + ) + table.add_row( + str(s.get("id", "")), + str(len(s.get("events", []))), + last_update, + ) + local_console.print(table) + + @list_app.command("credentials") def list_credentials_command( region: Optional[str] = typer.Option( diff --git a/docs/content/2.agentkit-cli/5.harness.md b/docs/content/2.agentkit-cli/5.harness.md index 33e4761..5e6ca94 100644 --- a/docs/content/2.agentkit-cli/5.harness.md +++ b/docs/content/2.agentkit-cli/5.harness.md @@ -265,3 +265,53 @@ agentkit deploy --harness my-harness \ agentkit list harness agentkit list harness --output json --region cn-beijing ``` + +## 8. list sessions —— 列出某 Harness 的会话 + +`agentkit list sessions --harness ` 列出某个已部署 harness 运行时上、**某个用户**的对话会话。它通过 `harness.json` 解析出运行时地址与凭证,直接调用运行时的 ADK 数据面端点: + +```text +GET {harness_url}/apps/harness/users/{user_id}/sessions +``` + +> 与 `list harness`(控制面,AK/SK 调火山 `ListRuntimes`)不同,本命令是**数据面**调用,寻址方式与 `invoke harness` 一致(按名从 `harness.json` 解析、用 harness 凭证鉴权)。 + +### user_id 的确定方式 + +ADK 的会话列表接口把 `user_id` 作为**必填路径参数**,没有"列出所有用户会话"的能力。因此 user_id 必须能被确定,按以下优先级: + +1. 显式 `--user-id `; +2. 未指定时,从 harness 凭证的 JWT `sub` 解出—— + - **custom_jwt(OAuth)harness**:默认使用 `agentkit login` 的 id_token(或 `-ak` 传入的 JWT),其 `sub` 即用户身份; + - **key_auth harness**:凭证是不透明 API Key、不是 JWT,**无法解出 user_id**,此时必须显式 `--user-id`,否则快速失败。 + +### 参数 + +| 参数 | 说明 | 默认值 | +| :--- | :--- | :--- | +| `--harness ` | **(必填)** harness 名称,从 `harness.json` 解析。 | — | +| `--user-id ` | 要查询的用户;缺省时尝试从凭证 JWT 的 `sub` 推导。 | 由 JWT 推导 | +| `--apikey`, `-ak ` | Bearer token(如 custom_jwt harness 的 OIDC JWT),覆盖 registry 中的凭证。 | 注册表凭证 | +| `--directory ` | `harness.json` 所在目录。 | `.`(当前目录) | +| `--output` | 输出格式 `table` / `json` / `yaml`。 | `table` | +| `--quiet`, `-q` | 仅打印会话 id。 | `false` | +| `--no-color`, `-nc` | 关闭彩色输出。 | `false` | + +表格输出包含 `SessionId`、`Events`(事件数)、`LastUpdate`(最近更新时间)等列。 + +### 例子 + +```bash +# custom_jwt(OAuth)harness:已 agentkit login,user_id 自动取自 JWT sub +agentkit list sessions --harness my-harness + +# 显式指定用户(key_auth harness 必须这样) +agentkit list sessions --harness my-harness --user-id alice + +# 传入 JWT 覆盖凭证(user_id 取自该 JWT 的 sub) +agentkit list sessions --harness my-harness -ak "" + +# 仅打印会话 id / JSON 输出 +agentkit list sessions --harness my-harness --user-id alice --quiet +agentkit list sessions --harness my-harness --user-id alice --output json +``` diff --git a/tests/toolkit/cli/test_cli_list_sessions.py b/tests/toolkit/cli/test_cli_list_sessions.py new file mode 100644 index 0000000..ddbe2ef --- /dev/null +++ b/tests/toolkit/cli/test_cli_list_sessions.py @@ -0,0 +1,168 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for ``agentkit list sessions --harness``.""" + +import base64 +import json + +import requests +from typer.testing import CliRunner + +from agentkit.toolkit.cli.cli import app + +runner = CliRunner() + + +def _jwt(sub: str) -> str: + """Build an unsigned JWT whose payload carries the given `sub` claim.""" + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = ( + base64.urlsafe_b64encode(json.dumps({"sub": sub}).encode()) + .rstrip(b"=") + .decode() + ) + return f"{header}.{payload}.sig" + + +class _Resp: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload if payload is not None else [] + self.text = text + + def json(self): + return self._payload + + +def _patch_registry(monkeypatch, entries): + monkeypatch.setattr( + "agentkit.toolkit.harness.load_harness_registry", + lambda directory: entries, + raising=True, + ) + + +def _patch_get(monkeypatch, resp): + """Patch requests.get; capture the URL it was called with.""" + captured = {} + + def fake_get(url, headers=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + return resp + + monkeypatch.setattr(requests, "get", fake_get) + return captured + + +_SESSIONS = [ + {"id": "sess-1", "userId": "u-1", "events": [1, 2], "lastUpdateTime": 0}, + {"id": "sess-2", "userId": "u-1", "events": [], "lastUpdateTime": 0}, +] + + +def test_list_sessions_explicit_user_id(monkeypatch): + _patch_registry(monkeypatch, {"my-harness": {"url": "https://x", "key": "sk-1"}}) + captured = _patch_get(monkeypatch, _Resp(200, _SESSIONS)) + + result = runner.invoke( + app, ["list", "sessions", "--harness", "my-harness", "--user-id", "u-1"] + ) + + assert result.exit_code == 0, result.output + assert captured["url"].endswith("/apps/harness/users/u-1/sessions") + assert "sess-1" in result.output + + +def test_list_sessions_user_id_from_jwt_sub(monkeypatch): + # custom_jwt harness (no stored key); user_id derives from the -ak JWT sub. + _patch_registry( + monkeypatch, {"my-harness": {"url": "https://x", "auth_type": "custom_jwt"}} + ) + captured = _patch_get(monkeypatch, _Resp(200, [])) + + result = runner.invoke( + app, + ["list", "sessions", "--harness", "my-harness", "-ak", _jwt("user-xyz")], + ) + + assert result.exit_code == 0, result.output + assert "/apps/harness/users/user-xyz/sessions" in captured["url"] + + +def test_list_sessions_requires_user_id_for_key_auth(monkeypatch): + # key_auth harness: the api key is not a JWT, so user_id can't be derived. + _patch_registry(monkeypatch, {"my-harness": {"url": "https://x", "key": "sk-1"}}) + + called = {"get": False} + + def fake_get(*a, **k): + called["get"] = True + return _Resp(200, []) + + monkeypatch.setattr(requests, "get", fake_get) + + result = runner.invoke(app, ["list", "sessions", "--harness", "my-harness"]) + + assert result.exit_code == 1 + assert "user_id" in result.output + assert called["get"] is False # fast-fails before any network call + + +def test_list_sessions_unknown_harness(monkeypatch): + _patch_registry(monkeypatch, {}) + + result = runner.invoke( + app, ["list", "sessions", "--harness", "nope", "--user-id", "u-1"] + ) + + assert result.exit_code == 1 + assert "not found in registry" in result.output + + +def test_list_sessions_quiet_prints_only_ids(monkeypatch): + _patch_registry(monkeypatch, {"my-harness": {"url": "https://x", "key": "sk-1"}}) + _patch_get(monkeypatch, _Resp(200, _SESSIONS)) + + result = runner.invoke( + app, + ["list", "sessions", "--harness", "my-harness", "--user-id", "u-1", "--quiet"], + ) + + assert result.exit_code == 0, result.output + assert result.output.split() == ["sess-1", "sess-2"] + + +def test_list_sessions_json_output(monkeypatch): + _patch_registry(monkeypatch, {"my-harness": {"url": "https://x", "key": "sk-1"}}) + _patch_get(monkeypatch, _Resp(200, _SESSIONS)) + + result = runner.invoke( + app, + [ + "list", + "sessions", + "--harness", + "my-harness", + "--user-id", + "u-1", + "--output", + "json", + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert [s["id"] for s in data] == ["sess-1", "sess-2"]