agentstack-cli 0.6.0rc1__py3-none-manylinux_2_34_aarch64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentstack_cli/__init__.py +164 -0
- agentstack_cli/api.py +160 -0
- agentstack_cli/async_typer.py +113 -0
- agentstack_cli/auth_manager.py +242 -0
- agentstack_cli/commands/__init__.py +3 -0
- agentstack_cli/commands/agent.py +1386 -0
- agentstack_cli/commands/build.py +222 -0
- agentstack_cli/commands/connector.py +301 -0
- agentstack_cli/commands/model.py +653 -0
- agentstack_cli/commands/platform/__init__.py +198 -0
- agentstack_cli/commands/platform/base_driver.py +217 -0
- agentstack_cli/commands/platform/lima_driver.py +277 -0
- agentstack_cli/commands/platform/wsl_driver.py +229 -0
- agentstack_cli/commands/self.py +213 -0
- agentstack_cli/commands/server.py +315 -0
- agentstack_cli/commands/user.py +87 -0
- agentstack_cli/configuration.py +79 -0
- agentstack_cli/console.py +25 -0
- agentstack_cli/data/.gitignore +2 -0
- agentstack_cli/data/helm-chart.tgz +0 -0
- agentstack_cli/data/lima-guestagent.Linux-aarch64.gz +0 -0
- agentstack_cli/data/limactl +0 -0
- agentstack_cli/utils.py +389 -0
- agentstack_cli-0.6.0rc1.dist-info/METADATA +107 -0
- agentstack_cli-0.6.0rc1.dist-info/RECORD +27 -0
- agentstack_cli-0.6.0rc1.dist-info/WHEEL +4 -0
- agentstack_cli-0.6.0rc1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import typing
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
import agentstack_cli.commands.agent
|
|
12
|
+
import agentstack_cli.commands.build
|
|
13
|
+
import agentstack_cli.commands.connector
|
|
14
|
+
import agentstack_cli.commands.model
|
|
15
|
+
import agentstack_cli.commands.platform
|
|
16
|
+
import agentstack_cli.commands.self
|
|
17
|
+
import agentstack_cli.commands.server
|
|
18
|
+
|
|
19
|
+
# import agentstack_cli.commands.user
|
|
20
|
+
from agentstack_cli.async_typer import AsyncTyper
|
|
21
|
+
from agentstack_cli.configuration import Configuration
|
|
22
|
+
|
|
23
|
+
logging.basicConfig(level=logging.INFO if Configuration().debug else logging.FATAL)
|
|
24
|
+
logging.getLogger("httpx").setLevel(logging.WARNING) # not sure why this is necessary
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
HELP_TEXT = """\
|
|
28
|
+
Usage: agentstack [OPTIONS] COMMAND [ARGS]...
|
|
29
|
+
|
|
30
|
+
╭─ Getting Started ──────────────────────────────────────────────────────────╮
|
|
31
|
+
│ ui Launch the web interface │
|
|
32
|
+
│ list View all available agents │
|
|
33
|
+
│ info Show agent details │
|
|
34
|
+
│ run Run an agent interactively │
|
|
35
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
36
|
+
|
|
37
|
+
╭─ Agent Management [Admin only] ────────────────────────────────────────────╮
|
|
38
|
+
│ add Install an agent (Docker, GitHub) │
|
|
39
|
+
│ remove Uninstall an agent │
|
|
40
|
+
│ update Update an agent │
|
|
41
|
+
│ logs Stream agent execution logs │
|
|
42
|
+
│ env Manage agent environment variables │
|
|
43
|
+
│ build Build an agent remotely │
|
|
44
|
+
│ client-side-build Build an agent container image locally │
|
|
45
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
46
|
+
|
|
47
|
+
╭─ Platform & Configuration ─────────────────────────────────────────────────╮
|
|
48
|
+
| connector Manage connectors to external services │
|
|
49
|
+
│ model Configure 15+ LLM providers [Admin only] │
|
|
50
|
+
│ platform Start, stop, or delete local platform [Local only] │
|
|
51
|
+
│ server Connect to remote Agent Stack servers │
|
|
52
|
+
│ user Manage users and roles [Admin only] │
|
|
53
|
+
│ self version Show Agent Stack CLI and Platform version │
|
|
54
|
+
│ self upgrade Upgrade Agent Stack CLI and Platform [Local only] │
|
|
55
|
+
│ self uninstall Uninstall Agent Stack CLI and Platform [Local only] │
|
|
56
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
57
|
+
|
|
58
|
+
╭─ Options ──────────────────────────────────────────────────────────────────╮
|
|
59
|
+
│ --help Show this help message │
|
|
60
|
+
│ --show-completion Show tab completion script │
|
|
61
|
+
│ --install-completion Enable tab completion for commands │
|
|
62
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
app = AsyncTyper()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback(invoke_without_command=True)
|
|
70
|
+
def main(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
help: bool = typer.Option(False, "--help", help="Show this message and exit."),
|
|
73
|
+
):
|
|
74
|
+
if help or ctx.invoked_subcommand is None:
|
|
75
|
+
typer.echo(HELP_TEXT)
|
|
76
|
+
raise typer.Exit()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
app.add_typer(
|
|
80
|
+
agentstack_cli.commands.model.app, name="model", no_args_is_help=True, help="Manage model providers. [Admin only]"
|
|
81
|
+
)
|
|
82
|
+
app.add_typer(
|
|
83
|
+
agentstack_cli.commands.agent.app,
|
|
84
|
+
name="agent",
|
|
85
|
+
no_args_is_help=True,
|
|
86
|
+
help="Manage agents. Some commands are [Admin only].",
|
|
87
|
+
)
|
|
88
|
+
app.add_typer(
|
|
89
|
+
agentstack_cli.commands.connector.app,
|
|
90
|
+
name="connector",
|
|
91
|
+
no_args_is_help=True,
|
|
92
|
+
help="Manage connectors to external services.",
|
|
93
|
+
)
|
|
94
|
+
app.add_typer(
|
|
95
|
+
agentstack_cli.commands.platform.app,
|
|
96
|
+
name="platform",
|
|
97
|
+
no_args_is_help=True,
|
|
98
|
+
help="Manage Agent Stack platform. [Local only]",
|
|
99
|
+
)
|
|
100
|
+
app.add_typer(agentstack_cli.commands.build.app, name="", no_args_is_help=True, help="Build agent images.")
|
|
101
|
+
app.add_typer(
|
|
102
|
+
agentstack_cli.commands.server.app,
|
|
103
|
+
name="server",
|
|
104
|
+
no_args_is_help=True,
|
|
105
|
+
help="Manage Agent Stack servers and authentication.",
|
|
106
|
+
)
|
|
107
|
+
app.add_typer(
|
|
108
|
+
agentstack_cli.commands.self.app,
|
|
109
|
+
name="self",
|
|
110
|
+
no_args_is_help=True,
|
|
111
|
+
help="Manage Agent Stack installation.",
|
|
112
|
+
hidden=True,
|
|
113
|
+
)
|
|
114
|
+
# TODO: Implement keycloak integration
|
|
115
|
+
# app.add_typer(
|
|
116
|
+
# agentstack_cli.commands.user.app,
|
|
117
|
+
# name="user",
|
|
118
|
+
# no_args_is_help=True,
|
|
119
|
+
# help="Manage users. [Admin only]",
|
|
120
|
+
# )
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
agent_alias = deepcopy(agentstack_cli.commands.agent.app)
|
|
124
|
+
for cmd in agent_alias.registered_commands:
|
|
125
|
+
cmd.rich_help_panel = "Agent commands"
|
|
126
|
+
|
|
127
|
+
app.add_typer(agent_alias, name="", no_args_is_help=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command("version")
|
|
131
|
+
async def version(
|
|
132
|
+
verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
|
|
133
|
+
):
|
|
134
|
+
"""Print version of the Agent Stack CLI."""
|
|
135
|
+
import agentstack_cli.commands.self
|
|
136
|
+
|
|
137
|
+
await agentstack_cli.commands.self.version(verbose=verbose)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command("ui")
|
|
141
|
+
async def ui():
|
|
142
|
+
"""Launch the graphical interface."""
|
|
143
|
+
import webbrowser
|
|
144
|
+
|
|
145
|
+
import agentstack_cli.commands.model
|
|
146
|
+
|
|
147
|
+
await agentstack_cli.commands.model.ensure_llm_provider()
|
|
148
|
+
|
|
149
|
+
config = Configuration()
|
|
150
|
+
active_server = config.auth_manager.active_server
|
|
151
|
+
|
|
152
|
+
if active_server:
|
|
153
|
+
if re.search(r"(localhost|127\.0\.0\.1):8333", active_server):
|
|
154
|
+
ui_url = re.sub(r":8333", ":8334", active_server)
|
|
155
|
+
else:
|
|
156
|
+
ui_url = active_server
|
|
157
|
+
else:
|
|
158
|
+
ui_url = "http://localhost:8334"
|
|
159
|
+
|
|
160
|
+
webbrowser.open(ui_url)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
app()
|
agentstack_cli/api.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import urllib
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from textwrap import indent
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import openai
|
|
16
|
+
import pydantic
|
|
17
|
+
from a2a.client import A2AClientHTTPError, Client, ClientConfig, ClientFactory
|
|
18
|
+
from a2a.types import AgentCard
|
|
19
|
+
from agentstack_sdk.platform.context import ContextToken
|
|
20
|
+
from httpx import HTTPStatusError
|
|
21
|
+
from httpx._types import RequestFiles
|
|
22
|
+
|
|
23
|
+
from agentstack_cli import configuration
|
|
24
|
+
from agentstack_cli.configuration import Configuration
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
config = Configuration()
|
|
29
|
+
|
|
30
|
+
API_BASE_URL = "api/v1/"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def api_request(
|
|
34
|
+
method: str,
|
|
35
|
+
path: str,
|
|
36
|
+
json: dict | None = None,
|
|
37
|
+
files: RequestFiles | None = None,
|
|
38
|
+
params: dict[str, Any] | None = None,
|
|
39
|
+
use_auth: bool = True,
|
|
40
|
+
) -> dict | None:
|
|
41
|
+
"""Make an API request to the server."""
|
|
42
|
+
async with configuration.use_platform_client() as client:
|
|
43
|
+
response = await client.request(
|
|
44
|
+
method,
|
|
45
|
+
urllib.parse.urljoin(API_BASE_URL, path),
|
|
46
|
+
json=json,
|
|
47
|
+
files=files,
|
|
48
|
+
params=params,
|
|
49
|
+
timeout=60,
|
|
50
|
+
headers=(
|
|
51
|
+
{"Authorization": f"Bearer {token}"}
|
|
52
|
+
if use_auth and (token := await config.auth_manager.load_auth_token())
|
|
53
|
+
else {}
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
if response.is_error:
|
|
57
|
+
error = ""
|
|
58
|
+
try:
|
|
59
|
+
error = response.json()
|
|
60
|
+
error = error.get("detail", str(error))
|
|
61
|
+
except Exception:
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
if response.status_code == 401:
|
|
64
|
+
message = f'{error}\nexport AGENTSTACK__ADMIN_PASSWORD="<PASSWORD>" to set the admin password.'
|
|
65
|
+
raise HTTPStatusError(message=message, request=response.request, response=response)
|
|
66
|
+
raise HTTPStatusError(message=error, request=response.request, response=response)
|
|
67
|
+
if response.content:
|
|
68
|
+
return response.json()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def api_stream(
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
json: dict | None = None,
|
|
75
|
+
params: dict[str, Any] | None = None,
|
|
76
|
+
use_auth: bool = True,
|
|
77
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
78
|
+
"""Make a streaming API request to the server."""
|
|
79
|
+
import json as jsonlib
|
|
80
|
+
|
|
81
|
+
async with (
|
|
82
|
+
configuration.use_platform_client() as client,
|
|
83
|
+
client.stream(
|
|
84
|
+
method,
|
|
85
|
+
urllib.parse.urljoin(API_BASE_URL, path),
|
|
86
|
+
json=json,
|
|
87
|
+
params=params,
|
|
88
|
+
timeout=timedelta(hours=1).total_seconds(),
|
|
89
|
+
headers=(
|
|
90
|
+
{"Authorization": f"Bearer {token}"}
|
|
91
|
+
if use_auth and (token := await config.auth_manager.load_auth_token())
|
|
92
|
+
else {}
|
|
93
|
+
),
|
|
94
|
+
) as response,
|
|
95
|
+
):
|
|
96
|
+
response: httpx.Response
|
|
97
|
+
if response.is_error:
|
|
98
|
+
error = ""
|
|
99
|
+
try:
|
|
100
|
+
[error] = [jsonlib.loads(message) async for message in response.aiter_text()]
|
|
101
|
+
error = error.get("detail", str(error))
|
|
102
|
+
except Exception:
|
|
103
|
+
response.raise_for_status()
|
|
104
|
+
raise HTTPStatusError(message=error, request=response.request, response=response)
|
|
105
|
+
async for line in response.aiter_lines():
|
|
106
|
+
if line:
|
|
107
|
+
yield jsonlib.loads(re.sub("^data:", "", line).strip())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def fetch_server_version() -> str | None:
|
|
111
|
+
"""Fetch server version from OpenAPI schema."""
|
|
112
|
+
|
|
113
|
+
class OpenAPIInfo(pydantic.BaseModel):
|
|
114
|
+
version: str
|
|
115
|
+
|
|
116
|
+
class OpenAPISchema(pydantic.BaseModel):
|
|
117
|
+
info: OpenAPIInfo
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
response = await api_request("GET", "openapi.json", use_auth=False)
|
|
121
|
+
if not response:
|
|
122
|
+
return None
|
|
123
|
+
schema = OpenAPISchema.model_validate(response)
|
|
124
|
+
return schema.info.version
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning("Failed to fetch server version: %s", e)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@asynccontextmanager
|
|
131
|
+
async def a2a_client(agent_card: AgentCard, context_token: ContextToken) -> AsyncIterator[Client]:
|
|
132
|
+
try:
|
|
133
|
+
async with httpx.AsyncClient(
|
|
134
|
+
headers={"Authorization": f"Bearer {context_token.token.get_secret_value()}"},
|
|
135
|
+
follow_redirects=True,
|
|
136
|
+
timeout=timedelta(hours=1).total_seconds(),
|
|
137
|
+
) as httpx_client:
|
|
138
|
+
yield ClientFactory(ClientConfig(httpx_client=httpx_client, use_client_preference=True)).create(
|
|
139
|
+
card=agent_card
|
|
140
|
+
)
|
|
141
|
+
except A2AClientHTTPError as ex:
|
|
142
|
+
card_data = json.dumps(
|
|
143
|
+
agent_card.model_dump(include={"url", "additional_interfaces", "preferred_transport"}), indent=2
|
|
144
|
+
)
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"The agent is not reachable, please check that the agent card is configured properly.\n"
|
|
147
|
+
f"Agent connection info:\n{indent(card_data, prefix=' ')}\n"
|
|
148
|
+
"Full Error:\n"
|
|
149
|
+
f"{indent(str(ex), prefix=' ')}"
|
|
150
|
+
) from ex
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@asynccontextmanager
|
|
154
|
+
async def openai_client() -> AsyncIterator[openai.AsyncOpenAI]:
|
|
155
|
+
async with Configuration().use_platform_client() as platform_client:
|
|
156
|
+
yield openai.AsyncOpenAI(
|
|
157
|
+
api_key=platform_client.headers.get("Authorization", "").removeprefix("Bearer ") or "dummy",
|
|
158
|
+
base_url=urllib.parse.urljoin(str(platform_client.base_url), urllib.parse.urljoin(API_BASE_URL, "openai")),
|
|
159
|
+
default_headers=platform_client.headers,
|
|
160
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
|
|
12
|
+
import rich.text
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import RenderResult
|
|
15
|
+
from rich.markdown import Heading, Markdown
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from typer.core import TyperGroup
|
|
18
|
+
|
|
19
|
+
from agentstack_cli.configuration import Configuration
|
|
20
|
+
from agentstack_cli.console import console, err_console
|
|
21
|
+
from agentstack_cli.utils import extract_messages, format_error
|
|
22
|
+
|
|
23
|
+
DEBUG = Configuration().debug
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _LeftAlignedHeading(Heading):
|
|
27
|
+
def __rich_console__(self, *args, **kwargs) -> RenderResult:
|
|
28
|
+
for elem in super().__rich_console__(*args, **kwargs):
|
|
29
|
+
if isinstance(elem, rich.text.Text):
|
|
30
|
+
elem.justify = "left"
|
|
31
|
+
yield elem
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Markdown.elements["heading_open"] = _LeftAlignedHeading
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def create_table(*args, no_wrap: bool = True, **kwargs) -> Iterator[Table]:
|
|
39
|
+
table = Table(*args, **kwargs, box=None, pad_edge=False, width=console.width, show_header=True)
|
|
40
|
+
yield table
|
|
41
|
+
for column in table.columns:
|
|
42
|
+
column.no_wrap = no_wrap
|
|
43
|
+
column.overflow = "ellipsis"
|
|
44
|
+
assert isinstance(column.header, str)
|
|
45
|
+
column.header = column.header.upper()
|
|
46
|
+
|
|
47
|
+
if not table.rows:
|
|
48
|
+
table._render = lambda *args, **kwargs: [rich.text.Text("<No items found>", style="italic")]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AliasGroup(TyperGroup):
|
|
52
|
+
"""Taken from https://github.com/fastapi/typer/issues/132#issuecomment-2417492805"""
|
|
53
|
+
|
|
54
|
+
_CMD_SPLIT_P = re.compile(r" ?[,|] ?")
|
|
55
|
+
|
|
56
|
+
def get_command(self, ctx, cmd_name):
|
|
57
|
+
cmd_name = self._group_cmd_name(cmd_name)
|
|
58
|
+
return super().get_command(ctx, cmd_name)
|
|
59
|
+
|
|
60
|
+
def _group_cmd_name(self, default_name):
|
|
61
|
+
for cmd in self.commands.values():
|
|
62
|
+
name = cmd.name
|
|
63
|
+
if name and default_name in self._CMD_SPLIT_P.split(name):
|
|
64
|
+
return name
|
|
65
|
+
return default_name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncTyper(typer.Typer):
|
|
69
|
+
def __init__(self, *args, **kwargs):
|
|
70
|
+
kwargs["cls"] = kwargs.get("cls", AliasGroup)
|
|
71
|
+
super().__init__(*args, **kwargs)
|
|
72
|
+
|
|
73
|
+
def command(self, *args, **kwargs):
|
|
74
|
+
parent_decorator = super().command(*args, **kwargs)
|
|
75
|
+
|
|
76
|
+
def decorator(f):
|
|
77
|
+
@functools.wraps(f)
|
|
78
|
+
def wrapped_f(*args, **kwargs):
|
|
79
|
+
try:
|
|
80
|
+
if inspect.iscoroutinefunction(f):
|
|
81
|
+
return asyncio.run(f(*args, **kwargs))
|
|
82
|
+
else:
|
|
83
|
+
return f(*args, **kwargs)
|
|
84
|
+
except* Exception as ex:
|
|
85
|
+
is_permission_error = False
|
|
86
|
+
is_connect_error = False
|
|
87
|
+
for exc_type, message in extract_messages(ex):
|
|
88
|
+
err_console.print(format_error(exc_type, message))
|
|
89
|
+
is_connect_error = is_connect_error or exc_type in ["ConnectionError", "ConnectError"]
|
|
90
|
+
is_permission_error = is_permission_error or (
|
|
91
|
+
exc_type == "HTTPStatusError" and "403" in message
|
|
92
|
+
)
|
|
93
|
+
err_console.print()
|
|
94
|
+
if is_connect_error:
|
|
95
|
+
err_console.hint(
|
|
96
|
+
"Start the Agent Stack platform using: [green]agentstack platform start[/green]. If that does not help, run [green]agentstack platform delete[/green] to clean up, then [green]agentstack platform start[/green] again."
|
|
97
|
+
)
|
|
98
|
+
elif is_permission_error:
|
|
99
|
+
err_console.hint(
|
|
100
|
+
"This command requires higher permissions than your account currently has. Contact your administrator for assistance."
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
err_console.hint(
|
|
104
|
+
"Are you having consistent problems? If so, try these troubleshooting steps: [green]agentstack platform delete[/green] to remove the platform, and [green]agentstack platform start[/green] to recreate it."
|
|
105
|
+
)
|
|
106
|
+
if DEBUG:
|
|
107
|
+
raise
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
parent_decorator(wrapped_f)
|
|
111
|
+
return f
|
|
112
|
+
|
|
113
|
+
return decorator
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import pathlib
|
|
4
|
+
import time
|
|
5
|
+
import typing
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthToken(BaseModel):
|
|
14
|
+
access_token: str
|
|
15
|
+
token_type: str = "Bearer"
|
|
16
|
+
expires_in: int | None = None
|
|
17
|
+
expires_at: int | None = None
|
|
18
|
+
refresh_token: str | None = None
|
|
19
|
+
scope: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthServer(BaseModel):
|
|
23
|
+
client_id: str = "df82a687-d647-4247-838b-7080d7d83f6c" # Backwards compatibility default
|
|
24
|
+
client_secret: str | None = None
|
|
25
|
+
token: AuthToken | None = None
|
|
26
|
+
registration_token: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Server(BaseModel):
|
|
30
|
+
authorization_servers: dict[str, AuthServer] = Field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Auth(BaseModel):
|
|
34
|
+
version: typing.Literal[1] = 1
|
|
35
|
+
servers: defaultdict[str, typing.Annotated[Server, Field(default_factory=Server)]] = Field(
|
|
36
|
+
default_factory=lambda: defaultdict(Server)
|
|
37
|
+
)
|
|
38
|
+
active_server: str | None = None
|
|
39
|
+
active_auth_server: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@typing.final
|
|
43
|
+
class AuthManager:
|
|
44
|
+
def __init__(self, config_path: pathlib.Path):
|
|
45
|
+
self._auth_path = config_path
|
|
46
|
+
self._auth = self._load()
|
|
47
|
+
|
|
48
|
+
def _load(self) -> Auth:
|
|
49
|
+
if not self._auth_path.exists():
|
|
50
|
+
return Auth()
|
|
51
|
+
return Auth.model_validate_json(self._auth_path.read_bytes())
|
|
52
|
+
|
|
53
|
+
def _save(self) -> None:
|
|
54
|
+
self._auth_path.write_text(self._auth.model_dump_json(indent=2))
|
|
55
|
+
|
|
56
|
+
def save_auth_token(
|
|
57
|
+
self,
|
|
58
|
+
server: str,
|
|
59
|
+
auth_server: str | None = None,
|
|
60
|
+
client_id: str | None = None,
|
|
61
|
+
client_secret: str | None = None,
|
|
62
|
+
token: dict[str, Any] | None = None,
|
|
63
|
+
registration_token: str | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
if auth_server is not None and client_id is not None and token is not None:
|
|
66
|
+
if token["access_token"]:
|
|
67
|
+
usetimestamp = int(time.time()) + int(token["expires_in"])
|
|
68
|
+
token["expires_at"] = usetimestamp
|
|
69
|
+
self._auth.servers[server].authorization_servers[auth_server] = AuthServer(
|
|
70
|
+
client_id=client_id,
|
|
71
|
+
client_secret=client_secret,
|
|
72
|
+
token=AuthToken(**token),
|
|
73
|
+
registration_token=registration_token,
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
self._auth.servers[server] # touch
|
|
77
|
+
self._save()
|
|
78
|
+
|
|
79
|
+
async def exchange_refresh_token(self, auth_server: str, token: AuthToken) -> dict[str, Any] | None:
|
|
80
|
+
"""
|
|
81
|
+
This method exchanges a refresh token for a new access token.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
async with httpx.AsyncClient(headers={"Accept": "application/json"}) as client:
|
|
85
|
+
resp = None
|
|
86
|
+
try:
|
|
87
|
+
resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
oidc = resp.json()
|
|
90
|
+
except Exception as e:
|
|
91
|
+
if resp:
|
|
92
|
+
error_details = resp.json()
|
|
93
|
+
print(f"error: {error_details['error']} error description: {error_details['error_description']}")
|
|
94
|
+
raise RuntimeError(f"OIDC discovery failed: {e}") from e
|
|
95
|
+
|
|
96
|
+
token_endpoint = oidc["token_endpoint"]
|
|
97
|
+
try:
|
|
98
|
+
client_id = (
|
|
99
|
+
self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_id
|
|
100
|
+
)
|
|
101
|
+
client_secret = (
|
|
102
|
+
self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_secret
|
|
103
|
+
)
|
|
104
|
+
resp = await client.post(
|
|
105
|
+
f"{token_endpoint}",
|
|
106
|
+
data={
|
|
107
|
+
"grant_type": "refresh_token",
|
|
108
|
+
"refresh_token": token.refresh_token,
|
|
109
|
+
"scope": token.scope,
|
|
110
|
+
"client_id": client_id,
|
|
111
|
+
}
|
|
112
|
+
| ({"client_secret": client_secret} if client_secret else {}),
|
|
113
|
+
)
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
new_token = resp.json()
|
|
116
|
+
except Exception as e:
|
|
117
|
+
if resp:
|
|
118
|
+
error_details = resp.json()
|
|
119
|
+
print(f"error: {error_details['error']} error description: {error_details['error_description']}")
|
|
120
|
+
raise RuntimeError(f"Failed to refresh token: {e}") from e
|
|
121
|
+
self.save_auth_token(
|
|
122
|
+
self._auth.active_server or "",
|
|
123
|
+
self._auth.active_auth_server or "",
|
|
124
|
+
self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_id or "",
|
|
125
|
+
self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_secret
|
|
126
|
+
or "",
|
|
127
|
+
token=new_token,
|
|
128
|
+
)
|
|
129
|
+
return new_token
|
|
130
|
+
|
|
131
|
+
async def load_auth_token(self) -> str | None:
|
|
132
|
+
active_res = self._auth.active_server
|
|
133
|
+
active_auth_server = self._auth.active_auth_server
|
|
134
|
+
if not active_res or not active_auth_server:
|
|
135
|
+
return None
|
|
136
|
+
server = self._auth.servers.get(active_res)
|
|
137
|
+
if not server:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
auth_server = server.authorization_servers.get(active_auth_server)
|
|
141
|
+
if not auth_server or not auth_server.token:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
if (auth_server.token.expires_at or 0) - 60 < time.time():
|
|
145
|
+
new_token = await self.exchange_refresh_token(active_auth_server, auth_server.token)
|
|
146
|
+
if new_token:
|
|
147
|
+
return new_token["access_token"]
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
return auth_server.token.access_token
|
|
151
|
+
|
|
152
|
+
async def deregister_client(self, auth_server, client_id, registration_token) -> None:
|
|
153
|
+
async with httpx.AsyncClient(headers={"Accept": "application/json"}) as client:
|
|
154
|
+
resp = None
|
|
155
|
+
try:
|
|
156
|
+
resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
|
|
157
|
+
resp.raise_for_status()
|
|
158
|
+
oidc = resp.json()
|
|
159
|
+
registration_endpoint = oidc["registration_endpoint"]
|
|
160
|
+
except Exception as e:
|
|
161
|
+
if resp:
|
|
162
|
+
error_details = resp.json()
|
|
163
|
+
print(f"error: {error_details['error']} error description: {error_details['error_description']}")
|
|
164
|
+
raise RuntimeError(f"OIDC discovery failed: {e}") from e
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
if client_id is not None and client_id != "" and registration_token is not None:
|
|
168
|
+
headers = {"authorization": f"bearer {registration_token}"}
|
|
169
|
+
resp = await client.delete(f"{registration_endpoint}/{client_id}", headers=headers)
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
if resp:
|
|
174
|
+
error_details = resp.json()
|
|
175
|
+
print(f"error: {error_details['error']} error description: {error_details['error_description']}")
|
|
176
|
+
raise RuntimeError(f"Dynamic client de-registration failed. {e}") from e
|
|
177
|
+
|
|
178
|
+
async def clear_auth_token(self, all: bool = False) -> None:
|
|
179
|
+
if all:
|
|
180
|
+
for server in self._auth.servers:
|
|
181
|
+
for auth_server in self._auth.servers[server].authorization_servers:
|
|
182
|
+
await self.deregister_client(
|
|
183
|
+
auth_server,
|
|
184
|
+
self._auth.servers[server].authorization_servers[auth_server].client_id,
|
|
185
|
+
self._auth.servers[server].authorization_servers[auth_server].registration_token,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
self._auth.servers = defaultdict(Server)
|
|
189
|
+
else:
|
|
190
|
+
if self._auth.active_server and self._auth.active_auth_server:
|
|
191
|
+
if (
|
|
192
|
+
self._auth.servers[self._auth.active_server]
|
|
193
|
+
.authorization_servers[self._auth.active_auth_server]
|
|
194
|
+
.client_id
|
|
195
|
+
):
|
|
196
|
+
await self.deregister_client(
|
|
197
|
+
self._auth.active_auth_server,
|
|
198
|
+
self._auth.servers[self._auth.active_server]
|
|
199
|
+
.authorization_servers[self._auth.active_auth_server]
|
|
200
|
+
.client_id,
|
|
201
|
+
self._auth.servers[self._auth.active_server]
|
|
202
|
+
.authorization_servers[self._auth.active_auth_server]
|
|
203
|
+
.registration_token,
|
|
204
|
+
)
|
|
205
|
+
del self._auth.servers[self._auth.active_server].authorization_servers[self._auth.active_auth_server]
|
|
206
|
+
if self._auth.active_server and not self._auth.servers[self._auth.active_server].authorization_servers:
|
|
207
|
+
del self._auth.servers[self._auth.active_server]
|
|
208
|
+
self._auth.active_server = None
|
|
209
|
+
self._auth.active_auth_server = None
|
|
210
|
+
self._save()
|
|
211
|
+
|
|
212
|
+
def get_server(self, server: str) -> Server | None:
|
|
213
|
+
return self._auth.servers.get(server)
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def servers(self) -> list[str]:
|
|
217
|
+
return list(self._auth.servers.keys())
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def active_server(self) -> str | None:
|
|
221
|
+
return self._auth.active_server
|
|
222
|
+
|
|
223
|
+
@active_server.setter
|
|
224
|
+
def active_server(self, server: str | None) -> None:
|
|
225
|
+
if server is not None and server not in self._auth.servers:
|
|
226
|
+
raise ValueError(f"Server {server} not found")
|
|
227
|
+
self._auth.active_server = server
|
|
228
|
+
self._save()
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def active_auth_server(self) -> str | None:
|
|
232
|
+
return self._auth.active_auth_server
|
|
233
|
+
|
|
234
|
+
@active_auth_server.setter
|
|
235
|
+
def active_auth_server(self, auth_server: str | None) -> None:
|
|
236
|
+
if auth_server is not None and (
|
|
237
|
+
self._auth.active_server not in self._auth.servers
|
|
238
|
+
or auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
|
|
239
|
+
):
|
|
240
|
+
raise ValueError(f"Auth server {auth_server} not found in active server")
|
|
241
|
+
self._auth.active_auth_server = auth_server
|
|
242
|
+
self._save()
|