forgexa-cli 1.17.5__tar.gz → 1.18.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.17.5
3
+ Version: 1.18.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.17.5"
2
+ __version__ = "1.18.0"
@@ -45,7 +45,7 @@ import time
45
45
  from dataclasses import dataclass, field
46
46
  from datetime import datetime, timezone
47
47
  from pathlib import Path
48
- from typing import Any
48
+ from typing import Any, Awaitable, Callable
49
49
  from urllib.parse import urlparse
50
50
  from uuid import UUID
51
51
 
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
523
523
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
524
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
525
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.17.5"
526
+ DAEMON_VERSION = "1.18.0"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -6303,11 +6303,13 @@ class ServerConnection:
6303
6303
  api_token: str,
6304
6304
  daemon_id: str,
6305
6305
  hardware_id: str | None = None,
6306
+ local_dev_token_provider: Callable[[], Awaitable[str]] | None = None,
6306
6307
  ):
6307
6308
  self.server_url = server_url.rstrip("/")
6308
6309
  self.api_token = api_token
6309
6310
  self.daemon_id = daemon_id
6310
6311
  self.hardware_id = hardware_id
6312
+ self._local_dev_token_provider = local_dev_token_provider
6311
6313
  self.runtime_id: str | None = None
6312
6314
  self.client = httpx.AsyncClient(
6313
6315
  headers={"Authorization": f"Bearer {api_token}"} if api_token else {},
@@ -6322,7 +6324,12 @@ class ServerConnection:
6322
6324
  # Short label for logging
6323
6325
  from urllib.parse import urlparse
6324
6326
  parsed = urlparse(server_url)
6327
+ hostname = (parsed.hostname or "").lower()
6325
6328
  self.label = parsed.hostname or server_url
6329
+ self._allow_local_dev_token_fallback = (
6330
+ local_dev_token_provider is not None
6331
+ and hostname in {"localhost", "127.0.0.1", "::1"}
6332
+ )
6326
6333
 
6327
6334
  def _apply_api_token(self, token: str) -> bool:
6328
6335
  token = str(token or "").strip()
@@ -6339,6 +6346,22 @@ class ServerConnection:
6339
6346
  except OSError:
6340
6347
  return ""
6341
6348
 
6349
+ async def _apply_local_dev_token_fallback(self) -> bool:
6350
+ if not self._allow_local_dev_token_fallback or self._local_dev_token_provider is None:
6351
+ return False
6352
+
6353
+ try:
6354
+ token = str(await self._local_dev_token_provider() or "").strip()
6355
+ except Exception as exc:
6356
+ logger.debug("[%s] Local-dev token fallback failed: %s", self.label, exc)
6357
+ return False
6358
+
6359
+ if not self._apply_api_token(token):
6360
+ return False
6361
+
6362
+ logger.info("[%s] Falling back to a local-dev JWT for localhost registration", self.label)
6363
+ return True
6364
+
6342
6365
  async def refresh_access_token(self) -> bool:
6343
6366
  """Refresh the daemon's user session token.
6344
6367
 
@@ -6456,8 +6479,11 @@ class ServerConnection:
6456
6479
 
6457
6480
  try:
6458
6481
  resp = await _register_once()
6459
- if resp.status_code == 401 and await self.refresh_access_token():
6460
- resp = await _register_once()
6482
+ if resp.status_code == 401:
6483
+ if await self.refresh_access_token():
6484
+ resp = await _register_once()
6485
+ elif await self._apply_local_dev_token_fallback():
6486
+ resp = await _register_once()
6461
6487
  resp.raise_for_status()
6462
6488
  data = resp.json()
6463
6489
  self.runtime_id = data["runtime_id"]
@@ -6567,6 +6593,7 @@ class RuntimeDaemon:
6567
6593
  # prevents duplicate registrations when the hostname changes.
6568
6594
  self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
6569
6595
  self.server_urls = settings.get_daemon_server_urls()
6596
+ self._has_explicit_api_token = bool(str(settings.DAEMON_API_TOKEN or "").strip())
6570
6597
  self.api_token = settings.DAEMON_API_TOKEN
6571
6598
  # If no explicit token, try user JWT from ~/.forgexa/token (written by `forgexa login`)
6572
6599
  if not self.api_token:
@@ -6669,25 +6696,46 @@ class RuntimeDaemon:
6669
6696
  async def _mint_local_dev_token() -> str:
6670
6697
  """Mint a JWT for local development when no explicit token is configured.
6671
6698
 
6672
- Only works when running in-process with the backend package (``make daemon``).
6673
- Queries the first active user and creates a long-lived access token so the
6674
- runtime gets proper owner_id + organization_id assignment.
6699
+ Queries the first available user and creates a long-lived access token so
6700
+ the runtime gets proper owner_id + organization_id assignment.
6701
+
6702
+ Avoid importing app.core.security here: some local host-Python setups used
6703
+ by `make dev` / `make restart` do not have password-hashing extras like
6704
+ bcrypt installed, but they can still mint JWTs for localhost development.
6675
6705
  """
6676
6706
  try:
6677
- from app.core.security import create_access_token
6678
6707
  from app.database import engine
6679
6708
  from datetime import timedelta
6680
- from sqlalchemy import text
6709
+ from sqlalchemy import case, select
6710
+ from app.models.user import User
6711
+
6712
+ try:
6713
+ import jwt as jwt_module
6714
+ except Exception:
6715
+ from jose import jwt as jwt_module
6681
6716
 
6682
6717
  async with engine.connect() as conn:
6683
6718
  row = await conn.execute(
6684
- text("SELECT id FROM users WHERE is_active = true ORDER BY created_at LIMIT 1")
6719
+ select(User.id)
6720
+ .order_by(
6721
+ case((User.status == "active", 0), else_=1),
6722
+ User.created_at,
6723
+ )
6724
+ .limit(1)
6685
6725
  )
6686
6726
  r = row.first()
6687
6727
  uid = str(r[0]) if r else None
6688
6728
 
6689
6729
  if uid:
6690
- token = create_access_token({"sub": uid}, expires_delta=timedelta(days=30))
6730
+ token = jwt_module.encode(
6731
+ {
6732
+ "sub": uid,
6733
+ "type": "access",
6734
+ "exp": datetime.now(timezone.utc) + timedelta(days=30),
6735
+ },
6736
+ settings.SECRET_KEY,
6737
+ algorithm="HS256",
6738
+ )
6691
6739
  logger.info("Minted local-dev JWT for user %s (no DAEMON_API_TOKEN configured)", uid)
6692
6740
  return token
6693
6741
  logger.warning("No active users in database; cannot mint local-dev token")
@@ -6928,9 +6976,19 @@ class RuntimeDaemon:
6928
6976
  for url in self.server_urls:
6929
6977
  if any(c.server_url == url.rstrip("/") for c in self.connections):
6930
6978
  continue # Already connected to this server
6931
- conn = ServerConnection(url, self.api_token, self.daemon_id, self.hardware_id)
6979
+ conn = ServerConnection(
6980
+ url,
6981
+ self.api_token,
6982
+ self.daemon_id,
6983
+ self.hardware_id,
6984
+ local_dev_token_provider=(
6985
+ self._mint_local_dev_token
6986
+ if not self._has_explicit_api_token else None
6987
+ ),
6988
+ )
6932
6989
  try:
6933
6990
  await conn.register(self.agents, self.max_concurrent)
6991
+ self.api_token = conn.api_token
6934
6992
  conn.start_services(self.heartbeat_interval, self.poll_interval, self.agents)
6935
6993
  await conn.start_heartbeat()
6936
6994
  self.connections.append(conn)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.17.5
3
+ Version: 1.18.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.17.5"
3
+ version = "1.18.0"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes