forgexa-cli 1.64.0__tar.gz → 1.65.0__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.64.0 → forgexa_cli-1.65.0}/PKG-INFO +1 -1
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/daemon.py +133 -33
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/pyproject.toml +1 -1
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/README.md +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/aws_evidence.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/setup.cfg +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.65.0"
|
|
@@ -133,6 +133,9 @@ _MANAGED_WSL_POLICY = getattr(_wsl_policy_module, "ACTIVE_POLICY", None)
|
|
|
133
133
|
async def _create_subprocess_exec(*command, **kwargs):
|
|
134
134
|
if _MANAGED_WSL_POLICY is not None:
|
|
135
135
|
return await _MANAGED_WSL_POLICY.spawn(*command, **kwargs)
|
|
136
|
+
# Desktop stdin is its parent-liveness control pipe, never child input.
|
|
137
|
+
# Agent callers that need prompt input explicitly pass subprocess.PIPE.
|
|
138
|
+
kwargs.setdefault("stdin", asyncio.subprocess.DEVNULL)
|
|
136
139
|
return await asyncio.create_subprocess_exec(*command, **kwargs)
|
|
137
140
|
|
|
138
141
|
|
|
@@ -1231,7 +1234,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1231
1234
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1232
1235
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1233
1236
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1234
|
-
DAEMON_VERSION = "1.
|
|
1237
|
+
DAEMON_VERSION = "1.65.0"
|
|
1235
1238
|
|
|
1236
1239
|
|
|
1237
1240
|
def _detect_client_type() -> str:
|
|
@@ -2767,6 +2770,27 @@ def _normalize_repo_identity(repo_url: str) -> str | None:
|
|
|
2767
2770
|
return f"{host}/{path}"
|
|
2768
2771
|
|
|
2769
2772
|
|
|
2773
|
+
_LOCAL_BINDING_GIT_CLEANUP_TIMEOUT_SECONDS = 2.0
|
|
2774
|
+
_LOCAL_BINDING_VERIFICATION_TIMEOUT_SECONDS = 25.0
|
|
2775
|
+
_LOCAL_BINDING_VERIFICATION_CONCURRENCY = 4
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
async def _stop_local_binding_git_probe(proc) -> None:
|
|
2779
|
+
"""Bound cleanup of this read-only probe, including Windows pipe teardown."""
|
|
2780
|
+
try:
|
|
2781
|
+
proc.kill()
|
|
2782
|
+
except ProcessLookupError:
|
|
2783
|
+
pass
|
|
2784
|
+
except OSError as exc:
|
|
2785
|
+
logger.warning("Local workspace Git probe termination failed (%s)", type(exc).__name__)
|
|
2786
|
+
try:
|
|
2787
|
+
# wait() can remain pending after Windows process exit until its pipe
|
|
2788
|
+
# transport closes. Drain it, but never wait indefinitely for cleanup.
|
|
2789
|
+
await asyncio.wait_for(proc.communicate(), timeout=_LOCAL_BINDING_GIT_CLEANUP_TIMEOUT_SECONDS)
|
|
2790
|
+
except (asyncio.TimeoutError, OSError) as exc:
|
|
2791
|
+
logger.warning("Local workspace Git probe cleanup did not complete (%s)", type(exc).__name__)
|
|
2792
|
+
|
|
2793
|
+
|
|
2770
2794
|
async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id: str) -> str | None:
|
|
2771
2795
|
if _MANAGED_WSL_POLICY is not None:
|
|
2772
2796
|
_MANAGED_WSL_POLICY.check_mapping()
|
|
@@ -2803,6 +2827,7 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
|
|
|
2803
2827
|
proc = await _create_subprocess_exec(
|
|
2804
2828
|
_resolve_git_command() if _MANAGED_WSL_POLICY is not None else "git", "remote", "get-url", "origin",
|
|
2805
2829
|
cwd=str(local_path),
|
|
2830
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
2806
2831
|
stdout=asyncio.subprocess.PIPE,
|
|
2807
2832
|
stderr=asyncio.subprocess.DEVNULL,
|
|
2808
2833
|
**_agent_process_group_kwargs(),
|
|
@@ -2810,10 +2835,15 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
|
|
|
2810
2835
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
2811
2836
|
except asyncio.TimeoutError:
|
|
2812
2837
|
if proc is not None:
|
|
2813
|
-
proc
|
|
2814
|
-
await proc.wait()
|
|
2838
|
+
await _stop_local_binding_git_probe(proc)
|
|
2815
2839
|
return "remote_unreadable"
|
|
2840
|
+
except asyncio.CancelledError:
|
|
2841
|
+
if proc is not None:
|
|
2842
|
+
await _stop_local_binding_git_probe(proc)
|
|
2843
|
+
raise
|
|
2816
2844
|
except OSError:
|
|
2845
|
+
if proc is not None:
|
|
2846
|
+
await _stop_local_binding_git_probe(proc)
|
|
2817
2847
|
return "remote_unreadable"
|
|
2818
2848
|
if proc.returncode != 0:
|
|
2819
2849
|
return "remote_unreadable"
|
|
@@ -9067,6 +9097,8 @@ class HeartbeatService:
|
|
|
9067
9097
|
self.daemon_id = daemon_id
|
|
9068
9098
|
self.interval = interval
|
|
9069
9099
|
self._task: asyncio.Task | None = None
|
|
9100
|
+
self._verification_task: asyncio.Task | None = None
|
|
9101
|
+
self._verification_requested: asyncio.Event | None = None
|
|
9070
9102
|
self._active_tasks = 0
|
|
9071
9103
|
self._agents: list[dict] = []
|
|
9072
9104
|
self._preflight_snapshot_provider = preflight_snapshot_provider or (lambda: None)
|
|
@@ -9081,10 +9113,10 @@ class HeartbeatService:
|
|
|
9081
9113
|
if _MANAGED_WSL_POLICY is not None and _MANAGED_WSL_POLICY.task is not None:
|
|
9082
9114
|
return # The task already retains mapping admission and the physical lock.
|
|
9083
9115
|
try:
|
|
9084
|
-
response = await self.client.get(
|
|
9116
|
+
response = await asyncio.wait_for(self.client.get(
|
|
9085
9117
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/verification",
|
|
9086
9118
|
timeout=10,
|
|
9087
|
-
)
|
|
9119
|
+
), timeout=10)
|
|
9088
9120
|
if response.status_code != 200:
|
|
9089
9121
|
detail = _safe_http_error_detail(response)
|
|
9090
9122
|
logger.warning(
|
|
@@ -9096,40 +9128,78 @@ class HeartbeatService:
|
|
|
9096
9128
|
bindings = response.json()
|
|
9097
9129
|
if not isinstance(bindings, list):
|
|
9098
9130
|
return
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
if
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9131
|
+
# Prioritize fresh bindings and isolate each Native validation. A
|
|
9132
|
+
# slow/broken old directory must not starve another pending one.
|
|
9133
|
+
bindings = sorted(
|
|
9134
|
+
(binding for binding in bindings if isinstance(binding, dict) and binding.get("id")),
|
|
9135
|
+
key=lambda binding: binding.get("status") != "pending",
|
|
9136
|
+
)
|
|
9137
|
+
limit = 1 if _MANAGED_WSL_POLICY is not None else _LOCAL_BINDING_VERIFICATION_CONCURRENCY
|
|
9138
|
+
semaphore = asyncio.Semaphore(limit)
|
|
9139
|
+
|
|
9140
|
+
async def verify_one(binding):
|
|
9141
|
+
async with semaphore:
|
|
9142
|
+
try:
|
|
9143
|
+
await asyncio.wait_for(
|
|
9144
|
+
self._verify_local_workspace_binding(binding),
|
|
9145
|
+
timeout=_LOCAL_BINDING_VERIFICATION_TIMEOUT_SECONDS,
|
|
9146
|
+
)
|
|
9147
|
+
except Exception as exc:
|
|
9148
|
+
# Local exceptions can contain paths; only their type
|
|
9149
|
+
# may enter the daemon log that is uploaded remotely.
|
|
9150
|
+
logger.warning("Local workspace binding verification failed (%s)", type(exc).__name__)
|
|
9151
|
+
|
|
9152
|
+
await asyncio.gather(*(verify_one(binding) for binding in bindings))
|
|
9120
9153
|
except Exception as exc:
|
|
9121
|
-
logger.warning("Local workspace binding verification failed
|
|
9154
|
+
logger.warning("Local workspace binding verification request failed (%s)", type(exc).__name__)
|
|
9155
|
+
|
|
9156
|
+
async def _verify_local_workspace_binding(self, binding: dict[str, Any]) -> None:
|
|
9157
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
9158
|
+
await _MANAGED_WSL_POLICY.sync_binding(self, binding)
|
|
9159
|
+
return
|
|
9160
|
+
failure_code = await _validate_local_workspace_binding(binding, self.runtime_id)
|
|
9161
|
+
if failure_code is None and binding.get("status") == "active":
|
|
9162
|
+
return
|
|
9163
|
+
response = await asyncio.wait_for(self.client.post(
|
|
9164
|
+
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/{binding['id']}/activate",
|
|
9165
|
+
json={"success": failure_code is None, "reason": failure_code},
|
|
9166
|
+
timeout=10,
|
|
9167
|
+
), timeout=10)
|
|
9168
|
+
if response.status_code != 200:
|
|
9169
|
+
detail = _safe_http_error_detail(response)
|
|
9170
|
+
logger.warning(
|
|
9171
|
+
"Local workspace binding verification update failed: %s%s",
|
|
9172
|
+
response.status_code,
|
|
9173
|
+
f" ({detail})" if detail else "",
|
|
9174
|
+
)
|
|
9122
9175
|
|
|
9123
9176
|
async def start(self):
|
|
9177
|
+
if self._task is not None and not self._task.done():
|
|
9178
|
+
return
|
|
9179
|
+
self._verification_requested = asyncio.Event()
|
|
9180
|
+
self._verification_task = asyncio.create_task(self._verification_loop())
|
|
9124
9181
|
self._task = asyncio.create_task(self._loop())
|
|
9125
9182
|
|
|
9126
9183
|
async def stop(self):
|
|
9127
|
-
|
|
9128
|
-
|
|
9184
|
+
tasks = [task for task in (self._task, self._verification_task) if task is not None]
|
|
9185
|
+
for task in tasks:
|
|
9186
|
+
task.cancel()
|
|
9187
|
+
if tasks:
|
|
9188
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
9189
|
+
self._task = self._verification_task = None
|
|
9190
|
+
self._verification_requested = None
|
|
9191
|
+
|
|
9192
|
+
async def _verification_loop(self):
|
|
9193
|
+
requested = self._verification_requested
|
|
9194
|
+
if requested is None:
|
|
9195
|
+
return
|
|
9196
|
+
while True:
|
|
9197
|
+
await requested.wait()
|
|
9198
|
+
requested.clear()
|
|
9129
9199
|
try:
|
|
9130
|
-
await self.
|
|
9131
|
-
except
|
|
9132
|
-
|
|
9200
|
+
await self._sync_local_workspace_bindings()
|
|
9201
|
+
except Exception as exc:
|
|
9202
|
+
logger.warning("Local workspace binding verification cycle failed (%s)", type(exc).__name__)
|
|
9133
9203
|
|
|
9134
9204
|
async def _loop(self):
|
|
9135
9205
|
while True:
|
|
@@ -9157,7 +9227,10 @@ class HeartbeatService:
|
|
|
9157
9227
|
)
|
|
9158
9228
|
if resp.status_code == 200:
|
|
9159
9229
|
self._on_success()
|
|
9160
|
-
|
|
9230
|
+
# Verification has its own single-flight worker. Never
|
|
9231
|
+
# await local Git/activation on the heartbeat's lifetime.
|
|
9232
|
+
if self._verification_requested is not None:
|
|
9233
|
+
self._verification_requested.set()
|
|
9161
9234
|
elif resp.status_code in (401, 403):
|
|
9162
9235
|
logger.warning("Heartbeat auth failed: %s %s", resp.status_code, resp.text)
|
|
9163
9236
|
self._on_auth_failure()
|
|
@@ -18681,10 +18754,37 @@ class RuntimeDaemon:
|
|
|
18681
18754
|
self._shutdown_event.set()
|
|
18682
18755
|
|
|
18683
18756
|
|
|
18757
|
+
def _start_desktop_parent_monitor(daemon, loop):
|
|
18758
|
+
"""Stop only an explicitly Desktop-owned daemon when its parent pipe closes."""
|
|
18759
|
+
if sys.platform != "win32" or os.environ.pop("FORGEXA_DESKTOP_PARENT_PIPE", None) != "1":
|
|
18760
|
+
return None
|
|
18761
|
+
stream = sys.stdin
|
|
18762
|
+
if stream is None:
|
|
18763
|
+
raise RuntimeError("Desktop parent pipe is unavailable")
|
|
18764
|
+
import threading
|
|
18765
|
+
|
|
18766
|
+
def monitor():
|
|
18767
|
+
try:
|
|
18768
|
+
while stream.read(1):
|
|
18769
|
+
pass
|
|
18770
|
+
except (OSError, ValueError):
|
|
18771
|
+
pass
|
|
18772
|
+
try:
|
|
18773
|
+
# Capture the running loop here, not in this background thread.
|
|
18774
|
+
loop.call_soon_threadsafe(daemon.handle_signal, signal.SIGTERM)
|
|
18775
|
+
except RuntimeError:
|
|
18776
|
+
pass # The daemon has already closed its loop.
|
|
18777
|
+
|
|
18778
|
+
thread = threading.Thread(target=monitor, name="desktop-parent-pipe", daemon=True)
|
|
18779
|
+
thread.start()
|
|
18780
|
+
return thread
|
|
18781
|
+
|
|
18782
|
+
|
|
18684
18783
|
async def main():
|
|
18685
18784
|
daemon = RuntimeDaemon()
|
|
18686
18785
|
|
|
18687
18786
|
loop = asyncio.get_event_loop()
|
|
18787
|
+
_start_desktop_parent_monitor(daemon, loop)
|
|
18688
18788
|
if sys.platform != "win32":
|
|
18689
18789
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
18690
18790
|
loop.add_signal_handler(sig, daemon.handle_signal, sig)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|