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.
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/PKG-INFO +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/agent_core.py +138 -29
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/daemon.py +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/main.py +77 -60
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/pyproject.toml +1 -1
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_local_bind_commands.py +111 -2
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/README.md +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/setup.cfg +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.62.3 → forgexa_cli-1.62.5}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.62.
|
|
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
|
-
|
|
866
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
896
|
-
|
|
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
|
|
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
|
|
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-
|
|
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", "
|
|
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": "
|
|
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
|
|
2890
|
-
|
|
2891
|
-
without replacing the user's
|
|
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
|
-
"--
|
|
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", "
|
|
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.
|
|
3233
|
-
|
|
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
|
|
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
|
|
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.
|
|
3429
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
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
|
-
|
|
3336
|
-
"
|
|
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
|
-
|
|
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:
|
|
@@ -106,7 +106,10 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
|
|
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
|
|
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
|