forgexa-cli 1.56.4__tar.gz → 1.57.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.56.4 → forgexa_cli-1.57.1}/PKG-INFO +1 -1
  2. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/agent_core.py +42 -1
  4. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/daemon.py +79 -7
  5. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/main.py +58 -0
  6. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/zcode_protocol.py +21 -2
  7. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
  8. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/pyproject.toml +1 -1
  9. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/README.md +0 -0
  10. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/_build_config.py +0 -0
  11. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/_local_bind.py +0 -0
  12. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/autoupgrade.py +0 -0
  13. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli/runtime_evidence.py +0 -0
  15. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/setup.cfg +0 -0
  21. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.56.4 → forgexa_cli-1.57.1}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.56.4 → forgexa_cli-1.57.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.56.4
3
+ Version: 1.57.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.56.4"
2
+ __version__ = "1.57.1"
@@ -2386,6 +2386,39 @@ _UNVERIFIED_AGENT_LIVE_GATE_REASONS = {
2386
2386
  ),
2387
2387
  }
2388
2388
 
2389
+ AGENT_LIVE_VERIFIED_FILE = Path.home() / ".forgexa" / "agent-live-verified"
2390
+
2391
+
2392
+ def agent_live_gate_approved(agent_id: str, env: dict[str, str] | None = None) -> bool:
2393
+ """Whether an operator recorded a live-acceptance pass for this agent.
2394
+
2395
+ Fail-closed by default: the gate only opens through an explicit
2396
+ ``forgexa agent approve-live <id>`` (persistent file, readable by
2397
+ GUI-launched runtimes) or the FORGEXA_AGENT_LIVE_VERIFIED env var —
2398
+ both mean a human actually ran the live start/resume/cancel/isolation
2399
+ acceptance against a real account/build and accepted the result.
2400
+ Agents without a live gate entry are NOT approved here: their gate is
2401
+ the class-level ``integration_verified`` flag, and returning True
2402
+ would silently disarm it for any driver marked unverified.
2403
+ """
2404
+ if agent_id.lower() not in _UNVERIFIED_AGENT_LIVE_GATE_REASONS:
2405
+ return False
2406
+ source_env = os.environ if env is None else env
2407
+ verified = {
2408
+ value.strip().lower()
2409
+ for value in source_env.get("FORGEXA_AGENT_LIVE_VERIFIED", "").split(",")
2410
+ if value.strip()
2411
+ }
2412
+ try:
2413
+ lines = AGENT_LIVE_VERIFIED_FILE.read_text(encoding="utf-8").splitlines()
2414
+ except OSError:
2415
+ lines = []
2416
+ for line in lines:
2417
+ value = line.split("#", 1)[0].strip()
2418
+ if value:
2419
+ verified.add(value.lower())
2420
+ return agent_id.lower() in verified
2421
+
2389
2422
 
2390
2423
  def agent_execution_verification_issue(agent_id: str, binary: str) -> str | None:
2391
2424
  """Return a fail-closed build/live-acceptance gate for local agents."""
@@ -2393,7 +2426,15 @@ def agent_execution_verification_issue(agent_id: str, binary: str) -> str | None
2393
2426
  build_issue = zcode_build_verification_issue(binary)
2394
2427
  if build_issue:
2395
2428
  return build_issue
2396
- return _UNVERIFIED_AGENT_LIVE_GATE_REASONS.get(agent_id)
2429
+ if agent_live_gate_approved(agent_id):
2430
+ return None
2431
+ reason = _UNVERIFIED_AGENT_LIVE_GATE_REASONS.get(agent_id)
2432
+ if reason:
2433
+ return (
2434
+ f"{reason}; run `forgexa agent approve-live {agent_id}` after "
2435
+ "completing the acceptance checklist"
2436
+ )
2437
+ return None
2397
2438
 
2398
2439
 
2399
2440
  _FORGEXA_CODING_AGENT_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash")
@@ -1150,7 +1150,7 @@ except (ImportError, ModuleNotFoundError):
1150
1150
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1151
1151
  # Kept in sync with pyproject.toml version via bump-version.sh.
1152
1152
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1153
- DAEMON_VERSION = "1.56.4"
1153
+ DAEMON_VERSION = "1.57.1"
1154
1154
 
1155
1155
 
1156
1156
  def _detect_client_type() -> str:
@@ -1215,6 +1215,38 @@ logging.basicConfig(
1215
1215
  # Routine poll/heartbeat requests otherwise consume the entire 500-line remote
1216
1216
  # log tail in about 20 minutes, hiding the task failure operators need to see.
1217
1217
  logging.getLogger("httpx").setLevel(logging.WARNING)
1218
+
1219
+
1220
+ def _pid_looks_like_forgexa_daemon(pid: int) -> bool:
1221
+ """Best-effort identity check before signalling a lock-file PID.
1222
+
1223
+ Reads the process cmdline (Linux /proc, macOS `ps`) and accepts the PID
1224
+ only when it mentions this daemon. Unknown/unreadable identity returns
1225
+ False — the conservative direction is leaving the stale lock to the
1226
+ operator, never killing an unrelated reused PID (audit AGT-05).
1227
+ """
1228
+ if pid <= 0:
1229
+ return False
1230
+ if pid == os.getpid():
1231
+ return True
1232
+ try:
1233
+ cmdline = ""
1234
+ try:
1235
+ with open(f"/proc/{pid}/cmdline", "rb") as fh:
1236
+ cmdline = fh.read().replace(b"\0", b" ").decode("utf-8", "replace")
1237
+ except OSError:
1238
+ import subprocess as _sp
1239
+
1240
+ result = _sp.run(
1241
+ ["ps", "-p", str(pid), "-o", "command="],
1242
+ capture_output=True, text=True, timeout=5,
1243
+ )
1244
+ cmdline = result.stdout or ""
1245
+ lowered = cmdline.lower()
1246
+ return "forgexa" in lowered or "daemon.py" in lowered
1247
+ except Exception:
1248
+ return False
1249
+
1218
1250
  logger = logging.getLogger("daemon")
1219
1251
  logger.info("Daemon log: %s", DAEMON_LOG_PATH)
1220
1252
 
@@ -6894,7 +6926,7 @@ class ProcessManager:
6894
6926
  task_id,
6895
6927
  stdin_input=agent_core.qoder_stdin_prompt(prompt),
6896
6928
  on_chunk=on_chunk,
6897
- env=agent_core.qoder_base_env(os.environ.copy()),
6929
+ env=await asyncio.to_thread(agent_core.qoder_base_env, os.environ.copy()),
6898
6930
  )
6899
6931
  finally:
6900
6932
  agent_core.release_file_lock(profile_lock)
@@ -6959,7 +6991,7 @@ class ProcessManager:
6959
6991
  task_id,
6960
6992
  stdin_input=prompt,
6961
6993
  on_chunk=on_chunk,
6962
- env=agent_core.trae_code_base_env(os.environ.copy()),
6994
+ env=await asyncio.to_thread(agent_core.trae_code_base_env, os.environ.copy()),
6963
6995
  )
6964
6996
  finally:
6965
6997
  agent_core.release_file_lock(profile_lock)
@@ -7020,6 +7052,9 @@ class ProcessManager:
7020
7052
  if on_chunk is None or event.get("type") not in {"model.streaming", "tool.updated"}:
7021
7053
  return
7022
7054
  payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
7055
+ # AGT-11 (audit): zcode 0.16.x never emits "model.streaming" —
7056
+ # final text is read back from session/messages after the turn
7057
+ # terminates. This branch is forward-compatibility only.
7023
7058
  if event.get("type") == "model.streaming":
7024
7059
  text = payload.get("text") or payload.get("delta") or payload.get("content")
7025
7060
  line = text.strip() if isinstance(text, str) else ""
@@ -7037,7 +7072,7 @@ class ProcessManager:
7037
7072
  client = ZCodeProtocolSession(
7038
7073
  [agent.command, "app-server"],
7039
7074
  cwd=cwd,
7040
- env=agent_core.zcode_base_env(os.environ.copy()),
7075
+ env=await asyncio.to_thread(agent_core.zcode_base_env, os.environ.copy()),
7041
7076
  on_event=on_event,
7042
7077
  permission_handler=agent_core.zcode_permission_response,
7043
7078
  )
@@ -7095,7 +7130,10 @@ class ProcessManager:
7095
7130
  stdout="",
7096
7131
  stderr=client.stderr_tail[-10000:] if client is not None else "",
7097
7132
  error=f"Timed out after {timeout}s (absolute limit)",
7098
- failure_code="agent_idle_timeout",
7133
+ # AGT-12 (audit): this is the run_turn absolute cap, not an
7134
+ # idle gap — a distinct code keeps retry/degrade policies
7135
+ # from misreading a too-long task as a stuck one.
7136
+ failure_code="zcode_turn_timeout",
7099
7137
  )
7100
7138
  except ZCodeProtocolError as exc:
7101
7139
  return TaskResult(
@@ -10916,8 +10954,22 @@ class RuntimeDaemon:
10916
10954
  # Lock held by another process — try to read its PID and kill it
10917
10955
  try:
10918
10956
  old_pid = int(lock_path.read_text().strip())
10919
- logger.warning("Another daemon is running (PID %d). Sending SIGTERM...", old_pid)
10920
- os.kill(old_pid, signal.SIGTERM)
10957
+ if not _pid_looks_like_forgexa_daemon(old_pid):
10958
+ # AGT-05 (audit): a stale PID reused by an unrelated
10959
+ # process must never be signalled — verify identity from
10960
+ # the process cmdline before killing (mirrors the Rust
10961
+ # daemon manager's check).
10962
+ logger.error(
10963
+ "Lock file names PID %d but it is not a Forgexa daemon — "
10964
+ "refusing to signal it; remove the stale lock manually",
10965
+ old_pid,
10966
+ )
10967
+ old_pid = 0
10968
+ if old_pid:
10969
+ logger.warning("Another daemon is running (PID %d). Sending SIGTERM...", old_pid)
10970
+ os.kill(old_pid, signal.SIGTERM)
10971
+ if not old_pid:
10972
+ raise ValueError("no verified daemon PID")
10921
10973
  # Wait up to 5 seconds for it to exit
10922
10974
  for _ in range(50):
10923
10975
  try:
@@ -15571,6 +15623,26 @@ class RuntimeDaemon:
15571
15623
  )
15572
15624
  return {"push_error": f"Would push to default branch {current_branch}"}
15573
15625
 
15626
+ # AGT-10 (audit): last line of defense after a collision was
15627
+ # manually released at gate review — the executed branch must be
15628
+ # this requirement's canonical branch (or the workflow's recorded
15629
+ # analysis branch), never another requirement's.
15630
+ if task.requirement_key and current_branch and expected_branch:
15631
+ if current_branch != expected_branch:
15632
+ logger.error(
15633
+ "CRITICAL: requirement %s is about to push branch '%s' "
15634
+ "which is not its canonical branch '%s' — aborting",
15635
+ task.requirement_key, current_branch, expected_branch,
15636
+ )
15637
+ return {
15638
+ "push_error": (
15639
+ f"Push blocked: branch '{current_branch}' does not belong to "
15640
+ f"requirement {task.requirement_key} (expected '{expected_branch}'). "
15641
+ "A cross-requirement branch collision was likely released at review; "
15642
+ "re-run the fix on the correct branch."
15643
+ ),
15644
+ }
15645
+
15574
15646
  if task.requirement_key and current_branch:
15575
15647
  forbidden_merges = await self._list_unpushed_merge_commits(
15576
15648
  workspace_path, current_branch, default_branch,
@@ -3612,6 +3612,48 @@ def cmd_zcode_approve_build(args: argparse.Namespace) -> None:
3612
3612
  print("running daemons and desktop sidecars pick this up on their next probe (no restart needed)")
3613
3613
 
3614
3614
 
3615
+ def cmd_agent_approve_live(args: argparse.Namespace) -> None:
3616
+ from datetime import datetime, timezone
3617
+
3618
+ from . import agent_core
3619
+
3620
+ agent_id = args.agent_id.strip().lower()
3621
+ gated = set(agent_core._UNVERIFIED_AGENT_LIVE_GATE_REASONS)
3622
+ if agent_id not in gated:
3623
+ print(
3624
+ f"error: unknown gated agent '{agent_id}'; gated agents: {', '.join(sorted(gated))}",
3625
+ file=sys.stderr,
3626
+ )
3627
+ return
3628
+ path = agent_core.AGENT_LIVE_VERIFIED_FILE
3629
+ existing = ""
3630
+ try:
3631
+ existing = path.read_text(encoding="utf-8")
3632
+ except OSError:
3633
+ pass
3634
+ approved = {
3635
+ line.split("#", 1)[0].strip().lower()
3636
+ for line in existing.splitlines() if line.split("#", 1)[0].strip()
3637
+ }
3638
+ if agent_id in approved:
3639
+ print(f"already live-verified: {agent_id}")
3640
+ print(f"allowlist file: {path}")
3641
+ return
3642
+ who = getpass.getuser()
3643
+ when = datetime.now(timezone.utc).isoformat(timespec="seconds")
3644
+ path.parent.mkdir(parents=True, exist_ok=True)
3645
+ suffix = "" if (not existing or existing.endswith("\n")) else "\n"
3646
+ path.write_text(
3647
+ existing + suffix
3648
+ + f"{agent_id} # live gate accepted by {who} at {when} via `forgexa agent approve-live`\n",
3649
+ encoding="utf-8",
3650
+ )
3651
+ print(f"live gate approved for agent: {agent_id}")
3652
+ print(f"accepted by: {who} at {when}")
3653
+ print(f"written to: {path}")
3654
+ print("only do this after completing the live start/resume/cancel/isolation acceptance checklist")
3655
+
3656
+
3615
3657
  def main() -> None:
3616
3658
  global _SERVER_URL_OVERRIDE, _output_format
3617
3659
 
@@ -3841,6 +3883,19 @@ def main() -> None:
3841
3883
  help="Path to the zcode binary (default: $FACTORY_ZCODE_PATH or `zcode` on PATH)",
3842
3884
  )
3843
3885
 
3886
+ # agent
3887
+ agent_p = sub.add_parser("agent", help="Agent gate management")
3888
+ agent_sub = agent_p.add_subparsers(dest="agent_cmd")
3889
+ agent_approve_live_p = agent_sub.add_parser(
3890
+ "approve-live",
3891
+ help="Record that an agent passed its live acceptance gate "
3892
+ "(opens the fail-closed live gate for daemon dispatch and desktop probes)",
3893
+ )
3894
+ agent_approve_live_p.add_argument(
3895
+ "agent_id", metavar="AGENT_ID",
3896
+ help="Gated agent id (e.g. trae, zcode)",
3897
+ )
3898
+
3844
3899
  # runtimes
3845
3900
  rt_p = sub.add_parser("runtimes", help="Runtime management")
3846
3901
  rt_sub = rt_p.add_subparsers(dest="rt_cmd")
@@ -3973,6 +4028,9 @@ def main() -> None:
3973
4028
  "zcode": lambda a: {
3974
4029
  "approve-build": cmd_zcode_approve_build,
3975
4030
  }.get(a.zcode_cmd, lambda _: zcode_p.print_help())(a),
4031
+ "agent": lambda a: {
4032
+ "approve-live": cmd_agent_approve_live,
4033
+ }.get(a.agent_cmd, lambda _: agent_p.print_help())(a),
3976
4034
  "runtimes": lambda a: {
3977
4035
  "list": cmd_runtimes_list,
3978
4036
  }.get(a.rt_cmd, lambda _: rt_p.print_help())(a),
@@ -278,7 +278,18 @@ class ZCodeProtocolSession:
278
278
  text = ""
279
279
  usage: dict[str, int] = {}
280
280
  if terminal.status == "success":
281
- text, usage = await self._fetch_turn_transcript(terminal.turn_id)
281
+ try:
282
+ text, usage = await self._fetch_turn_transcript(terminal.turn_id)
283
+ except ZCodeProtocolError as exc:
284
+ # AGT-06 (audit): the work already landed on disk — a
285
+ # transcript read-back failure must not flip a terminal
286
+ # success into a protocol failure (and a pointless re-run).
287
+ # Degrade to empty text with the reason surfaced.
288
+ logger.warning(
289
+ "zcode transcript read-back failed for session %s: %s",
290
+ self.session_id, exc,
291
+ )
292
+ text = ""
282
293
  return ZCodeTurnResult(
283
294
  status=terminal.status,
284
295
  session_id=self.session_id or "",
@@ -297,6 +308,7 @@ class ZCodeProtocolSession:
297
308
  return "", {}
298
309
  texts: list[str] = []
299
310
  totals = {"input_tokens": 0, "output_tokens": 0}
311
+ turn_known = isinstance(turn_id, str) and bool(turn_id)
300
312
  for message in messages:
301
313
  if not isinstance(message, dict):
302
314
  continue
@@ -305,7 +317,14 @@ class ZCodeProtocolSession:
305
317
  continue
306
318
  anchor = info.get("anchor") if isinstance(info.get("anchor"), dict) else {}
307
319
  message_turn = anchor.get("turnId")
308
- if turn_id and isinstance(message_turn, str) and message_turn != turn_id:
320
+ if turn_known and isinstance(message_turn, str) and message_turn != turn_id:
321
+ continue
322
+ if not turn_known and message_turn is not None:
323
+ # AGT-06 (audit): the ack/op-event channel never yielded a
324
+ # turnId — attributing the whole resumed session's assistant
325
+ # history to this turn would pollute the answer. Only
326
+ # un-anchored messages (the current in-flight exchange) may
327
+ # be attributed; anchored history belongs to earlier turns.
309
328
  continue
310
329
  parts = message.get("parts") if isinstance(message.get("parts"), list) else []
311
330
  for part in parts:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.56.4
3
+ Version: 1.57.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.56.4"
3
+ version = "1.57.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