meshagent-cli 0.6.1__py3-none-any.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.

Potentially problematic release.


This version of meshagent-cli might be problematic. Click here for more details.

@@ -0,0 +1,295 @@
1
+ import os
2
+ import json
3
+ import time
4
+ import base64
5
+ import hashlib
6
+ import secrets
7
+ import webbrowser
8
+ import asyncio
9
+ from pathlib import Path
10
+ from urllib.parse import urlencode
11
+ from aiohttp import web, ClientSession
12
+
13
+ # -----------------------------------------------------------------------------
14
+ # Config
15
+ # -----------------------------------------------------------------------------
16
+
17
+ CACHE_FILE = Path.home() / ".meshagent" / "session.json"
18
+ REDIRECT_PORT = 8765
19
+ REDIRECT_URL = f"http://localhost:{REDIRECT_PORT}/callback"
20
+
21
+ # Expected env vars:
22
+ # - MESHAGENT_API_URL (required): e.g., https://api.meshagent.com
23
+ # - MESHAGENT_OAUTH_CLIENT_ID (required)
24
+ # - MESHAGENT_OAUTH_CLIENT_SECRET (optional; only if your server requires it)
25
+ # - MESHAGENT_OAUTH_SCOPES (optional; defaults to "openid email profile")
26
+
27
+ # -----------------------------------------------------------------------------
28
+ # Helpers
29
+ # -----------------------------------------------------------------------------
30
+
31
+
32
+ def _ensure_cache_dir():
33
+ CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+
36
+ def _now() -> int:
37
+ return int(time.time())
38
+
39
+
40
+ def _b64url_no_pad(data: bytes) -> str:
41
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
42
+
43
+
44
+ def _pkce_pair():
45
+ """
46
+ Returns (code_verifier, code_challenge) using S256 per RFC 7636.
47
+ """
48
+ verifier = _b64url_no_pad(secrets.token_bytes(32))
49
+ digest = hashlib.sha256(verifier.encode()).digest()
50
+ challenge = _b64url_no_pad(digest)
51
+ return verifier, challenge
52
+
53
+
54
+ def _api_base() -> str:
55
+ api = os.getenv("MESHAGENT_API_URL", "https://api.meshagent.com")
56
+ if not api:
57
+ raise RuntimeError("MESHAGENT_API_URL is not set")
58
+ return api.rstrip("/")
59
+
60
+
61
+ def _authorization_url() -> str:
62
+ return f"{_api_base()}/oauth/authorize"
63
+
64
+
65
+ def _token_url() -> str:
66
+ return f"{_api_base()}/oauth/token"
67
+
68
+
69
+ def _client_id() -> str:
70
+ cid = os.getenv("MESHAGENT_OAUTH_CLIENT_ID", "p8xy1ZUi73jJUJbNfTg92HUSDpCSZJcc")
71
+ if not cid:
72
+ raise RuntimeError("MESHAGENT_OAUTH_CLIENT_ID is not set")
73
+ return cid
74
+
75
+
76
+ def _client_secret() -> str | None:
77
+ return os.getenv("MESHAGENT_OAUTH_CLIENT_SECRET")
78
+
79
+
80
+ def _scopes() -> str:
81
+ return os.getenv("MESHAGENT_OAUTH_SCOPES", "admin")
82
+
83
+
84
+ def _save(tokens: dict):
85
+ """
86
+ Persist minimal token info to disk.
87
+ Expected keys: access_token, refresh_token (optional), expires_at (epoch int).
88
+ """
89
+ _ensure_cache_dir()
90
+ CACHE_FILE.write_text(
91
+ json.dumps(
92
+ {
93
+ "access_token": tokens.get("access_token"),
94
+ "refresh_token": tokens.get("refresh_token"),
95
+ "expires_at": tokens.get("expires_at"),
96
+ "token_type": tokens.get("token_type", "Bearer"),
97
+ "scope": tokens.get("scope"),
98
+ "id_token": tokens.get("id_token"),
99
+ }
100
+ )
101
+ )
102
+
103
+
104
+ def _load() -> dict | None:
105
+ _ensure_cache_dir()
106
+ if CACHE_FILE.exists():
107
+ return json.loads(CACHE_FILE.read_text())
108
+
109
+
110
+ async def _post_form(url: str, form: dict) -> dict:
111
+ """
112
+ POST application/x-www-form-urlencoded and return parsed JSON or raise.
113
+ """
114
+ headers = {"Accept": "application/json"}
115
+ async with ClientSession() as s:
116
+ async with s.post(url, data=form, headers=headers) as resp:
117
+ text = await resp.text()
118
+ if resp.status >= 400:
119
+ raise RuntimeError(f"Token endpoint error {resp.status}: {text}")
120
+ try:
121
+ return json.loads(text)
122
+ except json.JSONDecodeError:
123
+ raise RuntimeError(
124
+ f"Unexpected non-JSON response from token endpoint: {text}"
125
+ )
126
+
127
+
128
+ # -----------------------------------------------------------------------------
129
+ # Local HTTP callback
130
+ # -----------------------------------------------------------------------------
131
+
132
+
133
+ async def _wait_for_code(expected_state: str) -> str:
134
+ """
135
+ Spin up a one-shot aiohttp server and await ?code=…&state=…
136
+ Validates 'state' if provided. Returns the 'code'.
137
+ """
138
+ app = web.Application()
139
+ code_fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
140
+
141
+ async def callback(request):
142
+ code = request.query.get("code")
143
+ state = request.query.get("state")
144
+ if expected_state and state != expected_state:
145
+ return web.Response(status=400, text="State mismatch. Close this tab.")
146
+ if code:
147
+ if not code_fut.done():
148
+ code_fut.set_result(code)
149
+ return web.Response(text="You may close this tab.")
150
+ return web.Response(status=400, text="Missing 'code'.")
151
+
152
+ app.add_routes([web.get("/callback", callback)])
153
+ runner = web.AppRunner(app, access_log=None)
154
+ await runner.setup()
155
+ site = web.TCPSite(runner, "localhost", REDIRECT_PORT)
156
+ await site.start()
157
+
158
+ try:
159
+ return await code_fut
160
+ finally:
161
+ await runner.cleanup()
162
+
163
+
164
+ # -----------------------------------------------------------------------------
165
+ # OAuth flows
166
+ # -----------------------------------------------------------------------------
167
+
168
+
169
+ async def _exchange_code_for_tokens(code: str, code_verifier: str) -> dict:
170
+ form = {
171
+ "grant_type": "authorization_code",
172
+ "code": code,
173
+ "redirect_uri": REDIRECT_URL,
174
+ "client_id": _client_id(),
175
+ "code_verifier": code_verifier,
176
+ }
177
+ # Include client_secret only if provided (public clients typically omit)
178
+ client_secret = _client_secret()
179
+ if client_secret:
180
+ form["client_secret"] = client_secret
181
+
182
+ token_json = await _post_form(_token_url(), form)
183
+
184
+ # Compute absolute expiry; default to 3600s if expires_in missing
185
+ expires_in = int(token_json.get("expires_in", 3600))
186
+ token_json["expires_at"] = _now() + max(0, expires_in - 30) # small safety skew
187
+ return token_json
188
+
189
+
190
+ async def _refresh_tokens(tokens: dict) -> dict:
191
+ if not tokens or not tokens.get("refresh_token"):
192
+ raise RuntimeError("No refresh token available to refresh access token.")
193
+
194
+ form = {
195
+ "grant_type": "refresh_token",
196
+ "refresh_token": tokens["refresh_token"],
197
+ "client_id": _client_id(),
198
+ }
199
+ client_secret = _client_secret()
200
+ if client_secret:
201
+ form["client_secret"] = client_secret
202
+
203
+ token_json = await _post_form(_token_url(), form)
204
+
205
+ # Some servers rotate refresh tokens; keep old one if none returned
206
+ token_json["refresh_token"] = token_json.get(
207
+ "refresh_token", tokens.get("refresh_token")
208
+ )
209
+ expires_in = int(token_json.get("expires_in", 3600))
210
+ token_json["expires_at"] = _now() + max(0, expires_in - 30)
211
+ return token_json
212
+
213
+
214
+ # -----------------------------------------------------------------------------
215
+ # Public API (unchanged names)
216
+ # -----------------------------------------------------------------------------
217
+
218
+
219
+ async def login():
220
+ """
221
+ Launches the system browser for OAuth 2.0 Authorization Code + PKCE.
222
+ Persists tokens to ~/.meshagent/session.json
223
+ """
224
+ authz = _authorization_url()
225
+ client_id = _client_id()
226
+ scope = _scopes()
227
+
228
+ code_verifier, code_challenge = _pkce_pair()
229
+ state = _b64url_no_pad(secrets.token_bytes(16))
230
+
231
+ query = {
232
+ "response_type": "code",
233
+ "client_id": client_id,
234
+ "redirect_uri": REDIRECT_URL,
235
+ "scope": scope,
236
+ "code_challenge": code_challenge,
237
+ "code_challenge_method": "S256",
238
+ "state": state,
239
+ }
240
+ auth_url = f"{authz}?{urlencode(query)}"
241
+
242
+ # Kick user to browser without blocking the loop
243
+ await asyncio.to_thread(webbrowser.open, auth_url)
244
+ print(f"Waiting for auth redirect on {auth_url}…")
245
+
246
+ # Await the auth code, then exchange for tokens
247
+ auth_code = await _wait_for_code(state)
248
+ print("Got code, exchanging…")
249
+
250
+ tokens = await _exchange_code_for_tokens(auth_code, code_verifier)
251
+ _save(tokens)
252
+ print("✅ Logged in (tokens cached).")
253
+
254
+
255
+ async def session():
256
+ """
257
+ Returns a tuple (client, tokens_dict)
258
+ - client is None (kept for backward compatibility with prior signature).
259
+ - tokens_dict contains access_token, refresh_token, expires_at, token_type, scope, id_token.
260
+ Will auto-refresh if expired/near-expiry and update the cache.
261
+ """
262
+ tokens = _load()
263
+ if not tokens:
264
+ return None, None
265
+
266
+ # Refresh if expired or within 5 min of expiry
267
+ if not tokens.get("expires_at") or tokens["expires_at"] <= _now() + 5 * 60:
268
+ try:
269
+ tokens = await _refresh_tokens(tokens)
270
+ _save(tokens)
271
+ except Exception as e:
272
+ # If refresh fails, wipe session to force re-login
273
+ print(f"⚠️ Token refresh failed: {e}")
274
+ return None, None
275
+
276
+ return None, tokens
277
+
278
+
279
+ async def logout():
280
+ """
281
+ Clears the cached tokens. (If your OAuth server supports revocation,
282
+ you can add a call here; not provided in the spec.)
283
+ """
284
+ _, tokens = await session()
285
+ # Optional: call a revocation endpoint here if your server provides one.
286
+ CACHE_FILE.unlink(missing_ok=True)
287
+ print("👋 Signed out")
288
+
289
+
290
+ async def get_access_token():
291
+ """
292
+ Returns a fresh access token, refreshing if needed.
293
+ """
294
+ _, tokens = await session()
295
+ return tokens["access_token"] if tokens else None
meshagent/cli/call.py ADDED
@@ -0,0 +1,224 @@
1
+ import typer
2
+ from rich import print
3
+ from typing import Annotated, Optional
4
+ from meshagent.cli.common_options import ProjectIdOption, RoomOption
5
+ import json
6
+ import aiohttp
7
+ from meshagent.api import (
8
+ RoomClient,
9
+ ParticipantToken,
10
+ WebSocketClientProtocol,
11
+ ParticipantGrant,
12
+ ApiScope,
13
+ )
14
+ from meshagent.api.helpers import meshagent_base_url, websocket_room_url
15
+ from meshagent.api.services import send_webhook
16
+ from meshagent.cli import async_typer
17
+ from meshagent.cli.helper import get_client, resolve_project_id, resolve_key
18
+ from meshagent.cli.helper import resolve_room
19
+ from urllib.parse import urlparse
20
+ from pathlib import PurePath
21
+ import socket
22
+ import ipaddress
23
+ import pathlib
24
+ from pydantic_yaml import parse_yaml_raw_as
25
+ from meshagent.api.participant_token import ParticipantTokenSpec
26
+
27
+ app = async_typer.AsyncTyper()
28
+
29
+ PRIVATE_NETS = (
30
+ ipaddress.ip_network("10.0.0.0/8"),
31
+ ipaddress.ip_network("172.16.0.0/12"),
32
+ ipaddress.ip_network("192.168.0.0/16"),
33
+ ipaddress.ip_network("169.254.0.0/16"), # IPv4 link-local
34
+ ipaddress.ip_network("fc00::/7"), # IPv6 unique-local
35
+ ipaddress.ip_network("fe80::/10"), # IPv6 link-local
36
+ )
37
+
38
+
39
+ def is_local_url(url: str) -> bool:
40
+ """
41
+ Return True if *url* points to the local machine or a private-LAN host.
42
+ """
43
+ # 1. Handle bare paths and file://
44
+ if "://" not in url:
45
+ return PurePath(url).is_absolute() or not ("/" in url or "\\" in url)
46
+ parsed = urlparse(url)
47
+ if parsed.scheme == "file":
48
+ return True
49
+
50
+ # 2. Quick loop-back check on hostname literal
51
+ hostname = parsed.hostname
52
+ if hostname in {"localhost", None}: # None ⇒ something like "http:///path"
53
+ return True
54
+
55
+ try:
56
+ # Accept both direct IP literals and DNS names
57
+ addr_info = socket.getaddrinfo(hostname, None)
58
+ except socket.gaierror:
59
+ return False # Unresolvable host ⇒ treat as non-local (or raise)
60
+
61
+ for *_, sockaddr in addr_info:
62
+ ip_str = sockaddr[0]
63
+ ip = ipaddress.ip_address(ip_str)
64
+
65
+ if ip.is_loopback:
66
+ return True
67
+ if any(ip in net for net in PRIVATE_NETS):
68
+ return True
69
+
70
+
71
+ @app.async_command("schema")
72
+ @app.async_command("toolkit")
73
+ @app.async_command("agent")
74
+ @app.async_command("tool")
75
+ async def make_call(
76
+ *,
77
+ project_id: ProjectIdOption = None,
78
+ room: RoomOption,
79
+ role: str = "agent",
80
+ local: Optional[bool] = None,
81
+ agent_name: Annotated[
82
+ Optional[str], typer.Option(..., help="deprecated and unused", hidden=True)
83
+ ] = None,
84
+ name: Annotated[str, typer.Option(..., help="deprecated", hidden=True)] = None,
85
+ participant_name: Annotated[
86
+ Optional[str],
87
+ typer.Option(..., help="the participant name to be used by the callee"),
88
+ ] = None,
89
+ url: Annotated[str, typer.Option(..., help="URL the agent should call")],
90
+ arguments: Annotated[
91
+ str, typer.Option(..., help="JSON string with arguments for the call")
92
+ ] = {},
93
+ permissions: Annotated[
94
+ Optional[str],
95
+ typer.Option(
96
+ "--permissions",
97
+ "-p",
98
+ help="File path to a token definition, if not specified default agent permissions will be used",
99
+ ),
100
+ ] = None,
101
+ key: Annotated[
102
+ str,
103
+ typer.Option("--key", help="an api key to sign the token with"),
104
+ ] = None,
105
+ ):
106
+ key = await resolve_key(project_id=project_id, key=key)
107
+
108
+ if permissions is not None:
109
+ with open(str(pathlib.Path(permissions).expanduser().resolve()), "rb") as f:
110
+ spec = parse_yaml_raw_as(ParticipantTokenSpec, f.read())
111
+
112
+ token = ParticipantToken(
113
+ name=spec.identity,
114
+ )
115
+ token.add_role_grant(role=role)
116
+ token.add_room_grant(room)
117
+ token.add_api_grant(spec.api)
118
+
119
+ else:
120
+ token = None
121
+
122
+ await _make_call(
123
+ project_id=project_id,
124
+ room=room,
125
+ role=role,
126
+ local=local,
127
+ agent_name=agent_name,
128
+ name=name,
129
+ participant_name=participant_name,
130
+ url=url,
131
+ arguments=arguments,
132
+ token=token,
133
+ key=key,
134
+ )
135
+
136
+
137
+ async def _make_call(
138
+ *,
139
+ project_id: ProjectIdOption = None,
140
+ room: RoomOption,
141
+ role: str = "agent",
142
+ local: Optional[bool] = None,
143
+ agent_name: Annotated[
144
+ Optional[str], typer.Option(..., help="deprecated and unused", hidden=True)
145
+ ] = None,
146
+ name: Annotated[str, typer.Option(..., help="deprecated", hidden=True)] = None,
147
+ participant_name: Annotated[
148
+ Optional[str],
149
+ typer.Option(..., help="the participant name to be used by the callee"),
150
+ ] = None,
151
+ url: Annotated[str, typer.Option(..., help="URL the agent should call")],
152
+ arguments: Annotated[
153
+ str, typer.Option(..., help="JSON string with arguments for the call")
154
+ ] = {},
155
+ token: Optional[ParticipantToken] = None,
156
+ permissions: Optional[ApiScope] = None,
157
+ key: str,
158
+ ):
159
+ """
160
+ Instruct an agent to 'call' a given URL with specific arguments.
161
+
162
+ """
163
+ if name is not None:
164
+ print("[yellow]name is deprecated and should no longer be passed[/yellow]")
165
+
166
+ if agent_name is not None:
167
+ print(
168
+ "[yellow]agent-name is deprecated and should no longer be passed, use participant-name instead[/yellow]"
169
+ )
170
+ participant_name = agent_name
171
+
172
+ if participant_name is None:
173
+ print("[red]--participant-name is required[/red]")
174
+ raise typer.Exit(1)
175
+
176
+ account_client = await get_client()
177
+ try:
178
+ project_id = await resolve_project_id(project_id=project_id)
179
+
180
+ room = resolve_room(room)
181
+
182
+ if token is None:
183
+ token = ParticipantToken(
184
+ name=participant_name,
185
+ )
186
+ token.add_api_grant(permissions or ApiScope.agent_default())
187
+ token.add_role_grant(role=role)
188
+ token.add_room_grant(room)
189
+ token.grants.append(ParticipantGrant(name="tunnel_ports", scope="9000"))
190
+
191
+ if local is None:
192
+ local = is_local_url(url)
193
+
194
+ if local:
195
+ async with aiohttp.ClientSession() as session:
196
+ event = "room.call"
197
+ data = {
198
+ "room_url": websocket_room_url(room_name=room),
199
+ "room_name": room,
200
+ "token": token.to_jwt(api_key=key),
201
+ "arguments": arguments,
202
+ }
203
+
204
+ await send_webhook(
205
+ session=session, url=url, event=event, data=data, secret=None
206
+ )
207
+ else:
208
+ print("[bold green]Connecting to room...[/bold green]")
209
+ async with RoomClient(
210
+ protocol=WebSocketClientProtocol(
211
+ url=websocket_room_url(
212
+ room_name=room, base_url=meshagent_base_url()
213
+ ),
214
+ token=token.to_jwt(api_key=key),
215
+ )
216
+ ) as client:
217
+ print("[bold green]Making agent call...[/bold green]")
218
+ await client.agents.make_call(
219
+ name=participant_name, url=url, arguments=json.loads(arguments)
220
+ )
221
+ print("[bold cyan]Call request sent successfully.[/bold cyan]")
222
+
223
+ finally:
224
+ await account_client.close()