forgexa-cli 1.63.0__tar.gz → 1.63.1__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.63.0 → forgexa_cli-1.63.1}/PKG-INFO +1 -1
  2. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/daemon.py +169 -57
  4. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/pyproject.toml +1 -1
  6. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/README.md +0 -0
  7. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli/zcode_protocol.py +0 -0
  15. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/setup.cfg +0 -0
  21. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.63.0 → forgexa_cli-1.63.1}/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.63.0
3
+ Version: 1.63.1
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.63.0"
2
+ __version__ = "1.63.1"
@@ -114,7 +114,6 @@ _redirect_prompt_through_task_file = agent_core._redirect_prompt_through_task_fi
114
114
  _agent_process_group_kwargs = agent_core._agent_process_group_kwargs
115
115
  _parse_semver = agent_core._parse_semver
116
116
  _check_agent_min_version = agent_core._check_agent_min_version
117
- _kill_proc = agent_core._kill_proc
118
117
  _extract_output_signals = agent_core._extract_output_signals
119
118
  _should_scan_short_success_stdout = agent_core._should_scan_short_success_stdout
120
119
  _copilot_completed_without_result = agent_core._copilot_completed_without_result
@@ -126,6 +125,23 @@ _parse_kimi_wire_metrics = agent_core._parse_kimi_wire_metrics
126
125
  _parse_copilot_output = agent_core._parse_copilot_output
127
126
  _extract_copilot_exit_code = agent_core._extract_copilot_exit_code
128
127
 
128
+ # Only the fixed, admitted entry can select managed mode, never an env flag.
129
+ _wsl_policy_module = sys.modules.get("app.wsl_runtime_policy") or sys.modules.get("wsl_runtime_policy")
130
+ _MANAGED_WSL_POLICY = getattr(_wsl_policy_module, "ACTIVE_POLICY", None)
131
+
132
+
133
+ async def _create_subprocess_exec(*command, **kwargs):
134
+ if _MANAGED_WSL_POLICY is not None:
135
+ return await _MANAGED_WSL_POLICY.spawn(*command, **kwargs)
136
+ return await asyncio.create_subprocess_exec(*command, **kwargs)
137
+
138
+
139
+ def _kill_proc(proc) -> None:
140
+ if _MANAGED_WSL_POLICY is not None:
141
+ _MANAGED_WSL_POLICY.cancel_process(proc)
142
+ else:
143
+ agent_core._kill_proc(proc)
144
+
129
145
  # ── httpx dependency — robust auto-install for standalone environments ──
130
146
  # When running inside the backend package, httpx is a declared dependency and
131
147
  # always available. In standalone contexts (desktop AppImage/DMG/MSI, CLI
@@ -136,10 +152,13 @@ _extract_copilot_exit_code = agent_core._extract_copilot_exit_code
136
152
  # 3. Auto-install via pip --target to the cached deps dir
137
153
  # (bypasses PEP 668 / externally-managed-environment on modern distros)
138
154
  # 4. Friendly error with OS-specific instructions if all else fails
139
- _HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
155
+ _HTTPX_DEPS_DIR = (str(_MANAGED_WSL_POLICY.state / "deps") if _MANAGED_WSL_POLICY is not None
156
+ else os.path.join(str(Path.home()), ".forgexa", "daemon", "deps"))
140
157
 
141
158
 
142
159
  def _cli_config_path() -> Path:
160
+ if _MANAGED_WSL_POLICY is not None:
161
+ return _MANAGED_WSL_POLICY.state / "config.json"
143
162
  return Path.home() / ".forgexa" / "config"
144
163
 
145
164
 
@@ -230,10 +249,14 @@ def _local_runtime_registration_path() -> Path:
230
249
  nor workspace paths, so desktop clients can safely use it to avoid
231
250
  binding a local folder to a runtime on another machine.
232
251
  """
252
+ if _MANAGED_WSL_POLICY is not None:
253
+ return _MANAGED_WSL_POLICY.state / "runtime-registrations.json"
233
254
  return Path.home() / ".forgexa" / "daemon" / "runtime-registrations.json"
234
255
 
235
256
 
236
257
  def _desktop_daemon_restart_request_path(instance_id: str) -> Path:
258
+ if _MANAGED_WSL_POLICY is not None:
259
+ return _MANAGED_WSL_POLICY.state / "restart-requests" / f"{instance_id}.json"
237
260
  return (
238
261
  Path.home()
239
262
  / ".forgexa"
@@ -324,6 +347,8 @@ def _clear_local_runtime_registration_snapshot(instance_id: str) -> None:
324
347
 
325
348
 
326
349
  def _cli_credentials_path() -> Path:
350
+ if _MANAGED_WSL_POLICY is not None:
351
+ return _MANAGED_WSL_POLICY.state / "credentials.json"
327
352
  return Path.home() / ".forgexa" / "credentials.json"
328
353
 
329
354
 
@@ -374,6 +399,11 @@ def _migrate_legacy_cli_credentials(*, write_lock_held: bool = False) -> dict:
374
399
 
375
400
  def _load_cli_credentials(*, write_lock_held: bool = False) -> dict:
376
401
  """Load issuer-scoped credentials, migrating legacy config on first use."""
402
+ if _MANAGED_WSL_POLICY is not None:
403
+ return {_normalize_issuer_url(_MANAGED_WSL_POLICY.config.serverUrl): {
404
+ "runtime_credential": _MANAGED_WSL_POLICY.credential,
405
+ "runtime_id": _MANAGED_WSL_POLICY.runtime_id,
406
+ }}
377
407
  path = _cli_credentials_path()
378
408
  if path.exists():
379
409
  try:
@@ -920,6 +950,9 @@ def _validate_httpx_imports() -> tuple[bool, str]:
920
950
  # Actual import sequence
921
951
  _httpx_ok, _httpx_missing = _validate_httpx_imports()
922
952
 
953
+ if not _httpx_ok and _MANAGED_WSL_POLICY is not None:
954
+ raise RuntimeError("Managed WSL runtime dependency closure is incomplete")
955
+
923
956
  if not _httpx_ok:
924
957
  # Check cached deps directory (previous auto-install)
925
958
  if _HTTPX_DEPS_DIR not in sys.path:
@@ -997,6 +1030,8 @@ def _make_httpx_ssl_context():
997
1030
 
998
1031
  # ── Settings: graceful fallback when running standalone (outside backend package) ──
999
1032
  try:
1033
+ if _MANAGED_WSL_POLICY is not None:
1034
+ raise ModuleNotFoundError("Managed WSL uses admitted standalone settings")
1000
1035
  from app.config import settings
1001
1036
  except (ImportError, ModuleNotFoundError):
1002
1037
  # Running standalone (e.g., from desktop app bundle or forgexa-cli).
@@ -1196,7 +1231,7 @@ except (ImportError, ModuleNotFoundError):
1196
1231
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1197
1232
  # Kept in sync with pyproject.toml version via bump-version.sh.
1198
1233
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1199
- DAEMON_VERSION = "1.63.0"
1234
+ DAEMON_VERSION = "1.63.1"
1200
1235
 
1201
1236
 
1202
1237
  def _detect_client_type() -> str:
@@ -1234,7 +1269,8 @@ _CLIENT_TYPE = _detect_client_type()
1234
1269
  # so logs are visible on the terminal without duplication.
1235
1270
  # Set DAEMON_NO_CONSOLE_LOG=1 to suppress the stream handler even on a TTY
1236
1271
  # (used by `forgexa daemon start` foreground mode for clean UX).
1237
- _log_dir = Path.home() / ".forgexa" / "daemon"
1272
+ _log_dir = (_MANAGED_WSL_POLICY.state / "logs" if _MANAGED_WSL_POLICY is not None
1273
+ else Path.home() / ".forgexa" / "daemon")
1238
1274
  _log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
1239
1275
  # Ensure directory has restricted permissions even if it already existed
1240
1276
  _log_dir.chmod(0o700)
@@ -1512,6 +1548,8 @@ def _agent_dicts_for_wire(agents: "list[DiscoveredAgent]") -> list[dict]:
1512
1548
 
1513
1549
  def _resolve_git_command() -> str | None:
1514
1550
  """Return Git's executable, including common Windows installer locations."""
1551
+ if _MANAGED_WSL_POLICY is not None:
1552
+ return _MANAGED_WSL_POLICY.executable("git")
1515
1553
  resolved = shutil.which("git")
1516
1554
  if resolved or sys.platform != "win32":
1517
1555
  return resolved
@@ -2478,6 +2516,8 @@ def _local_workspaces_mapping_path() -> Path:
2478
2516
 
2479
2517
 
2480
2518
  def _load_local_workspaces_mapping() -> dict:
2519
+ if _MANAGED_WSL_POLICY is not None:
2520
+ return _MANAGED_WSL_POLICY.mapping()
2481
2521
  path = _local_workspaces_mapping_path()
2482
2522
  if not path.exists():
2483
2523
  return {}
@@ -2519,6 +2559,8 @@ _LOCAL_BIND_SHARED_OUTPUT_MAX_LINES = 5_000
2519
2559
 
2520
2560
 
2521
2561
  def _local_bind_output_log_path(task_id: str) -> Path:
2562
+ if _MANAGED_WSL_POLICY is not None:
2563
+ return _MANAGED_WSL_POLICY.output_path(task_id)
2522
2564
  return Path.home() / ".forgexa" / "local-bind-output" / f"{task_id}.log"
2523
2565
 
2524
2566
 
@@ -2530,6 +2572,9 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
2530
2572
  """
2531
2573
  if not lines:
2532
2574
  return
2575
+ if _MANAGED_WSL_POLICY is not None:
2576
+ _MANAGED_WSL_POLICY.append_output(task_id, lines)
2577
+ return
2533
2578
  try:
2534
2579
  path = _local_bind_output_log_path(task_id)
2535
2580
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -2544,6 +2589,8 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
2544
2589
 
2545
2590
  def _local_bind_output_sharing_enabled(binding_id: str | None) -> bool:
2546
2591
  """Return the private binding's automatic transcript-sharing preference."""
2592
+ if _MANAGED_WSL_POLICY is not None:
2593
+ return False
2547
2594
  if not binding_id:
2548
2595
  return False
2549
2596
  entry = _load_local_workspaces_mapping().get(binding_id)
@@ -2588,6 +2635,8 @@ def _read_local_bind_shared_output_snapshot(task_id: str) -> dict | None:
2588
2635
 
2589
2636
  def _prepare_local_bind_completion(task: "TaskInfo", result: "TaskResult") -> None:
2590
2637
  """Attach the automatic private snapshot before stripping normal local output."""
2638
+ if _MANAGED_WSL_POLICY is not None:
2639
+ result.shared_local_output = None
2591
2640
  if _local_bind_output_sharing_enabled(task.local_workspace_binding_id):
2592
2641
  result.shared_local_output = _read_local_bind_shared_output_snapshot(task.task_id)
2593
2642
  _filter_local_bind_task_result(result)
@@ -2719,6 +2768,11 @@ def _normalize_repo_identity(repo_url: str) -> str | None:
2719
2768
 
2720
2769
 
2721
2770
  async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id: str) -> str | None:
2771
+ if _MANAGED_WSL_POLICY is not None:
2772
+ _MANAGED_WSL_POLICY.check_mapping()
2773
+ expected = _MANAGED_WSL_POLICY.config.binding
2774
+ if runtime_id != _MANAGED_WSL_POLICY.runtime_id or not expected or any(binding.get(key) != value for key, value in expected.items()):
2775
+ return "mapping_mismatch"
2722
2776
  binding_id = str(binding.get("id") or "")
2723
2777
  entry = _load_local_workspaces_mapping().get(binding_id)
2724
2778
  if not binding_id or not isinstance(entry, dict):
@@ -2746,8 +2800,8 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
2746
2800
 
2747
2801
  proc = None
2748
2802
  try:
2749
- proc = await asyncio.create_subprocess_exec(
2750
- "git", "remote", "get-url", "origin",
2803
+ proc = await _create_subprocess_exec(
2804
+ _resolve_git_command() if _MANAGED_WSL_POLICY is not None else "git", "remote", "get-url", "origin",
2751
2805
  cwd=str(local_path),
2752
2806
  stdout=asyncio.subprocess.PIPE,
2753
2807
  stderr=asyncio.subprocess.DEVNULL,
@@ -2826,6 +2880,8 @@ def _try_acquire_local_workspace_file_lock(binding_id: str, workspace_path: Path
2826
2880
 
2827
2881
 
2828
2882
  def _release_local_workspace_file_lock(lock_file) -> None:
2883
+ if _MANAGED_WSL_POLICY is not None and lock_file is _MANAGED_WSL_POLICY.task_lock:
2884
+ return # Only verified empty-tree cleanup releases the inherited locks.
2829
2885
  agent_core.release_file_lock(lock_file)
2830
2886
 
2831
2887
 
@@ -3573,6 +3629,9 @@ class WorkspaceManager:
3573
3629
  fails closed with a "local_binding_unavailable:" prefixed error. See
3574
3630
  docs/designs/forgexa-ide-design.md §6.2.
3575
3631
  """
3632
+ if _MANAGED_WSL_POLICY is not None:
3633
+ _MANAGED_WSL_POLICY.validate_task(task, runtime_id)
3634
+ _MANAGED_WSL_POLICY.check_mapping()
3576
3635
  binding_id = task.local_workspace_binding_id
3577
3636
  if not binding_id:
3578
3637
  raise RuntimeError("local_binding_unavailable: task has no local_workspace_binding_id")
@@ -4069,7 +4128,7 @@ class WorkspaceManager:
4069
4128
  # Warn if the clone has uncommitted changes so the operator knows
4070
4129
  # what will be lost when it is removed (L3 fix).
4071
4130
  try:
4072
- st_proc = await asyncio.create_subprocess_exec(
4131
+ st_proc = await _create_subprocess_exec(
4073
4132
  "git", "status", "--porcelain",
4074
4133
  cwd=str(ws_path),
4075
4134
  stdout=asyncio.subprocess.PIPE,
@@ -4242,7 +4301,7 @@ class WorkspaceManager:
4242
4301
  """
4243
4302
  try:
4244
4303
  # Get the local HEAD SHA
4245
- local_proc = await asyncio.create_subprocess_exec(
4304
+ local_proc = await _create_subprocess_exec(
4246
4305
  "git", "rev-parse", "HEAD",
4247
4306
  cwd=str(repo_path),
4248
4307
  stdout=asyncio.subprocess.PIPE,
@@ -4271,7 +4330,7 @@ class WorkspaceManager:
4271
4330
  # If HEAD is ahead of the merge-base, we count local-only commits — any
4272
4331
  # count > 0 means we should NOT call the branch-r check.
4273
4332
  try:
4274
- ahead_proc = await asyncio.create_subprocess_exec(
4333
+ ahead_proc = await _create_subprocess_exec(
4275
4334
  "git", "rev-list", "--count", "--right-only", "origin...HEAD",
4276
4335
  cwd=str(repo_path),
4277
4336
  stdout=asyncio.subprocess.PIPE,
@@ -4293,7 +4352,7 @@ class WorkspaceManager:
4293
4352
  except Exception:
4294
4353
  pass # If the ahead count fails, proceed with the branch-r check
4295
4354
 
4296
- check_proc = await asyncio.create_subprocess_exec(
4355
+ check_proc = await _create_subprocess_exec(
4297
4356
  "git", "branch", "-r", "--contains", local_head,
4298
4357
  cwd=str(repo_path),
4299
4358
  stdout=asyncio.subprocess.PIPE,
@@ -5184,7 +5243,7 @@ class WorkspaceManager:
5184
5243
  last_branch_fetch_error: str | None = None
5185
5244
  for _check_attempt in range(3 if not fresh_start else 1):
5186
5245
  try:
5187
- check_proc = await asyncio.create_subprocess_exec(
5246
+ check_proc = await _create_subprocess_exec(
5188
5247
  "git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}",
5189
5248
  cwd=str(main_repo),
5190
5249
  stdout=asyncio.subprocess.PIPE,
@@ -5684,7 +5743,7 @@ class WorkspaceManager:
5684
5743
  # orphaned processes from keeping pipes alive.
5685
5744
  # Windows note: start_new_session creates a new console process group;
5686
5745
  # we use taskkill /T there instead of killpg.
5687
- proc = await asyncio.create_subprocess_exec(
5746
+ proc = await _create_subprocess_exec(
5688
5747
  git_command, *longpath_args, *git_prefix_args, *args,
5689
5748
  stdin=asyncio.subprocess.DEVNULL,
5690
5749
  stdout=asyncio.subprocess.PIPE,
@@ -5709,7 +5768,9 @@ class WorkspaceManager:
5709
5768
  # ssh grandchild becomes orphaned, keeps the stderr pipe open,
5710
5769
  # and proc.communicate() can never return EOF.
5711
5770
  try:
5712
- if sys.platform != "win32":
5771
+ if _MANAGED_WSL_POLICY is not None:
5772
+ _kill_proc(proc)
5773
+ elif sys.platform != "win32":
5713
5774
  import signal as _signal
5714
5775
  try:
5715
5776
  os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
@@ -6752,7 +6813,7 @@ class ProcessManager:
6752
6813
  env.update(self._prepare_claude_environment(sandbox_root=Path(_claude_home)))
6753
6814
 
6754
6815
  try:
6755
- proc = await asyncio.create_subprocess_exec(
6816
+ proc = await _create_subprocess_exec(
6756
6817
  *cmd,
6757
6818
  stdout=asyncio.subprocess.PIPE,
6758
6819
  stderr=asyncio.subprocess.PIPE,
@@ -7828,7 +7889,7 @@ class ProcessManager:
7828
7889
  pass
7829
7890
 
7830
7891
  try:
7831
- proc = await asyncio.create_subprocess_exec(
7892
+ proc = await _create_subprocess_exec(
7832
7893
  *cmd,
7833
7894
  stdout=asyncio.subprocess.PIPE,
7834
7895
  stderr=asyncio.subprocess.PIPE,
@@ -8221,7 +8282,7 @@ class ProcessManager:
8221
8282
  proc = None
8222
8283
  try:
8223
8284
  cmd = _prepare_agent_command(cmd)
8224
- proc = await asyncio.create_subprocess_exec(
8285
+ proc = await _create_subprocess_exec(
8225
8286
  *cmd,
8226
8287
  stdout=asyncio.subprocess.PIPE,
8227
8288
  stderr=asyncio.subprocess.PIPE,
@@ -8313,7 +8374,7 @@ class ProcessManager:
8313
8374
  """Collect git diff stats from workspace."""
8314
8375
  info: dict[str, Any] = {}
8315
8376
  try:
8316
- proc = await asyncio.create_subprocess_exec(
8377
+ proc = await _create_subprocess_exec(
8317
8378
  "git", "diff", "--stat", "--cached",
8318
8379
  stdout=asyncio.subprocess.PIPE,
8319
8380
  stderr=asyncio.subprocess.PIPE,
@@ -8324,7 +8385,7 @@ class ProcessManager:
8324
8385
  stat_output = stdout.decode()
8325
8386
 
8326
8387
  # Also check unstaged
8327
- proc2 = await asyncio.create_subprocess_exec(
8388
+ proc2 = await _create_subprocess_exec(
8328
8389
  "git", "diff", "--stat",
8329
8390
  stdout=asyncio.subprocess.PIPE,
8330
8391
  stderr=asyncio.subprocess.PIPE,
@@ -8335,7 +8396,7 @@ class ProcessManager:
8335
8396
  stat_output += stdout2.decode()
8336
8397
 
8337
8398
  # Parse changed files
8338
- proc3 = await asyncio.create_subprocess_exec(
8399
+ proc3 = await _create_subprocess_exec(
8339
8400
  "git", "diff", "--name-only", "HEAD",
8340
8401
  stdout=asyncio.subprocess.PIPE,
8341
8402
  stderr=asyncio.subprocess.PIPE,
@@ -8347,7 +8408,7 @@ class ProcessManager:
8347
8408
  info["files_changed"] = files
8348
8409
 
8349
8410
  # Parse insertions/deletions
8350
- proc4 = await asyncio.create_subprocess_exec(
8411
+ proc4 = await _create_subprocess_exec(
8351
8412
  "git", "diff", "--shortstat", "HEAD",
8352
8413
  stdout=asyncio.subprocess.PIPE,
8353
8414
  stderr=asyncio.subprocess.PIPE,
@@ -8362,7 +8423,7 @@ class ProcessManager:
8362
8423
  info["lines_removed"] = int(removed.group(1)) if removed else 0
8363
8424
 
8364
8425
  # Get current branch
8365
- proc5 = await asyncio.create_subprocess_exec(
8426
+ proc5 = await _create_subprocess_exec(
8366
8427
  "git", "rev-parse", "--abbrev-ref", "HEAD",
8367
8428
  stdout=asyncio.subprocess.PIPE,
8368
8429
  stderr=asyncio.subprocess.PIPE,
@@ -8373,7 +8434,7 @@ class ProcessManager:
8373
8434
  info["branch"] = stdout5.decode().strip()
8374
8435
 
8375
8436
  # Get latest commit
8376
- proc6 = await asyncio.create_subprocess_exec(
8437
+ proc6 = await _create_subprocess_exec(
8377
8438
  "git", "rev-parse", "HEAD",
8378
8439
  stdout=asyncio.subprocess.PIPE,
8379
8440
  stderr=asyncio.subprocess.PIPE,
@@ -8419,7 +8480,7 @@ class ProcessManager:
8419
8480
  candidate_refs.append(ref)
8420
8481
 
8421
8482
  for candidate in candidate_refs:
8422
- proc_mb = await asyncio.create_subprocess_exec(
8483
+ proc_mb = await _create_subprocess_exec(
8423
8484
  "git", "merge-base", candidate, "HEAD",
8424
8485
  stdout=asyncio.subprocess.PIPE,
8425
8486
  stderr=asyncio.subprocess.PIPE,
@@ -8432,7 +8493,7 @@ class ProcessManager:
8432
8493
  break
8433
8494
 
8434
8495
  # Changed files since branch point
8435
- proc = await asyncio.create_subprocess_exec(
8496
+ proc = await _create_subprocess_exec(
8436
8497
  "git", "diff", "--name-only", base_ref, "HEAD",
8437
8498
  stdout=asyncio.subprocess.PIPE,
8438
8499
  stderr=asyncio.subprocess.PIPE,
@@ -8444,7 +8505,7 @@ class ProcessManager:
8444
8505
  info["files_changed"] = files
8445
8506
 
8446
8507
  # Lines added/removed since branch point
8447
- proc2 = await asyncio.create_subprocess_exec(
8508
+ proc2 = await _create_subprocess_exec(
8448
8509
  "git", "diff", "--shortstat", base_ref, "HEAD",
8449
8510
  stdout=asyncio.subprocess.PIPE,
8450
8511
  stderr=asyncio.subprocess.PIPE,
@@ -8459,7 +8520,7 @@ class ProcessManager:
8459
8520
  info["lines_removed"] = int(removed.group(1)) if removed else 0
8460
8521
 
8461
8522
  # Branch name
8462
- proc3 = await asyncio.create_subprocess_exec(
8523
+ proc3 = await _create_subprocess_exec(
8463
8524
  "git", "rev-parse", "--abbrev-ref", "HEAD",
8464
8525
  stdout=asyncio.subprocess.PIPE,
8465
8526
  stderr=asyncio.subprocess.PIPE,
@@ -8470,7 +8531,7 @@ class ProcessManager:
8470
8531
  info["branch"] = stdout3.decode().strip()
8471
8532
 
8472
8533
  # Commit SHA
8473
- proc4 = await asyncio.create_subprocess_exec(
8534
+ proc4 = await _create_subprocess_exec(
8474
8535
  "git", "rev-parse", "HEAD",
8475
8536
  stdout=asyncio.subprocess.PIPE,
8476
8537
  stderr=asyncio.subprocess.PIPE,
@@ -8787,7 +8848,7 @@ class RuntimePreflightMonitor:
8787
8848
  process group SIGKILL on POSIX) and waits for reaping before returning.
8788
8849
  """
8789
8850
  try:
8790
- proc = await asyncio.create_subprocess_exec(
8851
+ proc = await _create_subprocess_exec(
8791
8852
  *cmd,
8792
8853
  stdin=asyncio.subprocess.DEVNULL,
8793
8854
  stdout=asyncio.subprocess.PIPE,
@@ -8812,6 +8873,9 @@ class RuntimePreflightMonitor:
8812
8873
  @staticmethod
8813
8874
  def _kill_process_tree(proc):
8814
8875
  """Best-effort kill of the entire process subtree."""
8876
+ if _MANAGED_WSL_POLICY is not None:
8877
+ _kill_proc(proc)
8878
+ return
8815
8879
  try:
8816
8880
  if sys.platform == "win32":
8817
8881
  # Must be a BLOCKING call here: this is a plain (non-async)
@@ -9014,6 +9078,8 @@ class HeartbeatService:
9014
9078
  self._agents = [dict(agent) for agent in agents]
9015
9079
 
9016
9080
  async def _sync_local_workspace_bindings(self) -> None:
9081
+ if _MANAGED_WSL_POLICY is not None and _MANAGED_WSL_POLICY.task is not None:
9082
+ return # The task already retains mapping admission and the physical lock.
9017
9083
  try:
9018
9084
  response = await self.client.get(
9019
9085
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/verification",
@@ -9033,6 +9099,9 @@ class HeartbeatService:
9033
9099
  for binding in bindings:
9034
9100
  if not isinstance(binding, dict) or not binding.get("id"):
9035
9101
  continue
9102
+ if _MANAGED_WSL_POLICY is not None:
9103
+ await _MANAGED_WSL_POLICY.sync_binding(self, binding)
9104
+ continue
9036
9105
  failure_code = await _validate_local_workspace_binding(binding, self.runtime_id)
9037
9106
  if failure_code is None and binding.get("status") == "active":
9038
9107
  continue
@@ -9159,6 +9228,8 @@ class LogUploader:
9159
9228
 
9160
9229
  async def _upload(self):
9161
9230
  """Read daemon log tail and upload to server."""
9231
+ if _MANAGED_WSL_POLICY is not None:
9232
+ return # Private WSL logs are never uploaded, even when no task is active.
9162
9233
  try:
9163
9234
  if not DAEMON_LOG_PATH.exists():
9164
9235
  return
@@ -9352,6 +9423,11 @@ class ServerConnection:
9352
9423
  # honored here, which otherwise turns loopback calls into 502s.
9353
9424
  trust_env=False,
9354
9425
  )
9426
+ if _MANAGED_WSL_POLICY is not None:
9427
+ if (server_url != _MANAGED_WSL_POLICY.config.serverUrl or api_token != _MANAGED_WSL_POLICY.credential
9428
+ or daemon_id != _MANAGED_WSL_POLICY.daemon_id or hardware_id != _MANAGED_WSL_POLICY.hardware_id):
9429
+ raise ValueError("wsl_runtime_connection_invalid")
9430
+ self.client = _wsl_policy_module.PrivateClient(self.client, _MANAGED_WSL_POLICY)
9355
9431
  self.heartbeat: HeartbeatService | None = None
9356
9432
  self.poller: TaskPoller | None = None
9357
9433
  self.reporter: ProgressReporter | None = None
@@ -9909,6 +9985,8 @@ class ServerConnection:
9909
9985
  raise DaemonCredentialUpgradeRequiredError(code)
9910
9986
  resp.raise_for_status()
9911
9987
  data = resp.json()
9988
+ if _MANAGED_WSL_POLICY is not None and data.get("runtime_id") != _MANAGED_WSL_POLICY.runtime_id:
9989
+ raise DaemonCredentialUpgradeRequiredError("runtime_credential_runtime_mismatch")
9912
9990
  self.runtime_id = data["runtime_id"]
9913
9991
  logger.info("[%s] Registered with server. runtime_id=%s", self.label, self.runtime_id)
9914
9992
  self._publish_local_runtime_registration()
@@ -10607,23 +10685,26 @@ class RuntimeDaemon:
10607
10685
  _TASK_ATTACHMENT_MAX_COUNT = 20
10608
10686
  _TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
10609
10687
 
10610
- def __init__(self):
10688
+ def __init__(self, *, managed_policy=None):
10689
+ if managed_policy is not _MANAGED_WSL_POLICY:
10690
+ raise ValueError("wsl_runtime_entry_not_admitted")
10691
+ self.managed_policy = managed_policy
10611
10692
  # Compute stable hardware fingerprint from machine-id / MAC address.
10612
10693
  # This allows the server to identify the same physical machine even when
10613
10694
  # its IP address changes (e.g., DHCP reassignment, home vs. office).
10614
- self.hardware_id = get_hardware_id()
10695
+ self.hardware_id = managed_policy.hardware_id if managed_policy is not None else get_hardware_id()
10615
10696
 
10616
10697
  # daemon_id: prefer explicit config, fall back to hardware_id (stable),
10617
10698
  # then hostname. Using hardware_id as default instead of platform.node()
10618
10699
  # prevents duplicate registrations when the hostname changes.
10619
- self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
10620
- self.server_urls = settings.get_daemon_server_urls()
10621
- self.api_token = _configured_daemon_api_token()
10700
+ self.daemon_id = managed_policy.daemon_id if managed_policy is not None else settings.DAEMON_ID or self.hardware_id or platform.node()
10701
+ self.server_urls = [managed_policy.config.serverUrl] if managed_policy is not None else settings.get_daemon_server_urls()
10702
+ self.api_token = managed_policy.credential if managed_policy is not None else _configured_daemon_api_token()
10622
10703
  self._has_explicit_api_token = bool(self.api_token)
10623
- self.max_concurrent = settings.DAEMON_MAX_CONCURRENT
10704
+ self.max_concurrent = 1 if managed_policy is not None else settings.DAEMON_MAX_CONCURRENT
10624
10705
  self.poll_interval = settings.DAEMON_POLL_INTERVAL
10625
10706
  self.heartbeat_interval = settings.DAEMON_HEARTBEAT_INTERVAL
10626
- self.workspaces_root = settings.get_daemon_workspaces_root()
10707
+ self.workspaces_root = str(managed_policy.state / "workspaces") if managed_policy is not None else settings.get_daemon_workspaces_root()
10627
10708
 
10628
10709
  self.agents: list[DiscoveredAgent] = []
10629
10710
  self.active_tasks: dict[str, asyncio.Task] = {}
@@ -10676,12 +10757,16 @@ class RuntimeDaemon:
10676
10757
 
10677
10758
  async def _refresh_agents(self) -> None:
10678
10759
  """Re-discover local CLIs so upgrades reach the runtime heartbeat."""
10760
+ if self.managed_policy is not None and self.active_tasks:
10761
+ return
10679
10762
  try:
10680
10763
  refreshed_agents = await self.agent_discovery.discover()
10681
10764
  except Exception as exc:
10682
10765
  logger.warning("Agent inventory refresh failed: %s", exc)
10683
10766
  return
10684
10767
 
10768
+ if self.managed_policy is not None:
10769
+ refreshed_agents = [agent for agent in refreshed_agents if agent.agent_id in self.managed_policy.drivers]
10685
10770
  if refreshed_agents == self.agents:
10686
10771
  return
10687
10772
 
@@ -10725,6 +10810,8 @@ class RuntimeDaemon:
10725
10810
  )
10726
10811
 
10727
10812
  def _begin_desktop_restart_drain_if_requested(self) -> bool:
10813
+ if self.managed_policy is not None:
10814
+ return False
10728
10815
  target_version = _desktop_daemon_restart_target_version(
10729
10816
  self._local_runtime_instance_id
10730
10817
  )
@@ -11492,13 +11579,14 @@ class RuntimeDaemon:
11492
11579
  async def start(self):
11493
11580
  """Main entry point."""
11494
11581
  # Prevent multiple daemon instances on the same machine
11495
- self._acquire_lock()
11582
+ if self.managed_policy is None:
11583
+ self._acquire_lock()
11496
11584
  _write_local_runtime_registration_snapshot(
11497
11585
  self._local_runtime_instance_id,
11498
11586
  "starting",
11499
11587
  {},
11500
11588
  )
11501
- cleaned_processes = self._cleanup_stale_agent_processes()
11589
+ cleaned_processes = self._cleanup_stale_agent_processes() if self.managed_policy is None else 0
11502
11590
  if cleaned_processes:
11503
11591
  logger.warning(
11504
11592
  "Cleaned up %d orphaned Forgexa agent process group(s)",
@@ -11515,6 +11603,8 @@ class RuntimeDaemon:
11515
11603
 
11516
11604
  # 1. Discover agents
11517
11605
  self.agents = await self.agent_discovery.discover()
11606
+ if self.managed_policy is not None:
11607
+ self.agents = [agent for agent in self.agents if agent.agent_id in self.managed_policy.drivers]
11518
11608
  if not self.agents:
11519
11609
  logger.warning("No agent CLIs found. Daemon will run but cannot execute tasks.")
11520
11610
  else:
@@ -11609,7 +11699,8 @@ class RuntimeDaemon:
11609
11699
 
11610
11700
  # Clean up stale Copilot/Claude temp directories left by prior OOM-killed
11611
11701
  # daemon processes. This runs once at startup so /tmp doesn't fill up.
11612
- await self._cleanup_stale_tmp_dirs()
11702
+ if self.managed_policy is None:
11703
+ await self._cleanup_stale_tmp_dirs()
11613
11704
 
11614
11705
  # 3. Main loop
11615
11706
  try:
@@ -11657,9 +11748,10 @@ class RuntimeDaemon:
11657
11748
 
11658
11749
  # Opportunistic GC: remove old deploy-merge worktrees (throttled internally).
11659
11750
  try:
11660
- await self._cleanup_old_merge_worktrees(
11661
- self.connections[0] if self.connections else None
11662
- )
11751
+ if self.managed_policy is None:
11752
+ await self._cleanup_old_merge_worktrees(
11753
+ self.connections[0] if self.connections else None
11754
+ )
11663
11755
  except Exception:
11664
11756
  pass # GC failure must never block the poll loop
11665
11757
 
@@ -11715,7 +11807,7 @@ class RuntimeDaemon:
11715
11807
  )
11716
11808
  # Always poll AI jobs so server-requested cancellations can stop a
11717
11809
  # process that is itself consuming the last available slot.
11718
- ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks else []
11810
+ ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks and self.managed_policy is None else []
11719
11811
  _cancel_server_requested_execution_tasks(conn)
11720
11812
  _cancel_server_requested_ai_jobs(conn)
11721
11813
  total_active = len(self.active_tasks)
@@ -12059,8 +12151,23 @@ class RuntimeDaemon:
12059
12151
  async def report_task_complete(result: TaskResult) -> None:
12060
12152
  if is_local_bind:
12061
12153
  _prepare_local_bind_completion(task, result)
12154
+ if self.managed_policy is not None and not await self.managed_policy.finish_task():
12155
+ result.status = "failed"
12156
+ result.failure_code = "wsl_runtime_recovery_required"
12157
+ result.error = "Managed local writer cleanup is unconfirmed"
12062
12158
  await reporter.report_complete(task.task_id, result)
12063
12159
 
12160
+ if self.managed_policy is not None:
12161
+ try:
12162
+ await self.managed_policy.authorize_task(task, conn)
12163
+ except ValueError as exc:
12164
+ await report_task_complete(TaskResult(
12165
+ status="failed", exit_code=-1, stdout="", stderr="",
12166
+ error="Managed local dispatch was refused", failure_code=getattr(exc, "code", "wsl_runtime_dispatch_refused"),
12167
+ execution_attempt_id=task.execution_attempt_id,
12168
+ ))
12169
+ return
12170
+
12064
12171
  default_branch = str((task.project or {}).get("default_branch") or "").strip()
12065
12172
  node_before_sha = ""
12066
12173
  # Use workspace-level granularity (requirement_workflow_id or graph_id) so that
@@ -12090,9 +12197,8 @@ class RuntimeDaemon:
12090
12197
  # but this is a separate, complementary guard against two
12091
12198
  # DAEMON PROCESSES (e.g. a zombie/restarted instance) operating
12092
12199
  # on the same real directory at the same time.
12093
- local_bind_file_lock = _try_acquire_local_workspace_file_lock(
12094
- task.local_workspace_binding_id
12095
- )
12200
+ local_bind_file_lock = (self.managed_policy.task_lock if self.managed_policy is not None
12201
+ else _try_acquire_local_workspace_file_lock(task.local_workspace_binding_id))
12096
12202
  if local_bind_file_lock is None:
12097
12203
  project_lock.release()
12098
12204
  await report_task_complete(TaskResult(
@@ -12978,13 +13084,19 @@ class RuntimeDaemon:
12978
13084
  logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
12979
13085
 
12980
13086
  except Exception as e:
12981
- logger.exception("Task %s failed with exception", task.task_id)
13087
+ if self.managed_policy is not None:
13088
+ _append_local_bind_output_log(task.task_id, [str(e)])
13089
+ logger.warning("Managed local task failed: %s", getattr(e, "code", "local_task_failed"))
13090
+ else:
13091
+ logger.exception("Task %s failed with exception", task.task_id)
12982
13092
  await report_task_complete(TaskResult(
12983
13093
  status="failed", exit_code=-1, stdout="", stderr="",
12984
13094
  error=str(e),
12985
13095
  execution_attempt_id=task.execution_attempt_id,
12986
13096
  ))
12987
13097
  finally:
13098
+ if self.managed_policy is not None:
13099
+ await self.managed_policy.finish_task()
12988
13100
  if attachment_staging_dir is not None:
12989
13101
  shutil.rmtree(attachment_staging_dir, ignore_errors=True)
12990
13102
  if workspace_execution_lock is not None:
@@ -15641,7 +15753,7 @@ class RuntimeDaemon:
15641
15753
  # Unstage if it was already staged
15642
15754
  if is_staged_new:
15643
15755
  try:
15644
- proc = await asyncio.create_subprocess_exec(
15756
+ proc = await _create_subprocess_exec(
15645
15757
  "git", "restore", "--staged", file_path,
15646
15758
  cwd=str(workspace_path),
15647
15759
  stdout=asyncio.subprocess.PIPE,
@@ -15696,7 +15808,7 @@ class RuntimeDaemon:
15696
15808
  lost). That is legitimate, intentional work — not noise — so it
15697
15809
  must be allowed to commit; we only log it for audit visibility.
15698
15810
  """
15699
- proc = await asyncio.create_subprocess_exec(
15811
+ proc = await _create_subprocess_exec(
15700
15812
  "git", "diff", "--raw", "--no-abbrev", "--no-renames", "-z", *diff_args,
15701
15813
  cwd=str(workspace_path),
15702
15814
  stdout=asyncio.subprocess.PIPE,
@@ -15954,7 +16066,7 @@ class RuntimeDaemon:
15954
16066
  except RuntimeError:
15955
16067
  return {"commit_error": "Refusing to commit: the task baseline changed while preparing the commit"}
15956
16068
  _git_name, _git_email = _resolve_git_author(task.project)
15957
- proc = await asyncio.create_subprocess_exec(
16069
+ proc = await _create_subprocess_exec(
15958
16070
  "git", "commit", "-m", commit_msg,
15959
16071
  cwd=str(workspace_path),
15960
16072
  stdout=asyncio.subprocess.PIPE,
@@ -16118,7 +16230,7 @@ class RuntimeDaemon:
16118
16230
  return {"commit_error": mode_change_error}
16119
16231
 
16120
16232
  # Check for uncommitted changes
16121
- proc = await asyncio.create_subprocess_exec(
16233
+ proc = await _create_subprocess_exec(
16122
16234
  "git", "status", "--porcelain",
16123
16235
  cwd=str(workspace_path),
16124
16236
  stdout=asyncio.subprocess.PIPE,
@@ -16137,7 +16249,7 @@ class RuntimeDaemon:
16137
16249
  await self._remove_root_scratch_files(workspace_path, task, stdout.decode())
16138
16250
 
16139
16251
  # Stage all changes
16140
- proc = await asyncio.create_subprocess_exec(
16252
+ proc = await _create_subprocess_exec(
16141
16253
  "git", "add", "-A",
16142
16254
  cwd=str(workspace_path),
16143
16255
  stdout=asyncio.subprocess.PIPE,
@@ -16186,7 +16298,7 @@ class RuntimeDaemon:
16186
16298
  logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
16187
16299
  commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
16188
16300
  _git_name, _git_email = _resolve_git_author(task.project)
16189
- proc = await asyncio.create_subprocess_exec(
16301
+ proc = await _create_subprocess_exec(
16190
16302
  "git", "commit", "-m", commit_msg,
16191
16303
  cwd=str(workspace_path),
16192
16304
  stdout=asyncio.subprocess.PIPE,
@@ -16913,7 +17025,7 @@ class RuntimeDaemon:
16913
17025
  """Collect staged diff stats for building a rich commit message."""
16914
17026
  status_map: dict[str, str] = {}
16915
17027
 
16916
- proc = await asyncio.create_subprocess_exec(
17028
+ proc = await _create_subprocess_exec(
16917
17029
  "git", "diff", "--cached", "--name-status",
16918
17030
  cwd=str(cwd),
16919
17031
  stdout=asyncio.subprocess.PIPE,
@@ -16927,7 +17039,7 @@ class RuntimeDaemon:
16927
17039
  status_map[parts[1].strip()] = parts[0].strip()
16928
17040
 
16929
17041
  files: list[dict] = []
16930
- proc = await asyncio.create_subprocess_exec(
17042
+ proc = await _create_subprocess_exec(
16931
17043
  "git", "diff", "--cached", "--numstat",
16932
17044
  cwd=str(cwd),
16933
17045
  stdout=asyncio.subprocess.PIPE,
@@ -16956,7 +17068,7 @@ class RuntimeDaemon:
16956
17068
  for file_info in files[:20]:
16957
17069
  file_info["symbols"] = await self._extract_staged_file_symbols(cwd, file_info["path"])
16958
17070
 
16959
- proc = await asyncio.create_subprocess_exec(
17071
+ proc = await _create_subprocess_exec(
16960
17072
  "git", "diff", "--cached", "--stat",
16961
17073
  cwd=str(cwd),
16962
17074
  stdout=asyncio.subprocess.PIPE,
@@ -16985,7 +17097,7 @@ class RuntimeDaemon:
16985
17097
  )
16986
17098
 
16987
17099
  try:
16988
- proc = await asyncio.create_subprocess_exec(
17100
+ proc = await _create_subprocess_exec(
16989
17101
  "git", "diff", "--cached", "--unified=0", "--", filepath,
16990
17102
  cwd=str(cwd),
16991
17103
  stdout=asyncio.subprocess.PIPE,
@@ -18030,7 +18142,7 @@ class RuntimeDaemon:
18030
18142
  resolve_result = await self.process_manager.run_agent(agent, _cr_task, workspace_path)
18031
18143
 
18032
18144
  # 6. Check if conflicts are resolved
18033
- proc = await asyncio.create_subprocess_exec(
18145
+ proc = await _create_subprocess_exec(
18034
18146
  "git", "diff", "--name-only", "--diff-filter=U",
18035
18147
  cwd=str(workspace_path),
18036
18148
  stdout=asyncio.subprocess.PIPE,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.63.0
3
+ Version: 1.63.1
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.63.0"
3
+ version = "1.63.1"
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"
File without changes
File without changes