forgexa-cli 1.41.5__tar.gz → 1.42.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.
Files changed (27) hide show
  1. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/PKG-INFO +1 -1
  2. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/agent_core.py +233 -88
  4. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/daemon.py +104 -50
  5. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/pyproject.toml +1 -1
  7. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/README.md +0 -0
  8. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  14. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  15. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
  16. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/requires.txt +0 -0
  17. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/top_level.txt +0 -0
  18. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/setup.cfg +0 -0
  19. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_auth_and_runtime_commands.py +0 -0
  20. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_autoupgrade.py +0 -0
  21. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_check_command.py +0 -0
  22. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
  23. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_local_bind_commands.py +0 -0
  24. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_runtime_credentials.py +0 -0
  25. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_session_credentials.py +0 -0
  26. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_silent_install.py +0 -0
  27. {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.41.5
3
+ Version: 1.42.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.41.5"
2
+ __version__ = "1.42.1"
@@ -41,11 +41,15 @@ if sys.version_info < (3, 9): # noqa: UP036 - gate guards 3.9+ runtime on end-u
41
41
 
42
42
  import asyncio
43
43
  import base64
44
+ import errno
45
+ import hashlib
44
46
  import json
45
47
  import logging
46
48
  import os
47
49
  import shutil
48
50
  import subprocess
51
+ import tempfile
52
+ import time
49
53
  from dataclasses import dataclass
50
54
  from pathlib import Path
51
55
  from typing import Any
@@ -223,14 +227,14 @@ def _agent_process_group_kwargs() -> dict[str, int | bool]:
223
227
  def _parse_semver(v: str) -> tuple[int, int, int] | None:
224
228
  """Parse a semver string into a comparable (major, minor, patch) tuple.
225
229
 
226
- Strips a leading 'v' and ignores pre-release/build suffixes so that
227
- '1.4.0-beta.1' and 'v1.4.0' both return (1, 4, 0).
230
+ Finds an embedded version and ignores pre-release/build suffixes so that
231
+ 'GitHub Copilot CLI 1.0.81.' and 'v1.4.0-beta.1' are both supported.
228
232
  Returns None when the string cannot be parsed (e.g. 'unknown', 'latest'),
229
233
  so callers can skip the version check rather than erroneously rejecting
230
234
  a valid agent whose version string is non-standard.
231
235
  """
232
236
  import re as _re
233
- m = _re.match(r"^v?(\d+)\.(\d+)\.(\d+)", v.strip())
237
+ m = _re.search(r"(?<!\d)v?(\d+)\.(\d+)\.(\d+)", v.strip())
234
238
  if m:
235
239
  return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
236
240
  return None
@@ -241,10 +245,9 @@ def _check_agent_min_version(agent_id: str, version: str, spec: dict) -> tuple[b
241
245
 
242
246
  Returns (min_version_ok, version_warning). version_warning is empty
243
247
  whenever there's nothing to warn about: no minimum is defined for this
244
- agent (``spec`` has no ``min_version`` key), the version string couldn't
245
- be parsed (never treated as a hard failure -- unparseable versions are
246
- skipped, not rejected, matching the pre-existing opencode behavior), or
247
- the installed version already meets the minimum.
248
+ agent, an optional minimum has an unparseable version, or the installed
249
+ version meets the minimum. ``strict_min_version`` makes an unparseable
250
+ version a hard failure for integrations with known incompatible releases.
248
251
 
249
252
  This is the single source of truth for per-agent minimum-version gating,
250
253
  consumed by both AgentDiscovery.discover() (so `forgexa check` and the
@@ -257,6 +260,14 @@ def _check_agent_min_version(agent_id: str, version: str, spec: dict) -> tuple[b
257
260
  return True, ""
258
261
  parsed = _parse_semver(version)
259
262
  if parsed is None:
263
+ if spec.get("strict_min_version"):
264
+ min_str = ".".join(str(x) for x in min_version)
265
+ upgrade_hint = spec.get("upgrade_hint", f"upgrade the {agent_id} CLI")
266
+ return (
267
+ False,
268
+ f"Could not verify that {agent_id} meets the minimum supported "
269
+ f"version v{min_str}+. Fix: {upgrade_hint}.",
270
+ )
260
271
  return True, ""
261
272
  if parsed >= tuple(min_version):
262
273
  return True, ""
@@ -337,6 +348,13 @@ class AgentDiscovery:
337
348
  "invoke_modes": ["cli"],
338
349
  "env_path_override": "FACTORY_COPILOT_PATH",
339
350
  "compatibility_level": "L3",
351
+ "min_version": (1, 0, 70),
352
+ "strict_min_version": True,
353
+ "min_version_reason": (
354
+ "Older releases have known authentication persistence bugs in "
355
+ "one-shot prompt sessions and across runtime restarts."
356
+ ),
357
+ "upgrade_hint": "copilot update",
340
358
  },
341
359
  }
342
360
 
@@ -634,23 +652,50 @@ class AgentDiscovery:
634
652
  # ── Advisory file locks ──
635
653
 
636
654
 
637
- def acquire_file_lock(path: str | Path):
638
- """Open ``path`` and take a non-blocking exclusive OS-level lock on it.
639
-
640
- POSIX: ``fcntl.flock(LOCK_EX|LOCK_NB)``; Windows: ``msvcrt.locking`` with
641
- ``LK_NBLCK``. Returns the open file handle to hold for the lock's
642
- duration, or ``None`` when another process already holds the lock.
655
+ def acquire_file_lock(
656
+ path: str | Path,
657
+ *,
658
+ blocking: bool = False,
659
+ timeout_seconds: float = 10.0,
660
+ ):
661
+ """Open ``path`` and take an exclusive OS-level lock on it.
662
+
663
+ By default the lock is non-blocking. Set ``blocking=True`` for short,
664
+ bounded serialized state updates. Returns the open file handle to hold for
665
+ the lock's duration, or ``None`` when the lock cannot be acquired in time.
643
666
  Callers must release via :func:`release_file_lock` in a ``finally`` block.
644
667
  """
645
668
  lock_path = Path(path)
646
669
  lock_path.parent.mkdir(parents=True, exist_ok=True)
647
- lock_file = open(lock_path, "w")
670
+ lock_file = open(lock_path, "a+b")
648
671
  try:
672
+ lock_file.seek(0, os.SEEK_END)
673
+ if lock_file.tell() == 0:
674
+ lock_file.write(b"\0")
675
+ lock_file.flush()
676
+ lock_file.seek(0)
677
+ deadline = time.monotonic() + timeout_seconds if blocking else None
649
678
  if sys.platform == "win32":
650
679
  import msvcrt
651
- msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
652
- else:
653
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
680
+ while True:
681
+ try:
682
+ if sys.platform == "win32":
683
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
684
+ else:
685
+ fcntl.flock(
686
+ lock_file.fileno(),
687
+ fcntl.LOCK_EX | fcntl.LOCK_NB,
688
+ )
689
+ break
690
+ except OSError as exc:
691
+ if (
692
+ not blocking
693
+ or exc.errno not in {errno.EACCES, errno.EAGAIN}
694
+ or deadline is None
695
+ or time.monotonic() >= deadline
696
+ ):
697
+ raise
698
+ time.sleep(0.05)
654
699
  except OSError:
655
700
  lock_file.close()
656
701
  return None
@@ -668,10 +713,13 @@ def release_file_lock(lock_file) -> None:
668
713
  msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
669
714
  else:
670
715
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
671
- except OSError:
716
+ except Exception:
672
717
  pass
673
718
  finally:
674
- lock_file.close()
719
+ try:
720
+ lock_file.close()
721
+ except Exception:
722
+ pass
675
723
 
676
724
 
677
725
  # ── Process Manager ──
@@ -1721,6 +1769,87 @@ def copilot_base_env(env: dict[str, str], *, copilot_home: str | Path) -> dict[s
1721
1769
  return env
1722
1770
 
1723
1771
 
1772
+ def copilot_auth_state_signature(copilot_home: str | Path) -> str | None:
1773
+ """Return a content signature for Copilot's persisted authentication state."""
1774
+ config_file = Path(copilot_home) / "config.json"
1775
+ if not config_file.exists():
1776
+ return ""
1777
+ try:
1778
+ return hashlib.sha256(config_file.read_bytes()).hexdigest()
1779
+ except OSError:
1780
+ logger.warning("Could not read Copilot authentication state", exc_info=True)
1781
+ return None
1782
+
1783
+
1784
+ def _copilot_auth_lock_path(copilot_home: str | Path) -> Path:
1785
+ home = Path(copilot_home)
1786
+ return home.with_name(f".{home.name}-auth-state.lock")
1787
+
1788
+
1789
+ def persist_copilot_auth_state(
1790
+ run_home: str | Path,
1791
+ persistent_home: str | Path,
1792
+ baseline_signature: str | None = None,
1793
+ ) -> None:
1794
+ """Persist credentials refreshed inside an isolated Copilot home.
1795
+
1796
+ Copilot stores credentials in ``config.json`` when no system credential
1797
+ store is available. Session data must remain isolated because reusing it
1798
+ can make retries inherit completed todos from an earlier task.
1799
+ """
1800
+ lock_file = None
1801
+ temp_name: str | None = None
1802
+ try:
1803
+ source = Path(run_home) / "config.json"
1804
+ if not source.is_file():
1805
+ return
1806
+ source_signature = copilot_auth_state_signature(run_home)
1807
+ if baseline_signature is not None and source_signature == baseline_signature:
1808
+ return
1809
+
1810
+ destination_dir = Path(persistent_home)
1811
+ destination_dir.mkdir(parents=True, exist_ok=True)
1812
+ lock_file = acquire_file_lock(
1813
+ _copilot_auth_lock_path(destination_dir),
1814
+ blocking=True,
1815
+ )
1816
+ if lock_file is None:
1817
+ logger.warning("Could not lock Copilot authentication state for update")
1818
+ return
1819
+
1820
+ destination = destination_dir / source.name
1821
+ destination_signature = copilot_auth_state_signature(destination_dir)
1822
+ if destination_signature == source_signature:
1823
+ return
1824
+ if baseline_signature is None and destination.is_file():
1825
+ if destination.stat().st_mtime_ns >= source.stat().st_mtime_ns:
1826
+ return
1827
+
1828
+ temp_fd, temp_name = tempfile.mkstemp(prefix=".config.json.", dir=destination_dir)
1829
+ os.close(temp_fd)
1830
+ shutil.copy2(source, temp_name)
1831
+ os.chmod(temp_name, 0o600)
1832
+ os.utime(temp_name, None)
1833
+ for attempt in range(3):
1834
+ try:
1835
+ os.replace(temp_name, destination)
1836
+ temp_name = None
1837
+ break
1838
+ except PermissionError:
1839
+ if sys.platform != "win32" or attempt == 2:
1840
+ raise
1841
+ time.sleep(0.05)
1842
+ except OSError:
1843
+ logger.warning("Could not persist refreshed Copilot credentials", exc_info=True)
1844
+ finally:
1845
+ if temp_name is not None:
1846
+ try:
1847
+ Path(temp_name).unlink(missing_ok=True)
1848
+ except OSError:
1849
+ logger.debug("Could not remove temporary Copilot config", exc_info=True)
1850
+ release_file_lock(lock_file)
1851
+
1852
+
1724
1853
  def prepare_copilot_home() -> str:
1725
1854
  """Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
1726
1855
 
@@ -1733,80 +1862,96 @@ def prepare_copilot_home() -> str:
1733
1862
  Mirror the user-facing ~/.copilot state into a writable daemon-owned
1734
1863
  directory, then point COPILOT_HOME there for non-interactive runs.
1735
1864
 
1736
- Stale-auth cleanup: Before the per-task isolation fix, the daemon
1737
- pointed COPILOT_HOME directly at this master directory, so Copilot
1738
- wrote its own OAuth token (plain-text on Linux systems without a
1739
- credential store) here. Those files are NOT present in ~/.copilot/
1740
- (the user's interactive home), so the normal mirror loop never
1741
- overwrites them — they persist across restarts and poison isolated
1742
- temp dirs. We clean up any file/dir in the master home that has no
1743
- counterpart in ~/.copilot/ (excluding session-state and logs which
1744
- are deliberately retained) to ensure the master home stays a clean
1745
- mirror of the user-facing directory.
1865
+ A one-time migration removes stale files left when older daemons pointed
1866
+ COPILOT_HOME directly at the master directory. After migration, refreshed
1867
+ authentication state written by isolated runs is retained even when it
1868
+ has no counterpart in the interactive home.
1746
1869
  """
1747
1870
  source_root = Path.home() / ".copilot"
1748
1871
  target_root = Path.home() / ".forgexa" / "copilot-home"
1749
- target_root.mkdir(parents=True, exist_ok=True)
1750
-
1751
- if not source_root.exists():
1752
- return str(target_root)
1753
-
1754
- # P3-3.5: Prune old session-state WAL/SHM files and log files > 7 days.
1755
- # These accumulate indefinitely without cleanup.
1756
- import time as _time
1757
- _prune_cutoff = _time.time() - 7 * 86400 # 7 days
1758
- for _prune_dir_name in ("session-state", "logs"):
1759
- _prune_dir = target_root / _prune_dir_name
1760
- if not _prune_dir.is_dir():
1761
- continue
1762
- for _old_file in _prune_dir.iterdir():
1763
- try:
1764
- if _old_file.is_file() and _old_file.stat().st_mtime < _prune_cutoff:
1765
- _old_file.unlink(missing_ok=True)
1766
- logger.debug("Pruned old Copilot home file: %s", _old_file)
1767
- except Exception as _prune_err:
1768
- logger.debug("Could not prune %s: %s", _old_file, _prune_err)
1769
-
1770
- # Collect names present in source for stale-file detection.
1771
- source_names: set[str] = {child.name for child in source_root.iterdir()}
1772
-
1773
- # Remove master-home entries that are absent from ~/.copilot/.
1774
- # These are typically old Copilot OAuth token files written when
1775
- # COPILOT_HOME pointed here directly (pre-isolation-fix era).
1776
- _keep_always = {"session-state", "logs"}
1777
- for target_child in list(target_root.iterdir()):
1778
- if target_child.name in _keep_always:
1779
- continue
1780
- if target_child.name not in source_names:
1781
- try:
1782
- if target_child.is_dir():
1783
- shutil.rmtree(str(target_child), ignore_errors=True)
1784
- else:
1785
- target_child.unlink(missing_ok=True)
1786
- logger.debug("Copilot home: removed stale %s", target_child.name)
1787
- except Exception as exc:
1788
- logger.debug("Copilot home: could not remove stale %s: %s", target_child.name, exc)
1872
+ lock_file = None
1873
+ try:
1874
+ target_root.mkdir(parents=True, exist_ok=True)
1875
+ lock_file = acquire_file_lock(
1876
+ _copilot_auth_lock_path(target_root),
1877
+ blocking=True,
1878
+ )
1879
+ if lock_file is None:
1880
+ logger.warning("Could not lock Copilot home for synchronization")
1881
+ return str(target_root)
1882
+ if not source_root.exists():
1883
+ return str(target_root)
1884
+
1885
+ prune_cutoff = time.time() - 7 * 86400
1886
+ for prune_dir_name in ("session-state", "logs"):
1887
+ prune_dir = target_root / prune_dir_name
1888
+ if not prune_dir.is_dir():
1889
+ continue
1890
+ for old_file in prune_dir.iterdir():
1891
+ try:
1892
+ if old_file.is_file() and old_file.stat().st_mtime < prune_cutoff:
1893
+ old_file.unlink(missing_ok=True)
1894
+ logger.debug("Pruned old Copilot home file: %s", old_file)
1895
+ except OSError as exc:
1896
+ logger.debug("Could not prune %s: %s", old_file, exc)
1897
+
1898
+ source_names = {child.name for child in source_root.iterdir()}
1899
+ migration_marker = target_root.parent / ".copilot-stale-auth-cleaned-v2"
1900
+ if not migration_marker.exists():
1901
+ cleanup_succeeded = True
1902
+ for target_child in list(target_root.iterdir()):
1903
+ if target_child.name in {"session-state", "logs"}:
1904
+ continue
1905
+ if target_child.name not in source_names:
1906
+ try:
1907
+ if target_child.is_dir():
1908
+ shutil.rmtree(str(target_child))
1909
+ else:
1910
+ target_child.unlink(missing_ok=True)
1911
+ logger.debug("Copilot home: removed stale %s", target_child.name)
1912
+ except OSError as exc:
1913
+ cleanup_succeeded = False
1914
+ logger.debug(
1915
+ "Copilot home: could not remove stale %s: %s",
1916
+ target_child.name,
1917
+ exc,
1918
+ )
1919
+ if cleanup_succeeded:
1920
+ migration_marker.touch(exist_ok=True)
1921
+ os.chmod(migration_marker, 0o600)
1789
1922
 
1790
- for child in source_root.iterdir():
1791
- if child.name in {"logs", "session-state"}:
1792
- continue
1923
+ for child in source_root.iterdir():
1924
+ if child.name in {"logs", "session-state"}:
1925
+ continue
1793
1926
 
1794
- dest = target_root / child.name
1795
- try:
1796
- if child.is_dir():
1797
- if child.name != "ide":
1798
- continue
1799
- dest.mkdir(parents=True, exist_ok=True)
1800
- for sub in child.iterdir():
1801
- if not sub.is_file() or sub.name.endswith(".lock"):
1927
+ dest = target_root / child.name
1928
+ try:
1929
+ if child.is_dir():
1930
+ if child.name != "ide":
1802
1931
  continue
1803
- shutil.copy2(sub, dest / sub.name)
1804
- elif child.is_file():
1805
- shutil.copy2(child, dest)
1806
- except Exception as exc:
1807
- logger.debug(
1808
- "Copilot home mirror skipped %s -> %s: %s",
1809
- child, dest, exc,
1810
- )
1932
+ dest.mkdir(parents=True, exist_ok=True)
1933
+ for sub in child.iterdir():
1934
+ if not sub.is_file() or sub.name.endswith(".lock"):
1935
+ continue
1936
+ shutil.copy2(sub, dest / sub.name)
1937
+ elif child.is_file():
1938
+ if (
1939
+ child.name == "config.json"
1940
+ and dest.is_file()
1941
+ and dest.stat().st_mtime_ns >= child.stat().st_mtime_ns
1942
+ ):
1943
+ continue
1944
+ shutil.copy2(child, dest)
1945
+ except OSError as exc:
1946
+ logger.debug(
1947
+ "Copilot home mirror skipped %s -> %s: %s",
1948
+ child,
1949
+ dest,
1950
+ exc,
1951
+ )
1952
+ except OSError:
1953
+ logger.warning("Could not prepare writable Copilot home", exc_info=True)
1954
+ finally:
1955
+ release_file_lock(lock_file)
1811
1956
 
1812
1957
  return str(target_root)
@@ -910,7 +910,7 @@ except (ImportError, ModuleNotFoundError):
910
910
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
911
911
  # Kept in sync with pyproject.toml version via bump-version.sh.
912
912
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
913
- DAEMON_VERSION = "1.41.5"
913
+ DAEMON_VERSION = "1.42.1"
914
914
 
915
915
 
916
916
  def _detect_client_type() -> str:
@@ -972,6 +972,9 @@ logging.basicConfig(
972
972
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
973
973
  handlers=_log_handlers,
974
974
  )
975
+ # Routine poll/heartbeat requests otherwise consume the entire 500-line remote
976
+ # log tail in about 20 minutes, hiding the task failure operators need to see.
977
+ logging.getLogger("httpx").setLevel(logging.WARNING)
975
978
  logger = logging.getLogger("daemon")
976
979
  logger.info("Daemon log: %s", DAEMON_LOG_PATH)
977
980
 
@@ -984,6 +987,29 @@ def _format_exc(exc: BaseException) -> str:
984
987
  return f"{type(exc).__name__}: {detail}" if detail else type(exc).__name__
985
988
 
986
989
 
990
+ def _safe_agent_error_detail(value: object) -> str:
991
+ """Return a bounded agent error detail without credentials or tokens."""
992
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
993
+ text = re.sub(
994
+ r"(?i)\b(authorization|api[_-]?key|token|password|secret)\b"
995
+ r"(\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+",
996
+ r"\1\2[redacted]",
997
+ text,
998
+ )
999
+ text = re.sub(
1000
+ r"(?i)([?&](?:authorization|api[_-]?key|token|password|secret)=)[^&\s]+",
1001
+ r"\1[redacted]",
1002
+ text,
1003
+ )
1004
+ text = re.sub(r"(?i)\bbearer\s+[^\s,;]+", "Bearer [redacted]", text)
1005
+ text = re.sub(
1006
+ r"(?i)\b(?:github_pat_[a-z0-9_]+|gh[pousr]_[a-z0-9]+)\b",
1007
+ "[redacted-github-token]",
1008
+ text,
1009
+ )
1010
+ return text[:1500]
1011
+
1012
+
987
1013
  # ── Hardware ID — stable cross-IP machine fingerprint ──
988
1014
 
989
1015
 
@@ -5034,7 +5060,10 @@ class ProcessManager:
5034
5060
  # ── Copilot: JSONL mode but no turn completion and no content ──
5035
5061
  if agent_id == "copilot" and json_line_count > 0:
5036
5062
  if not has_result and not has_meaningful_content and not has_assistant_events:
5037
- return "Copilot produced no result output (check GitHub authentication: run 'gh auth login')"
5063
+ return (
5064
+ "Copilot produced no result output "
5065
+ "(check authentication: run 'copilot login')"
5066
+ )
5038
5067
 
5039
5068
  return None
5040
5069
 
@@ -5972,8 +6001,8 @@ class ProcessManager:
5972
6001
  """Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
5973
6002
 
5974
6003
  Uses TERM=dumb to suppress TTY-detection (copilot suspends when it
5975
- can't acquire a pseudo-terminal). Requires GitHub login:
5976
- ``gh auth login`` or GITHUB_TOKEN env var must be set.
6004
+ can't acquire a pseudo-terminal). Requires ``copilot login`` or an
6005
+ explicitly configured supported token environment variable.
5977
6006
 
5978
6007
  Flags:
5979
6008
  -p / --prompt Non-interactive prompt (exits after completion).
@@ -6003,11 +6032,13 @@ class ProcessManager:
6003
6032
  # session-state persists from the prior attempt all todos are already
6004
6033
  # "done" and the agent exits immediately without producing any output.
6005
6034
  _is_outer_call = _isolated_copilot_home is None
6035
+ _master_copilot_home: str | None = None
6036
+ _copilot_auth_baseline: str | None = None
6006
6037
  if _is_outer_call:
6007
- master_home = self._prepare_copilot_home()
6038
+ _master_copilot_home = await asyncio.to_thread(self._prepare_copilot_home)
6008
6039
  _isolated_copilot_home = tempfile.mkdtemp(prefix=f"copilot-{task_id[:8]}-")
6009
6040
  try:
6010
- for child in Path(master_home).iterdir():
6041
+ for child in Path(_master_copilot_home).iterdir():
6011
6042
  if child.name in {"session-state", "logs"}:
6012
6043
  continue
6013
6044
  dest = Path(_isolated_copilot_home) / child.name
@@ -6020,42 +6051,27 @@ class ProcessManager:
6020
6051
  logger.debug("Copilot home copy %s -> %s: %s", child, dest, _cp_exc)
6021
6052
  except Exception as _iter_exc:
6022
6053
  logger.debug("Copilot home isolation warning: %s", _iter_exc)
6054
+ _copilot_auth_baseline = agent_core.copilot_auth_state_signature(
6055
+ _isolated_copilot_home,
6056
+ )
6023
6057
 
6024
6058
  env = agent_core.copilot_base_env(
6025
6059
  os.environ.copy(),
6026
6060
  copilot_home=_isolated_copilot_home,
6027
6061
  )
6028
-
6029
- # Inject GH_TOKEN from the gh CLI for headless/automation auth.
6030
- # Per `copilot login --help`, env-var auth is the recommended method
6031
- # for automation: COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN.
6032
- # GH_TOKEN accepts OAuth tokens from the GitHub CLI (gh) app, which
6033
- # is exactly what `gh auth token` returns.
6034
- #
6035
- # This bypasses any stale Copilot-specific OAuth token file that may
6036
- # be cached in COPILOT_HOME from an earlier run (e.g., after a
6037
- # password change when `gh auth login` refreshes gh's token but does
6038
- # NOT update Copilot's own stored token). After `gh auth login` the
6039
- # daemon works again without requiring `copilot login` or manual
6040
- # deletion of ~/.forgexa/copilot-home/.
6041
- if not any(env.get(k) for k in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")):
6042
- try:
6043
- _gh_proc = await asyncio.create_subprocess_exec(
6044
- "gh", "auth", "token",
6045
- stdout=asyncio.subprocess.PIPE,
6046
- stderr=asyncio.subprocess.DEVNULL,
6047
- stdin=asyncio.subprocess.DEVNULL,
6048
- )
6049
- _gh_token_out, _ = await asyncio.wait_for(_gh_proc.communicate(), timeout=10.0)
6050
- _gh_token = (_gh_token_out or b"").decode().strip()
6051
- if _gh_token:
6052
- env["GH_TOKEN"] = _gh_token
6053
- logger.debug("Copilot task %s: injected GH_TOKEN from gh CLI", task_id)
6054
- except Exception as _gh_exc:
6055
- logger.debug(
6056
- "Copilot task %s: could not inject GH_TOKEN (%s); falling back to stored auth",
6057
- task_id, _gh_exc,
6058
- )
6062
+ auth_env_var = next(
6063
+ (
6064
+ key
6065
+ for key in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
6066
+ if env.get(key)
6067
+ ),
6068
+ None,
6069
+ )
6070
+ logger.info(
6071
+ "Copilot task %s authentication source: %s",
6072
+ task_id,
6073
+ auth_env_var or "stored Copilot login",
6074
+ )
6059
6075
 
6060
6076
  model_override = os.environ.get("FACTORY_COPILOT_MODEL")
6061
6077
  # FACTORY_COPILOT_REASONING is opt-in: only pass --effort when the env
@@ -6112,6 +6128,19 @@ class ProcessManager:
6112
6128
  # stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
6113
6129
  # CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
6114
6130
  # headless environment and processes -p as the complete task specification.
6131
+ proc: asyncio.subprocess.Process | None = None
6132
+
6133
+ async def _terminate_copilot_process(
6134
+ process: asyncio.subprocess.Process | None,
6135
+ ) -> None:
6136
+ if process is None:
6137
+ return
6138
+ _kill_proc(process)
6139
+ try:
6140
+ await asyncio.wait_for(process.wait(), timeout=5)
6141
+ except (Exception, asyncio.CancelledError):
6142
+ pass
6143
+
6115
6144
  try:
6116
6145
  proc = await asyncio.create_subprocess_exec(
6117
6146
  *cmd,
@@ -6267,8 +6296,9 @@ class ProcessManager:
6267
6296
  if effective_rc == 1 and _no_tools and not copilot_specific_error:
6268
6297
  logger.error(
6269
6298
  "Copilot exitCode=1 with no tool calls for task %s after retries. "
6270
- "Likely causes: (1) GitHub auth expired — run `gh auth status`; if "
6271
- "expired, run `copilot login` (Copilot's own auth) and `gh auth login`; "
6299
+ "Likely causes: (1) Copilot auth expired — run `copilot login`; "
6300
+ "if COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN is explicitly set, "
6301
+ "verify that it belongs to the intended Copilot account; "
6272
6302
  "(2) Copilot subscription inactive; "
6273
6303
  "(3) --allow-all flag not supported by this CLI version (%s). "
6274
6304
  "To reproduce manually: %s",
@@ -6282,9 +6312,9 @@ class ProcessManager:
6282
6312
  if _is_auth_error:
6283
6313
  logger.error(
6284
6314
  "Copilot auth error for task %s: %s. "
6285
- "Fix: (1) run 'gh auth login' to refresh gh credentials; "
6286
- "(2) run 'copilot login' to refresh Copilot's own OAuth token; "
6287
- "(3) if the issue persists, delete ~/.forgexa/copilot-home/ and retry.",
6315
+ "Fix: (1) run 'copilot login' to refresh Copilot credentials; "
6316
+ "(2) verify any explicitly configured COPILOT_GITHUB_TOKEN, GH_TOKEN, "
6317
+ "or GITHUB_TOKEN belongs to the intended Copilot account.",
6288
6318
  task_id,
6289
6319
  structured_errors[-1] if structured_errors else "auth failure",
6290
6320
  )
@@ -6299,6 +6329,13 @@ class ProcessManager:
6299
6329
  failure_error = f"Copilot exited with code {effective_rc}: {stderr[-500:].strip()}"
6300
6330
  else:
6301
6331
  failure_error = f"Copilot exited with code {effective_rc}"
6332
+ logger.error(
6333
+ "Copilot task %s failed (process_rc=%s, result_exit=%s): %s",
6334
+ task_id,
6335
+ returncode,
6336
+ copilot_exit,
6337
+ _safe_agent_error_detail(failure_error),
6338
+ )
6302
6339
  return TaskResult(
6303
6340
  status="failed",
6304
6341
  exit_code=effective_rc,
@@ -6307,6 +6344,10 @@ class ProcessManager:
6307
6344
  error=failure_error,
6308
6345
  metrics=metrics,
6309
6346
  )
6347
+ except asyncio.CancelledError:
6348
+ active_proc = self.active_processes.get(task_id) or proc
6349
+ await _terminate_copilot_process(active_proc)
6350
+ raise
6310
6351
  except FileNotFoundError as exc:
6311
6352
  logger.exception("Copilot executable was not found for task %s", task_id)
6312
6353
  return TaskResult(
@@ -6321,7 +6362,9 @@ class ProcessManager:
6321
6362
  failure_code="agent_launch_not_found",
6322
6363
  )
6323
6364
  except asyncio.TimeoutError as exc:
6324
- _kill_proc(self.active_processes.pop(task_id, None) or proc)
6365
+ await _terminate_copilot_process(
6366
+ self.active_processes.pop(task_id, None) or proc,
6367
+ )
6325
6368
  _err = (
6326
6369
  f"Agent produced no stdout, stderr, or workspace changes for "
6327
6370
  f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
@@ -6337,11 +6380,9 @@ class ProcessManager:
6337
6380
  )
6338
6381
  except Exception as exc:
6339
6382
  logger.exception("Copilot stream error for task %s", task_id)
6340
- if task_id in self.active_processes:
6341
- try:
6342
- self.active_processes[task_id].kill()
6343
- except Exception:
6344
- pass
6383
+ await _terminate_copilot_process(
6384
+ self.active_processes.pop(task_id, None) or proc,
6385
+ )
6345
6386
  return TaskResult(
6346
6387
  status="failed", exit_code=-1, stdout="", stderr="",
6347
6388
  error=f"Stream processing error: {exc}",
@@ -6349,6 +6390,13 @@ class ProcessManager:
6349
6390
  finally:
6350
6391
  self.active_processes.pop(task_id, None)
6351
6392
  if _is_outer_call and _isolated_copilot_home:
6393
+ if _master_copilot_home:
6394
+ await asyncio.to_thread(
6395
+ agent_core.persist_copilot_auth_state,
6396
+ _isolated_copilot_home,
6397
+ _master_copilot_home,
6398
+ _copilot_auth_baseline,
6399
+ )
6352
6400
  WorkspaceManager._safe_rmtree(Path(_isolated_copilot_home))
6353
6401
  if _task_file is not None:
6354
6402
  try:
@@ -9054,7 +9102,10 @@ class RuntimeDaemon:
9054
9102
  "opencode": "curl -sSL https://opencode.ai/install | sh",
9055
9103
  "gemini": "npm install -g @google/gemini-cli",
9056
9104
  "kimi": "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
9057
- "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
9105
+ "copilot": (
9106
+ "Install GitHub Copilot CLI or the VS Code Copilot Chat "
9107
+ "extension. Then run: copilot login"
9108
+ ),
9058
9109
  }
9059
9110
  hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
9060
9111
  logger.error("No agent found for type '%s' on this runtime", task.agent_type)
@@ -11157,7 +11208,10 @@ class RuntimeDaemon:
11157
11208
  "opencode": "curl -sSL https://opencode.ai/install | sh",
11158
11209
  "gemini": "npm install -g @google/gemini-cli",
11159
11210
  "kimi": "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
11160
- "copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
11211
+ "copilot": (
11212
+ "Install GitHub Copilot CLI or the VS Code Copilot Chat "
11213
+ "extension. Then run: copilot login"
11214
+ ),
11161
11215
  }
11162
11216
  hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
11163
11217
  no_agent_attempts = (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.41.5
3
+ Version: 1.42.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.41.5"
3
+ version = "1.42.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