agentstack-cli 0.6.0rc3__py3-none-manylinux_2_34_aarch64.whl → 0.6.1rc1__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/auth_manager.py +142 -90
- agentstack_cli/commands/agent.py +43 -5
- agentstack_cli/commands/build.py +1 -2
- agentstack_cli/commands/connector.py +5 -4
- agentstack_cli/commands/model.py +2 -1
- agentstack_cli/commands/platform/__init__.py +14 -16
- agentstack_cli/commands/platform/base_driver.py +101 -39
- agentstack_cli/commands/platform/lima_driver.py +2 -0
- agentstack_cli/commands/platform/wsl_driver.py +5 -8
- agentstack_cli/commands/self.py +2 -2
- agentstack_cli/commands/server.py +178 -127
- agentstack_cli/commands/user.py +1 -1
- agentstack_cli/configuration.py +21 -1
- agentstack_cli/data/helm-chart.tgz +0 -0
- agentstack_cli/server_utils.py +40 -0
- agentstack_cli/utils.py +18 -34
- {agentstack_cli-0.6.0rc3.dist-info → agentstack_cli-0.6.1rc1.dist-info}/METADATA +37 -37
- agentstack_cli-0.6.1rc1.dist-info/RECORD +28 -0
- agentstack_cli-0.6.0rc3.dist-info/RECORD +0 -27
- {agentstack_cli-0.6.0rc3.dist-info → agentstack_cli-0.6.1rc1.dist-info}/WHEEL +0 -0
- {agentstack_cli-0.6.0rc3.dist-info → agentstack_cli-0.6.1rc1.dist-info}/entry_points.txt +0 -0
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import abc
|
|
5
5
|
import importlib.resources
|
|
6
|
+
import json
|
|
6
7
|
import pathlib
|
|
7
8
|
import shlex
|
|
8
9
|
import typing
|
|
10
|
+
from enum import StrEnum
|
|
9
11
|
from subprocess import CompletedProcess
|
|
10
12
|
from textwrap import dedent
|
|
11
13
|
|
|
@@ -17,6 +19,13 @@ from agentstack_cli.configuration import Configuration
|
|
|
17
19
|
from agentstack_cli.utils import merge, run_command
|
|
18
20
|
|
|
19
21
|
|
|
22
|
+
class ImagePullMode(StrEnum):
|
|
23
|
+
guest = "guest"
|
|
24
|
+
host = "host"
|
|
25
|
+
hybrid = "hybrid"
|
|
26
|
+
skip = "skip"
|
|
27
|
+
|
|
28
|
+
|
|
20
29
|
class BaseDriver(abc.ABC):
|
|
21
30
|
vm_name: str
|
|
22
31
|
|
|
@@ -54,6 +63,34 @@ class BaseDriver(abc.ABC):
|
|
|
54
63
|
@abc.abstractmethod
|
|
55
64
|
async def exec(self, command: list[str]) -> None: ...
|
|
56
65
|
|
|
66
|
+
def _canonify(self, tag: str) -> str:
|
|
67
|
+
return tag if "." in tag.split("/")[0] else f"docker.io/{tag}"
|
|
68
|
+
|
|
69
|
+
async def _grab_image_shas(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
mode: typing.Literal["guest", "host"],
|
|
73
|
+
) -> dict[str, str]:
|
|
74
|
+
return {
|
|
75
|
+
tag: sha
|
|
76
|
+
for line in (
|
|
77
|
+
await run_command(
|
|
78
|
+
["docker", "images", "--digests"],
|
|
79
|
+
"Listing host images",
|
|
80
|
+
)
|
|
81
|
+
if mode == "host"
|
|
82
|
+
else await self.run_in_vm(
|
|
83
|
+
["k3s", "ctr", "image", "ls"],
|
|
84
|
+
"Listing guest images",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
.stdout.decode()
|
|
88
|
+
.splitlines()[1:]
|
|
89
|
+
if (x := line.split())
|
|
90
|
+
and (sha := x[2])
|
|
91
|
+
and ((tag := self._canonify((x[0] + ":" + x[1]) if mode == "host" else x[0])) in self.loaded_images)
|
|
92
|
+
}
|
|
93
|
+
|
|
57
94
|
async def install_tools(self) -> None:
|
|
58
95
|
# Configure k3s registry for local registry access
|
|
59
96
|
registry_config = dedent(
|
|
@@ -103,10 +140,7 @@ class BaseDriver(abc.ABC):
|
|
|
103
140
|
self,
|
|
104
141
|
set_values_list: list[str],
|
|
105
142
|
values_file: pathlib.Path | None = None,
|
|
106
|
-
|
|
107
|
-
pull_on_host: bool = False,
|
|
108
|
-
skip_pull: bool = False,
|
|
109
|
-
skip_restart_deployments: bool = False,
|
|
143
|
+
image_pull_mode: ImagePullMode = ImagePullMode.guest,
|
|
110
144
|
) -> None:
|
|
111
145
|
_ = await self.run_in_vm(
|
|
112
146
|
["sh", "-c", "mkdir -p /tmp/agentstack && cat >/tmp/agentstack/chart.tgz"],
|
|
@@ -138,37 +172,42 @@ class BaseDriver(abc.ABC):
|
|
|
138
172
|
input=yaml.dump(values).encode("utf-8"),
|
|
139
173
|
)
|
|
140
174
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
175
|
+
self.loaded_images = {
|
|
176
|
+
self._canonify(typing.cast(str, yaml.safe_load(line)))
|
|
177
|
+
for line in (
|
|
178
|
+
await self.run_in_vm(
|
|
179
|
+
[
|
|
180
|
+
"/bin/bash",
|
|
181
|
+
"-c",
|
|
182
|
+
"helm template agentstack /tmp/agentstack/chart.tgz --values=/tmp/agentstack/values.yaml "
|
|
183
|
+
+ " ".join(shlex.quote(f"--set={value}") for value in set_values_list)
|
|
184
|
+
+ " | sed -n '/^\\s*image:/{ /{{/!{ s/.*image:\\s*//p } }'",
|
|
185
|
+
],
|
|
186
|
+
"Listing necessary images",
|
|
187
|
+
)
|
|
151
188
|
)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return tag if "." in tag.split("/")[0] else f"docker.io/{tag}"
|
|
189
|
+
.stdout.decode()
|
|
190
|
+
.splitlines()
|
|
191
|
+
}
|
|
156
192
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
images_to_pull = required_images - images_to_import
|
|
193
|
+
images_to_import_from_host = set[str]()
|
|
194
|
+
shas_guest_before = dict[str, str]()
|
|
160
195
|
|
|
161
|
-
if
|
|
162
|
-
|
|
196
|
+
if image_pull_mode in {ImagePullMode.host, ImagePullMode.hybrid}:
|
|
197
|
+
shas_guest_before = await self._grab_image_shas(mode="guest")
|
|
198
|
+
shas_host = await self._grab_image_shas(mode="host")
|
|
199
|
+
if image_pull_mode == ImagePullMode.host and (images_to_pull := self.loaded_images - shas_host.keys()):
|
|
163
200
|
for image in images_to_pull:
|
|
164
|
-
await run_command(
|
|
165
|
-
|
|
166
|
-
|
|
201
|
+
await run_command(
|
|
202
|
+
["docker", "pull", image],
|
|
203
|
+
f"Pulling image {image} on host",
|
|
204
|
+
)
|
|
205
|
+
shas_host = await self._grab_image_shas(mode="host")
|
|
206
|
+
images_to_import_from_host = dict(shas_host.items() - shas_guest_before.items()).keys() & self.loaded_images
|
|
207
|
+
await self.import_images(*images_to_import_from_host)
|
|
167
208
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
for image in images_to_pull:
|
|
209
|
+
if image_pull_mode in {ImagePullMode.guest, ImagePullMode.hybrid}:
|
|
210
|
+
for image in self.loaded_images - images_to_import_from_host:
|
|
172
211
|
async for attempt in AsyncRetrying(stop=stop_after_attempt(5)):
|
|
173
212
|
with attempt:
|
|
174
213
|
attempt_num = attempt.retry_state.attempt_number
|
|
@@ -176,10 +215,6 @@ class BaseDriver(abc.ABC):
|
|
|
176
215
|
["k3s", "ctr", "image", "pull", image],
|
|
177
216
|
f"Pulling image {image}" + (f" (attempt {attempt_num})" if attempt_num > 1 else ""),
|
|
178
217
|
)
|
|
179
|
-
elif images_to_import:
|
|
180
|
-
await self.import_images(*images_to_import)
|
|
181
|
-
|
|
182
|
-
self.loaded_images = required_images
|
|
183
218
|
|
|
184
219
|
kubeconfig_path = anyio.Path(Configuration().lima_home) / self.vm_name / "copied-from-guest" / "kubeconfig.yaml"
|
|
185
220
|
await kubeconfig_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -210,8 +245,35 @@ class BaseDriver(abc.ABC):
|
|
|
210
245
|
"Deploying Agent Stack platform with Helm",
|
|
211
246
|
)
|
|
212
247
|
|
|
213
|
-
if
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
248
|
+
if shas_guest_before and (
|
|
249
|
+
replaced_digests := set(shas_guest_before.values())
|
|
250
|
+
- set((await self._grab_image_shas(mode="guest")).values())
|
|
251
|
+
):
|
|
252
|
+
for pod in dict.get(
|
|
253
|
+
json.loads(
|
|
254
|
+
(
|
|
255
|
+
await self.run_in_vm(
|
|
256
|
+
["k3s", "kubectl", "get", "pods", "-o", "json", "--all-namespaces"],
|
|
257
|
+
"Getting pods",
|
|
258
|
+
)
|
|
259
|
+
).stdout
|
|
260
|
+
),
|
|
261
|
+
"items",
|
|
262
|
+
[],
|
|
263
|
+
):
|
|
264
|
+
if any(
|
|
265
|
+
container_status.get("imageID", "") in replaced_digests
|
|
266
|
+
for container_status in pod.get("status", {}).get("containerStatuses", [])
|
|
267
|
+
):
|
|
268
|
+
await self.run_in_vm(
|
|
269
|
+
[
|
|
270
|
+
"k3s",
|
|
271
|
+
"kubectl",
|
|
272
|
+
"delete",
|
|
273
|
+
"pod",
|
|
274
|
+
pod["metadata"]["name"],
|
|
275
|
+
"-n",
|
|
276
|
+
pod["metadata"]["namespace"],
|
|
277
|
+
],
|
|
278
|
+
f"Removing pod with obsolete image {pod['metadata']['namespace']}/{pod['metadata']['name']}",
|
|
279
|
+
)
|
|
@@ -181,6 +181,8 @@ class LimaDriver(BaseDriver):
|
|
|
181
181
|
|
|
182
182
|
@typing.override
|
|
183
183
|
async def import_images(self, *tags: str):
|
|
184
|
+
if not tags:
|
|
185
|
+
return
|
|
184
186
|
image_dir = anyio.Path("/tmp/agentstack")
|
|
185
187
|
await image_dir.mkdir(exist_ok=True, parents=True)
|
|
186
188
|
image_file = str(uuid.uuid4())
|
|
@@ -13,7 +13,7 @@ import anyio
|
|
|
13
13
|
import pydantic
|
|
14
14
|
import yaml
|
|
15
15
|
|
|
16
|
-
from agentstack_cli.commands.platform.base_driver import BaseDriver
|
|
16
|
+
from agentstack_cli.commands.platform.base_driver import BaseDriver, ImagePullMode
|
|
17
17
|
from agentstack_cli.configuration import Configuration
|
|
18
18
|
from agentstack_cli.console import console
|
|
19
19
|
from agentstack_cli.utils import run_command
|
|
@@ -130,13 +130,10 @@ class WSLDriver(BaseDriver):
|
|
|
130
130
|
self,
|
|
131
131
|
set_values_list: list[str],
|
|
132
132
|
values_file: pathlib.Path | None = None,
|
|
133
|
-
|
|
134
|
-
pull_on_host: bool = False,
|
|
135
|
-
skip_pull: bool = False,
|
|
136
|
-
skip_restart_deployments: bool = False,
|
|
133
|
+
image_pull_mode: ImagePullMode = ImagePullMode.guest,
|
|
137
134
|
) -> None:
|
|
138
|
-
if
|
|
139
|
-
raise NotImplementedError("
|
|
135
|
+
if image_pull_mode in {ImagePullMode.host, ImagePullMode.hybrid}:
|
|
136
|
+
raise NotImplementedError("Importing host images is not supported on Windows.")
|
|
140
137
|
|
|
141
138
|
host_ip = (
|
|
142
139
|
(
|
|
@@ -162,7 +159,7 @@ class WSLDriver(BaseDriver):
|
|
|
162
159
|
}
|
|
163
160
|
).encode(),
|
|
164
161
|
)
|
|
165
|
-
await super().deploy(set_values_list=set_values_list, values_file=values_file,
|
|
162
|
+
await super().deploy(set_values_list=set_values_list, values_file=values_file, image_pull_mode=image_pull_mode)
|
|
166
163
|
await self.run_in_vm(
|
|
167
164
|
["sh", "-c", "cat >/etc/systemd/system/kubectl-port-forward@.service"],
|
|
168
165
|
"Installing systemd unit for port-forwarding",
|
agentstack_cli/commands/self.py
CHANGED
|
@@ -130,7 +130,7 @@ async def install(
|
|
|
130
130
|
).execute_async()
|
|
131
131
|
):
|
|
132
132
|
try:
|
|
133
|
-
await agentstack_cli.commands.platform.start(set_values_list=[],
|
|
133
|
+
await agentstack_cli.commands.platform.start(set_values_list=[], verbose=verbose)
|
|
134
134
|
already_started = True
|
|
135
135
|
console.print()
|
|
136
136
|
except Exception:
|
|
@@ -190,7 +190,7 @@ async def upgrade(
|
|
|
190
190
|
"Upgrading agentstack-cli",
|
|
191
191
|
env={"PATH": _path()},
|
|
192
192
|
)
|
|
193
|
-
await agentstack_cli.commands.platform.start(set_values_list=[],
|
|
193
|
+
await agentstack_cli.commands.platform.start(set_values_list=[], verbose=verbose)
|
|
194
194
|
await version(verbose=verbose)
|
|
195
195
|
|
|
196
196
|
|
|
@@ -13,6 +13,7 @@ import httpx
|
|
|
13
13
|
import typer
|
|
14
14
|
import uvicorn
|
|
15
15
|
from authlib.common.security import generate_token
|
|
16
|
+
from authlib.oauth2.rfc6749.errors import InvalidGrantError, OAuth2Error
|
|
16
17
|
from authlib.oauth2.rfc7636 import create_s256_code_challenge
|
|
17
18
|
from fastapi import FastAPI, Request
|
|
18
19
|
from fastapi.responses import HTMLResponse
|
|
@@ -104,11 +105,19 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
|
|
|
104
105
|
|
|
105
106
|
server = server.rstrip("/")
|
|
106
107
|
|
|
108
|
+
log_in_message = "No authentication tokens found for this server. Proceeding to log in."
|
|
109
|
+
|
|
107
110
|
if server_data := config.auth_manager.get_server(server):
|
|
108
|
-
console.info("
|
|
111
|
+
console.info("Logging in to an already logged in server.")
|
|
109
112
|
auth_server = None
|
|
110
113
|
auth_servers = list(server_data.authorization_servers.keys())
|
|
111
|
-
if
|
|
114
|
+
if not auth_servers:
|
|
115
|
+
# Known server with no auth servers - save and exit
|
|
116
|
+
config.auth_manager.active_server = server
|
|
117
|
+
config.auth_manager.active_auth_server = auth_server
|
|
118
|
+
console.success(f"Logged in to [cyan]{server}[/cyan].")
|
|
119
|
+
return
|
|
120
|
+
elif len(auth_servers) == 1:
|
|
112
121
|
auth_server = auth_servers[0]
|
|
113
122
|
elif len(auth_servers) > 1:
|
|
114
123
|
auth_server = await inquirer.select( # type: ignore
|
|
@@ -127,129 +136,106 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
|
|
|
127
136
|
if not auth_server:
|
|
128
137
|
console.info("Action cancelled.")
|
|
129
138
|
sys.exit(1)
|
|
130
|
-
else:
|
|
131
|
-
console.info("No authentication tokens found for this server. Proceeding to log in.")
|
|
132
|
-
async with httpx.AsyncClient() as client:
|
|
133
|
-
resp = await client.get(f"{server}/.well-known/oauth-protected-resource/", follow_redirects=True)
|
|
134
|
-
if resp.is_error:
|
|
135
|
-
console.error("This server does not appear to run a compatible version of Agent Stack Platform.")
|
|
136
|
-
sys.exit(1)
|
|
137
|
-
metadata = resp.json()
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
# Validate that the token is still valid by attempting to load it
|
|
141
|
+
# Keep the original active server/auth server in case of failure
|
|
142
|
+
previous_server = config.auth_manager.active_server
|
|
143
|
+
previous_auth_server = config.auth_manager.active_auth_server
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
registration_token = None
|
|
145
|
+
config.auth_manager.active_server = server
|
|
146
|
+
config.auth_manager.active_auth_server = auth_server
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
try:
|
|
149
|
+
token = await config.auth_manager.load_auth_token()
|
|
150
|
+
if not token:
|
|
151
|
+
# No token available, need to log in
|
|
152
|
+
# Restore previous state until login completes
|
|
153
|
+
config.auth_manager.active_server = previous_server
|
|
154
|
+
config.auth_manager.active_auth_server = previous_auth_server
|
|
155
|
+
# Fall through to login flow below
|
|
150
156
|
else:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if not client_id:
|
|
201
|
-
client_id = (
|
|
202
|
-
await inquirer.text( # type: ignore
|
|
203
|
-
message="Enter Client ID (default agentstack-cli):",
|
|
204
|
-
instruction=f"(Redirect URI: {REDIRECT_URI})",
|
|
205
|
-
).execute_async()
|
|
206
|
-
or "agentstack-cli"
|
|
207
|
-
)
|
|
208
|
-
if not client_id:
|
|
209
|
-
raise RuntimeError("Client ID is mandatory. Action cancelled.")
|
|
210
|
-
client_secret = (
|
|
211
|
-
await inquirer.text( # type: ignore
|
|
212
|
-
message="Enter Client Secret (optional):"
|
|
213
|
-
).execute_async()
|
|
214
|
-
or None
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
code_verifier = generate_token(64)
|
|
218
|
-
|
|
219
|
-
auth_url = f"{oidc['authorization_endpoint']}?{
|
|
220
|
-
urlencode(
|
|
221
|
-
{
|
|
222
|
-
'client_id': client_id,
|
|
223
|
-
'response_type': 'code',
|
|
224
|
-
'redirect_uri': REDIRECT_URI,
|
|
225
|
-
'scope': ' '.join(metadata.get('scopes_supported', ['openid', 'email', 'profile'])),
|
|
226
|
-
'code_challenge': typing.cast(str, create_s256_code_challenge(code_verifier)),
|
|
227
|
-
'code_challenge_method': 'S256',
|
|
228
|
-
}
|
|
229
|
-
)
|
|
230
|
-
}"
|
|
157
|
+
console.success(f"Logged in to [cyan]{server}[/cyan].")
|
|
158
|
+
return
|
|
159
|
+
except InvalidGrantError:
|
|
160
|
+
# Token refresh failed due to invalid/expired refresh token
|
|
161
|
+
log_in_message = "Your session has expired. Please log in again."
|
|
162
|
+
# Restore previous state until login completes
|
|
163
|
+
config.auth_manager.active_server = previous_server
|
|
164
|
+
config.auth_manager.active_auth_server = previous_auth_server
|
|
165
|
+
# Fall through to login flow below
|
|
166
|
+
except OAuth2Error as e:
|
|
167
|
+
# Other OAuth2 protocol errors - report but don't continue
|
|
168
|
+
console.error(f"OAuth2 error: {e.description}")
|
|
169
|
+
config.auth_manager.active_server = previous_server
|
|
170
|
+
config.auth_manager.active_auth_server = previous_auth_server
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
except RuntimeError as e:
|
|
173
|
+
# Network or OIDC discovery errors - report but don't continue
|
|
174
|
+
console.error(f"Failed to validate authentication: {e}")
|
|
175
|
+
console.hint("Check your network connection and try again.")
|
|
176
|
+
config.auth_manager.active_server = previous_server
|
|
177
|
+
config.auth_manager.active_auth_server = previous_auth_server
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
|
|
180
|
+
# Starting the login flow
|
|
181
|
+
console.info(log_in_message)
|
|
182
|
+
|
|
183
|
+
async with httpx.AsyncClient() as client:
|
|
184
|
+
resp = await client.get(f"{server}/.well-known/oauth-protected-resource/", follow_redirects=True)
|
|
185
|
+
if resp.is_error:
|
|
186
|
+
console.error("This server does not appear to run a compatible version of Agent Stack Platform.")
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
metadata = resp.json()
|
|
189
|
+
|
|
190
|
+
auth_servers = metadata.get("authorization_servers", [])
|
|
191
|
+
auth_server = None
|
|
192
|
+
token = None
|
|
193
|
+
|
|
194
|
+
client_id = config.client_id
|
|
195
|
+
client_secret = config.client_secret
|
|
196
|
+
registration_token = None
|
|
197
|
+
|
|
198
|
+
if auth_servers:
|
|
199
|
+
if len(auth_servers) == 1:
|
|
200
|
+
auth_server = auth_servers[0]
|
|
201
|
+
else:
|
|
202
|
+
auth_server = await inquirer.select( # type: ignore
|
|
203
|
+
message="Select an authorization server:",
|
|
204
|
+
choices=auth_servers,
|
|
205
|
+
).execute_async()
|
|
231
206
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
console.warning("Could not open browser. Please visit the above URL manually.")
|
|
207
|
+
if not auth_server:
|
|
208
|
+
raise RuntimeError("No authorization server selected.")
|
|
235
209
|
|
|
236
|
-
|
|
210
|
+
async with httpx.AsyncClient() as client:
|
|
211
|
+
try:
|
|
212
|
+
resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
|
|
213
|
+
resp.raise_for_status()
|
|
214
|
+
oidc = resp.json()
|
|
215
|
+
except Exception as e:
|
|
216
|
+
raise RuntimeError(f"OIDC discovery failed: {e}") from e
|
|
217
|
+
|
|
218
|
+
registration_endpoint = oidc["registration_endpoint"]
|
|
219
|
+
if not client_id and registration_endpoint:
|
|
237
220
|
async with httpx.AsyncClient() as client:
|
|
238
|
-
|
|
221
|
+
resp = None
|
|
239
222
|
try:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
223
|
+
app_name = get_unique_app_name()
|
|
224
|
+
resp = await client.post(
|
|
225
|
+
registration_endpoint,
|
|
226
|
+
json={
|
|
227
|
+
"client_name": app_name,
|
|
228
|
+
"grant_types": ["authorization_code", "refresh_token"],
|
|
229
|
+
"enforce_pkce": True,
|
|
230
|
+
"all_users_entitled": True,
|
|
231
|
+
"redirect_uris": [REDIRECT_URI],
|
|
249
232
|
},
|
|
250
233
|
)
|
|
251
|
-
|
|
252
|
-
|
|
234
|
+
resp.raise_for_status()
|
|
235
|
+
data = resp.json()
|
|
236
|
+
client_id = data["client_id"]
|
|
237
|
+
client_secret = data["client_secret"]
|
|
238
|
+
registration_token = data["registration_access_token"]
|
|
253
239
|
except Exception as e:
|
|
254
240
|
if resp:
|
|
255
241
|
try:
|
|
@@ -257,22 +243,87 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
|
|
|
257
243
|
console.warning(
|
|
258
244
|
f"error: {error_details['error']} error description: {error_details['error_description']}"
|
|
259
245
|
)
|
|
246
|
+
|
|
260
247
|
except Exception:
|
|
261
248
|
console.info("no parsable json response.")
|
|
249
|
+
console.warning(f" Dynamic client registration failed. Proceed with manual input. {e!s}")
|
|
262
250
|
|
|
263
|
-
|
|
251
|
+
if not client_id:
|
|
252
|
+
client_id = (
|
|
253
|
+
await inquirer.text( # type: ignore
|
|
254
|
+
message="Enter Client ID:",
|
|
255
|
+
instruction=f"(Redirect URI: {REDIRECT_URI})",
|
|
256
|
+
).execute_async()
|
|
257
|
+
or "agentstack-cli"
|
|
258
|
+
)
|
|
259
|
+
if not client_id:
|
|
260
|
+
raise RuntimeError("Client ID is mandatory. Action cancelled.")
|
|
261
|
+
client_secret = (
|
|
262
|
+
await inquirer.text( # type: ignore
|
|
263
|
+
message="Enter Client Secret (optional):"
|
|
264
|
+
).execute_async()
|
|
265
|
+
or None
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
code_verifier = generate_token(64)
|
|
269
|
+
|
|
270
|
+
auth_url = f"{oidc['authorization_endpoint']}?{
|
|
271
|
+
urlencode(
|
|
272
|
+
{
|
|
273
|
+
'client_id': client_id,
|
|
274
|
+
'response_type': 'code',
|
|
275
|
+
'redirect_uri': REDIRECT_URI,
|
|
276
|
+
'scope': ' '.join(metadata.get('scopes_supported', ['openid', 'email', 'profile'])),
|
|
277
|
+
'code_challenge': typing.cast(str, create_s256_code_challenge(code_verifier)),
|
|
278
|
+
'code_challenge_method': 'S256',
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
}"
|
|
282
|
+
|
|
283
|
+
console.info(f"Opening browser for login: [cyan]{auth_url}[/cyan]")
|
|
284
|
+
if not webbrowser.open(auth_url):
|
|
285
|
+
console.warning("Could not open browser. Please visit the above URL manually.")
|
|
286
|
+
|
|
287
|
+
code = await _wait_for_auth_code()
|
|
288
|
+
async with httpx.AsyncClient() as client:
|
|
289
|
+
token_resp = None
|
|
290
|
+
try:
|
|
291
|
+
token_resp = await client.post(
|
|
292
|
+
oidc["token_endpoint"],
|
|
293
|
+
data={
|
|
294
|
+
"grant_type": "authorization_code",
|
|
295
|
+
"code": code,
|
|
296
|
+
"redirect_uri": REDIRECT_URI,
|
|
297
|
+
"client_id": client_id,
|
|
298
|
+
"client_secret": client_secret,
|
|
299
|
+
"code_verifier": code_verifier,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
token_resp.raise_for_status()
|
|
303
|
+
token = token_resp.json()
|
|
304
|
+
except Exception as e:
|
|
305
|
+
if token_resp:
|
|
306
|
+
try:
|
|
307
|
+
error_details = token_resp.json()
|
|
308
|
+
console.warning(
|
|
309
|
+
f"error: {error_details['error']} error description: {error_details['error_description']}"
|
|
310
|
+
)
|
|
311
|
+
except Exception:
|
|
312
|
+
console.info("no parsable json response.")
|
|
264
313
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
314
|
+
raise RuntimeError(f"Token request failed: {e}") from e
|
|
315
|
+
|
|
316
|
+
if not token:
|
|
317
|
+
raise RuntimeError("Login timed out or not successful.")
|
|
318
|
+
|
|
319
|
+
config.auth_manager.save_auth_info(
|
|
320
|
+
server=server,
|
|
321
|
+
auth_server=auth_server,
|
|
322
|
+
client_id=client_id,
|
|
323
|
+
client_secret=client_secret,
|
|
324
|
+
token=token,
|
|
325
|
+
registration_token=registration_token,
|
|
326
|
+
)
|
|
276
327
|
|
|
277
328
|
config.auth_manager.active_server = server
|
|
278
329
|
config.auth_manager.active_auth_server = auth_server
|
agentstack_cli/commands/user.py
CHANGED
|
@@ -11,7 +11,7 @@ from rich.table import Column
|
|
|
11
11
|
|
|
12
12
|
from agentstack_cli.async_typer import AsyncTyper, console, create_table
|
|
13
13
|
from agentstack_cli.configuration import Configuration
|
|
14
|
-
from agentstack_cli.
|
|
14
|
+
from agentstack_cli.server_utils import announce_server_action, confirm_server_action
|
|
15
15
|
|
|
16
16
|
app = AsyncTyper()
|
|
17
17
|
configuration = Configuration()
|
agentstack_cli/configuration.py
CHANGED
|
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
|
|
|
13
13
|
import pydantic
|
|
14
14
|
import pydantic_settings
|
|
15
15
|
from agentstack_sdk.platform import PlatformClient, use_platform_client
|
|
16
|
+
from authlib.oauth2.rfc6749.errors import InvalidGrantError, OAuth2Error
|
|
16
17
|
from pydantic import HttpUrl, SecretStr
|
|
17
18
|
|
|
18
19
|
from agentstack_cli.auth_manager import AuthManager
|
|
@@ -64,9 +65,28 @@ class Configuration(pydantic_settings.BaseSettings):
|
|
|
64
65
|
"Run [green]agentstack platform start[/green] to start a local server, or [green]agentstack server login[/green] to connect to a remote one."
|
|
65
66
|
)
|
|
66
67
|
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
re_auth_message = (
|
|
70
|
+
f"Run [green]agentstack server login {self.auth_manager.active_server}[/green] to re-authenticate."
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
auth_token = await self.auth_manager.load_auth_token()
|
|
74
|
+
except InvalidGrantError:
|
|
75
|
+
console.error("Your session has expired.")
|
|
76
|
+
console.hint(re_auth_message)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
except OAuth2Error as e:
|
|
79
|
+
console.error(f"OAuth2 error: {e.description}")
|
|
80
|
+
console.hint(re_auth_message)
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
except RuntimeError as e:
|
|
83
|
+
console.error(f"Failed to load authentication: {e}")
|
|
84
|
+
console.hint("Check your network connection and try again.")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
67
87
|
async with use_platform_client(
|
|
68
88
|
auth=(self.username, self.password.get_secret_value()) if self.password else None,
|
|
69
|
-
auth_token=
|
|
89
|
+
auth_token=auth_token,
|
|
70
90
|
base_url=self.auth_manager.active_server + "/",
|
|
71
91
|
) as client:
|
|
72
92
|
yield client
|
|
Binary file
|