forgexa-cli 1.14.8__tar.gz → 1.14.10__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.8 → forgexa_cli-1.14.10}/PKG-INFO +1 -1
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli/daemon.py +162 -23
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/pyproject.toml +1 -1
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/README.md +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/setup.cfg +0 -0
- {forgexa_cli-1.14.8 → forgexa_cli-1.14.10}/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.10"
|
|
@@ -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.10"
|
|
527
527
|
|
|
528
528
|
|
|
529
529
|
def _detect_client_type() -> str:
|
|
@@ -1892,8 +1892,16 @@ class WorkspaceManager:
|
|
|
1892
1892
|
# the initial analysis. This ensures a hard error (and workspace
|
|
1893
1893
|
# re-clone) when the branch sync fails, rather than silently proceeding
|
|
1894
1894
|
# with a stale workspace that will cause a non-fast-forward push later.
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
+
)
|
|
1897
1905
|
)
|
|
1898
1906
|
ws_path = await self._create_worktree(
|
|
1899
1907
|
project_dir, repo_url, default_branch, workspace_key, branch_name,
|
|
@@ -2385,25 +2393,66 @@ class WorkspaceManager:
|
|
|
2385
2393
|
cwd=ws_path, timeout=settings.GIT_FETCH_TIMEOUT, project_key=project_key,
|
|
2386
2394
|
)
|
|
2387
2395
|
except RuntimeError as _pre_fe2:
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2396
|
+
_sync_err_str = str(_pre_fe2)
|
|
2397
|
+
_sync_err_lower_pre = _sync_err_str.lower()
|
|
2398
|
+
_is_not_found_pre = (
|
|
2399
|
+
"couldn't find remote ref" in _sync_err_lower_pre
|
|
2400
|
+
or "\u65e0\u6cd5\u627e\u5230\u8fdc\u7a0b\u5f15\u7528" in _sync_err_lower_pre
|
|
2401
|
+
or "remote ref does not exist" in _sync_err_lower_pre
|
|
2392
2402
|
)
|
|
2393
|
-
|
|
2403
|
+
if _is_not_found_pre:
|
|
2404
|
+
# Branch doesn't exist on remote — expected for fresh analysis.
|
|
2405
|
+
# Log at INFO, not WARNING, so operators aren't misled.
|
|
2406
|
+
logger.info(
|
|
2407
|
+
"fetch branch %s not found on remote for worktree %s "
|
|
2408
|
+
"(branch was deleted or never created — normal for fresh analysis)",
|
|
2409
|
+
branch_name, ws_path,
|
|
2410
|
+
)
|
|
2411
|
+
else:
|
|
2412
|
+
logger.warning(
|
|
2413
|
+
"fetch branch %s failed for worktree %s: %s "
|
|
2414
|
+
"(likely auth/SSH issue — will retry in sync loop)",
|
|
2415
|
+
branch_name, ws_path, _pre_fe2,
|
|
2416
|
+
)
|
|
2417
|
+
_last_sync_err = _sync_err_str[:300]
|
|
2394
2418
|
_branch_fetch_ok = False
|
|
2395
2419
|
|
|
2396
2420
|
if fresh_start and not _branch_fetch_ok:
|
|
2397
|
-
#
|
|
2398
|
-
#
|
|
2399
|
-
#
|
|
2400
|
-
#
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2421
|
+
# Distinguish "branch not found" from "auth/network failure".
|
|
2422
|
+
# When the branch simply doesn't exist on the remote, that is the
|
|
2423
|
+
# *expected* state for a fresh analysis restart (the branch was
|
|
2424
|
+
# deleted by a prior fresh run or was never pushed). In that case
|
|
2425
|
+
# keep fresh_start=True so the worktree is correctly reset to the
|
|
2426
|
+
# default branch.
|
|
2427
|
+
# Only block fresh_start when the fetch failed for reasons other
|
|
2428
|
+
# than "not found" (e.g. auth/SSH/network issues) where we genuinely
|
|
2429
|
+
# cannot determine whether implementation commits exist on the remote.
|
|
2430
|
+
_err_lower = _last_sync_err.lower()
|
|
2431
|
+
_branch_not_found = (
|
|
2432
|
+
"couldn't find remote ref" in _err_lower
|
|
2433
|
+
or "无法找到远程引用" in _err_lower # Chinese git output
|
|
2434
|
+
or "remote ref does not exist" in _err_lower
|
|
2435
|
+
or "not found" in _err_lower
|
|
2405
2436
|
)
|
|
2406
|
-
|
|
2437
|
+
if _branch_not_found:
|
|
2438
|
+
# Branch is gone from remote — ideal state for a fresh restart.
|
|
2439
|
+
logger.info(
|
|
2440
|
+
"Fresh start for %s: remote branch not found — expected for a "
|
|
2441
|
+
"fresh analysis restart (branch was deleted or never existed). "
|
|
2442
|
+
"Proceeding with clean worktree reset to origin/%s",
|
|
2443
|
+
branch_name, default_branch,
|
|
2444
|
+
)
|
|
2445
|
+
# fresh_start remains True
|
|
2446
|
+
else:
|
|
2447
|
+
# Cannot determine remote branch state (auth/network failure) —
|
|
2448
|
+
# prohibit reset to avoid silently destroying implementation commits
|
|
2449
|
+
# that may exist on the remote but could not be fetched.
|
|
2450
|
+
logger.warning(
|
|
2451
|
+
"Fresh start blocked for %s: branch fetch failed (%s) — "
|
|
2452
|
+
"remote state unknown, refusing to reset to avoid data loss",
|
|
2453
|
+
branch_name, _last_sync_err[:200],
|
|
2454
|
+
)
|
|
2455
|
+
fresh_start = False
|
|
2407
2456
|
|
|
2408
2457
|
if fresh_start:
|
|
2409
2458
|
# Safety check: if the branch already has IMPLEMENTATION commits
|
|
@@ -4476,23 +4525,35 @@ class ProcessManager:
|
|
|
4476
4525
|
The daemon service runs with ProtectSystem=strict and only allows
|
|
4477
4526
|
writes under ~/.forgexa plus the workspace root. Claude Code writes
|
|
4478
4527
|
config, debug logs, cache, and session state under XDG paths and
|
|
4479
|
-
~/.claude by default,
|
|
4528
|
+
~/.claude by default, and newer builds may still touch $HOME/.claude.json
|
|
4529
|
+
even when XDG paths are set. That can fail with EROFS inside the daemon.
|
|
4480
4530
|
|
|
4481
|
-
Point Claude at writable daemon-owned directories
|
|
4482
|
-
|
|
4531
|
+
Point Claude at writable daemon-owned directories, redirect HOME to
|
|
4532
|
+
the same writable sandbox, and mirror only the small set of user
|
|
4533
|
+
config files it needs for headless execution.
|
|
4483
4534
|
"""
|
|
4484
4535
|
sandbox_root = Path.home() / ".forgexa" / "claude-home"
|
|
4485
4536
|
config_root = sandbox_root / "config"
|
|
4486
4537
|
data_root = sandbox_root / "data"
|
|
4487
4538
|
cache_root = sandbox_root / "cache"
|
|
4488
4539
|
state_root = sandbox_root / "state"
|
|
4540
|
+
home_claude_dir = sandbox_root / ".claude"
|
|
4489
4541
|
claude_config_dir = config_root / "claude"
|
|
4490
4542
|
|
|
4491
|
-
for path in (
|
|
4543
|
+
for path in (
|
|
4544
|
+
config_root,
|
|
4545
|
+
data_root,
|
|
4546
|
+
cache_root,
|
|
4547
|
+
state_root,
|
|
4548
|
+
home_claude_dir,
|
|
4549
|
+
claude_config_dir,
|
|
4550
|
+
):
|
|
4492
4551
|
path.mkdir(parents=True, exist_ok=True)
|
|
4493
4552
|
|
|
4494
4553
|
source_files = [
|
|
4554
|
+
(Path.home() / ".claude.json", sandbox_root / ".claude.json"),
|
|
4495
4555
|
(Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
|
|
4556
|
+
(Path.home() / ".claude" / "settings.json", home_claude_dir / "settings.json"),
|
|
4496
4557
|
(Path.home() / ".claude" / "settings.json", claude_config_dir / "settings.json"),
|
|
4497
4558
|
]
|
|
4498
4559
|
for source, dest in source_files:
|
|
@@ -4506,6 +4567,7 @@ class ProcessManager:
|
|
|
4506
4567
|
)
|
|
4507
4568
|
|
|
4508
4569
|
env = {
|
|
4570
|
+
"HOME": str(sandbox_root),
|
|
4509
4571
|
"XDG_CONFIG_HOME": str(config_root),
|
|
4510
4572
|
"XDG_DATA_HOME": str(data_root),
|
|
4511
4573
|
"XDG_CACHE_HOME": str(cache_root),
|
|
@@ -4533,6 +4595,17 @@ class ProcessManager:
|
|
|
4533
4595
|
|
|
4534
4596
|
Mirror the user-facing ~/.copilot state into a writable daemon-owned
|
|
4535
4597
|
directory, then point COPILOT_HOME there for non-interactive runs.
|
|
4598
|
+
|
|
4599
|
+
Stale-auth cleanup: Before the per-task isolation fix, the daemon
|
|
4600
|
+
pointed COPILOT_HOME directly at this master directory, so Copilot
|
|
4601
|
+
wrote its own OAuth token (plain-text on Linux systems without a
|
|
4602
|
+
credential store) here. Those files are NOT present in ~/.copilot/
|
|
4603
|
+
(the user's interactive home), so the normal mirror loop never
|
|
4604
|
+
overwrites them — they persist across restarts and poison isolated
|
|
4605
|
+
temp dirs. We clean up any file/dir in the master home that has no
|
|
4606
|
+
counterpart in ~/.copilot/ (excluding session-state and logs which
|
|
4607
|
+
are deliberately retained) to ensure the master home stays a clean
|
|
4608
|
+
mirror of the user-facing directory.
|
|
4536
4609
|
"""
|
|
4537
4610
|
source_root = Path.home() / ".copilot"
|
|
4538
4611
|
target_root = Path.home() / ".forgexa" / "copilot-home"
|
|
@@ -4541,6 +4614,26 @@ class ProcessManager:
|
|
|
4541
4614
|
if not source_root.exists():
|
|
4542
4615
|
return str(target_root)
|
|
4543
4616
|
|
|
4617
|
+
# Collect names present in source for stale-file detection.
|
|
4618
|
+
source_names: set[str] = {child.name for child in source_root.iterdir()}
|
|
4619
|
+
|
|
4620
|
+
# Remove master-home entries that are absent from ~/.copilot/.
|
|
4621
|
+
# These are typically old Copilot OAuth token files written when
|
|
4622
|
+
# COPILOT_HOME pointed here directly (pre-isolation-fix era).
|
|
4623
|
+
_keep_always = {"session-state", "logs"}
|
|
4624
|
+
for target_child in list(target_root.iterdir()):
|
|
4625
|
+
if target_child.name in _keep_always:
|
|
4626
|
+
continue
|
|
4627
|
+
if target_child.name not in source_names:
|
|
4628
|
+
try:
|
|
4629
|
+
if target_child.is_dir():
|
|
4630
|
+
shutil.rmtree(str(target_child), ignore_errors=True)
|
|
4631
|
+
else:
|
|
4632
|
+
target_child.unlink(missing_ok=True)
|
|
4633
|
+
logger.debug("Copilot home: removed stale %s", target_child.name)
|
|
4634
|
+
except Exception as exc:
|
|
4635
|
+
logger.debug("Copilot home: could not remove stale %s: %s", target_child.name, exc)
|
|
4636
|
+
|
|
4544
4637
|
for child in source_root.iterdir():
|
|
4545
4638
|
if child.name in {"logs", "session-state"}:
|
|
4546
4639
|
continue
|
|
@@ -4628,6 +4721,37 @@ class ProcessManager:
|
|
|
4628
4721
|
env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
|
|
4629
4722
|
env["COPILOT_HOME"] = _isolated_copilot_home
|
|
4630
4723
|
|
|
4724
|
+
# Inject GH_TOKEN from the gh CLI for headless/automation auth.
|
|
4725
|
+
# Per `copilot login --help`, env-var auth is the recommended method
|
|
4726
|
+
# for automation: COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN.
|
|
4727
|
+
# GH_TOKEN accepts OAuth tokens from the GitHub CLI (gh) app, which
|
|
4728
|
+
# is exactly what `gh auth token` returns.
|
|
4729
|
+
#
|
|
4730
|
+
# This bypasses any stale Copilot-specific OAuth token file that may
|
|
4731
|
+
# be cached in COPILOT_HOME from an earlier run (e.g., after a
|
|
4732
|
+
# password change when `gh auth login` refreshes gh's token but does
|
|
4733
|
+
# NOT update Copilot's own stored token). After `gh auth login` the
|
|
4734
|
+
# daemon works again without requiring `copilot login` or manual
|
|
4735
|
+
# deletion of ~/.forgexa/copilot-home/.
|
|
4736
|
+
if not any(env.get(k) for k in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")):
|
|
4737
|
+
try:
|
|
4738
|
+
_gh_proc = await asyncio.create_subprocess_exec(
|
|
4739
|
+
"gh", "auth", "token",
|
|
4740
|
+
stdout=asyncio.subprocess.PIPE,
|
|
4741
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
4742
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
4743
|
+
)
|
|
4744
|
+
_gh_token_out, _ = await asyncio.wait_for(_gh_proc.communicate(), timeout=10.0)
|
|
4745
|
+
_gh_token = (_gh_token_out or b"").decode().strip()
|
|
4746
|
+
if _gh_token:
|
|
4747
|
+
env["GH_TOKEN"] = _gh_token
|
|
4748
|
+
logger.debug("Copilot task %s: injected GH_TOKEN from gh CLI", task_id)
|
|
4749
|
+
except Exception as _gh_exc:
|
|
4750
|
+
logger.debug(
|
|
4751
|
+
"Copilot task %s: could not inject GH_TOKEN (%s); falling back to stored auth",
|
|
4752
|
+
task_id, _gh_exc,
|
|
4753
|
+
)
|
|
4754
|
+
|
|
4631
4755
|
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
4632
4756
|
# FACTORY_COPILOT_REASONING is opt-in: only pass --effort when the env
|
|
4633
4757
|
# var is explicitly set to a non-empty string. The default (unset / "")
|
|
@@ -4768,12 +4892,27 @@ class ProcessManager:
|
|
|
4768
4892
|
if effective_rc == 1 and _no_tools and not copilot_specific_error:
|
|
4769
4893
|
logger.error(
|
|
4770
4894
|
"Copilot exitCode=1 with no tool calls for task %s after retries. "
|
|
4771
|
-
"Likely causes: (1) GitHub auth expired — run `gh auth status
|
|
4772
|
-
"`
|
|
4895
|
+
"Likely causes: (1) GitHub auth expired — run `gh auth status`; if "
|
|
4896
|
+
"expired, run `copilot login` (Copilot's own auth) and `gh auth login`; "
|
|
4897
|
+
"(2) Copilot subscription inactive; "
|
|
4773
4898
|
"(3) --allow-all flag not supported by this CLI version (%s). "
|
|
4774
4899
|
"To reproduce manually: %s",
|
|
4775
4900
|
task_id, agent.version, _cmd_display,
|
|
4776
4901
|
)
|
|
4902
|
+
# Detect auth-specific error for targeted diagnostics.
|
|
4903
|
+
_is_auth_error = any(
|
|
4904
|
+
"gh auth login" in str(msg) or "authenticate with the GitHub" in str(msg)
|
|
4905
|
+
for msg in structured_errors
|
|
4906
|
+
)
|
|
4907
|
+
if _is_auth_error:
|
|
4908
|
+
logger.error(
|
|
4909
|
+
"Copilot auth error for task %s: %s. "
|
|
4910
|
+
"Fix: (1) run 'gh auth login' to refresh gh credentials; "
|
|
4911
|
+
"(2) run 'copilot login' to refresh Copilot's own OAuth token; "
|
|
4912
|
+
"(3) if the issue persists, delete ~/.forgexa/copilot-home/ and retry.",
|
|
4913
|
+
task_id,
|
|
4914
|
+
structured_errors[-1] if structured_errors else "auth failure",
|
|
4915
|
+
)
|
|
4777
4916
|
if copilot_specific_error:
|
|
4778
4917
|
failure_error = copilot_specific_error[:1500]
|
|
4779
4918
|
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
|