forgexa-cli 1.42.0__tar.gz → 1.43.2__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.42.0 → forgexa_cli-1.43.2}/PKG-INFO +1 -1
  2. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/agent_core.py +367 -89
  4. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/daemon.py +162 -51
  5. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/pyproject.toml +1 -1
  7. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/README.md +0 -0
  8. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  14. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  15. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
  16. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/requires.txt +0 -0
  17. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/top_level.txt +0 -0
  18. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/setup.cfg +0 -0
  19. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_auth_and_runtime_commands.py +0 -0
  20. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_autoupgrade.py +0 -0
  21. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_check_command.py +0 -0
  22. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
  23. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_local_bind_commands.py +0 -0
  24. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_runtime_credentials.py +0 -0
  25. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_session_credentials.py +0 -0
  26. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_silent_install.py +0 -0
  27. {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/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.42.0
3
+ Version: 1.43.2
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.42.0"
2
+ __version__ = "1.43.2"
@@ -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,194 @@ def copilot_base_env(env: dict[str, str], *, copilot_home: str | Path) -> dict[s
1721
1769
  return env
1722
1770
 
1723
1771
 
1772
+ COPILOT_TOKEN_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
1773
+
1774
+
1775
+ def copilot_interactive_home() -> Path:
1776
+ """Return the login home selected for interactive Copilot CLI use."""
1777
+ configured_home = os.environ.get("COPILOT_HOME", "").strip()
1778
+ if configured_home:
1779
+ return Path(configured_home).expanduser()
1780
+ return Path.home() / ".copilot"
1781
+
1782
+
1783
+ def copilot_recovery_home(destination_home: str | Path) -> Path | None:
1784
+ """Return a populated login home distinct from the failed destination."""
1785
+ destination = Path(destination_home).resolve()
1786
+ candidates = (copilot_interactive_home(), Path.home() / ".copilot")
1787
+ seen: set[Path] = set()
1788
+ for candidate in candidates:
1789
+ resolved = candidate.resolve()
1790
+ if resolved in seen or resolved == destination:
1791
+ continue
1792
+ seen.add(resolved)
1793
+ if (resolved / "config.json").is_file():
1794
+ return resolved
1795
+ return None
1796
+
1797
+
1798
+ def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
1799
+ """Return whether Copilot failed before work while resolving model access."""
1800
+ detail = " ".join(str(message or "").lower() for message in messages)
1801
+ return (
1802
+ "421" in detail
1803
+ and "misdirected request" in detail
1804
+ and (
1805
+ "retrieve the list of available models" in detail
1806
+ or "failed to load models" in detail
1807
+ )
1808
+ )
1809
+
1810
+
1811
+ def copilot_auth_state_signature(copilot_home: str | Path) -> str | None:
1812
+ """Return a content signature for Copilot's persisted authentication state."""
1813
+ config_file = Path(copilot_home) / "config.json"
1814
+ if not config_file.exists():
1815
+ return ""
1816
+ try:
1817
+ return hashlib.sha256(config_file.read_bytes()).hexdigest()
1818
+ except OSError:
1819
+ logger.warning("Could not read Copilot authentication state", exc_info=True)
1820
+ return None
1821
+
1822
+
1823
+ def _copilot_auth_lock_path(copilot_home: str | Path) -> Path:
1824
+ home = Path(copilot_home)
1825
+ return home.with_name(f".{home.name}-auth-state.lock")
1826
+
1827
+
1828
+ def replace_copilot_auth_state(
1829
+ source_home: str | Path,
1830
+ destination_home: str | Path,
1831
+ expected_destination_signature: str | None = None,
1832
+ ) -> bool:
1833
+ """Replace an isolated Copilot auth file from a known login home."""
1834
+ source = Path(source_home) / "config.json"
1835
+ destination_dir = Path(destination_home)
1836
+ destination = destination_dir / source.name
1837
+ if not source.is_file():
1838
+ return False
1839
+ lock_file = None
1840
+ temp_name: str | None = None
1841
+ try:
1842
+ source_signature = copilot_auth_state_signature(source_home)
1843
+ if source_signature is None:
1844
+ return False
1845
+ destination_dir.mkdir(parents=True, exist_ok=True)
1846
+ lock_file = acquire_file_lock(
1847
+ _copilot_auth_lock_path(destination_dir),
1848
+ blocking=True,
1849
+ )
1850
+ if lock_file is None:
1851
+ logger.warning("Could not lock Copilot authentication state for restore")
1852
+ return False
1853
+ destination_signature = copilot_auth_state_signature(destination_home)
1854
+ if (
1855
+ expected_destination_signature is not None
1856
+ and destination_signature != expected_destination_signature
1857
+ ):
1858
+ return False
1859
+ if destination_signature == source_signature:
1860
+ return False
1861
+ temp_fd, temp_name = tempfile.mkstemp(prefix=".config.json.", dir=destination_dir)
1862
+ os.close(temp_fd)
1863
+ shutil.copy2(source, temp_name)
1864
+ os.chmod(temp_name, 0o600)
1865
+ for attempt in range(3):
1866
+ try:
1867
+ os.replace(temp_name, destination)
1868
+ temp_name = None
1869
+ break
1870
+ except PermissionError:
1871
+ if sys.platform != "win32" or attempt == 2:
1872
+ raise
1873
+ time.sleep(0.05)
1874
+ return True
1875
+ except OSError:
1876
+ logger.warning("Could not restore interactive Copilot credentials", exc_info=True)
1877
+ return False
1878
+ finally:
1879
+ if temp_name is not None:
1880
+ try:
1881
+ Path(temp_name).unlink(missing_ok=True)
1882
+ except OSError:
1883
+ logger.debug("Could not remove temporary Copilot config", exc_info=True)
1884
+ release_file_lock(lock_file)
1885
+
1886
+
1887
+ def persist_copilot_auth_state(
1888
+ run_home: str | Path,
1889
+ persistent_home: str | Path,
1890
+ baseline_signature: str | None = None,
1891
+ ) -> None:
1892
+ """Persist credentials refreshed inside an isolated Copilot home.
1893
+
1894
+ Copilot stores credentials in ``config.json`` when no system credential
1895
+ store is available. Session data must remain isolated because reusing it
1896
+ can make retries inherit completed todos from an earlier task.
1897
+ """
1898
+ lock_file = None
1899
+ temp_name: str | None = None
1900
+ try:
1901
+ source = Path(run_home) / "config.json"
1902
+ if not source.is_file():
1903
+ return
1904
+ source_signature = copilot_auth_state_signature(run_home)
1905
+ if baseline_signature is not None and source_signature == baseline_signature:
1906
+ return
1907
+
1908
+ destination_dir = Path(persistent_home)
1909
+ destination_dir.mkdir(parents=True, exist_ok=True)
1910
+ lock_file = acquire_file_lock(
1911
+ _copilot_auth_lock_path(destination_dir),
1912
+ blocking=True,
1913
+ )
1914
+ if lock_file is None:
1915
+ logger.warning("Could not lock Copilot authentication state for update")
1916
+ return
1917
+
1918
+ destination = destination_dir / source.name
1919
+ destination_signature = copilot_auth_state_signature(destination_dir)
1920
+ if destination_signature == source_signature:
1921
+ return
1922
+ if (
1923
+ baseline_signature is not None
1924
+ and destination_signature != baseline_signature
1925
+ ):
1926
+ logger.info(
1927
+ "Skipping stale Copilot authentication update because another "
1928
+ "run refreshed the persistent state first"
1929
+ )
1930
+ return
1931
+ if baseline_signature is None and destination.is_file():
1932
+ if destination.stat().st_mtime_ns >= source.stat().st_mtime_ns:
1933
+ return
1934
+
1935
+ temp_fd, temp_name = tempfile.mkstemp(prefix=".config.json.", dir=destination_dir)
1936
+ os.close(temp_fd)
1937
+ shutil.copy2(source, temp_name)
1938
+ os.chmod(temp_name, 0o600)
1939
+ os.utime(temp_name, None)
1940
+ for attempt in range(3):
1941
+ try:
1942
+ os.replace(temp_name, destination)
1943
+ temp_name = None
1944
+ break
1945
+ except PermissionError:
1946
+ if sys.platform != "win32" or attempt == 2:
1947
+ raise
1948
+ time.sleep(0.05)
1949
+ except OSError:
1950
+ logger.warning("Could not persist refreshed Copilot credentials", exc_info=True)
1951
+ finally:
1952
+ if temp_name is not None:
1953
+ try:
1954
+ Path(temp_name).unlink(missing_ok=True)
1955
+ except OSError:
1956
+ logger.debug("Could not remove temporary Copilot config", exc_info=True)
1957
+ release_file_lock(lock_file)
1958
+
1959
+
1724
1960
  def prepare_copilot_home() -> str:
1725
1961
  """Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
1726
1962
 
@@ -1733,80 +1969,122 @@ def prepare_copilot_home() -> str:
1733
1969
  Mirror the user-facing ~/.copilot state into a writable daemon-owned
1734
1970
  directory, then point COPILOT_HOME there for non-interactive runs.
1735
1971
 
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.
1972
+ A one-time migration removes stale files left when older daemons pointed
1973
+ COPILOT_HOME directly at the master directory. After migration, refreshed
1974
+ authentication state written by isolated runs is retained even when it
1975
+ has no counterpart in the interactive home.
1746
1976
  """
1747
- source_root = Path.home() / ".copilot"
1977
+ source_root = copilot_interactive_home()
1748
1978
  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:
1979
+ lock_file = None
1980
+ try:
1981
+ target_root.mkdir(parents=True, exist_ok=True)
1982
+ lock_file = acquire_file_lock(
1983
+ _copilot_auth_lock_path(target_root),
1984
+ blocking=True,
1985
+ )
1986
+ if lock_file is None:
1987
+ logger.warning("Could not lock Copilot home for synchronization")
1988
+ return str(target_root)
1989
+ if source_root.resolve() == target_root.resolve():
1990
+ return str(target_root)
1991
+ if not source_root.exists():
1992
+ return str(target_root)
1993
+
1994
+ prune_cutoff = time.time() - 7 * 86400
1995
+ for prune_dir_name in ("session-state", "logs"):
1996
+ prune_dir = target_root / prune_dir_name
1997
+ if not prune_dir.is_dir():
1998
+ continue
1999
+ for old_file in prune_dir.iterdir():
2000
+ try:
2001
+ if old_file.is_file() and old_file.stat().st_mtime < prune_cutoff:
2002
+ old_file.unlink(missing_ok=True)
2003
+ logger.debug("Pruned old Copilot home file: %s", old_file)
2004
+ except OSError as exc:
2005
+ logger.debug("Could not prune %s: %s", old_file, exc)
2006
+
2007
+ source_names = {child.name for child in source_root.iterdir()}
2008
+ migration_marker = target_root.parent / ".copilot-stale-auth-cleaned-v2"
2009
+ if not migration_marker.exists():
2010
+ cleanup_succeeded = True
2011
+ for target_child in list(target_root.iterdir()):
2012
+ if target_child.name in {"session-state", "logs"}:
2013
+ continue
2014
+ if target_child.name not in source_names:
2015
+ try:
2016
+ if target_child.is_dir():
2017
+ shutil.rmtree(str(target_child))
2018
+ else:
2019
+ target_child.unlink(missing_ok=True)
2020
+ logger.debug("Copilot home: removed stale %s", target_child.name)
2021
+ except OSError as exc:
2022
+ cleanup_succeeded = False
2023
+ logger.debug(
2024
+ "Copilot home: could not remove stale %s: %s",
2025
+ target_child.name,
2026
+ exc,
2027
+ )
2028
+ if cleanup_succeeded:
2029
+ migration_marker.touch(exist_ok=True)
2030
+ os.chmod(migration_marker, 0o600)
2031
+
2032
+ source_auth_signature = copilot_auth_state_signature(source_root)
2033
+ source_signature_marker = target_root.parent / ".copilot-login-source.sha256"
2034
+ recorded_source_signature: str | None = None
2035
+ if source_signature_marker.is_file():
1781
2036
  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)
1789
-
1790
- for child in source_root.iterdir():
1791
- if child.name in {"logs", "session-state"}:
1792
- continue
2037
+ recorded_source_signature = source_signature_marker.read_text().strip()
2038
+ except OSError:
2039
+ logger.debug("Could not read Copilot login source signature", exc_info=True)
2040
+ source_auth_changed = bool(
2041
+ source_auth_signature
2042
+ and recorded_source_signature is not None
2043
+ and source_auth_signature != recorded_source_signature
2044
+ )
2045
+ source_config_synced = False
2046
+ for child in source_root.iterdir():
2047
+ if child.name in {"logs", "session-state"}:
2048
+ continue
1793
2049
 
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"):
2050
+ dest = target_root / child.name
2051
+ try:
2052
+ if child.is_dir():
2053
+ if child.name != "ide":
1802
2054
  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
- )
2055
+ dest.mkdir(parents=True, exist_ok=True)
2056
+ for sub in child.iterdir():
2057
+ if not sub.is_file() or sub.name.endswith(".lock"):
2058
+ continue
2059
+ shutil.copy2(sub, dest / sub.name)
2060
+ elif child.is_file():
2061
+ if (
2062
+ child.name == "config.json"
2063
+ and not source_auth_changed
2064
+ and dest.is_file()
2065
+ and dest.stat().st_mtime_ns >= child.stat().st_mtime_ns
2066
+ ):
2067
+ source_config_synced = True
2068
+ continue
2069
+ shutil.copy2(child, dest)
2070
+ if child.name == "config.json":
2071
+ source_config_synced = True
2072
+ except OSError as exc:
2073
+ logger.debug(
2074
+ "Copilot home mirror skipped %s -> %s: %s",
2075
+ child,
2076
+ dest,
2077
+ exc,
2078
+ )
2079
+ if source_config_synced and source_auth_signature:
2080
+ try:
2081
+ source_signature_marker.write_text(source_auth_signature)
2082
+ os.chmod(source_signature_marker, 0o600)
2083
+ except OSError:
2084
+ logger.debug("Could not record Copilot login source signature", exc_info=True)
2085
+ except OSError:
2086
+ logger.warning("Could not prepare writable Copilot home", exc_info=True)
2087
+ finally:
2088
+ release_file_lock(lock_file)
1811
2089
 
1812
2090
  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.42.0"
913
+ DAEMON_VERSION = "1.43.2"
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
 
@@ -4755,7 +4781,8 @@ class ProcessManager:
4755
4781
  "error during sign-in",
4756
4782
  "websockettransporterror",
4757
4783
  "websocket receive failed",
4758
- "421 misdirected request",
4784
+ "misdirected request",
4785
+ "could not retrieve the list of available models",
4759
4786
  "failed to initialize mcp client",
4760
4787
  # "api error" removed: too broad — matches agent-generated code/output
4761
4788
  # discussing API errors. Real API transport errors are covered by the
@@ -5034,7 +5061,10 @@ class ProcessManager:
5034
5061
  # ── Copilot: JSONL mode but no turn completion and no content ──
5035
5062
  if agent_id == "copilot" and json_line_count > 0:
5036
5063
  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')"
5064
+ return (
5065
+ "Copilot produced no result output "
5066
+ "(check authentication: run 'copilot login')"
5067
+ )
5038
5068
 
5039
5069
  return None
5040
5070
 
@@ -5967,13 +5997,15 @@ class ProcessManager:
5967
5997
  *,
5968
5998
  disable_builtin_mcps: bool = False,
5969
5999
  _retry_without_effort: bool = False,
6000
+ _retry_without_token_env: bool = False,
5970
6001
  _isolated_copilot_home: str | None = None,
6002
+ _auth_state: dict[str, bool] | None = None,
5971
6003
  ) -> TaskResult:
5972
6004
  """Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
5973
6005
 
5974
6006
  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.
6007
+ can't acquire a pseudo-terminal). Requires ``copilot login`` or an
6008
+ explicitly configured supported token environment variable.
5977
6009
 
5978
6010
  Flags:
5979
6011
  -p / --prompt Non-interactive prompt (exits after completion).
@@ -6003,11 +6035,15 @@ class ProcessManager:
6003
6035
  # session-state persists from the prior attempt all todos are already
6004
6036
  # "done" and the agent exits immediately without producing any output.
6005
6037
  _is_outer_call = _isolated_copilot_home is None
6038
+ if _auth_state is None:
6039
+ _auth_state = {"persist": True}
6040
+ _master_copilot_home: str | None = None
6041
+ _copilot_auth_baseline: str | None = None
6006
6042
  if _is_outer_call:
6007
- master_home = self._prepare_copilot_home()
6043
+ _master_copilot_home = await asyncio.to_thread(self._prepare_copilot_home)
6008
6044
  _isolated_copilot_home = tempfile.mkdtemp(prefix=f"copilot-{task_id[:8]}-")
6009
6045
  try:
6010
- for child in Path(master_home).iterdir():
6046
+ for child in Path(_master_copilot_home).iterdir():
6011
6047
  if child.name in {"session-state", "logs"}:
6012
6048
  continue
6013
6049
  dest = Path(_isolated_copilot_home) / child.name
@@ -6020,42 +6056,30 @@ class ProcessManager:
6020
6056
  logger.debug("Copilot home copy %s -> %s: %s", child, dest, _cp_exc)
6021
6057
  except Exception as _iter_exc:
6022
6058
  logger.debug("Copilot home isolation warning: %s", _iter_exc)
6059
+ _copilot_auth_baseline = agent_core.copilot_auth_state_signature(
6060
+ _isolated_copilot_home,
6061
+ )
6023
6062
 
6024
6063
  env = agent_core.copilot_base_env(
6025
6064
  os.environ.copy(),
6026
6065
  copilot_home=_isolated_copilot_home,
6027
6066
  )
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
- )
6067
+ if _retry_without_token_env:
6068
+ for key in agent_core.COPILOT_TOKEN_ENV_VARS:
6069
+ env.pop(key, None)
6070
+ auth_env_var = next(
6071
+ (
6072
+ key
6073
+ for key in agent_core.COPILOT_TOKEN_ENV_VARS
6074
+ if env.get(key)
6075
+ ),
6076
+ None,
6077
+ )
6078
+ logger.info(
6079
+ "Copilot task %s authentication source: %s",
6080
+ task_id,
6081
+ auth_env_var or "stored Copilot login",
6082
+ )
6059
6083
 
6060
6084
  model_override = os.environ.get("FACTORY_COPILOT_MODEL")
6061
6085
  # FACTORY_COPILOT_REASONING is opt-in: only pass --effort when the env
@@ -6112,6 +6136,19 @@ class ProcessManager:
6112
6136
  # stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
6113
6137
  # CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
6114
6138
  # headless environment and processes -p as the complete task specification.
6139
+ proc: asyncio.subprocess.Process | None = None
6140
+
6141
+ async def _terminate_copilot_process(
6142
+ process: asyncio.subprocess.Process | None,
6143
+ ) -> None:
6144
+ if process is None:
6145
+ return
6146
+ _kill_proc(process)
6147
+ try:
6148
+ await asyncio.wait_for(process.wait(), timeout=5)
6149
+ except (Exception, asyncio.CancelledError):
6150
+ pass
6151
+
6115
6152
  try:
6116
6153
  proc = await asyncio.create_subprocess_exec(
6117
6154
  *cmd,
@@ -6201,11 +6238,58 @@ class ProcessManager:
6201
6238
  on_chunk,
6202
6239
  disable_builtin_mcps=True,
6203
6240
  _retry_without_effort=_retry_without_effort,
6241
+ _retry_without_token_env=_retry_without_token_env,
6204
6242
  _isolated_copilot_home=_isolated_copilot_home,
6243
+ _auth_state=_auth_state,
6205
6244
  )
6206
6245
  retry_result.metrics["copilot_builtin_mcp_fallback"] = True
6207
6246
  return retry_result
6208
6247
 
6248
+ model_discovery_error = agent_core.is_copilot_model_discovery_auth_error(
6249
+ *structured_errors,
6250
+ stderr,
6251
+ )
6252
+ if model_discovery_error and not self._copilot_called_any_tools(stdout):
6253
+ isolated_config = Path(_isolated_copilot_home) / "config.json"
6254
+ recovery_home = agent_core.copilot_recovery_home(
6255
+ _isolated_copilot_home,
6256
+ )
6257
+ can_retry_native_auth = bool(
6258
+ isolated_config.is_file()
6259
+ or recovery_home is not None
6260
+ )
6261
+ if not _retry_without_token_env and can_retry_native_auth:
6262
+ restored_interactive_auth = bool(
6263
+ recovery_home
6264
+ and await asyncio.to_thread(
6265
+ agent_core.replace_copilot_auth_state,
6266
+ recovery_home,
6267
+ _isolated_copilot_home,
6268
+ )
6269
+ )
6270
+ logger.warning(
6271
+ "Copilot model discovery failed with HTTP 421 before work for "
6272
+ "task %s; retrying once with token environment variables removed%s",
6273
+ task_id,
6274
+ " and interactive login restored" if restored_interactive_auth else "",
6275
+ )
6276
+ retry_result = await self._run_copilot(
6277
+ agent,
6278
+ prompt,
6279
+ cwd,
6280
+ timeout,
6281
+ task_id,
6282
+ on_chunk,
6283
+ disable_builtin_mcps=disable_builtin_mcps,
6284
+ _retry_without_effort=_retry_without_effort,
6285
+ _retry_without_token_env=True,
6286
+ _isolated_copilot_home=_isolated_copilot_home,
6287
+ _auth_state=_auth_state,
6288
+ )
6289
+ retry_result.metrics["copilot_model_discovery_recovery"] = True
6290
+ return retry_result
6291
+ _auth_state["persist"] = False
6292
+
6209
6293
  if copilot_exit == 0 and returncode == 0:
6210
6294
  return TaskResult(
6211
6295
  status="success",
@@ -6261,14 +6345,17 @@ class ProcessManager:
6261
6345
  agent, prompt, cwd, timeout, task_id, on_chunk,
6262
6346
  disable_builtin_mcps=disable_builtin_mcps,
6263
6347
  _retry_without_effort=True,
6348
+ _retry_without_token_env=_retry_without_token_env,
6264
6349
  _isolated_copilot_home=_isolated_copilot_home,
6350
+ _auth_state=_auth_state,
6265
6351
  )
6266
6352
  # Exhausted retries — emit an actionable diagnostic hint
6267
6353
  if effective_rc == 1 and _no_tools and not copilot_specific_error:
6268
6354
  logger.error(
6269
6355
  "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`; "
6356
+ "Likely causes: (1) Copilot auth expired — run `copilot login`; "
6357
+ "if COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN is explicitly set, "
6358
+ "verify that it belongs to the intended Copilot account; "
6272
6359
  "(2) Copilot subscription inactive; "
6273
6360
  "(3) --allow-all flag not supported by this CLI version (%s). "
6274
6361
  "To reproduce manually: %s",
@@ -6282,9 +6369,9 @@ class ProcessManager:
6282
6369
  if _is_auth_error:
6283
6370
  logger.error(
6284
6371
  "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.",
6372
+ "Fix: (1) run 'copilot login' to refresh Copilot credentials; "
6373
+ "(2) verify any explicitly configured COPILOT_GITHUB_TOKEN, GH_TOKEN, "
6374
+ "or GITHUB_TOKEN belongs to the intended Copilot account.",
6288
6375
  task_id,
6289
6376
  structured_errors[-1] if structured_errors else "auth failure",
6290
6377
  )
@@ -6299,6 +6386,13 @@ class ProcessManager:
6299
6386
  failure_error = f"Copilot exited with code {effective_rc}: {stderr[-500:].strip()}"
6300
6387
  else:
6301
6388
  failure_error = f"Copilot exited with code {effective_rc}"
6389
+ logger.error(
6390
+ "Copilot task %s failed (process_rc=%s, result_exit=%s): %s",
6391
+ task_id,
6392
+ returncode,
6393
+ copilot_exit,
6394
+ _safe_agent_error_detail(failure_error),
6395
+ )
6302
6396
  return TaskResult(
6303
6397
  status="failed",
6304
6398
  exit_code=effective_rc,
@@ -6307,6 +6401,10 @@ class ProcessManager:
6307
6401
  error=failure_error,
6308
6402
  metrics=metrics,
6309
6403
  )
6404
+ except asyncio.CancelledError:
6405
+ active_proc = self.active_processes.get(task_id) or proc
6406
+ await _terminate_copilot_process(active_proc)
6407
+ raise
6310
6408
  except FileNotFoundError as exc:
6311
6409
  logger.exception("Copilot executable was not found for task %s", task_id)
6312
6410
  return TaskResult(
@@ -6321,7 +6419,9 @@ class ProcessManager:
6321
6419
  failure_code="agent_launch_not_found",
6322
6420
  )
6323
6421
  except asyncio.TimeoutError as exc:
6324
- _kill_proc(self.active_processes.pop(task_id, None) or proc)
6422
+ await _terminate_copilot_process(
6423
+ self.active_processes.pop(task_id, None) or proc,
6424
+ )
6325
6425
  _err = (
6326
6426
  f"Agent produced no stdout, stderr, or workspace changes for "
6327
6427
  f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
@@ -6337,11 +6437,9 @@ class ProcessManager:
6337
6437
  )
6338
6438
  except Exception as exc:
6339
6439
  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
6440
+ await _terminate_copilot_process(
6441
+ self.active_processes.pop(task_id, None) or proc,
6442
+ )
6345
6443
  return TaskResult(
6346
6444
  status="failed", exit_code=-1, stdout="", stderr="",
6347
6445
  error=f"Stream processing error: {exc}",
@@ -6349,6 +6447,13 @@ class ProcessManager:
6349
6447
  finally:
6350
6448
  self.active_processes.pop(task_id, None)
6351
6449
  if _is_outer_call and _isolated_copilot_home:
6450
+ if _master_copilot_home and _auth_state["persist"]:
6451
+ await asyncio.to_thread(
6452
+ agent_core.persist_copilot_auth_state,
6453
+ _isolated_copilot_home,
6454
+ _master_copilot_home,
6455
+ _copilot_auth_baseline,
6456
+ )
6352
6457
  WorkspaceManager._safe_rmtree(Path(_isolated_copilot_home))
6353
6458
  if _task_file is not None:
6354
6459
  try:
@@ -9054,7 +9159,10 @@ class RuntimeDaemon:
9054
9159
  "opencode": "curl -sSL https://opencode.ai/install | sh",
9055
9160
  "gemini": "npm install -g @google/gemini-cli",
9056
9161
  "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",
9162
+ "copilot": (
9163
+ "Install GitHub Copilot CLI or the VS Code Copilot Chat "
9164
+ "extension. Then run: copilot login"
9165
+ ),
9058
9166
  }
9059
9167
  hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
9060
9168
  logger.error("No agent found for type '%s' on this runtime", task.agent_type)
@@ -11157,7 +11265,10 @@ class RuntimeDaemon:
11157
11265
  "opencode": "curl -sSL https://opencode.ai/install | sh",
11158
11266
  "gemini": "npm install -g @google/gemini-cli",
11159
11267
  "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",
11268
+ "copilot": (
11269
+ "Install GitHub Copilot CLI or the VS Code Copilot Chat "
11270
+ "extension. Then run: copilot login"
11271
+ ),
11161
11272
  }
11162
11273
  hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
11163
11274
  no_agent_attempts = (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.42.0
3
+ Version: 1.43.2
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.42.0"
3
+ version = "1.43.2"
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