forgexa-cli 1.14.7__tar.gz → 1.14.9__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.14.7 → forgexa_cli-1.14.9}/PKG-INFO +1 -1
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli/daemon.py +190 -25
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/pyproject.toml +1 -1
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/README.md +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/setup.cfg +0 -0
- {forgexa_cli-1.14.7 → forgexa_cli-1.14.9}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.14.
|
|
2
|
+
__version__ = "1.14.9"
|
|
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
523
523
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
524
524
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
525
525
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
526
|
-
DAEMON_VERSION = "1.14.
|
|
526
|
+
DAEMON_VERSION = "1.14.9"
|
|
527
527
|
|
|
528
528
|
|
|
529
529
|
def _detect_client_type() -> str:
|
|
@@ -1711,17 +1711,27 @@ class WorkspaceManager:
|
|
|
1711
1711
|
|
|
1712
1712
|
@staticmethod
|
|
1713
1713
|
def _safe_rmtree(path: Path) -> None:
|
|
1714
|
-
"""Remove a directory tree, handling
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1714
|
+
"""Remove a directory tree, handling two distinct Windows failure modes.
|
|
1715
|
+
|
|
1716
|
+
Mode 1 — Read-only git objects (chmod 0444):
|
|
1717
|
+
Plain shutil.rmtree(path, ignore_errors=True) silently skips
|
|
1718
|
+
read-only files. Fixed by the onerror callback that chmod’s each
|
|
1719
|
+
entry writable before retrying.
|
|
1720
|
+
|
|
1721
|
+
Mode 2 — Windows reserved device names (NUL, CON, AUX, PRN,
|
|
1722
|
+
COM1–COM9, LPT1–LPT9):
|
|
1723
|
+
The Win32 API intercepts these names before they reach the NTFS
|
|
1724
|
+
driver, so os.unlink / shutil.rmtree / PowerShell Remove-Item all
|
|
1725
|
+
fail with a permissions or access error. The solution is the
|
|
1726
|
+
\\?\ extended-length path prefix, which instructs Windows to
|
|
1727
|
+
skip Win32 name parsing and pass the path directly to the NT
|
|
1728
|
+
kernel file-system layer. 'cmd /c rmdir /s /q' reliably handles
|
|
1729
|
+
this when given a \\?\ prefixed path.
|
|
1730
|
+
|
|
1731
|
+
Deletion strategy (each stage only runs if the path still exists):
|
|
1732
|
+
1. shutil.rmtree with onerror chmod — handles read-only objects.
|
|
1733
|
+
2. Windows only: subprocess cmd /c rmdir /s /q \\\\?\\<path> —
|
|
1734
|
+
handles reserved names and any entries missed by stage 1.
|
|
1725
1735
|
"""
|
|
1726
1736
|
import shutil
|
|
1727
1737
|
import stat as _stat
|
|
@@ -1738,6 +1748,39 @@ class WorkspaceManager:
|
|
|
1738
1748
|
|
|
1739
1749
|
shutil.rmtree(path, onerror=_handle_readonly)
|
|
1740
1750
|
|
|
1751
|
+
# Windows fallback: reserved device names survive shutil.rmtree.
|
|
1752
|
+
# cmd /c rmdir /s /q with the \\?\ extended-length prefix bypasses
|
|
1753
|
+
# Win32 name interception and reaches the NTFS layer directly.
|
|
1754
|
+
if path.exists() and sys.platform == "win32":
|
|
1755
|
+
try:
|
|
1756
|
+
import subprocess
|
|
1757
|
+
resolved = str(path.resolve())
|
|
1758
|
+
# Prepend \\?\ extended-length prefix if not already present.
|
|
1759
|
+
# In Python source "\\\\?\\" represents the 4-char string \\?\
|
|
1760
|
+
# which is the Win32 flag to bypass reserved-name interception.
|
|
1761
|
+
if not resolved.startswith("\\\\?\\"):
|
|
1762
|
+
unc = "\\\\?\\" + resolved
|
|
1763
|
+
else:
|
|
1764
|
+
unc = resolved
|
|
1765
|
+
result = subprocess.run(
|
|
1766
|
+
["cmd", "/c", "rmdir", "/s", "/q", unc],
|
|
1767
|
+
capture_output=True,
|
|
1768
|
+
timeout=60,
|
|
1769
|
+
)
|
|
1770
|
+
if result.returncode != 0 and path.exists():
|
|
1771
|
+
logger.warning(
|
|
1772
|
+
"_safe_rmtree: cmd rmdir could not fully remove %s "
|
|
1773
|
+
"(exit %d: %s). Manual deletion may be required.",
|
|
1774
|
+
path,
|
|
1775
|
+
result.returncode,
|
|
1776
|
+
result.stderr.decode(errors="replace").strip()[:200],
|
|
1777
|
+
)
|
|
1778
|
+
except Exception as exc:
|
|
1779
|
+
logger.warning(
|
|
1780
|
+
"_safe_rmtree: Windows rmdir fallback failed for %s: %s",
|
|
1781
|
+
path, exc,
|
|
1782
|
+
)
|
|
1783
|
+
|
|
1741
1784
|
async def _clone_repo(
|
|
1742
1785
|
self,
|
|
1743
1786
|
repo_url: str,
|
|
@@ -1849,8 +1892,16 @@ class WorkspaceManager:
|
|
|
1849
1892
|
# the initial analysis. This ensures a hard error (and workspace
|
|
1850
1893
|
# re-clone) when the branch sync fails, rather than silently proceeding
|
|
1851
1894
|
# with a stale workspace that will cause a non-fast-forward push later.
|
|
1852
|
-
|
|
1853
|
-
|
|
1895
|
+
#
|
|
1896
|
+
# IMPORTANT: Fresh analysis tasks MUST NOT set expect_branch even when
|
|
1897
|
+
# task.analysis_branch is populated from workflow metadata. That value
|
|
1898
|
+
# reflects the branch from a *previous* analysis run (e.g. a prior Copilot
|
|
1899
|
+
# analysis) which may have been deleted as part of the fresh restart.
|
|
1900
|
+
# Only refine/continuation tasks require the branch to already exist.
|
|
1901
|
+
expect_branch = (not is_fresh_start) and (
|
|
1902
|
+
bool(task.analysis_branch) or (
|
|
1903
|
+
bool(task.requirement_key) and task.node_type != "analysis"
|
|
1904
|
+
)
|
|
1854
1905
|
)
|
|
1855
1906
|
ws_path = await self._create_worktree(
|
|
1856
1907
|
project_dir, repo_url, default_branch, workspace_key, branch_name,
|
|
@@ -2351,16 +2402,41 @@ class WorkspaceManager:
|
|
|
2351
2402
|
_branch_fetch_ok = False
|
|
2352
2403
|
|
|
2353
2404
|
if fresh_start and not _branch_fetch_ok:
|
|
2354
|
-
#
|
|
2355
|
-
#
|
|
2356
|
-
#
|
|
2357
|
-
#
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2405
|
+
# Distinguish "branch not found" from "auth/network failure".
|
|
2406
|
+
# When the branch simply doesn't exist on the remote, that is the
|
|
2407
|
+
# *expected* state for a fresh analysis restart (the branch was
|
|
2408
|
+
# deleted by a prior fresh run or was never pushed). In that case
|
|
2409
|
+
# keep fresh_start=True so the worktree is correctly reset to the
|
|
2410
|
+
# default branch.
|
|
2411
|
+
# Only block fresh_start when the fetch failed for reasons other
|
|
2412
|
+
# than "not found" (e.g. auth/SSH/network issues) where we genuinely
|
|
2413
|
+
# cannot determine whether implementation commits exist on the remote.
|
|
2414
|
+
_err_lower = _last_sync_err.lower()
|
|
2415
|
+
_branch_not_found = (
|
|
2416
|
+
"couldn't find remote ref" in _err_lower
|
|
2417
|
+
or "无法找到远程引用" in _err_lower # Chinese git output
|
|
2418
|
+
or "remote ref does not exist" in _err_lower
|
|
2419
|
+
or "not found" in _err_lower
|
|
2362
2420
|
)
|
|
2363
|
-
|
|
2421
|
+
if _branch_not_found:
|
|
2422
|
+
# Branch is gone from remote — ideal state for a fresh restart.
|
|
2423
|
+
logger.info(
|
|
2424
|
+
"Fresh start for %s: remote branch not found — expected for a "
|
|
2425
|
+
"fresh analysis restart (branch was deleted or never existed). "
|
|
2426
|
+
"Proceeding with clean worktree reset to origin/%s",
|
|
2427
|
+
branch_name, default_branch,
|
|
2428
|
+
)
|
|
2429
|
+
# fresh_start remains True
|
|
2430
|
+
else:
|
|
2431
|
+
# Cannot determine remote branch state (auth/network failure) —
|
|
2432
|
+
# prohibit reset to avoid silently destroying implementation commits
|
|
2433
|
+
# that may exist on the remote but could not be fetched.
|
|
2434
|
+
logger.warning(
|
|
2435
|
+
"Fresh start blocked for %s: branch fetch failed (%s) — "
|
|
2436
|
+
"remote state unknown, refusing to reset to avoid data loss",
|
|
2437
|
+
branch_name, _last_sync_err[:200],
|
|
2438
|
+
)
|
|
2439
|
+
fresh_start = False
|
|
2364
2440
|
|
|
2365
2441
|
if fresh_start:
|
|
2366
2442
|
# Safety check: if the branch already has IMPLEMENTATION commits
|
|
@@ -4490,6 +4566,17 @@ class ProcessManager:
|
|
|
4490
4566
|
|
|
4491
4567
|
Mirror the user-facing ~/.copilot state into a writable daemon-owned
|
|
4492
4568
|
directory, then point COPILOT_HOME there for non-interactive runs.
|
|
4569
|
+
|
|
4570
|
+
Stale-auth cleanup: Before the per-task isolation fix, the daemon
|
|
4571
|
+
pointed COPILOT_HOME directly at this master directory, so Copilot
|
|
4572
|
+
wrote its own OAuth token (plain-text on Linux systems without a
|
|
4573
|
+
credential store) here. Those files are NOT present in ~/.copilot/
|
|
4574
|
+
(the user's interactive home), so the normal mirror loop never
|
|
4575
|
+
overwrites them — they persist across restarts and poison isolated
|
|
4576
|
+
temp dirs. We clean up any file/dir in the master home that has no
|
|
4577
|
+
counterpart in ~/.copilot/ (excluding session-state and logs which
|
|
4578
|
+
are deliberately retained) to ensure the master home stays a clean
|
|
4579
|
+
mirror of the user-facing directory.
|
|
4493
4580
|
"""
|
|
4494
4581
|
source_root = Path.home() / ".copilot"
|
|
4495
4582
|
target_root = Path.home() / ".forgexa" / "copilot-home"
|
|
@@ -4498,6 +4585,26 @@ class ProcessManager:
|
|
|
4498
4585
|
if not source_root.exists():
|
|
4499
4586
|
return str(target_root)
|
|
4500
4587
|
|
|
4588
|
+
# Collect names present in source for stale-file detection.
|
|
4589
|
+
source_names: set[str] = {child.name for child in source_root.iterdir()}
|
|
4590
|
+
|
|
4591
|
+
# Remove master-home entries that are absent from ~/.copilot/.
|
|
4592
|
+
# These are typically old Copilot OAuth token files written when
|
|
4593
|
+
# COPILOT_HOME pointed here directly (pre-isolation-fix era).
|
|
4594
|
+
_keep_always = {"session-state", "logs"}
|
|
4595
|
+
for target_child in list(target_root.iterdir()):
|
|
4596
|
+
if target_child.name in _keep_always:
|
|
4597
|
+
continue
|
|
4598
|
+
if target_child.name not in source_names:
|
|
4599
|
+
try:
|
|
4600
|
+
if target_child.is_dir():
|
|
4601
|
+
shutil.rmtree(str(target_child), ignore_errors=True)
|
|
4602
|
+
else:
|
|
4603
|
+
target_child.unlink(missing_ok=True)
|
|
4604
|
+
logger.debug("Copilot home: removed stale %s", target_child.name)
|
|
4605
|
+
except Exception as exc:
|
|
4606
|
+
logger.debug("Copilot home: could not remove stale %s: %s", target_child.name, exc)
|
|
4607
|
+
|
|
4501
4608
|
for child in source_root.iterdir():
|
|
4502
4609
|
if child.name in {"logs", "session-state"}:
|
|
4503
4610
|
continue
|
|
@@ -4585,6 +4692,37 @@ class ProcessManager:
|
|
|
4585
4692
|
env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
|
|
4586
4693
|
env["COPILOT_HOME"] = _isolated_copilot_home
|
|
4587
4694
|
|
|
4695
|
+
# Inject GH_TOKEN from the gh CLI for headless/automation auth.
|
|
4696
|
+
# Per `copilot login --help`, env-var auth is the recommended method
|
|
4697
|
+
# for automation: COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN.
|
|
4698
|
+
# GH_TOKEN accepts OAuth tokens from the GitHub CLI (gh) app, which
|
|
4699
|
+
# is exactly what `gh auth token` returns.
|
|
4700
|
+
#
|
|
4701
|
+
# This bypasses any stale Copilot-specific OAuth token file that may
|
|
4702
|
+
# be cached in COPILOT_HOME from an earlier run (e.g., after a
|
|
4703
|
+
# password change when `gh auth login` refreshes gh's token but does
|
|
4704
|
+
# NOT update Copilot's own stored token). After `gh auth login` the
|
|
4705
|
+
# daemon works again without requiring `copilot login` or manual
|
|
4706
|
+
# deletion of ~/.forgexa/copilot-home/.
|
|
4707
|
+
if not any(env.get(k) for k in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")):
|
|
4708
|
+
try:
|
|
4709
|
+
_gh_proc = await asyncio.create_subprocess_exec(
|
|
4710
|
+
"gh", "auth", "token",
|
|
4711
|
+
stdout=asyncio.subprocess.PIPE,
|
|
4712
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
4713
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
4714
|
+
)
|
|
4715
|
+
_gh_token_out, _ = await asyncio.wait_for(_gh_proc.communicate(), timeout=10.0)
|
|
4716
|
+
_gh_token = (_gh_token_out or b"").decode().strip()
|
|
4717
|
+
if _gh_token:
|
|
4718
|
+
env["GH_TOKEN"] = _gh_token
|
|
4719
|
+
logger.debug("Copilot task %s: injected GH_TOKEN from gh CLI", task_id)
|
|
4720
|
+
except Exception as _gh_exc:
|
|
4721
|
+
logger.debug(
|
|
4722
|
+
"Copilot task %s: could not inject GH_TOKEN (%s); falling back to stored auth",
|
|
4723
|
+
task_id, _gh_exc,
|
|
4724
|
+
)
|
|
4725
|
+
|
|
4588
4726
|
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
4589
4727
|
# FACTORY_COPILOT_REASONING is opt-in: only pass --effort when the env
|
|
4590
4728
|
# var is explicitly set to a non-empty string. The default (unset / "")
|
|
@@ -4616,6 +4754,18 @@ class ProcessManager:
|
|
|
4616
4754
|
*cmd,
|
|
4617
4755
|
stdout=asyncio.subprocess.PIPE,
|
|
4618
4756
|
stderr=asyncio.subprocess.PIPE,
|
|
4757
|
+
# stdin=DEVNULL is critical on Windows.
|
|
4758
|
+
# Without it, the child process inherits the parent daemon's stdin.
|
|
4759
|
+
# On Windows, the daemon often has a real console handle on stdin
|
|
4760
|
+
# (e.g. from a CMD/PowerShell window or the Desktop app's console).
|
|
4761
|
+
# Copilot CLI detects this via GetConsoleMode(GetStdHandle(STD_INPUT))
|
|
4762
|
+
# and enters interactive/session-check mode, ignoring -p as a task:
|
|
4763
|
+
# it checks the session todo table (empty), reports "no tracked work
|
|
4764
|
+
# items", and waits for user input instead of executing the task.
|
|
4765
|
+
# On Linux/macOS (daemon as service), stdin is typically /dev/null so
|
|
4766
|
+
# the bug doesn't manifest. Explicit DEVNULL ensures non-interactive
|
|
4767
|
+
# mode on all platforms, matching the intended headless operation.
|
|
4768
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
4619
4769
|
cwd=str(cwd),
|
|
4620
4770
|
env=env,
|
|
4621
4771
|
limit=100 * 1024 * 1024,
|
|
@@ -4713,12 +4863,27 @@ class ProcessManager:
|
|
|
4713
4863
|
if effective_rc == 1 and _no_tools and not copilot_specific_error:
|
|
4714
4864
|
logger.error(
|
|
4715
4865
|
"Copilot exitCode=1 with no tool calls for task %s after retries. "
|
|
4716
|
-
"Likely causes: (1) GitHub auth expired — run `gh auth status
|
|
4717
|
-
"`
|
|
4866
|
+
"Likely causes: (1) GitHub auth expired — run `gh auth status`; if "
|
|
4867
|
+
"expired, run `copilot login` (Copilot's own auth) and `gh auth login`; "
|
|
4868
|
+
"(2) Copilot subscription inactive; "
|
|
4718
4869
|
"(3) --allow-all flag not supported by this CLI version (%s). "
|
|
4719
4870
|
"To reproduce manually: %s",
|
|
4720
4871
|
task_id, agent.version, _cmd_display,
|
|
4721
4872
|
)
|
|
4873
|
+
# Detect auth-specific error for targeted diagnostics.
|
|
4874
|
+
_is_auth_error = any(
|
|
4875
|
+
"gh auth login" in str(msg) or "authenticate with the GitHub" in str(msg)
|
|
4876
|
+
for msg in structured_errors
|
|
4877
|
+
)
|
|
4878
|
+
if _is_auth_error:
|
|
4879
|
+
logger.error(
|
|
4880
|
+
"Copilot auth error for task %s: %s. "
|
|
4881
|
+
"Fix: (1) run 'gh auth login' to refresh gh credentials; "
|
|
4882
|
+
"(2) run 'copilot login' to refresh Copilot's own OAuth token; "
|
|
4883
|
+
"(3) if the issue persists, delete ~/.forgexa/copilot-home/ and retry.",
|
|
4884
|
+
task_id,
|
|
4885
|
+
structured_errors[-1] if structured_errors else "auth failure",
|
|
4886
|
+
)
|
|
4722
4887
|
if copilot_specific_error:
|
|
4723
4888
|
failure_error = copilot_specific_error[:1500]
|
|
4724
4889
|
elif stderr.strip():
|
|
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
|