meshagent-cli 0.5.18__py3-none-any.whl → 0.6.0__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.

@@ -1,79 +1,153 @@
1
1
  import os
2
2
  import json
3
+ import time
4
+ import base64
5
+ import hashlib
6
+ import secrets
3
7
  import webbrowser
4
8
  import asyncio
5
9
  from pathlib import Path
6
- from aiohttp import web
7
- from supabase._async.client import (
8
- AsyncClient,
9
- create_client,
10
- ) # async flavour :contentReference[oaicite:1]{index=1}
11
- from supabase.lib.client_options import ClientOptions
12
- from supabase_auth import AsyncMemoryStorage
13
-
14
- AUTH_URL = os.getenv("MESHAGENT_AUTH_URL", "https://infra.meshagent.com")
15
- AUTH_ANON_KEY = os.getenv(
16
- "MESHAGENT_AUTH_ANON_KEY",
17
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5memhyeWpoc3RjZXdkeWdvampzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ2MzU2MjgsImV4cCI6MjA1MDIxMTYyOH0.ujx9CIbYEvWbA77ogB1gg1Jrv3KtpB1rWh_LRRLpcow",
18
- )
10
+ from urllib.parse import urlencode
11
+ from aiohttp import web, ClientSession
12
+
13
+ # -----------------------------------------------------------------------------
14
+ # Config
15
+ # -----------------------------------------------------------------------------
16
+
19
17
  CACHE_FILE = Path.home() / ".meshagent" / "session.json"
20
18
  REDIRECT_PORT = 8765
21
19
  REDIRECT_URL = f"http://localhost:{REDIRECT_PORT}/callback"
22
20
 
23
- # ---------- helpers ----------------------------------------------------------
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
+ # -----------------------------------------------------------------------------
24
30
 
25
31
 
26
32
  def _ensure_cache_dir():
27
33
  CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
28
34
 
29
35
 
30
- async def _client() -> AsyncClient:
31
- return await create_client(
32
- AUTH_URL,
33
- AUTH_ANON_KEY,
34
- options=ClientOptions(
35
- flow_type="pkce", # OAuth + PKCE :contentReference[oaicite:2]{index=2}
36
- auto_refresh_token=True,
37
- persist_session=False,
38
- storage=AsyncMemoryStorage(),
39
- ),
40
- )
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
+
41
60
 
61
+ def _authorization_url() -> str:
62
+ return f"{_api_base()}/oauth/authorize"
42
63
 
43
- def _save(s):
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
+ """
44
89
  _ensure_cache_dir()
45
90
  CACHE_FILE.write_text(
46
91
  json.dumps(
47
92
  {
48
- "access_token": s.access_token,
49
- "refresh_token": s.refresh_token,
50
- "expires_at": s.expires_at, # int (seconds since epoch)
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"),
51
99
  }
52
100
  )
53
101
  )
54
102
 
55
103
 
56
- def _load():
104
+ def _load() -> dict | None:
57
105
  _ensure_cache_dir()
58
106
  if CACHE_FILE.exists():
59
107
  return json.loads(CACHE_FILE.read_text())
60
108
 
61
109
 
62
- # ---------- local HTTP callback ---------------------------------------------
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
+
63
127
 
128
+ # -----------------------------------------------------------------------------
129
+ # Local HTTP callback
130
+ # -----------------------------------------------------------------------------
64
131
 
65
- async def _wait_for_code() -> str:
66
- """Spin up a one-shot aiohttp server and await ?code=…"""
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
+ """
67
138
  app = web.Application()
68
139
  code_fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
69
140
 
70
141
  async def callback(request):
71
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.")
72
146
  if code:
73
147
  if not code_fut.done():
74
148
  code_fut.set_result(code)
75
149
  return web.Response(text="You may close this tab.")
76
- return web.Response(status=400)
150
+ return web.Response(status=400, text="Missing 'code'.")
77
151
 
78
152
  app.add_routes([web.get("/callback", callback)])
79
153
  runner = web.AppRunner(app, access_log=None)
@@ -87,52 +161,135 @@ async def _wait_for_code() -> str:
87
161
  await runner.cleanup()
88
162
 
89
163
 
90
- # ---------- public API -------------------------------------------------------
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
+ # -----------------------------------------------------------------------------
91
217
 
92
218
 
93
219
  async def login():
94
- supa = await _client()
95
-
96
- # 1️⃣ Build provider URL – async now
97
- res = await supa.auth.sign_in_with_oauth(
98
- {
99
- "provider": os.getenv("MESHAGENT_AUTH_PROVIDER", "google"),
100
- "options": {"redirect_to": REDIRECT_URL, "scopes": "email"},
101
- }
102
- ) # :contentReference[oaicite:3]{index=3}
103
- oauth_url = res.url
104
-
105
- # 2️⃣ Kick user to browser without blocking the loop
106
- await asyncio.to_thread(webbrowser.open, oauth_url)
107
- print(f"Waiting for auth redirect on {oauth_url}…")
108
-
109
- # 3️⃣ Await the auth code, then exchange for tokens
110
- auth_code = await _wait_for_code()
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)
111
248
  print("Got code, exchanging…")
112
- sess = await supa.auth.exchange_code_for_session({"auth_code": auth_code}) #
113
- _save(sess.session)
114
- print("✅ Logged in as", sess.user.email)
249
+
250
+ tokens = await _exchange_code_for_tokens(auth_code, code_verifier)
251
+ _save(tokens)
252
+ print("✅ Logged in (tokens cached).")
115
253
 
116
254
 
117
255
  async def session():
118
- supa = await _client()
119
- cached = _load()
120
- fresh = None
121
- if cached:
122
- await supa.auth.set_session(cached["access_token"], cached["refresh_token"])
123
- fresh = await supa.auth.get_session() # returns a Session object
124
- _save(fresh)
125
- return supa, fresh
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
126
277
 
127
278
 
128
279
  async def logout():
129
- supa, s = await session()
130
- if s:
131
- await supa.auth.sign_out()
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.
132
286
  CACHE_FILE.unlink(missing_ok=True)
133
287
  print("👋 Signed out")
134
288
 
135
289
 
136
290
  async def get_access_token():
137
- supa, fresh = await session()
138
- return fresh.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  import typer
2
2
  from rich import print
3
3
  from typing import Annotated, Optional
4
- from meshagent.cli.common_options import ProjectIdOption, ApiKeyIdOption, RoomOption
4
+ from meshagent.cli.common_options import ProjectIdOption, RoomOption
5
5
  import json
6
6
  import aiohttp
7
7
  from meshagent.api import (
@@ -9,16 +9,20 @@ from meshagent.api import (
9
9
  ParticipantToken,
10
10
  WebSocketClientProtocol,
11
11
  ParticipantGrant,
12
+ ApiScope,
12
13
  )
13
14
  from meshagent.api.helpers import meshagent_base_url, websocket_room_url
14
15
  from meshagent.api.services import send_webhook
15
16
  from meshagent.cli import async_typer
16
- from meshagent.cli.helper import get_client, resolve_project_id
17
- from meshagent.cli.helper import resolve_api_key, resolve_room
17
+ from meshagent.cli.helper import get_client, resolve_project_id, resolve_key
18
+ from meshagent.cli.helper import resolve_room
18
19
  from urllib.parse import urlparse
19
20
  from pathlib import PurePath
20
21
  import socket
21
22
  import ipaddress
23
+ import pathlib
24
+ from pydantic_yaml import parse_yaml_raw_as
25
+ from meshagent.api.participant_token import ParticipantTokenSpec
22
26
 
23
27
  app = async_typer.AsyncTyper()
24
28
 
@@ -72,7 +76,6 @@ async def make_call(
72
76
  *,
73
77
  project_id: ProjectIdOption = None,
74
78
  room: RoomOption,
75
- api_key_id: ApiKeyIdOption = None,
76
79
  role: str = "agent",
77
80
  local: Optional[bool] = None,
78
81
  agent_name: Annotated[
@@ -87,12 +90,76 @@ async def make_call(
87
90
  arguments: Annotated[
88
91
  str, typer.Option(..., help="JSON string with arguments for the call")
89
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,
90
158
  ):
91
159
  """
92
160
  Instruct an agent to 'call' a given URL with specific arguments.
93
161
 
94
162
  """
95
-
96
163
  if name is not None:
97
164
  print("[yellow]name is deprecated and should no longer be passed[/yellow]")
98
165
 
@@ -109,21 +176,17 @@ async def make_call(
109
176
  account_client = await get_client()
110
177
  try:
111
178
  project_id = await resolve_project_id(project_id=project_id)
112
- api_key_id = await resolve_api_key(project_id, api_key_id)
179
+
113
180
  room = resolve_room(room)
114
181
 
115
- key = (
116
- await account_client.decrypt_project_api_key(
117
- project_id=project_id, id=api_key_id
182
+ if token is None:
183
+ token = ParticipantToken(
184
+ name=participant_name,
118
185
  )
119
- )["token"]
120
-
121
- token = ParticipantToken(
122
- name=participant_name, project_id=project_id, api_key_id=api_key_id
123
- )
124
- token.add_role_grant(role=role)
125
- token.add_room_grant(room)
126
- token.grants.append(ParticipantGrant(name="tunnel_ports", scope="9000"))
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"))
127
190
 
128
191
  if local is None:
129
192
  local = is_local_url(url)
@@ -134,7 +197,7 @@ async def make_call(
134
197
  data = {
135
198
  "room_url": websocket_room_url(room_name=room),
136
199
  "room_name": room,
137
- "token": token.to_jwt(token=key),
200
+ "token": token.to_jwt(api_key=key),
138
201
  "arguments": arguments,
139
202
  }
140
203
 
@@ -148,7 +211,7 @@ async def make_call(
148
211
  url=websocket_room_url(
149
212
  room_name=room, base_url=meshagent_base_url()
150
213
  ),
151
- token=token.to_jwt(token=key),
214
+ token=token.to_jwt(api_key=key),
152
215
  )
153
216
  ) as client:
154
217
  print("[bold green]Making agent call...[/bold green]")