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.
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/PKG-INFO +1 -1
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/agent_core.py +233 -88
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/daemon.py +104 -50
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/pyproject.toml +1 -1
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/README.md +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/setup.cfg +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.41.5 → forgexa_cli-1.42.1}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
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
|
-
|
|
227
|
-
'1.
|
|
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.
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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, "
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
|
716
|
+
except Exception:
|
|
672
717
|
pass
|
|
673
718
|
finally:
|
|
674
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
if target_child.
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1923
|
+
for child in source_root.iterdir():
|
|
1924
|
+
if child.name in {"logs", "session-state"}:
|
|
1925
|
+
continue
|
|
1793
1926
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
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.
|
|
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
|
|
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).
|
|
5976
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
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)
|
|
6271
|
-
"
|
|
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 '
|
|
6286
|
-
"(2)
|
|
6287
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
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":
|
|
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":
|
|
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 = (
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|