forgexa-cli 1.56.3__tar.gz → 1.57.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.56.3 → forgexa_cli-1.57.0}/PKG-INFO +1 -1
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/agent_core.py +42 -1
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/daemon.py +49 -3
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/main.py +58 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/zcode_protocol.py +21 -2
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/pyproject.toml +1 -1
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/README.md +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/setup.cfg +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.56.3 → forgexa_cli-1.57.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.57.0"
|
|
@@ -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
|
-
|
|
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.
|
|
1153
|
+
DAEMON_VERSION = "1.57.0"
|
|
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
|
|
|
@@ -10916,8 +10948,22 @@ class RuntimeDaemon:
|
|
|
10916
10948
|
# Lock held by another process — try to read its PID and kill it
|
|
10917
10949
|
try:
|
|
10918
10950
|
old_pid = int(lock_path.read_text().strip())
|
|
10919
|
-
|
|
10920
|
-
|
|
10951
|
+
if not _pid_looks_like_forgexa_daemon(old_pid):
|
|
10952
|
+
# AGT-05 (audit): a stale PID reused by an unrelated
|
|
10953
|
+
# process must never be signalled — verify identity from
|
|
10954
|
+
# the process cmdline before killing (mirrors the Rust
|
|
10955
|
+
# daemon manager's check).
|
|
10956
|
+
logger.error(
|
|
10957
|
+
"Lock file names PID %d but it is not a Forgexa daemon — "
|
|
10958
|
+
"refusing to signal it; remove the stale lock manually",
|
|
10959
|
+
old_pid,
|
|
10960
|
+
)
|
|
10961
|
+
old_pid = 0
|
|
10962
|
+
if old_pid:
|
|
10963
|
+
logger.warning("Another daemon is running (PID %d). Sending SIGTERM...", old_pid)
|
|
10964
|
+
os.kill(old_pid, signal.SIGTERM)
|
|
10965
|
+
if not old_pid:
|
|
10966
|
+
raise ValueError("no verified daemon PID")
|
|
10921
10967
|
# Wait up to 5 seconds for it to exit
|
|
10922
10968
|
for _ in range(50):
|
|
10923
10969
|
try:
|
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|