swarph-cli 0.9.0__tar.gz → 0.9.2__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 (51) hide show
  1. {swarph_cli-0.9.0/src/swarph_cli.egg-info → swarph_cli-0.9.2}/PKG-INFO +3 -3
  2. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/pyproject.toml +6 -3
  3. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/import_session.py +35 -9
  5. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/spawn.py +72 -29
  6. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/watchdog.py +82 -35
  7. {swarph_cli-0.9.0 → swarph_cli-0.9.2/src/swarph_cli.egg-info}/PKG-INFO +3 -3
  8. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/requires.txt +1 -1
  9. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_cell_loader.py +1 -1
  10. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_import_command.py +32 -0
  11. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_spawn_command.py +36 -1
  12. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_watchdog.py +109 -0
  13. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/LICENSE +0 -0
  14. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/README.md +0 -0
  15. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/setup.cfg +0 -0
  16. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/caller.py +0 -0
  17. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/cell.py +0 -0
  18. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/__init__.py +0 -0
  19. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/chat.py +0 -0
  20. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/daemon.py +0 -0
  21. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/hook_output.py +0 -0
  22. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/install_hook.py +0 -0
  23. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/memory_sync.py +0 -0
  24. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/mesh.py +0 -0
  25. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/onboard.py +0 -0
  26. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/ratify.py +0 -0
  27. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/main.py +0 -0
  28. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/parsers/__init__.py +0 -0
  29. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/parsers/claude.py +0 -0
  30. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  31. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  32. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  33. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
  34. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  35. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  36. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/top_level.txt +0 -0
  37. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_chat_command.py +0 -0
  38. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_claude_parser.py +0 -0
  39. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_daemon_command.py +0 -0
  40. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_hook_output.py +0 -0
  41. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_install_hook.py +0 -0
  42. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_main.py +0 -0
  43. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_memory_sync.py +0 -0
  44. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_mesh_command.py +0 -0
  45. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_mesh_sidecar.py +0 -0
  46. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_onboard_command.py +0 -0
  47. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_ratify_command.py +0 -0
  48. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_smoke_chat.py +0 -0
  49. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_smoke_one_shot.py +0 -0
  50. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_smoke_phase_5_5.py +0 -0
  51. {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_spawn_windows_relaunch.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.9.0
4
- Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider, via a ProviderMembrane), cell.yaml, session import, watchdog. v0.9.0 adds `swarph mesh` (send/inbox/register with per-peer tokens) + a provider-agnostic inbox sidecar, and `assisted_memory` (git-backed durable memory: restore-on-spawn + saver loop + current-task anchor) toggled per cell.
3
+ Version: 0.9.2
4
+ Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider via a ProviderMembrane), cell.yaml, `swarph mesh` (send/inbox/register with per-peer tokens) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.2 makes the watchdog deploy-safe: no false A2 when no tmux server is reachable (root-cron), and F3 uses window/session activity so active sessions are correctly detected.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -25,7 +25,7 @@ Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: swarph-mesh>=0.5.0
28
- Requires-Dist: swarph-shared>=0.3.0
28
+ Requires-Dist: swarph-shared>=0.3.1
29
29
  Requires-Dist: PyYAML>=6.0
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.9.0"
8
- description = "The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider, via a ProviderMembrane), cell.yaml, session import, watchdog. v0.9.0 adds `swarph mesh` (send/inbox/register with per-peer tokens) + a provider-agnostic inbox sidecar, and `assisted_memory` (git-backed durable memory: restore-on-spawn + saver loop + current-task anchor) toggled per cell."
7
+ version = "0.9.2"
8
+ description = "The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider via a ProviderMembrane), cell.yaml, `swarph mesh` (send/inbox/register with per-peer tokens) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.2 makes the watchdog deploy-safe: no false A2 when no tmux server is reachable (root-cron), and F3 uses window/session activity so active sessions are correctly detected."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -37,7 +37,10 @@ dependencies = [
37
37
  # cell.yaml-format-home RESOLVED). swarph-cli's cell.py now
38
38
  # re-exports data shapes from swarph_shared.cell + keeps file
39
39
  # I/O + sidecar + slot allocation locally.
40
- "swarph-shared>=0.3.0",
40
+ # >=0.3.1 is load-bearing: 0.3.1 is the first release whose
41
+ # scrub_env_for_subprocess closes the ANTHROPIC_AUTH_TOKEN/*_BASE_URL
42
+ # billing-redirect class that the membrane env builders delegate to.
43
+ "swarph-shared>=0.3.1",
41
44
  # Phase 7 spawn — cell.yaml parser. PyYAML 6.x is the standard.
42
45
  # Stays a swarph-cli dep since file I/O is operator-tooling-layer
43
46
  # concern; swarph-shared cell module is pure-stdlib.
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.9.0"
19
+ __version__ = "0.9.2"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
  import argparse
31
31
  import json
32
32
  import os
33
+ import re
33
34
  import sys
34
35
  from datetime import datetime, timezone
35
36
  from pathlib import Path
@@ -110,20 +111,45 @@ def _detect_format(path: Path) -> Optional[str]:
110
111
  return None
111
112
 
112
113
 
114
+ def _safe_session_filename(raw: str) -> str:
115
+ """Reduce an arbitrary session identifier to a SAFE basename confined to
116
+ the sessions dir.
117
+
118
+ The identifier may come from the parsed JSONL (``session_id``), which is
119
+ untrusted when importing a foreign file: an absolute value (``/etc/cron.d/
120
+ evil``) would make ``base / name`` discard ``base`` entirely, and ``../``
121
+ would escape it (adversarial-sweep path-traversal). Strip directory
122
+ components and whitelist the charset so the result is always a plain
123
+ filename inside the sessions dir.
124
+ """
125
+ # ``Path(...).name`` drops any directory components, incl a leading "/".
126
+ base_name = Path(str(raw)).name
127
+ safe = re.sub(r"[^A-Za-z0-9._-]", "_", base_name)
128
+ # Reject names that are empty or pure dots ("", ".", "..", ...).
129
+ if not safe.strip("."):
130
+ safe = "imported-session"
131
+ return safe
132
+
133
+
113
134
  def _swarph_native_path(target_session: Optional[str], result: ImportResult) -> Path:
114
- """Resolve the target swarph-native session file path."""
135
+ """Resolve the target swarph-native session file path (path-traversal-safe)."""
115
136
  base = Path.home() / ".swarph" / "sessions"
116
137
  base.mkdir(parents=True, exist_ok=True)
117
- if target_session:
118
- name = target_session
119
- else:
120
- name = (
121
- result.metadata.get("session_id")
122
- or Path(result.report.source_path).stem
123
- )
138
+ raw = target_session or (
139
+ result.metadata.get("session_id")
140
+ or Path(result.report.source_path).stem
141
+ )
142
+ name = _safe_session_filename(raw)
124
143
  if not name.endswith(".jsonl"):
125
144
  name = f"{name}.jsonl"
126
- return base / name
145
+ out = base / name
146
+ # Defense in depth: the sanitized name has no separators, so this always
147
+ # holds — but assert the write target is a direct child of the sessions dir.
148
+ if out.resolve().parent != base.resolve():
149
+ raise ValueError(
150
+ f"import: refusing to write session outside {base} (got {out})"
151
+ )
152
+ return out
127
153
 
128
154
 
129
155
  def _write_swarph_native_session(
@@ -42,6 +42,7 @@ from swarph_cli.cell import (
42
42
  read_starter_prompt,
43
43
  resolve_cell_path,
44
44
  )
45
+ from swarph_shared.subprocess_env import scrub_env_for_subprocess
45
46
 
46
47
 
47
48
  _BANNER = """\
@@ -79,11 +80,13 @@ Anything after a literal `--` is passed through to the provider CLI unchanged
79
80
  """
80
81
 
81
82
 
82
- _CODEX_BILLING_LEAK_KEYS = (
83
- "OPENAI_API_KEY",
84
- "OPENAI_API_BASE",
85
- "OPENAI_BASE_URL",
86
- "CODEX_API_KEY",
83
+ # Codex-specific org-scoping keys NOT covered by the shared billing denylist
84
+ # (scrub_env_for_subprocess already strips OPENAI_API_KEY / OPENAI_API_BASE /
85
+ # OPENAI_BASE_URL via its explicit set + *_BASE_URL/*_API_KEY suffix sweep, and
86
+ # CODEX_API_KEY via the *_API_KEY suffix). These two route billing to a specific
87
+ # org rather than redirect the endpoint, so they live as a codex-layer extra on
88
+ # top of the canonical scrub.
89
+ _CODEX_EXTRA_LEAK_KEYS = (
87
90
  "OPENAI_ORG_ID",
88
91
  "OPENAI_ORGANIZATION",
89
92
  )
@@ -323,13 +326,30 @@ def _codex_sandbox(cell: Cell) -> str:
323
326
  return sandbox
324
327
 
325
328
 
329
+ def _claude_env() -> dict[str, str]:
330
+ """Subscription-billing env for an interactive ``claude`` session.
331
+
332
+ The canonical billing-redirect scrub plus the SWARPH_SPAWN marker. Without
333
+ this an ``ANTHROPIC_BASE_URL`` / ``ANTHROPIC_AUTH_TOKEN`` set in the parent
334
+ env (an identity proxy / metered relay) would be inherited by the spawned
335
+ ``claude`` and silently flip it off subscription auth to a metered endpoint
336
+ while still reporting ``cost_usd`` 0.0 — the adversarial-sweep CRIT.
337
+ """
338
+ env = scrub_env_for_subprocess()
339
+ env["SWARPH_SPAWN"] = "1"
340
+ return env
341
+
342
+
326
343
  def _agy_env() -> dict[str, str]:
327
- """Return a copy of the environment scrubbed of billing credentials."""
328
- env = os.environ.copy()
329
- env.pop("GOOGLE_APPLICATION_CREDENTIALS", None)
330
- env.pop("GOOGLE_CLOUD_PROJECT", None)
331
- env.pop("VERTEX_PROJECT", None)
332
- env.pop("VERTEX_LOCATION", None)
344
+ """Subscription-billing env for an ``agy`` (antigravity/Gemini) session.
345
+
346
+ Delegates to the shared scrub, which strips the full billing-redirect class
347
+ (GEMINI_API_KEY / GOOGLE_API_KEY / GEMINI_BASE_URL / GOOGLE_APPLICATION_
348
+ CREDENTIALS / GOOGLE_CLOUD_PROJECT / VERTEX_*) — a superset of the four GCP
349
+ keys this previously popped by hand.
350
+ """
351
+ env = scrub_env_for_subprocess()
352
+ env["SWARPH_SPAWN"] = "1"
333
353
  return env
334
354
 
335
355
 
@@ -373,11 +393,14 @@ def _build_codex_argv(cell: Cell, passthrough: list[str]) -> list[str]:
373
393
 
374
394
 
375
395
  def _scrubbed_codex_env() -> dict[str, str]:
376
- env = {
377
- key: value
378
- for key, value in os.environ.items()
379
- if key not in _CODEX_BILLING_LEAK_KEYS
380
- }
396
+ """Subscription-billing env for a ``codex`` (GPT) session.
397
+
398
+ The shared billing-redirect scrub plus the codex-specific org-scoping keys
399
+ (see ``_CODEX_EXTRA_LEAK_KEYS``) that the shared denylist does not cover.
400
+ """
401
+ env = scrub_env_for_subprocess()
402
+ for key in _CODEX_EXTRA_LEAK_KEYS:
403
+ env.pop(key, None)
381
404
  env["SWARPH_SPAWN"] = "1"
382
405
  return env
383
406
 
@@ -648,14 +671,14 @@ class ClaudeMembrane(ProviderMembrane):
648
671
  print(f"swarph spawn: cannot chdir to {cell.cwd}: {exc}", file=sys.stderr)
649
672
  return 1
650
673
 
651
- # v0.7 PR-C set SWARPH_SPAWN=1 env so a SessionStart hook
652
- # installed via `swarph install-hook` knows the prompt was
653
- # already injected via --append-system-prompt and skips
654
- # double-injection. The env propagates through execv since we
655
- # don't use execve with a custom env.
656
- os.environ["SWARPH_SPAWN"] = "1"
674
+ # Exec with the billing-redirect-scrubbed env (NOT raw inherited env) so
675
+ # a parent-set ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN can't silently
676
+ # flip the spawned claude off subscription billing. SWARPH_SPAWN=1 (set
677
+ # in _claude_env) tells a `swarph install-hook` SessionStart hook the
678
+ # prompt was already injected via --append-system-prompt, so it skips
679
+ # double-injection. execve carries exactly this env to the child.
657
680
  try:
658
- os.execv(binary, argv)
681
+ os.execve(binary, argv, _claude_env())
659
682
  except OSError as exc:
660
683
  print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
661
684
  return 1
@@ -723,12 +746,10 @@ class AntigravityMembrane(ProviderMembrane):
723
746
  )
724
747
 
725
748
  def launch(self, cell: Cell, binary: str, argv: list[str]) -> int:
726
- env = _agy_env()
727
- os.environ.clear()
728
- os.environ.update(env)
729
- os.environ["SWARPH_SPAWN"] = "1"
749
+ # execve carries exactly the scrubbed env to the child without mutating
750
+ # this process's os.environ first (so a failed exec leaves us intact).
730
751
  try:
731
- os.execv(binary, argv)
752
+ os.execve(binary, argv, _agy_env())
732
753
  except OSError as exc:
733
754
  print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
734
755
  return 1
@@ -741,6 +762,17 @@ MEMBRANES: dict[str, ProviderMembrane] = {
741
762
  "antigravity": AntigravityMembrane(),
742
763
  }
743
764
 
765
+ # Defensive coupling: MEMBRANES must stay in lockstep with the shared provider
766
+ # whitelist. A future VALID_PROVIDERS entry without a matching membrane would
767
+ # otherwise surface as a raw KeyError at spawn time — fail loud at import instead.
768
+ from swarph_shared.cell import VALID_PROVIDERS as _VALID_PROVIDERS # noqa: E402
769
+
770
+ if set(MEMBRANES) != _VALID_PROVIDERS:
771
+ raise RuntimeError(
772
+ f"MEMBRANES {sorted(MEMBRANES)} out of sync with VALID_PROVIDERS "
773
+ f"{sorted(_VALID_PROVIDERS)} — add the missing provider membrane."
774
+ )
775
+
744
776
 
745
777
  def run_spawn(argv: Optional[list[str]] = None) -> int:
746
778
  if argv is None:
@@ -781,7 +813,18 @@ def run_spawn(argv: Optional[list[str]] = None) -> int:
781
813
  print(f"swarph spawn: {exc}", file=sys.stderr)
782
814
  return 1
783
815
 
784
- membrane = MEMBRANES[cell.provider]
816
+ membrane = MEMBRANES.get(cell.provider)
817
+ if membrane is None:
818
+ # Defense-in-depth: VALID_PROVIDERS (in load_cell) normally rejects
819
+ # unknown providers first, and _validate_routing covers the routing-field
820
+ # path — but a routing-less cell of an unmembraned provider would have hit
821
+ # a raw KeyError here. Fail clean instead (silent-failure-hunter #4).
822
+ print(
823
+ f"swarph spawn: provider {cell.provider!r} is not supported by this "
824
+ f"spawn membrane (have: {', '.join(sorted(MEMBRANES))}).",
825
+ file=sys.stderr,
826
+ )
827
+ return 1
785
828
 
786
829
  session_id: Optional[str]
787
830
  was_generated = False
@@ -358,28 +358,70 @@ def _gateway_recent_recovery_event(
358
358
  return None
359
359
 
360
360
 
361
+ def _pid_under(pid: int, ancestors: set, _max_depth: int = 40) -> bool:
362
+ """Walk the PPID chain up from ``pid``; True if any ancestor is in
363
+ ``ancestors``. Reads ``/proc/<pid>/stat`` (Linux). The ``comm`` field can
364
+ contain spaces/parens, so parse PPID after the final ``)``."""
365
+ cur = pid
366
+ for _ in range(_max_depth):
367
+ if cur in ancestors:
368
+ return True
369
+ if cur <= 1:
370
+ return False
371
+ try:
372
+ with open(f"/proc/{cur}/stat", encoding="utf-8") as f:
373
+ data = f.read()
374
+ after = data[data.rfind(")") + 2:].split()
375
+ cur = int(after[1]) # stat field 4 (PPID): state, ppid, ...
376
+ except (OSError, ValueError, IndexError):
377
+ return False
378
+ return cur in ancestors
379
+
380
+
361
381
  def _process_alive(tmux_session: str) -> bool:
362
- """Detect if a claude process is running inside the tmux session.
382
+ """Detect if a claude process is running INSIDE the named tmux session.
363
383
 
364
- Returns True iff there's at least one ``claude`` process whose
365
- parent is the named tmux session. Best-effort; uses pgrep+ps; falls
366
- back to True (assume alive) if detection itself errors so we don't
367
- fire A2 on detection-broken-system.
384
+ Scopes to the session's pane PIDs (and their descendants) rather than a
385
+ host-wide ``pgrep claude``: on a multi-session host, an unrelated cell's
386
+ claude would otherwise mask THIS session's death and suppress the A2 alert
387
+ (adversarial-sweep MED). Best-effort; falls back to True (assume alive) on
388
+ detection error so a broken detector never false-fires A2.
368
389
  """
369
390
  try:
370
- result = subprocess.run(
391
+ panes = subprocess.run(
392
+ ["tmux", "list-panes", "-t", tmux_session, "-F", "#{pane_pid}"],
393
+ capture_output=True, text=True, timeout=5,
394
+ )
395
+ if panes.returncode != 0:
396
+ # list-panes fails for TWO very different reasons:
397
+ # (a) the tmux server is reachable but this session is genuinely
398
+ # gone → really dead → False (let A2 fire).
399
+ # (b) there is no tmux server reachable from THIS uid (e.g. the
400
+ # watchdog runs as root while sessions live under ubuntu's
401
+ # /tmp/tmux-1000 socket) → we simply CAN'T determine liveness
402
+ # via tmux → must NOT false-fire A2.
403
+ # Distinguish by probing the server itself.
404
+ server = subprocess.run(
405
+ ["tmux", "list-sessions"],
406
+ capture_output=True, text=True, timeout=5,
407
+ )
408
+ if server.returncode != 0:
409
+ return True # no reachable tmux server here → can't tell → assume alive
410
+ return False # server reachable, session absent → genuinely dead
411
+ pane_pids = {int(p) for p in panes.stdout.split() if p.strip().isdigit()}
412
+ if not pane_pids:
413
+ return True # ambiguous (session with no pane pids) → don't false-fire
414
+
415
+ pg = subprocess.run(
371
416
  ["pgrep", "-f", "claude"],
372
417
  capture_output=True, text=True, timeout=5,
373
418
  )
374
- if result.returncode != 0:
375
- return False # no claude process anywhere
376
- # At least one claude found return True.
377
- # We don't enforce tmux-parentage here since pgrep matching by
378
- # tmux-pane-process is fragile across tmux versions; "any claude"
379
- # is sufficient for FALLBACK signal per mother #1021 AND-gate
380
- # design (cursor staleness is the PRIMARY).
381
- return bool(result.stdout.strip())
382
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
419
+ if pg.returncode != 0:
420
+ return False # no claude process anywhere on the host
421
+ claude_pids = [int(p) for p in pg.stdout.split() if p.strip().isdigit()]
422
+ # Alive only if a claude process is a descendant of THIS session's panes.
423
+ return any(_pid_under(cpid, pane_pids) for cpid in claude_pids)
424
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
383
425
  return True # assume alive on detection error
384
426
 
385
427
 
@@ -406,34 +448,39 @@ def _tmux_send_keys(name: str, text: str) -> bool:
406
448
 
407
449
 
408
450
  def _pane_activity_age_sec(name: str) -> Optional[int]:
409
- """Age in seconds since the tmux pane's last activity event.
410
-
411
- Reads tmux's `#{pane_activity}` format variable, which returns a unix
412
- epoch timestamp of the most recent activity in the active pane of the
413
- target session. Returns None if tmux is missing, the session doesn't
414
- exist, or tmux's output isn't parseable as an integer epoch.
415
-
416
- Used by F3 (mother #1087 / drop-on-meta-edge proposal) as a third
417
- AND-gate input to distinguish (a) session genuinely stalled from (b)
418
- session actively working in a long bash block. cursor-mtime alone
419
- measures "time since last turn-end" not "time since last activity";
420
- pane_activity covers the mid-turn-active case.
421
-
422
- Returns None on detection error so the caller can fall through to
423
- the legacy AND-gate behavior F3 is a strengthening of the gate,
424
- not a replacement of it.
451
+ """Age in seconds since the target session's most recent tmux activity.
452
+
453
+ Reads tmux activity-timestamp format vars and uses the MOST RECENT
454
+ (max epoch) across pane / window / session. ``#{pane_activity}`` is only
455
+ populated when monitor-activity is on (empty on a default tmux 3.x), so
456
+ relying on it alone made F3 a no-op it returned None and never
457
+ suppressed A1 for a genuinely-active session (adversarial-deploy finding
458
+ 2026-06-02). ``#{window_activity}`` / ``#{session_activity}`` are tracked
459
+ unconditionally and give the same "is this session alive right now" signal.
460
+
461
+ Used by F3 (mother #1087) as a third AND-gate input to distinguish (a) a
462
+ session genuinely stalled from (b) one actively working in a long bash
463
+ block where cursor-mtime (last turn-end) is stale but the session is alive.
464
+
465
+ Returns None only when NO activity timestamp is parseable (tmux missing /
466
+ session absent), so the caller falls through to the legacy AND-gate.
425
467
  """
426
468
  try:
427
469
  result = subprocess.run(
428
- ["tmux", "display", "-p", "-t", name, "#{pane_activity}"],
470
+ ["tmux", "display", "-p", "-t", name,
471
+ "#{pane_activity}|#{window_activity}|#{session_activity}"],
429
472
  capture_output=True, text=True, timeout=5,
430
473
  )
431
474
  if result.returncode != 0:
432
475
  return None
433
- out = result.stdout.strip()
434
- if not out:
476
+ epochs = []
477
+ for tok in result.stdout.strip().split("|"):
478
+ tok = tok.strip()
479
+ if tok.isdigit():
480
+ epochs.append(int(tok))
481
+ if not epochs:
435
482
  return None
436
- return max(0, _now() - int(out))
483
+ return max(0, _now() - max(epochs))
437
484
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
438
485
  return None
439
486
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.9.0
4
- Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider, via a ProviderMembrane), cell.yaml, session import, watchdog. v0.9.0 adds `swarph mesh` (send/inbox/register with per-peer tokens) + a provider-agnostic inbox sidecar, and `assisted_memory` (git-backed durable memory: restore-on-spawn + saver loop + current-task anchor) toggled per cell.
3
+ Version: 0.9.2
4
+ Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider via a ProviderMembrane), cell.yaml, `swarph mesh` (send/inbox/register with per-peer tokens) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.2 makes the watchdog deploy-safe: no false A2 when no tmux server is reachable (root-cron), and F3 uses window/session activity so active sessions are correctly detected.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -25,7 +25,7 @@ Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: swarph-mesh>=0.5.0
28
- Requires-Dist: swarph-shared>=0.3.0
28
+ Requires-Dist: swarph-shared>=0.3.1
29
29
  Requires-Dist: PyYAML>=6.0
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -1,5 +1,5 @@
1
1
  swarph-mesh>=0.5.0
2
- swarph-shared>=0.3.0
2
+ swarph-shared>=0.3.1
3
3
  PyYAML>=6.0
4
4
 
5
5
  [dev]
@@ -194,7 +194,7 @@ def test_load_cell_top_level_must_be_mapping(tmp_path):
194
194
 
195
195
  def test_load_cell_invalid_peer_name_rejected(cell_yaml_factory):
196
196
  path = cell_yaml_factory(name="UPPER_CASE")
197
- with pytest.raises(CellError, match="kebab/snake-case"):
197
+ with pytest.raises(CellError, match="kebab-case"):
198
198
  load_cell(path)
199
199
 
200
200
 
@@ -286,3 +286,35 @@ def test_swarph_native_path_falls_back_to_filename_stem(tmp_path, monkeypatch):
286
286
  )
287
287
  p = _swarph_native_path(None, result)
288
288
  assert p.name == "no-session-id.jsonl"
289
+
290
+
291
+ def test_swarph_native_path_rejects_path_traversal(tmp_path, monkeypatch):
292
+ """Adversarial-sweep MED: a malicious imported JSONL whose session_id is a
293
+ traversal or absolute path must NOT escape the sessions dir on write."""
294
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
295
+ from swarph_cli.parsers.claude import ImportReport, ImportResult
296
+
297
+ sessions = tmp_path / ".swarph" / "sessions"
298
+ for evil in ("../../../../etc/cron.d/evil", "/etc/cron.d/evil",
299
+ "../../.bashrc", "a/b/c"):
300
+ result = ImportResult(
301
+ messages=[],
302
+ report=ImportReport(source_path="/x/foo.jsonl"),
303
+ metadata={"session_id": evil},
304
+ )
305
+ p = _swarph_native_path(None, result)
306
+ # Always a direct child of the sessions dir — never escapes.
307
+ assert p.resolve().parent == sessions.resolve(), f"{evil!r} -> {p}"
308
+ assert "/" not in p.name.replace(".jsonl", "")
309
+ assert ".." not in p.name
310
+
311
+
312
+ def test_swarph_native_path_explicit_target_also_sanitized(tmp_path, monkeypatch):
313
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
314
+ from swarph_cli.parsers.claude import ImportReport, ImportResult
315
+
316
+ result = ImportResult(messages=[], report=ImportReport(source_path="/x/foo.jsonl"),
317
+ metadata={"session_id": "S-1"})
318
+ p = _swarph_native_path("/etc/passwd", result)
319
+ assert p.parent == tmp_path / ".swarph" / "sessions"
320
+ assert p.name == "passwd.jsonl"
@@ -901,19 +901,54 @@ def test_build_agy_argv_sandbox_false(tmp_path):
901
901
 
902
902
 
903
903
  def test_agy_env_scrub(monkeypatch):
904
- """Verify _agy_env correctly scrubs billing environment variables."""
904
+ """Verify _agy_env scrubs the full billing-redirect class via the shared
905
+ scrub — including the GEMINI_API_KEY/GOOGLE_API_KEY/GEMINI_BASE_URL keys the
906
+ old hand-rolled four-key pop missed."""
905
907
  from swarph_cli.commands.spawn import _agy_env
906
908
 
907
909
  monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/some/path.json")
908
910
  monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "project-123")
909
911
  monkeypatch.setenv("VERTEX_PROJECT", "v-project")
910
912
  monkeypatch.setenv("VERTEX_LOCATION", "us-central1")
913
+ # Previously NOT scrubbed by _agy_env's four-key pop — now covered:
914
+ monkeypatch.setenv("GEMINI_API_KEY", "leak")
915
+ monkeypatch.setenv("GOOGLE_API_KEY", "leak")
916
+ monkeypatch.setenv("GEMINI_BASE_URL", "https://metered.example")
917
+ monkeypatch.setenv("KEEP_ME", "ok")
911
918
 
912
919
  env = _agy_env()
913
920
  assert "GOOGLE_APPLICATION_CREDENTIALS" not in env
914
921
  assert "GOOGLE_CLOUD_PROJECT" not in env
915
922
  assert "VERTEX_PROJECT" not in env
916
923
  assert "VERTEX_LOCATION" not in env
924
+ assert "GEMINI_API_KEY" not in env
925
+ assert "GOOGLE_API_KEY" not in env
926
+ assert "GEMINI_BASE_URL" not in env
927
+ assert env["KEEP_ME"] == "ok"
928
+ assert env["SWARPH_SPAWN"] == "1"
929
+
930
+
931
+ def test_claude_env_scrubs_billing_redirect(monkeypatch):
932
+ """CRIT regression (adversarial-sweep 2026-06-01): the interactive claude
933
+ membrane must NOT inherit ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL from the
934
+ parent env — they would silently flip the spawned claude off subscription
935
+ auth to a metered/relay endpoint while cost_usd still reports 0.0."""
936
+ from swarph_cli.commands.spawn import _claude_env
937
+
938
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-leak")
939
+ monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "tok-leak")
940
+ monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://metered.relay.example")
941
+ monkeypatch.setenv("PATH", "/usr/bin:/bin")
942
+ monkeypatch.setenv("KEEP_ME", "ok")
943
+
944
+ env = _claude_env()
945
+ assert "ANTHROPIC_API_KEY" not in env
946
+ assert "ANTHROPIC_AUTH_TOKEN" not in env
947
+ assert "ANTHROPIC_BASE_URL" not in env
948
+ # Non-billing env survives so the claude TUI still works.
949
+ assert "PATH" in env
950
+ assert env["KEEP_ME"] == "ok"
951
+ assert env["SWARPH_SPAWN"] == "1"
917
952
 
918
953
 
919
954
  def test_run_spawn_antigravity_dry_run(tmp_path, isolated_xdg, capsys):
@@ -132,6 +132,7 @@ def test_stale_cursor_alive_process_unread_dms_fires_a1(
132
132
  with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
133
133
  patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=3), \
134
134
  patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
135
+ patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
135
136
  patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
136
137
  rc = run_watchdog(argv=[
137
138
  "--check", "--cell", "lab",
@@ -279,6 +280,7 @@ def test_a1_fires_at_most_once_per_stale_window(
279
280
  with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
280
281
  patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=3), \
281
282
  patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
283
+ patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
282
284
  patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
283
285
  # First invocation — A1 fires
284
286
  rc1 = run_watchdog(argv=[
@@ -322,6 +324,7 @@ def test_a1_rearms_after_cursor_advance(
322
324
  with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
323
325
  patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=2), \
324
326
  patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
327
+ patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
325
328
  patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
326
329
  # First A1 fires
327
330
  run_watchdog(argv=[
@@ -482,6 +485,7 @@ def test_a2_escalation_clears_a1_marker(
482
485
  with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
483
486
  patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=5), \
484
487
  patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
488
+ patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
485
489
  patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True):
486
490
  # First fire — record marker
487
491
  run_watchdog(argv=[
@@ -712,3 +716,108 @@ def test_resolve_swarph_bin_relative_with_slash_resolves_to_absolute(tmp_path, m
712
716
  f"resolver returned non-absolute path: {resolved!r}"
713
717
  )
714
718
  assert resolved == str(fake)
719
+
720
+
721
+ # ---------------------------------------------------------------------------
722
+ # _process_alive — session-scoped (adversarial-sweep MED, watchdog.py:361)
723
+ # ---------------------------------------------------------------------------
724
+
725
+ import os
726
+ import swarph_cli.commands.watchdog as _wd
727
+
728
+
729
+ class _R:
730
+ def __init__(self, returncode, stdout=""):
731
+ self.returncode = returncode
732
+ self.stdout = stdout
733
+
734
+
735
+ def _fake_run_factory(panes_rc, panes_out, pgrep_rc, pgrep_out, sessions_rc=0):
736
+ """Mock subprocess.run distinguishing tmux list-panes vs list-sessions."""
737
+ def fake_run(cmd, **kw):
738
+ if cmd[0] == "tmux" and cmd[1] == "list-panes":
739
+ return _R(panes_rc, panes_out)
740
+ if cmd[0] == "tmux" and cmd[1] == "list-sessions":
741
+ return _R(sessions_rc, "" if sessions_rc else "lab: 1 windows\n")
742
+ if cmd[0] == "pgrep":
743
+ return _R(pgrep_rc, pgrep_out)
744
+ return _R(1, "")
745
+ return fake_run
746
+
747
+
748
+ def test_pid_under_walks_proc_ancestry():
749
+ pid = os.getpid()
750
+ assert _wd._pid_under(pid, {pid}) is True # self
751
+ assert _wd._pid_under(pid, {os.getppid()}) is True # parent
752
+ assert _wd._pid_under(pid, {999999999}) is False # bogus ancestor
753
+
754
+
755
+ def test_process_alive_ignores_host_wide_claude(monkeypatch):
756
+ """A claude process that is NOT a descendant of THIS session's panes must
757
+ NOT count as alive (the old host-wide pgrep masked dead sessions)."""
758
+ monkeypatch.setattr(_wd.subprocess, "run",
759
+ _fake_run_factory(0, "1000\n", 0, "2000\n"))
760
+ monkeypatch.setattr(_wd, "_pid_under", lambda pid, anc, **k: False)
761
+ assert _wd._process_alive("mysess") is False
762
+
763
+
764
+ def test_process_alive_true_when_claude_under_session(monkeypatch):
765
+ monkeypatch.setattr(_wd.subprocess, "run",
766
+ _fake_run_factory(0, "1000\n", 0, "2000\n"))
767
+ monkeypatch.setattr(_wd, "_pid_under", lambda pid, anc, **k: True)
768
+ assert _wd._process_alive("mysess") is True
769
+
770
+
771
+ def test_process_alive_false_when_session_absent_but_server_up(monkeypatch):
772
+ """Server reachable + this session genuinely gone → dead (A2 may fire)."""
773
+ monkeypatch.setattr(_wd.subprocess, "run",
774
+ _fake_run_factory(1, "", 0, "2000\n", sessions_rc=0))
775
+ assert _wd._process_alive("ghost") is False
776
+
777
+
778
+ def test_process_alive_assumes_alive_when_no_tmux_server(monkeypatch):
779
+ """Regression (deploy 2026-06-02): the watchdog runs as ROOT, whose tmux
780
+ socket is empty — list-panes AND list-sessions both fail. We CANNOT
781
+ determine liveness via tmux, so must assume alive, NOT false-fire an A2
782
+ respawn of a session that's actually alive under ubuntu's tmux."""
783
+ monkeypatch.setattr(_wd.subprocess, "run",
784
+ _fake_run_factory(1, "", 0, "2000\n", sessions_rc=1))
785
+ assert _wd._process_alive("lab") is True
786
+
787
+
788
+ def test_process_alive_false_when_no_claude_anywhere(monkeypatch):
789
+ monkeypatch.setattr(_wd.subprocess, "run",
790
+ _fake_run_factory(0, "1000\n", 1, ""))
791
+ assert _wd._process_alive("mysess") is False
792
+
793
+
794
+ # ---------------------------------------------------------------------------
795
+ # _pane_activity_age_sec — fallback across pane/window/session (F3 fix 2026-06-02)
796
+ # ---------------------------------------------------------------------------
797
+
798
+
799
+ def test_pane_activity_age_falls_back_when_pane_empty(monkeypatch):
800
+ """pane_activity is empty without monitor-activity (tmux 3.x). F3 must fall
801
+ back to window/session activity, NOT return None — returning None made F3 a
802
+ no-op and let A1 fire against a genuinely-active session."""
803
+ recent = _wd._now() - 30
804
+ monkeypatch.setattr(_wd.subprocess, "run",
805
+ lambda cmd, **kw: _R(0, f"|{recent}|{recent - 5}\n"))
806
+ age = _wd._pane_activity_age_sec("lab")
807
+ assert age is not None
808
+ assert 25 <= age <= 40 # ~30s, allowing for execution slack
809
+
810
+
811
+ def test_pane_activity_age_none_when_all_blank(monkeypatch):
812
+ monkeypatch.setattr(_wd.subprocess, "run", lambda cmd, **kw: _R(0, "||\n"))
813
+ assert _wd._pane_activity_age_sec("lab") is None
814
+
815
+
816
+ def test_pane_activity_age_takes_most_recent(monkeypatch):
817
+ """Uses the MAX (most recent) epoch across the three vars."""
818
+ now = _wd._now()
819
+ # pane empty, window 500s ago, session 10s ago → most recent = 10s
820
+ monkeypatch.setattr(_wd.subprocess, "run",
821
+ lambda cmd, **kw: _R(0, f"|{now - 500}|{now - 10}\n"))
822
+ age = _wd._pane_activity_age_sec("lab")
823
+ assert age is not None and age <= 20
File without changes
File without changes
File without changes