forgexa-cli 1.62.3__tar.gz → 1.62.5__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 (29) hide show
  1. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/PKG-INFO +1 -1
  2. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/agent_core.py +138 -29
  4. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/daemon.py +1 -1
  5. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/main.py +77 -60
  6. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/pyproject.toml +1 -1
  8. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_local_bind_commands.py +111 -2
  9. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/README.md +0 -0
  10. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/_build_config.py +0 -0
  11. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/_local_bind.py +0 -0
  12. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/autoupgrade.py +0 -0
  13. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/runtime_evidence.py +0 -0
  15. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/zcode_protocol.py +0 -0
  16. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  17. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  18. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
  19. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/requires.txt +0 -0
  20. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/top_level.txt +0 -0
  21. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/setup.cfg +0 -0
  22. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_auth_and_runtime_commands.py +0 -0
  23. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_autoupgrade.py +0 -0
  24. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_check_command.py +0 -0
  25. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_expiry_warnings_and_revoke.py +0 -0
  26. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/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.62.3
3
+ Version: 1.62.5
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.62.3"
2
+ __version__ = "1.62.5"
@@ -48,6 +48,7 @@ import logging
48
48
  import os
49
49
  import platform
50
50
  import re
51
+ import shlex
51
52
  import shutil
52
53
  import subprocess
53
54
  import tempfile
@@ -862,8 +863,11 @@ class AgentDiscovery:
862
863
  """Reject a Windows-side agent before its version probe can run via WSL interop."""
863
864
  if sys.platform != "linux" or not os.environ.get("WSL_DISTRO_NAME"):
864
865
  return False
865
- resolved = Path(path).resolve()
866
- if resolved.suffix.lower() in {".exe", ".cmd", ".bat"}:
866
+ try:
867
+ resolved = Path(path).resolve()
868
+ except (OSError, RuntimeError):
869
+ return True
870
+ if resolved.suffix.lower() in {".exe", ".cmd", ".bat", ".ps1", ".com"}:
867
871
  return True
868
872
  parts = resolved.parts
869
873
  if len(parts) >= 3 and parts[1] == "mnt" and len(parts[2]) == 1 and parts[2].isalpha():
@@ -872,15 +876,80 @@ class AgentDiscovery:
872
876
  try:
873
877
  mounts = Path("/proc/mounts").read_text(encoding="utf-8")
874
878
  except OSError:
875
- return False
879
+ return True
876
880
  for line in mounts.splitlines():
877
881
  fields = line.split()
878
882
  if len(fields) >= 3 and fields[2] in {"9p", "drvfs"}:
879
- mount = Path(fields[1].replace("\\040", " "))
883
+ mount = Path(re.sub(r"\\([0-7]{3})", lambda value: chr(int(value[1], 8)), fields[1]))
880
884
  if resolved == mount or mount in resolved.parents:
881
885
  return True
882
886
  return False
883
887
 
888
+ @staticmethod
889
+ def _wsl_execution_issue(
890
+ path: str, env: dict[str, str] | None = None,
891
+ ) -> str | None:
892
+ """Validate the selected WSL CLI and declared interpreter without spawning.
893
+
894
+ This is same-side admission, not a sandbox for the CLI's own commands.
895
+ Native Windows and non-WSL Linux retain their existing launch behavior.
896
+ """
897
+ if sys.platform != "linux" or not os.environ.get("WSL_DISTRO_NAME"):
898
+ return None
899
+ issue = "WSL Agent requires an available Linux-side CLI and interpreter; Windows interop or unverifiable tools are refused. Install the CLI inside the selected WSL distribution."
900
+ candidate = path
901
+ seen: set[str] = set()
902
+ for _ in range(4):
903
+ if not Path(candidate).is_absolute():
904
+ return issue
905
+ if AgentDiscovery._wsl_windows_executable(candidate):
906
+ return issue
907
+ try:
908
+ resolved = Path(candidate).resolve(strict=True)
909
+ if str(resolved) in seen or not resolved.is_file():
910
+ return issue
911
+ seen.add(str(resolved))
912
+ with resolved.open("rb") as stream:
913
+ header = stream.read(512)
914
+ except (OSError, RuntimeError):
915
+ return issue
916
+ if header.startswith(b"MZ"):
917
+ return issue
918
+ if not header.startswith(b"#!"):
919
+ return None
920
+ try:
921
+ line = header.split(b"\n", 1)[0][2:].decode("utf-8", errors="strict")
922
+ parts = shlex.split(line)
923
+ except (UnicodeError, ValueError):
924
+ return issue
925
+ if not parts or not Path(parts[0]).is_absolute():
926
+ return issue
927
+ interpreter = parts[0]
928
+ if Path(interpreter).name == "env":
929
+ if AgentDiscovery._wsl_windows_executable(interpreter):
930
+ return issue
931
+ try:
932
+ with Path(interpreter).open("rb") as stream:
933
+ if stream.read(4) != b"\x7fELF":
934
+ return issue
935
+ except OSError:
936
+ return issue
937
+ arguments = parts[1:]
938
+ if arguments[:1] == ["-S"]:
939
+ arguments = arguments[1:]
940
+ if not arguments or arguments[0].startswith("-") or "=" in arguments[0]:
941
+ return issue
942
+ search_path = (env if env is not None else os.environ).get("PATH", "")
943
+ # Relative/empty PATH entries resolve against the child's cwd,
944
+ # not this host's cwd. Refuse rather than check another tool.
945
+ if not search_path or any(not Path(entry).is_absolute() for entry in search_path.split(os.pathsep)):
946
+ return issue
947
+ interpreter = shutil.which(arguments[0], path=search_path)
948
+ if not interpreter:
949
+ return issue
950
+ candidate = interpreter
951
+ return issue
952
+
884
953
  async def discover(self) -> list[DiscoveredAgent]:
885
954
  self._expand_path()
886
955
  # Probe bwrap support once at discovery time and log a clear warning
@@ -892,8 +961,9 @@ class AgentDiscovery:
892
961
  cmd = custom_path or spec["commands"][0]
893
962
  resolved = shutil.which(cmd)
894
963
  if resolved:
895
- if self._wsl_windows_executable(resolved):
896
- logger.warning("Agent %s resolved only to a Windows executable inside WSL; skipping", agent_id)
964
+ issue = await asyncio.to_thread(self._wsl_execution_issue, resolved)
965
+ if issue:
966
+ logger.warning("Agent %s has an unavailable Linux-side WSL toolchain; skipping", agent_id)
897
967
  continue
898
968
  version = await self._get_version(spec["detect"], resolved)
899
969
  if version is None and "detect_alt" in spec:
@@ -1977,10 +2047,24 @@ def claude_base_env(
1977
2047
  return env
1978
2048
 
1979
2049
 
2050
+ def _mirror_skill_directory(source: Path, destination: Path) -> None:
2051
+ if source.resolve() == destination.resolve():
2052
+ return
2053
+ try:
2054
+ if destination.is_symlink() or destination.is_file():
2055
+ destination.unlink()
2056
+ elif destination.is_dir():
2057
+ shutil.rmtree(destination)
2058
+ if source.is_dir():
2059
+ shutil.copytree(source, destination)
2060
+ except OSError as exc:
2061
+ logger.debug("Agent skill mirror skipped %s -> %s: %s", source, destination, exc)
2062
+
2063
+
1980
2064
  def prepare_claude_environment(
1981
2065
  sandbox_root: Path | None = None,
1982
2066
  ) -> dict[str, str]:
1983
- """Prepare an isolated Claude home and mirror stable configuration files.
2067
+ """Prepare an isolated Claude home and mirror configuration and skills.
1984
2068
 
1985
2069
  When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
1986
2070
  directory. When None, falls back to the shared legacy path under ~/.forgexa.
@@ -1990,8 +2074,8 @@ def prepare_claude_environment(
1990
2074
  Claude use its default config, cache, and session locations under a
1991
2075
  writable task sandbox.
1992
2076
 
1993
- Mirror only stable authentication and settings files, including Claude
1994
- Code's current credential store. Runtime state such as session-env,
2077
+ Mirror stable authentication, settings and installed skill bundles, including
2078
+ Claude Code's current credential store. Runtime state such as session-env,
1995
2079
  projects, task locks, and history must never cross into an isolated daemon
1996
2080
  invocation. Do not set CLAUDE_CODE_DEBUG_LOGS_DIR:
1997
2081
  Claude Code 2.1.63 treats a directory supplied through that variable as
@@ -2028,6 +2112,9 @@ def prepare_claude_environment(
2028
2112
  source, dest, exc,
2029
2113
  )
2030
2114
 
2115
+ for relative_root in (Path(".claude/skills"), Path(".agents/skills")):
2116
+ _mirror_skill_directory(Path.home() / relative_root, sandbox_root / relative_root)
2117
+
2031
2118
  env = {
2032
2119
  "HOME": str(sandbox_root),
2033
2120
  "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
@@ -2375,7 +2462,7 @@ def deepseek_harness_build_info(binary: str) -> dict:
2375
2462
  if result.returncode or not re.fullmatch(r"\d+\.\d+\.\d+", node_version) or parsed is None or not ((parsed[0] == 22 and parsed >= (22, 19, 0)) or parsed[0] >= 24):
2376
2463
  raise RuntimeError("DeepSeek Harness requires Node 22.19+ or Node 24+")
2377
2464
  digest = hashlib.sha256()
2378
- digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-2".encode())
2465
+ digest.update(f"{sys.platform}:{platform.machine()}:{platform.release()}:{node_version}:harness-policy-3".encode())
2379
2466
  extensions = [manifest.parent for manifest in (roots[0] / "node_modules" / "@deepseek-ai").glob("dsh*/package.json")]
2380
2467
  for root in sorted(set([*roots, *extensions])):
2381
2468
  files = [root / "package.json"] + sorted((root / "lib").rglob("*.js"))
@@ -2500,10 +2587,15 @@ def deepseek_harness_overlay(*, cwd: Path, credentials_path: Path, provider: str
2500
2587
  {"id": "tools", "config": {"mode": "native"}},
2501
2588
  {"id": "session-log-deepseek", "config": {"enabled": False}},
2502
2589
  {"id": "plugin-package-inventory-deepseek", "config": {"enabled": False}},
2590
+ {"id": "skill-filesystem", "disabled": False, "config": {
2591
+ "dshHome": str(Path(os.environ.get("DSH_HOME", str(Path.home() / ".dsh"))).expanduser().resolve()),
2592
+ "watch": False,
2593
+ }},
2594
+ {"id": "tool-skill", "disabled": False},
2503
2595
  ]
2504
2596
  disabled = (
2505
2597
  "session-telemetry-otel", "hmr", "plugin-manager", "tool-plugin-manager",
2506
- "llm-pi-ai", "skill-filesystem", "tool-skill", "tool-subagent", "tool-subagent-fork",
2598
+ "llm-pi-ai", "tool-subagent", "tool-subagent-fork",
2507
2599
  "tool-subagent-control", "tool-subagent-list-agents", "tool-workflow",
2508
2600
  "goal-round-driver", "command-goal", "tool-goal", "command-feedback", "tool-web",
2509
2601
  )
@@ -2748,7 +2840,7 @@ def mimo_base_env(env: dict[str, str]) -> dict[str, str]:
2748
2840
  "MIMOCODE_PURE": "true",
2749
2841
  "MIMOCODE_DISABLE_DEFAULT_PLUGINS": "true",
2750
2842
  "MIMOCODE_DISABLE_PROJECT_CONFIG": "true",
2751
- "MIMOCODE_DISABLE_EXTERNAL_SKILLS": "true",
2843
+ "MIMOCODE_DISABLE_EXTERNAL_SKILLS": "false",
2752
2844
  "MIMOCODE_DISABLE_LSP_DOWNLOAD": "true",
2753
2845
  "MIMOCODE_DISABLE_CLAUDE_CODE_MCP": "true",
2754
2846
  "MIMOCODE_DISABLE_AUTOUPDATE": "true",
@@ -2771,7 +2863,7 @@ def mimo_base_env(env: dict[str, str]) -> dict[str, str]:
2771
2863
  },
2772
2864
  "external_directory": "deny",
2773
2865
  "task": "deny",
2774
- "skill": "deny",
2866
+ "skill": {"*": "allow", "compose:*": "deny"},
2775
2867
  "webfetch": "deny",
2776
2868
  "websearch": "deny",
2777
2869
  "codesearch": "deny",
@@ -2886,16 +2978,16 @@ def build_qwen_command(
2886
2978
  ) -> list[str]:
2887
2979
  """Run Qwen Code headlessly with its user-managed local profile.
2888
2980
 
2889
- Qwen Code owns authentication, endpoint, and model configuration. Safe
2890
- mode disables project/user customizations for this Forgexa invocation
2891
- without replacing the user's native profile.
2981
+ Qwen Code owns authentication, endpoint, model and skill discovery. The
2982
+ environment policy disables hooks and MCP; CLI overrides disable extensions
2983
+ without suppressing native skills or replacing the user's profile.
2892
2984
  """
2893
2985
  cmd = [
2894
2986
  binary,
2895
2987
  "--output-format", "stream-json",
2896
2988
  "--include-partial-messages",
2897
2989
  "--approval-mode", approval_mode,
2898
- "--safe-mode",
2990
+ "--extensions", "",
2899
2991
  ]
2900
2992
  if sandbox:
2901
2993
  cmd.append("--sandbox")
@@ -2971,6 +3063,9 @@ def zcode_base_env(env: dict[str, str]) -> dict[str, str]:
2971
3063
  storage.chmod(0o700)
2972
3064
  except OSError as exc:
2973
3065
  raise RuntimeError(f"could not prepare isolated ZCode profile: {exc}") from exc
3066
+ source_home = Path(source_env.get("HOME") or source_env.get("USERPROFILE") or str(Path.home())).expanduser()
3067
+ for relative_root in (Path(".zcode/skills"), Path(".agents/skills")):
3068
+ _mirror_skill_directory(source_home / relative_root, root / relative_root)
2974
3069
  safe_keys = {
2975
3070
  "PATH", "PATHEXT", "SystemRoot", "WINDIR", "COMSPEC",
2976
3071
  "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_CTYPE", "TERM",
@@ -3073,9 +3168,9 @@ def zcode_build_verification_issue(binary: str | Path, env: dict[str, str] | Non
3073
3168
  return None
3074
3169
 
3075
3170
 
3076
- ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write")
3171
+ ZCODE_ALLOWED_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Skill")
3077
3172
  ZCODE_DENIED_TOOLS = (
3078
- "Bash", "MCP", "Plugin", "Skill", "Task", "WebFetch", "WebSearch",
3173
+ "Bash", "MCP", "Plugin", "Task", "WebFetch", "WebSearch",
3079
3174
  )
3080
3175
 
3081
3176
 
@@ -3152,11 +3247,10 @@ def agent_execution_verification_issue(agent_id: str, binary: str) -> str | None
3152
3247
  return None
3153
3248
 
3154
3249
 
3155
- _FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash")
3250
+ _FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill")
3156
3251
  _FORGEXA_CODING_AGENT_DENIED_TOOLS = (
3157
3252
  "Agent",
3158
3253
  "Task",
3159
- "Skill",
3160
3254
  "SlashCommand",
3161
3255
  "WebFetch",
3162
3256
  "WebSearch",
@@ -3229,8 +3323,8 @@ def build_qoder_command(
3229
3323
  """Run Qoder CLI through its isolated headless stream-json protocol.
3230
3324
 
3231
3325
  Qoder receives model selection through its official ``QODER_MODEL``
3232
- environment variable. All settings sources stay disabled so user, project,
3233
- and local hooks, plugins, and skills cannot affect a Forgexa task.
3326
+ environment variable. Settings sources stay disabled to isolate hooks and
3327
+ plugins; installed skills remain available through the managed profile.
3234
3328
  """
3235
3329
  cmd = [
3236
3330
  binary,
@@ -3382,7 +3476,7 @@ def _link_qoder_profile_entry(
3382
3476
 
3383
3477
 
3384
3478
  def qoder_managed_profile_dir(env: dict[str, str] | None = None) -> Path:
3385
- """Create a stable Qoder profile containing only auth and model metadata."""
3479
+ """Create a stable Qoder profile containing auth, model metadata and skills."""
3386
3480
  source_env = os.environ if env is None else env
3387
3481
  source_root = _qoder_source_profile_root(source_env)
3388
3482
  managed_root = _qoder_managed_profile_root(source_root)
@@ -3393,13 +3487,13 @@ def qoder_managed_profile_dir(env: dict[str, str] | None = None) -> Path:
3393
3487
  except OSError as exc:
3394
3488
  raise RuntimeError(f"could not create managed Qoder profile: {exc}") from exc
3395
3489
 
3396
- for name in (".auth", ".models", "installation_id"):
3490
+ for name in (".auth", ".models", "installation_id", "skills"):
3397
3491
  _link_qoder_profile_entry(source_root, managed_root, name)
3398
3492
  return managed_root
3399
3493
 
3400
3494
 
3401
3495
  def qoder_base_env(env: dict[str, str]) -> dict[str, str]:
3402
- """Constrain Qoder while linking only user-managed auth and model metadata."""
3496
+ """Constrain Qoder while preserving user-managed auth, model and skills."""
3403
3497
  env = dict(env)
3404
3498
  local_model = qoder_local_model(env)
3405
3499
  managed_profile = qoder_managed_profile_dir(env)
@@ -3425,9 +3519,8 @@ def qwen_base_env(env: dict[str, str]) -> dict[str, str]:
3425
3519
  """Return a constrained environment without replacing Qwen's local profile.
3426
3520
 
3427
3521
  Authentication, endpoint, and model selection remain entirely under the
3428
- user's Qwen Code configuration. The command's native ``--safe-mode`` flag
3429
- handles customization isolation; this function only disables telemetry for
3430
- the Forgexa-managed process.
3522
+ user's Qwen Code configuration. A system override isolates executable
3523
+ customizations without disabling installed skills.
3431
3524
  """
3432
3525
  env = dict(env)
3433
3526
  for key in (
@@ -3439,7 +3532,23 @@ def qwen_base_env(env: dict[str, str]) -> dict[str, str]:
3439
3532
  "QWEN_CODE_SYSTEM_SETTINGS_PATH",
3440
3533
  ):
3441
3534
  env.pop(key, None)
3535
+ settings_dir = Path.home() / ".forgexa" / "qwen"
3536
+ settings_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
3537
+ settings_path = settings_dir / "settings.json"
3538
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=settings_dir, delete=False) as temporary:
3539
+ json.dump({
3540
+ "disableAllHooks": True,
3541
+ "mcp": {"excluded": ["*"], "serverCommand": ""},
3542
+ "tools": {"discoveryCommand": "", "callCommand": ""},
3543
+ "skills": {"disabledLevels": ["extension"]},
3544
+ }, temporary)
3545
+ temporary_path = Path(temporary.name)
3546
+ try:
3547
+ os.replace(temporary_path, settings_path)
3548
+ finally:
3549
+ temporary_path.unlink(missing_ok=True)
3442
3550
  env.update({
3551
+ "QWEN_CODE_SYSTEM_SETTINGS_PATH": str(settings_path),
3443
3552
  "QWEN_USAGE_STATISTICS_ENABLED": "0",
3444
3553
  "QWEN_TELEMETRY_ENABLED": "0",
3445
3554
  "QWEN_TELEMETRY_LOG_PROMPTS": "0",
@@ -1196,7 +1196,7 @@ except (ImportError, ModuleNotFoundError):
1196
1196
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1197
1197
  # Kept in sync with pyproject.toml version via bump-version.sh.
1198
1198
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1199
- DAEMON_VERSION = "1.62.3"
1199
+ DAEMON_VERSION = "1.62.5"
1200
1200
 
1201
1201
 
1202
1202
  def _detect_client_type() -> str:
@@ -3266,7 +3266,8 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3266
3266
  )
3267
3267
  break
3268
3268
 
3269
- if binding is None:
3269
+ created_binding = binding is None
3270
+ if created_binding:
3270
3271
  binding = _post(
3271
3272
  f"/projects/{args.project}/local-workspace-bindings",
3272
3273
  {"runtime_id": runtime["id"], "multi_binding_capable": True},
@@ -3275,69 +3276,85 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3275
3276
  if not binding_id:
3276
3277
  print("Error: failed to create local workspace binding", file=sys.stderr)
3277
3278
  sys.exit(1)
3278
- expected_identity = binding.get("repo_identity")
3279
- if isinstance(expected_identity, str) and expected_identity != identity:
3280
- try:
3281
- _delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3282
- except Exception:
3283
- pass
3284
- print(
3285
- "Error: this folder's Git remote does not match the selected Forgexa project's repository "
3286
- f"(project: {expected_identity}, this folder: {identity}). "
3287
- "Open the matching folder or update the project's repository URL.",
3288
- file=sys.stderr,
3289
- )
3290
- sys.exit(1)
3291
-
3292
- # A folder can only ever be validly bound to one binding_id at a time -
3293
- # drop any other (necessarily stale) entry already pointing at this same
3294
- # path first. Without this, a stale entry left behind by an earlier
3295
- # attempt (e.g. a re-registered runtime after a reinstall, or a
3296
- # verification that timed out client-side) can linger alongside the new
3297
- # one, and agent_host.py's `_match_local_binding_id` (a plain path scan
3298
- # with no tie-breaking) can resolve to the wrong, orphaned binding_id
3299
- # instead of the one just verified. The whole read-modify-write runs
3300
- # under the cross-process mapping lock shared with the Desktop writer.
3301
- with local_workspaces_mapping_lock(local_path):
3302
- mapping = load_local_workspaces_mapping()
3303
- mapping = {
3304
- key: entry
3305
- for key, entry in mapping.items()
3306
- if key == binding_id or entry.get("local_path") != local_path_str
3307
- }
3308
- mapping[binding_id] = {
3309
- "project_id": args.project,
3310
- "user_id": user_id,
3311
- "runtime_id": runtime["id"],
3312
- "repo_identity": identity,
3313
- "local_path": str(local_path),
3314
- "share_agent_output": True,
3315
- }
3316
- save_local_workspaces_mapping(mapping)
3317
-
3318
- _post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
3319
- deadline = time.monotonic() + 35
3320
- while True:
3321
- verified = _get(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3322
- status = verified.get("status", "unknown") if isinstance(verified, dict) else "unknown"
3323
- if status == "active":
3324
- print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
3325
- break
3326
- if status == "stale":
3327
- print(
3328
- f"Local daemon could not verify binding {binding_id[:8]}: "
3329
- f"{verified.get('validation_error') or 'unknown validation failure'}",
3330
- file=sys.stderr,
3331
- )
3332
- sys.exit(1)
3333
- if time.monotonic() >= deadline:
3279
+ try:
3280
+ expected_identity = binding.get("repo_identity")
3281
+ if isinstance(expected_identity, str) and expected_identity != identity:
3334
3282
  print(
3335
- f"Timed out waiting for the daemon to verify binding {binding_id[:8]}. "
3336
- "Restart or update the daemon and try again.",
3283
+ "Error: this folder's Git remote does not match the selected Forgexa project's repository "
3284
+ f"(project: {expected_identity}, this folder: {identity}). "
3285
+ "Open the matching folder or update the project's repository URL.",
3337
3286
  file=sys.stderr,
3338
3287
  )
3339
3288
  sys.exit(1)
3340
- time.sleep(1)
3289
+ if not created_binding and binding.get("status") == "active":
3290
+ print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
3291
+ return
3292
+
3293
+ # A folder can only ever be validly bound to one binding_id at a time -
3294
+ # drop any other (necessarily stale) entry already pointing at this same
3295
+ # path first. Without this, a stale entry left behind by an earlier
3296
+ # attempt (e.g. a re-registered runtime after a reinstall, or a
3297
+ # verification that timed out client-side) can linger alongside the new
3298
+ # one, and agent_host.py's `_match_local_binding_id` (a plain path scan
3299
+ # with no tie-breaking) can resolve to the wrong, orphaned binding_id
3300
+ # instead of the one just verified. The whole read-modify-write runs
3301
+ # under the cross-process mapping lock shared with the Desktop writer.
3302
+ with local_workspaces_mapping_lock(local_path):
3303
+ mapping = load_local_workspaces_mapping()
3304
+ share_agent_output = mapping.get(binding_id, {}).get("share_agent_output") is not False
3305
+ mapping = {
3306
+ key: entry
3307
+ for key, entry in mapping.items()
3308
+ if key == binding_id
3309
+ or Path(str(entry.get("local_path") or "")).expanduser().resolve() != local_path
3310
+ }
3311
+ mapping[binding_id] = {
3312
+ "project_id": args.project,
3313
+ "user_id": user_id,
3314
+ "runtime_id": runtime["id"],
3315
+ "repo_identity": identity,
3316
+ "local_path": str(local_path),
3317
+ "share_agent_output": share_agent_output,
3318
+ }
3319
+ save_local_workspaces_mapping(mapping)
3320
+
3321
+ _post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
3322
+ deadline = time.monotonic() + 35
3323
+ while True:
3324
+ verified = _get(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3325
+ status = verified.get("status", "unknown") if isinstance(verified, dict) else "unknown"
3326
+ if status == "active":
3327
+ print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
3328
+ break
3329
+ if status == "revoked":
3330
+ print(f"Local workspace binding {binding_id[:8]} was revoked; bind this folder again.", file=sys.stderr)
3331
+ sys.exit(1)
3332
+ if status == "stale":
3333
+ print(
3334
+ f"Local daemon could not verify binding {binding_id[:8]}: "
3335
+ f"{verified.get('validation_error') or 'unknown validation failure'}",
3336
+ file=sys.stderr,
3337
+ )
3338
+ sys.exit(1)
3339
+ if time.monotonic() >= deadline:
3340
+ print(
3341
+ f"Timed out waiting for the daemon to verify binding {binding_id[:8]}. "
3342
+ "Restart or update the daemon and try again.",
3343
+ file=sys.stderr,
3344
+ )
3345
+ sys.exit(1)
3346
+ time.sleep(1)
3347
+ except (Exception, SystemExit, KeyboardInterrupt):
3348
+ if created_binding:
3349
+ try:
3350
+ _delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3351
+ with local_workspaces_mapping_lock(local_path):
3352
+ mapping = load_local_workspaces_mapping()
3353
+ if mapping.pop(binding_id, None) is not None:
3354
+ save_local_workspaces_mapping(mapping)
3355
+ except (Exception, SystemExit):
3356
+ pass
3357
+ raise
3341
3358
 
3342
3359
 
3343
3360
  def cmd_project_unbind_local(args: argparse.Namespace) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.62.3
3
+ Version: 1.62.5
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.62.3"
3
+ version = "1.62.5"
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"
@@ -106,7 +106,10 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
106
106
  }
107
107
 
108
108
 
109
- def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
109
+ @pytest.mark.parametrize("path_alias", [False, True])
110
+ def test_bind_local_replaces_stale_entry_for_same_local_path(
111
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path_alias: bool,
112
+ ) -> None:
110
113
  """A stale entry from an earlier attempt (e.g. before a daemon/runtime
111
114
  reinstall) must not linger alongside a freshly-verified one for the same
112
115
  folder - agent_host.py's _match_local_binding_id does a plain path scan
@@ -125,7 +128,7 @@ def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, mon
125
128
  "user_id": "user-1",
126
129
  "runtime_id": "runtime-old",
127
130
  "repo_identity": "github.com/acme/widgets",
128
- "local_path": str(repo.resolve()),
131
+ "local_path": str(repo.resolve()) + ("/." if path_alias else ""),
129
132
  },
130
133
  }))
131
134
 
@@ -165,6 +168,112 @@ def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, mon
165
168
  }
166
169
 
167
170
 
171
+ @pytest.mark.parametrize("stage", ["mapping", "verification", "stale", "revoked", "timeout", "rollback"])
172
+ def test_bind_local_rolls_back_only_new_binding_on_failure(
173
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stage: str,
174
+ ) -> None:
175
+ from forgexa_cli import _local_bind
176
+
177
+ monkeypatch.setenv("HOME", str(tmp_path))
178
+ repo = tmp_path / "widgets"
179
+ _init_git_repo_with_remote(repo)
180
+ mapping_path = tmp_path / ".forgexa" / "local-workspaces.json"
181
+ mapping_path.parent.mkdir()
182
+ original = {"binding-other": {"project_id": "project-1", "local_path": str(tmp_path / "other")}}
183
+ mapping_path.write_text(json.dumps(original))
184
+ monkeypatch.setattr(main, "_get_runtimes", lambda: [{"id": "runtime-1", "pool_type": "personal"}])
185
+ deletes = []
186
+
187
+ def fake_get(path: str):
188
+ if path == "/auth/me":
189
+ return {"id": "user-1"}
190
+ return {"id": "binding-new", "status": stage if stage in ("stale", "revoked") else "pending"}
191
+
192
+ def fake_post(path: str, data=None):
193
+ if path.endswith("/local-workspace-bindings"):
194
+ return {"id": "binding-new", "status": "pending", "repo_identity": "github.com/acme/widgets"}
195
+ if stage in ("verification", "rollback"):
196
+ raise RuntimeError("Verification unavailable")
197
+ return {"status": "pending"}
198
+
199
+ def fake_delete(path: str):
200
+ deletes.append(path)
201
+ if stage == "rollback":
202
+ raise SystemExit(1)
203
+
204
+ monkeypatch.setattr(main, "_get", fake_get)
205
+ monkeypatch.setattr(main, "_post", fake_post)
206
+ monkeypatch.setattr(main, "_delete", fake_delete)
207
+ clock = iter([0, 36])
208
+ monkeypatch.setattr(main, "time", SimpleNamespace(
209
+ monotonic=lambda: next(clock, 36),
210
+ sleep=lambda duration: pytest.fail("A terminal failure must not keep polling"),
211
+ ))
212
+ if stage == "mapping":
213
+ def fail_save(mapping):
214
+ raise RuntimeError("Mapping unavailable")
215
+ monkeypatch.setattr(_local_bind, "save_local_workspaces_mapping", fail_save)
216
+
217
+ args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
218
+ with pytest.raises((SystemExit, RuntimeError)):
219
+ main.cmd_project_bind_local(args)
220
+
221
+ assert deletes == ["/projects/project-1/local-workspace-bindings/binding-new"]
222
+ mapping = json.loads(mapping_path.read_text())
223
+ assert mapping["binding-other"] == original["binding-other"]
224
+ assert ("binding-new" in mapping) == (stage == "rollback")
225
+
226
+
227
+ @pytest.mark.parametrize("status", ["active", "stale", "repository_mismatch"])
228
+ def test_bind_local_preserves_existing_binding_and_output_sharing(
229
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str,
230
+ ) -> None:
231
+ monkeypatch.setenv("HOME", str(tmp_path))
232
+ repo = tmp_path / "widgets"
233
+ _init_git_repo_with_remote(repo)
234
+ mapping_path = tmp_path / ".forgexa" / "local-workspaces.json"
235
+ mapping_path.parent.mkdir()
236
+ entry = {
237
+ "project_id": "project-1", "user_id": "user-1", "runtime_id": "runtime-1",
238
+ "repo_identity": "github.com/acme/widgets", "local_path": str(repo),
239
+ "share_agent_output": False,
240
+ }
241
+ mapping_path.write_text(json.dumps({"binding-existing": entry}))
242
+ monkeypatch.setattr(main, "_get_runtimes", lambda: [{"id": "runtime-1", "pool_type": "personal"}])
243
+ posts = []
244
+ deletes = []
245
+
246
+ def fake_get(path: str):
247
+ if path == "/auth/me":
248
+ return {"id": "user-1"}
249
+ if path.endswith("/local-workspace-bindings"):
250
+ return [{
251
+ "id": "binding-existing", "status": "active" if status == "repository_mismatch" else status,
252
+ "repo_identity": "github.com/acme/other" if status == "repository_mismatch" else "github.com/acme/widgets",
253
+ "lease_held": status == "active",
254
+ }]
255
+ return {"id": "binding-existing", "status": "active"}
256
+
257
+ def fake_post(path: str, data=None):
258
+ posts.append(path)
259
+ assert path.endswith("/verify")
260
+ return {"status": "pending"}
261
+
262
+ monkeypatch.setattr(main, "_get", fake_get)
263
+ monkeypatch.setattr(main, "_post", fake_post)
264
+ monkeypatch.setattr(main, "_delete", lambda path: deletes.append(path))
265
+ args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
266
+ if status == "repository_mismatch":
267
+ with pytest.raises(SystemExit):
268
+ main.cmd_project_bind_local(args)
269
+ else:
270
+ main.cmd_project_bind_local(args)
271
+
272
+ assert posts == (["/projects/project-1/local-workspace-bindings/binding-existing/verify"] if status == "stale" else [])
273
+ assert deletes == []
274
+ assert json.loads(mapping_path.read_text()) == {"binding-existing": entry}
275
+
276
+
168
277
  def test_bind_local_rejects_non_git_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
169
278
  monkeypatch.setenv("HOME", str(tmp_path))
170
279
  plain_dir = tmp_path / "not-a-repo"
File without changes
File without changes