forgexa-cli 1.15.7__tar.gz → 1.16.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.15.7
3
+ Version: 1.16.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.15.7"
2
+ __version__ = "1.16.0"
@@ -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.15.7"
526
+ DAEMON_VERSION = "1.16.0"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -558,7 +558,9 @@ _CLIENT_TYPE = _detect_client_type()
558
558
  # Set DAEMON_NO_CONSOLE_LOG=1 to suppress the stream handler even on a TTY
559
559
  # (used by `forgexa daemon start` foreground mode for clean UX).
560
560
  _log_dir = Path.home() / ".forgexa" / "daemon"
561
- _log_dir.mkdir(parents=True, exist_ok=True)
561
+ _log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
562
+ # Ensure directory has restricted permissions even if it already existed
563
+ _log_dir.chmod(0o700)
562
564
  DAEMON_LOG_PATH = _log_dir / "daemon.log"
563
565
 
564
566
  _log_handlers: list[logging.Handler] = [
@@ -568,6 +570,9 @@ _log_handlers: list[logging.Handler] = [
568
570
  backupCount=5,
569
571
  ),
570
572
  ]
573
+ # L-4: Restrict log file to owner-read/write only (shared-host protection)
574
+ if DAEMON_LOG_PATH.exists():
575
+ DAEMON_LOG_PATH.chmod(0o600)
571
576
  if sys.stderr.isatty() and not os.environ.get("DAEMON_NO_CONSOLE_LOG"):
572
577
  _log_handlers.append(logging.StreamHandler(sys.stderr))
573
578
 
@@ -3182,7 +3187,10 @@ class WorkspaceManager:
3182
3187
  key_content = self._ssh_keys.get(project_key)
3183
3188
  if key_content:
3184
3189
  import stat as stat_mod
3185
- fd, key_path = tempfile.mkstemp(prefix="forgexa_ssh_", suffix=".key")
3190
+ # F-9: Use a dedicated directory with restricted permissions for SSH keys
3191
+ _ssh_dir = Path.home() / ".forgexa" / "ssh"
3192
+ _ssh_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
3193
+ fd, key_path = tempfile.mkstemp(prefix="forgexa_ssh_", suffix=".key", dir=str(_ssh_dir))
3186
3194
  try:
3187
3195
  os.write(fd, key_content.encode() if isinstance(key_content, str) else key_content)
3188
3196
  if not key_content.rstrip().endswith("\n"):
@@ -478,8 +478,8 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
478
478
  # ── Update-check helpers ──────────────────────────────────────────────────────
479
479
  #
480
480
  # Strategy (borrowed from gh CLI / update-notifier):
481
- # 1. At the start of main(): launch a daemon thread that calls PyPI and
482
- # writes ~/.forgexa/update-check.json (only when the cache is stale).
481
+ # 1. At the start of main(): launch a detached Python worker that calls PyPI
482
+ # and writes ~/.forgexa/update-check.json (only when the cache is stale).
483
483
  # 2. At the end of main(): read the cache (instant) and, if a newer version
484
484
  # is available, print one notice to stderr.
485
485
  #
@@ -534,31 +534,44 @@ def _version_is_newer(candidate: str, reference: str) -> bool:
534
534
  return False
535
535
 
536
536
 
537
- def _launch_update_check_thread() -> None:
538
- """Start a fire-and-forget daemon thread that fetches the latest PyPI version.
537
+ def _run_update_check() -> None:
538
+ """Fetch the latest published CLI version and refresh the local cache."""
539
+ try:
540
+ import httpx
541
+
542
+ with httpx.Client(timeout=5) as client:
543
+ resp = client.get("https://pypi.org/pypi/forgexa-cli/json")
544
+ resp.raise_for_status()
545
+ latest = str(resp.json()["info"]["version"])
546
+ _write_update_cache(latest)
547
+ except Exception:
548
+ pass # Network errors are silently ignored
539
549
 
540
- The thread only runs if the cache is stale (> TTL). Because it is a daemon
541
- thread the OS kills it automatically when the main process exits, so it never
542
- delays shutdown.
550
+
551
+ def _launch_update_check_process() -> None:
552
+ """Start a detached worker process that refreshes the update cache.
553
+
554
+ Short-lived commands like `forgexa version` can exit before a daemon thread
555
+ finishes its PyPI request. A separate Python process survives long enough to
556
+ populate the cache without blocking the foreground command.
543
557
  """
544
558
  _, checked_at = _read_update_cache()
545
559
  if time.time() - checked_at <= _UPDATE_CHECK_TTL:
546
560
  return # Cache is fresh — nothing to do
547
561
 
548
- import threading
549
-
550
- def _worker() -> None:
551
- try:
552
- import httpx
553
- with httpx.Client(timeout=5) as client:
554
- resp = client.get("https://pypi.org/pypi/forgexa-cli/json")
555
- latest = str(resp.json()["info"]["version"])
556
- _write_update_cache(latest)
557
- except Exception:
558
- pass # Network errors are silently ignored
559
-
560
- t = threading.Thread(target=_worker, daemon=True, name="forgexa-update-check")
561
- t.start()
562
+ try:
563
+ subprocess.Popen(
564
+ [
565
+ sys.executable,
566
+ "-c",
567
+ "from forgexa_cli.main import _run_update_check; _run_update_check()",
568
+ ],
569
+ stdin=subprocess.DEVNULL,
570
+ stdout=subprocess.DEVNULL,
571
+ stderr=subprocess.DEVNULL,
572
+ )
573
+ except Exception:
574
+ pass
562
575
 
563
576
 
564
577
  def _print_update_notice_if_available() -> None:
@@ -1750,7 +1763,7 @@ def main() -> None:
1750
1763
  and getattr(args, "follow", False)
1751
1764
  )
1752
1765
  if not _skip_update_check:
1753
- _launch_update_check_thread()
1766
+ _launch_update_check_process()
1754
1767
 
1755
1768
  dispatch: dict = {
1756
1769
  "version": cmd_version,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.15.7
3
+ Version: 1.16.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.15.7"
3
+ version = "1.16.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"
@@ -207,6 +207,65 @@ def test_normalize_target_version_rejects_invalid_input() -> None:
207
207
  main._normalize_target_version("../../1.2.3")
208
208
 
209
209
 
210
+ def test_run_update_check_writes_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
211
+ monkeypatch.setattr(main, "_UPDATE_CACHE_PATH", tmp_path / "update-check.json")
212
+
213
+ class FakeResponse:
214
+ def raise_for_status(self) -> None:
215
+ return None
216
+
217
+ def json(self) -> dict:
218
+ return {"info": {"version": "1.15.7"}}
219
+
220
+ class FakeClient:
221
+ def __init__(self, **kwargs):
222
+ assert kwargs["timeout"] == 5
223
+
224
+ def __enter__(self):
225
+ return self
226
+
227
+ def __exit__(self, *args):
228
+ return False
229
+
230
+ def get(self, url: str) -> FakeResponse:
231
+ assert url == "https://pypi.org/pypi/forgexa-cli/json"
232
+ return FakeResponse()
233
+
234
+ monkeypatch.setattr(httpx, "Client", FakeClient)
235
+
236
+ main._run_update_check()
237
+
238
+ payload = json.loads((tmp_path / "update-check.json").read_text())
239
+ assert payload["latest_version"] == "1.15.7"
240
+ assert payload["checked_at"] > 0
241
+
242
+
243
+ def test_launch_update_check_process_spawns_background_python_when_cache_is_stale(
244
+ monkeypatch: pytest.MonkeyPatch,
245
+ ) -> None:
246
+ calls: list[tuple[list[str], dict]] = []
247
+
248
+ monkeypatch.setattr(main, "_read_update_cache", lambda: (None, 0.0))
249
+ monkeypatch.setattr(main.time, "time", lambda: float(main._UPDATE_CHECK_TTL + 10))
250
+ monkeypatch.setattr(main.sys, "executable", "/usr/bin/python3")
251
+
252
+ def fake_popen(cmd: list[str], **kwargs):
253
+ calls.append((cmd, kwargs))
254
+ return SimpleNamespace()
255
+
256
+ monkeypatch.setattr(main.subprocess, "Popen", fake_popen)
257
+
258
+ main._launch_update_check_process()
259
+
260
+ assert len(calls) == 1
261
+ cmd, kwargs = calls[0]
262
+ assert cmd[:2] == ["/usr/bin/python3", "-c"]
263
+ assert cmd[2] == "from forgexa_cli.main import _run_update_check; _run_update_check()"
264
+ assert kwargs["stdin"] is main.subprocess.DEVNULL
265
+ assert kwargs["stdout"] is main.subprocess.DEVNULL
266
+ assert kwargs["stderr"] is main.subprocess.DEVNULL
267
+
268
+
210
269
  def test_build_upgrade_plan_for_pipx_latest(monkeypatch: pytest.MonkeyPatch) -> None:
211
270
  monkeypatch.setattr(main, "_cli_distribution", lambda: SimpleNamespace(version="1.12.3"))
212
271
  monkeypatch.setattr(main, "_distribution_root", lambda dist: Path("/installed/site-packages"))
File without changes
File without changes