forgexa-cli 1.17.6__tar.gz → 1.18.1__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.6
3
+ Version: 1.18.1
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.6"
2
+ __version__ = "1.18.1"
@@ -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
 
@@ -403,6 +403,10 @@ except (ImportError, ModuleNotFoundError):
403
403
  def DAEMON_API_TOKEN(self) -> str:
404
404
  return os.environ.get("DAEMON_API_TOKEN", "")
405
405
 
406
+ @property
407
+ def SECRET_KEY(self) -> str:
408
+ return os.environ.get("SECRET_KEY", "change-me-in-production")
409
+
406
410
  @property
407
411
  def DAEMON_POLL_INTERVAL(self) -> int:
408
412
  return int(os.environ.get("DAEMON_POLL_INTERVAL", "3"))
@@ -523,7 +527,7 @@ except (ImportError, ModuleNotFoundError):
523
527
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
528
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
529
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.17.6"
530
+ DAEMON_VERSION = "1.18.1"
527
531
 
528
532
 
529
533
  def _detect_client_type() -> str:
@@ -6303,11 +6307,13 @@ class ServerConnection:
6303
6307
  api_token: str,
6304
6308
  daemon_id: str,
6305
6309
  hardware_id: str | None = None,
6310
+ local_dev_token_provider: Callable[[], Awaitable[str]] | None = None,
6306
6311
  ):
6307
6312
  self.server_url = server_url.rstrip("/")
6308
6313
  self.api_token = api_token
6309
6314
  self.daemon_id = daemon_id
6310
6315
  self.hardware_id = hardware_id
6316
+ self._local_dev_token_provider = local_dev_token_provider
6311
6317
  self.runtime_id: str | None = None
6312
6318
  self.client = httpx.AsyncClient(
6313
6319
  headers={"Authorization": f"Bearer {api_token}"} if api_token else {},
@@ -6322,7 +6328,12 @@ class ServerConnection:
6322
6328
  # Short label for logging
6323
6329
  from urllib.parse import urlparse
6324
6330
  parsed = urlparse(server_url)
6331
+ hostname = (parsed.hostname or "").lower()
6325
6332
  self.label = parsed.hostname or server_url
6333
+ self._allow_local_dev_token_fallback = (
6334
+ local_dev_token_provider is not None
6335
+ and hostname in {"localhost", "127.0.0.1", "::1"}
6336
+ )
6326
6337
 
6327
6338
  def _apply_api_token(self, token: str) -> bool:
6328
6339
  token = str(token or "").strip()
@@ -6339,6 +6350,22 @@ class ServerConnection:
6339
6350
  except OSError:
6340
6351
  return ""
6341
6352
 
6353
+ async def _apply_local_dev_token_fallback(self) -> bool:
6354
+ if not self._allow_local_dev_token_fallback or self._local_dev_token_provider is None:
6355
+ return False
6356
+
6357
+ try:
6358
+ token = str(await self._local_dev_token_provider() or "").strip()
6359
+ except Exception as exc:
6360
+ logger.debug("[%s] Local-dev token fallback failed: %s", self.label, exc)
6361
+ return False
6362
+
6363
+ if not self._apply_api_token(token):
6364
+ return False
6365
+
6366
+ logger.info("[%s] Falling back to a local-dev JWT for localhost registration", self.label)
6367
+ return True
6368
+
6342
6369
  async def refresh_access_token(self) -> bool:
6343
6370
  """Refresh the daemon's user session token.
6344
6371
 
@@ -6456,8 +6483,11 @@ class ServerConnection:
6456
6483
 
6457
6484
  try:
6458
6485
  resp = await _register_once()
6459
- if resp.status_code == 401 and await self.refresh_access_token():
6460
- resp = await _register_once()
6486
+ if resp.status_code == 401:
6487
+ if await self.refresh_access_token():
6488
+ resp = await _register_once()
6489
+ elif await self._apply_local_dev_token_fallback():
6490
+ resp = await _register_once()
6461
6491
  resp.raise_for_status()
6462
6492
  data = resp.json()
6463
6493
  self.runtime_id = data["runtime_id"]
@@ -6567,6 +6597,7 @@ class RuntimeDaemon:
6567
6597
  # prevents duplicate registrations when the hostname changes.
6568
6598
  self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
6569
6599
  self.server_urls = settings.get_daemon_server_urls()
6600
+ self._has_explicit_api_token = bool(str(settings.DAEMON_API_TOKEN or "").strip())
6570
6601
  self.api_token = settings.DAEMON_API_TOKEN
6571
6602
  # If no explicit token, try user JWT from ~/.forgexa/token (written by `forgexa login`)
6572
6603
  if not self.api_token:
@@ -6669,25 +6700,46 @@ class RuntimeDaemon:
6669
6700
  async def _mint_local_dev_token() -> str:
6670
6701
  """Mint a JWT for local development when no explicit token is configured.
6671
6702
 
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.
6703
+ Queries the first available user and creates a long-lived access token so
6704
+ the runtime gets proper owner_id + organization_id assignment.
6705
+
6706
+ Avoid importing app.core.security here: some local host-Python setups used
6707
+ by `make dev` / `make restart` do not have password-hashing extras like
6708
+ bcrypt installed, but they can still mint JWTs for localhost development.
6675
6709
  """
6676
6710
  try:
6677
- from app.core.security import create_access_token
6678
6711
  from app.database import engine
6679
6712
  from datetime import timedelta
6680
- from sqlalchemy import text
6713
+ from sqlalchemy import case, select
6714
+ from app.models.user import User
6715
+
6716
+ try:
6717
+ import jwt as jwt_module
6718
+ except Exception:
6719
+ from jose import jwt as jwt_module
6681
6720
 
6682
6721
  async with engine.connect() as conn:
6683
6722
  row = await conn.execute(
6684
- text("SELECT id FROM users WHERE is_active = true ORDER BY created_at LIMIT 1")
6723
+ select(User.id)
6724
+ .order_by(
6725
+ case((User.status == "active", 0), else_=1),
6726
+ User.created_at,
6727
+ )
6728
+ .limit(1)
6685
6729
  )
6686
6730
  r = row.first()
6687
6731
  uid = str(r[0]) if r else None
6688
6732
 
6689
6733
  if uid:
6690
- token = create_access_token({"sub": uid}, expires_delta=timedelta(days=30))
6734
+ token = jwt_module.encode(
6735
+ {
6736
+ "sub": uid,
6737
+ "type": "access",
6738
+ "exp": datetime.now(timezone.utc) + timedelta(days=30),
6739
+ },
6740
+ settings.SECRET_KEY,
6741
+ algorithm="HS256",
6742
+ )
6691
6743
  logger.info("Minted local-dev JWT for user %s (no DAEMON_API_TOKEN configured)", uid)
6692
6744
  return token
6693
6745
  logger.warning("No active users in database; cannot mint local-dev token")
@@ -6928,9 +6980,19 @@ class RuntimeDaemon:
6928
6980
  for url in self.server_urls:
6929
6981
  if any(c.server_url == url.rstrip("/") for c in self.connections):
6930
6982
  continue # Already connected to this server
6931
- conn = ServerConnection(url, self.api_token, self.daemon_id, self.hardware_id)
6983
+ conn = ServerConnection(
6984
+ url,
6985
+ self.api_token,
6986
+ self.daemon_id,
6987
+ self.hardware_id,
6988
+ local_dev_token_provider=(
6989
+ self._mint_local_dev_token
6990
+ if not self._has_explicit_api_token else None
6991
+ ),
6992
+ )
6932
6993
  try:
6933
6994
  await conn.register(self.agents, self.max_concurrent)
6995
+ self.api_token = conn.api_token
6934
6996
  conn.start_services(self.heartbeat_interval, self.poll_interval, self.agents)
6935
6997
  await conn.start_heartbeat()
6936
6998
  self.connections.append(conn)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.17.6
3
+ Version: 1.18.1
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.6"
3
+ version = "1.18.1"
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