swarph-cli 0.12.0__tar.gz → 0.13.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.
Files changed (110) hide show
  1. {swarph_cli-0.12.0/src/swarph_cli.egg-info → swarph_cli-0.13.0}/PKG-INFO +2 -2
  2. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/pyproject.toml +2 -2
  3. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/verify.py +30 -3
  5. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/cell.py +4 -0
  6. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/spawn.py +219 -14
  7. {swarph_cli-0.12.0 → swarph_cli-0.13.0/src/swarph_cli.egg-info}/PKG-INFO +2 -2
  8. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/requires.txt +1 -1
  9. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_command_dispatch.py +14 -0
  10. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_verify.py +72 -0
  11. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_command.py +219 -0
  12. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/LICENSE +0 -0
  13. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/README.md +0 -0
  14. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/setup.cfg +0 -0
  15. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/caller.py +0 -0
  16. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/__init__.py +0 -0
  17. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/harden.py +0 -0
  18. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/lineage.py +0 -0
  19. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/liveness.py +0 -0
  20. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/manifest.py +0 -0
  21. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/paths.py +0 -0
  22. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/cell.py +0 -0
  23. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/__init__.py +0 -0
  24. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/add.py +0 -0
  25. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/chat.py +0 -0
  26. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/compress.py +0 -0
  27. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/daemon.py +0 -0
  28. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/hook_output.py +0 -0
  29. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/hooks.py +0 -0
  30. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/import_session.py +0 -0
  31. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/init.py +0 -0
  32. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/install_hook.py +0 -0
  33. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/mcp_server.py +0 -0
  34. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/memory_sync.py +0 -0
  35. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/mesh.py +0 -0
  36. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/onboard.py +0 -0
  37. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
  38. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/ratify.py +0 -0
  39. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/security.py +0 -0
  40. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/watchdog.py +0 -0
  41. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/__init__.py +0 -0
  42. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/levers.py +0 -0
  43. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/marker.py +0 -0
  44. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/verify.py +0 -0
  45. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/main.py +0 -0
  46. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/parsers/__init__.py +0 -0
  47. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/parsers/claude.py +0 -0
  48. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  49. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  50. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  51. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
  52. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  53. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  54. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  55. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_add_security_gate.py +0 -0
  56. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_add.py +0 -0
  57. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_hash.py +0 -0
  58. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_lib.py +0 -0
  59. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_mcp.py +0 -0
  60. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_skill.py +0 -0
  61. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_tool.py +0 -0
  62. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_uri.py +0 -0
  63. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_lineage.py +0 -0
  64. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_liveness.py +0 -0
  65. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_manifest.py +0 -0
  66. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_paths.py +0 -0
  67. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_security.py +0 -0
  68. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_harden.py +0 -0
  69. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_loader.py +0 -0
  70. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_chat_command.py +0 -0
  71. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_claude_parser.py +0 -0
  72. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_claude_tmux_template.py +0 -0
  73. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_command.py +0 -0
  74. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_levers.py +0 -0
  75. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_marker.py +0 -0
  76. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_verify.py +0 -0
  77. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_daemon_command.py +0 -0
  78. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_feature_to_uri.py +0 -0
  79. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hook_output.py +0 -0
  80. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_add.py +0 -0
  81. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_bundle.py +0 -0
  82. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_init.py +0 -0
  83. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_lifecycle.py +0 -0
  84. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_merge.py +0 -0
  85. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_import_command.py +0 -0
  86. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_init_command.py +0 -0
  87. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_install_hook.py +0 -0
  88. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_main.py +0 -0
  89. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_mcp_server.py +0 -0
  90. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_memory_sync.py +0 -0
  91. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_mesh_command.py +0 -0
  92. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_mesh_sidecar.py +0 -0
  93. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_onboard_command.py +0 -0
  94. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_protocol_handler.py +0 -0
  95. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_ratify_command.py +0 -0
  96. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_security.py +0 -0
  97. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_session_path_role_gate.py +0 -0
  98. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_smoke_chat.py +0 -0
  99. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_smoke_one_shot.py +0 -0
  100. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_smoke_phase_5_5.py +0 -0
  101. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_live_pin.py +0 -0
  102. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_mitosis_lineage.py +0 -0
  103. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_tmux_session.py +0 -0
  104. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_windows_relaunch.py +0 -0
  105. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog.py +0 -0
  106. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_dm_wake.py +0 -0
  107. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  108. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  109. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_model_rung.py +0 -0
  110. {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_stale_peers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -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.1
28
+ Requires-Dist: swarph-shared>=0.3.3
29
29
  Requires-Dist: PyYAML>=6.0
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -40,7 +40,7 @@ dependencies = [
40
40
  # >=0.3.1 is load-bearing: 0.3.1 is the first release whose
41
41
  # scrub_env_for_subprocess closes the ANTHROPIC_AUTH_TOKEN/*_BASE_URL
42
42
  # billing-redirect class that the membrane env builders delegate to.
43
- "swarph-shared>=0.3.1",
43
+ "swarph-shared>=0.3.3",
44
44
  # Phase 7 spawn — cell.yaml parser. PyYAML 6.x is the standard.
45
45
  # Stays a swarph-cli dep since file I/O is operator-tooling-layer
46
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.12.0"
19
+ __version__ = "0.13.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -18,7 +18,7 @@ Two checks:
18
18
  """
19
19
  from __future__ import annotations
20
20
 
21
- from dataclasses import dataclass
21
+ from dataclasses import dataclass, field
22
22
  from pathlib import Path
23
23
  from typing import List, Optional, Tuple
24
24
 
@@ -27,7 +27,7 @@ from swarph_shared.cell import Cell, CellError
27
27
  from swarph_cli.cell import _read_session_sidecar, load_cell, resolve_cell_path, session_state_path
28
28
  from swarph_cli.capture import manifest
29
29
  from swarph_cli.capture.liveness import probe_holder_liveness
30
- from swarph_cli.capture.paths import CaptureRoleError, validate_role
30
+ from swarph_cli.capture.paths import CaptureRoleError, manifest_path, validate_role
31
31
 
32
32
 
33
33
  @dataclass
@@ -35,6 +35,10 @@ class VerifyResult:
35
35
  ok: bool
36
36
  code: int
37
37
  reason: str
38
+ # Non-fatal advisories surfaced loudly (stderr/journal) regardless of `ok`.
39
+ # The gate stays fail-LOUD even when it ALLOWS: an un-hardened live cell
40
+ # passes (no manifest = no proof of double-resume) but must NOT pass mute.
41
+ warnings: List[str] = field(default_factory=list)
38
42
 
39
43
 
40
44
  def expected_project_dir(cwd: Path) -> str:
@@ -95,6 +99,28 @@ def verify_cell(role: str) -> VerifyResult:
95
99
  )
96
100
  # jsonls == [] → pin minted but session never ran; spawn will create it. OK.
97
101
 
102
+ # Un-hardened-but-LIVE warning (science-claude co-review, mesh #2811): a
103
+ # hand-deployed cell with a real running session (pin + jsonl) but NO capture
104
+ # manifest of its own is UNPROTECTED — it isn't registered as a holder, so the
105
+ # per-UUID sweep below can't see it and a second spawn wouldn't be refused.
106
+ # verify ALLOWS (no manifest = no proof of a double-resume) but must warn
107
+ # loudly, never pass mute (its fail-LOUD contract). Gated on jsonls so a
108
+ # minted-but-unstarted pin (benign fresh state) stays quiet.
109
+ #
110
+ # Existence check, NOT read_manifest(role) (science-claude #2826): read_manifest
111
+ # json.loads-RAISES on a corrupt manifest, and this runs in the @.service
112
+ # ExecStart gate — a throw here = no spawn. A manifest that EXISTS but is
113
+ # corrupt must route to find_pin_holders' fail-CLOSED (code 5) below, not throw
114
+ # here and not warn "no manifest" (the file is present, just damaged).
115
+ warnings: List[str] = []
116
+ if jsonls and not manifest_path(role).exists():
117
+ warnings.append(
118
+ f"no capture manifest for {role!r} — this cell is UNPROTECTED against "
119
+ f"double-resume whenever live (the per-UUID liveness check has nothing "
120
+ f"to probe for it). Run `swarph cell harden {role}` to capture its "
121
+ f"revival kit."
122
+ )
123
+
98
124
  # (b) liveness probe — cross-NAME sweep over every manifest pinning this
99
125
  # UUID, not just this role's own (spec §4.3 blocking fix: two roles
100
126
  # pinning one UUID both look clean per-role).
@@ -128,5 +154,6 @@ def verify_cell(role: str) -> VerifyResult:
128
154
  return VerifyResult(
129
155
  True, 0,
130
156
  f"cleared stale live-pin(s) ({', '.join(cleared)}) — allow",
157
+ warnings=warnings,
131
158
  )
132
- return VerifyResult(True, 0, "ok")
159
+ return VerifyResult(True, 0, "ok", warnings=warnings)
@@ -54,6 +54,10 @@ def _run_verify(rest: List[str]) -> int:
54
54
  result = _verify.verify_cell(role)
55
55
  stream = sys.stdout if result.ok else sys.stderr
56
56
  print(f"[verify {role}] {'OK' if result.ok else 'REFUSE'}: {result.reason}", file=stream)
57
+ # Warnings are always loud (stderr/journal) even on an OK verdict — the gate
58
+ # is fail-LOUD; a silent pass on an unprotected cell is the bug being fixed.
59
+ for w in result.warnings:
60
+ print(f"[verify {role}] WARN: {w}", file=sys.stderr)
57
61
  return result.code
58
62
 
59
63
 
@@ -484,6 +484,99 @@ def _scrubbed_codex_env() -> dict[str, str]:
484
484
  return env
485
485
 
486
486
 
487
+ #: grok cell isolated-HOME subdir, created/used INSIDE the cell cwd so the
488
+ #: cell's grok sessions + memory + auth.json symlink never mix with the
489
+ #: operator's personal ``~/.grok`` (cell.yaml: "Separate HOME (~/.grok-cell
490
+ #: inside cwd)").
491
+ _GROK_CELL_HOME_SUBDIR = ".grok-cell"
492
+
493
+ #: Metered xAI billing keys popped explicitly (belt-and-suspenders on top of the
494
+ #: canonical ``*_API_KEY`` / ``*_BASE_URL`` suffix sweep) so a grok cell can
495
+ #: never be flipped off its $0 OIDC auth onto a metered endpoint. The suffix
496
+ #: sweep already catches ``XAI_API_KEY`` / ``GROK_API_KEY``; the ``*_API_BASE``
497
+ #: / ``*_API_HOST`` variants do NOT match a suffix, so they live here.
498
+ _GROK_EXTRA_LEAK_KEYS = (
499
+ "XAI_API_KEY",
500
+ "GROK_API_KEY",
501
+ "GROK_CODE_XAI_API_KEY",
502
+ "XAI_API_BASE",
503
+ "XAI_API_HOST",
504
+ "GROK_API_HOST",
505
+ )
506
+
507
+
508
+ def _grok_env(cell: Cell) -> dict[str, str]:
509
+ """Subscription/OIDC env for a local ``grok`` CELL session ($0 path).
510
+
511
+ On top of the canonical billing scrub:
512
+ * the explicit ``_GROK_EXTRA_LEAK_KEYS`` pop (metered xAI endpoints the
513
+ suffix sweep misses), keeping grok on its $0 OIDC auth.
514
+ * MESH_GATEWAY_TOKEN popped — the per-cell auth CUTOVER: with the shared
515
+ token absent the swarph resolver loads the cell's per-peer token file
516
+ (symlinked into the isolated HOME at
517
+ ``.grok-cell/.config/swarph/grok-researcher.peer_token``) instead of the
518
+ cell riding the operator's shared token. (``_TOKEN`` is not a scrub
519
+ suffix, so this MUST be explicit — omitting it is the mint≠cutover gap.)
520
+ * HOME → an ISOLATED dir inside the cell cwd (created if absent), with the
521
+ operator's ``~/.grok/auth.json`` symlinked in for $0 OIDC, so the cell's
522
+ sessions/memory/auth never mix with the operator's personal ``~/.grok``.
523
+ * SWARPH_SPAWN marker — the tmux re-entry loop-guard (see
524
+ ``_launch_via_tmux``) and the install-hook double-inject guard.
525
+ """
526
+ env = scrub_env_for_subprocess()
527
+ for key in _GROK_EXTRA_LEAK_KEYS:
528
+ env.pop(key, None)
529
+ env.pop("MESH_GATEWAY_TOKEN", None)
530
+ env["SWARPH_SPAWN"] = "1"
531
+
532
+ cell_home = cell.cwd / _GROK_CELL_HOME_SUBDIR
533
+ cell_home.mkdir(parents=True, exist_ok=True)
534
+ try:
535
+ op_auth = Path.home() / ".grok" / "auth.json"
536
+ link = cell_home / "auth.json"
537
+ if op_auth.exists() and not link.exists():
538
+ link.symlink_to(op_auth)
539
+ except OSError:
540
+ # Best-effort: a pre-existing link / race / read-only HOME must not
541
+ # crash the spawn — grok will fall back to its own auth flow.
542
+ pass
543
+ env["HOME"] = str(cell_home)
544
+ return env
545
+
546
+
547
+ def _build_grok_argv(
548
+ cell: Cell,
549
+ session_id: Optional[str],
550
+ effective_role: Optional[str],
551
+ passthrough: list[str],
552
+ ) -> list[str]:
553
+ """``grok --cwd <cwd> [--resume <id>] [--agent <role>] --always-approve``.
554
+
555
+ Pinned-session (R5): ``--resume <session_id>`` attaches grok's native
556
+ cwd-keyed session (``~/.grok/sessions/<encoded-cwd>/<uuid>``) so the cell
557
+ keeps its continuity + identity + inbox across respawns — the load-bearing
558
+ reason grok ``uses_pinned_session`` (not fresh-per-spawn). ``--agent`` gives
559
+ the cell its role identity. ``--always-approve`` is the cell autonomy
560
+ posture (a mesh cell runs unattended — the spawn-equivalent of codex ``-a``
561
+ / agy ``--sandbox``); opt out via ``extra.always_approve: false``.
562
+
563
+ No starter flag: grok's interactive mode has no system-prompt-override (the
564
+ real flags ``--prompt-file`` / ``-p`` are single-turn/headless), and the
565
+ cell's identity is carried by ``--agent`` + grok's own durable memory, so a
566
+ swarph starter is neither needed nor expressible here.
567
+ """
568
+ argv = ["grok", "--cwd", str(cell.cwd)]
569
+ if session_id:
570
+ argv.extend(["--resume", session_id])
571
+ role_value = effective_role if effective_role is not None else cell.role
572
+ if role_value:
573
+ argv.extend(["--agent", role_value])
574
+ if cell.extra.get("always_approve", True) is not False:
575
+ argv.append("--always-approve")
576
+ argv.extend(passthrough)
577
+ return argv
578
+
579
+
487
580
  def _print_banner() -> None:
488
581
  sys.stderr.write(_BANNER.format(version=__version__))
489
582
  sys.stderr.flush()
@@ -1010,18 +1103,39 @@ class ClaudeMembrane(ProviderMembrane):
1010
1103
  print(f"swarph spawn: cannot chdir to {cell.cwd}: {exc}", file=sys.stderr)
1011
1104
  return 1
1012
1105
 
1013
- # Exec with the billing-redirect-scrubbed env (NOT raw inherited env) so
1014
- # a parent-set ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN can't silently
1015
- # flip the spawned claude off subscription billing. SWARPH_SPAWN=1 (set
1016
- # in _claude_env) tells a `swarph install-hook` SessionStart hook the
1017
- # prompt was already injected via --append-system-prompt, so it skips
1018
- # double-injection. execve carries exactly this env to the child.
1106
+ # Launch with the billing-redirect-scrubbed env (NOT raw inherited env) so
1107
+ # a parent-set ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN can't silently flip
1108
+ # the spawned claude off subscription billing. SWARPH_SPAWN=1 (set in
1109
+ # _claude_env) tells a `swarph install-hook` SessionStart hook the prompt
1110
+ # was already injected via --append-system-prompt, so it skips double-
1111
+ # injection. The env carries to the child either way.
1112
+ env = _claude_env()
1113
+
1114
+ # Per-OS launch mechanism — the SAME split as the tmux attach, for the
1115
+ # SAME reason:
1116
+ # * POSIX: os.execve = a TRUE in-place replace. The claude session
1117
+ # inherits stdio/signals cleanly under the same PID, so when this IS a
1118
+ # tmux pane's root command tmux keeps the pane running claude.
1119
+ # * Windows: os.exec* is emulated as spawn-and-exit, NOT a replace — this
1120
+ # Python process exits and a NEW claude process is spawned. Inside a
1121
+ # tmux/psmux pane that COLLAPSES the pane (its root command exited),
1122
+ # orphaning claude => "tmux created but no claude" (the v0.12.0 Windows
1123
+ # breakage: the create path's inner `swarph spawn` reaches here). A
1124
+ # BLOCKING subprocess.run keeps THIS process alive as the pane root with
1125
+ # claude as its child, so the pane survives until claude exits. argv[0]
1126
+ # is the conventional "claude" argv0; the real exe is `binary`.
1127
+ if sys.platform == "win32":
1128
+ try:
1129
+ return subprocess.run([binary, *argv[1:]], env=env).returncode
1130
+ except OSError as exc:
1131
+ print(f"swarph spawn: launch failed: {exc}", file=sys.stderr)
1132
+ return 1
1019
1133
  try:
1020
- os.execve(binary, argv, _claude_env())
1134
+ os.execve(binary, argv, env)
1021
1135
  except OSError as exc:
1022
1136
  print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
1023
1137
  return 1
1024
- return 0 # unreachable, keeps type checker happy
1138
+ return 0 # unreachable on POSIX (execve replaces); keeps type checker happy
1025
1139
 
1026
1140
 
1027
1141
  class CodexMembrane(ProviderMembrane):
@@ -1095,21 +1209,112 @@ class AntigravityMembrane(ProviderMembrane):
1095
1209
  return 0 # unreachable, keeps type checker happy
1096
1210
 
1097
1211
 
1212
+ class GrokMembrane(ProviderMembrane):
1213
+ """Local ``grok`` CLI as a durable swarph CELL ($0 OIDC / subscription).
1214
+
1215
+ The CELL path — NOT the swarph-mesh ``--provider grok`` one-shot metered
1216
+ lane: exec the local ``grok`` agent TUI with an ISOLATED HOME inside the
1217
+ cell cwd (so the cell's sessions/memory/auth never mix with the operator's
1218
+ personal grok), launched in a NAMED tmux session (same mechanism as the
1219
+ claude cell) so it is durable + supervisable and lands in its OWN session,
1220
+ never a window of another cell's session.
1221
+
1222
+ Pinned-session (``uses_pinned_session`` True): grok exposes a native
1223
+ cwd-keyed session model under ``~/.grok/sessions/`` and a ``--resume <id>``
1224
+ verb, so the cell pins its session UUID via the R5 store (like claude) and
1225
+ ``--resume``\\s it on every respawn — preserving the cell's continuity,
1226
+ identity, and mesh inbox. (Continuity is the whole point of a persistent
1227
+ cell; a fresh session each spawn would orphan grok's running identity.)
1228
+ """
1229
+
1230
+ name = "grok"
1231
+
1232
+ def uses_pinned_session(self) -> bool:
1233
+ return True
1234
+
1235
+ def build_argv(
1236
+ self,
1237
+ cell: Cell,
1238
+ *,
1239
+ session_id: Optional[str],
1240
+ no_starter: bool,
1241
+ passthrough: list[str],
1242
+ effective_role: Optional[str],
1243
+ ) -> list[str]:
1244
+ return _build_grok_argv(cell, session_id, effective_role, passthrough)
1245
+
1246
+ def resolve_binary(self) -> Optional[str]:
1247
+ provider_bin = shutil.which("grok")
1248
+ if provider_bin is None:
1249
+ home_local = Path.home() / ".local" / "bin" / "grok"
1250
+ if home_local.exists():
1251
+ provider_bin = str(home_local)
1252
+ return provider_bin
1253
+
1254
+ def binary_not_found_message(self) -> str:
1255
+ return (
1256
+ "swarph spawn: 'grok' binary not found on PATH. "
1257
+ "Install the Grok CLI or set PATH explicitly."
1258
+ )
1259
+
1260
+ def pre_launch(
1261
+ self, cell: Cell, binary: str, argv: list[str], *, no_banner: bool,
1262
+ session_name: Optional[str] = None,
1263
+ ) -> Optional[int]:
1264
+ # Durable named-tmux launch — identical mechanism to the claude cell, so
1265
+ # the cell lands in its OWN tmux session (keyed on the operator-typed
1266
+ # spawn name), never a window of another session. No-op (falls through
1267
+ # to the in-place execve below) when already inside a tmux pane /
1268
+ # SWARPH_SPAWN set / tmux absent. Unlike claude there is no
1269
+ # Windows-Terminal relaunch fallback — grok's TUI has no conhost
1270
+ # Enter-inserts-'m' bug.
1271
+ if session_name and _launch_via_tmux(binary, argv, cell.cwd, session_name):
1272
+ return 0
1273
+ return None
1274
+
1275
+ def launch(self, cell: Cell, binary: str, argv: list[str]) -> int:
1276
+ try:
1277
+ os.chdir(cell.cwd)
1278
+ except OSError as exc:
1279
+ print(f"swarph spawn: cannot chdir to {cell.cwd}: {exc}", file=sys.stderr)
1280
+ return 1
1281
+ # execve carries the isolated-HOME + billing-scrubbed env to grok
1282
+ # without mutating this process's os.environ first (a failed exec leaves
1283
+ # us intact).
1284
+ try:
1285
+ os.execve(binary, argv, _grok_env(cell))
1286
+ except OSError as exc:
1287
+ print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
1288
+ return 1
1289
+ return 0 # unreachable on POSIX (execve replaces); keeps type checker happy
1290
+
1291
+
1098
1292
  MEMBRANES: dict[str, ProviderMembrane] = {
1099
1293
  "claude": ClaudeMembrane(),
1100
1294
  "codex": CodexMembrane(),
1101
1295
  "antigravity": AntigravityMembrane(),
1296
+ "grok": GrokMembrane(),
1102
1297
  }
1103
1298
 
1104
- # Defensive coupling: MEMBRANES must stay in lockstep with the shared provider
1105
- # whitelist. A future VALID_PROVIDERS entry without a matching membrane would
1106
- # otherwise surface as a raw KeyError at spawn time — fail loud at import instead.
1299
+ # Defensive coupling: every shared-whitelisted provider MUST have a membrane,
1300
+ # else a `cell.provider` that load_cell accepts would surface as a raw KeyError
1301
+ # in run_spawn — fail loud at import instead. The invariant is a SUBSET
1302
+ # (VALID_PROVIDERS ⊆ MEMBRANES), NOT equality: MEMBRANES may legitimately carry
1303
+ # a membrane AHEAD of the shared whitelist (e.g. GrokMembrane shipping before a
1304
+ # swarph_shared release adds 'grok' to VALID_PROVIDERS). An extra membrane is
1305
+ # harmless — load_cell still gates which providers are actually spawnable — so
1306
+ # strict equality would needlessly couple this package's release to the shared
1307
+ # one. (Cross-pkg: grok cells only become spawnable once swarph_shared whitelists
1308
+ # 'grok'; until then load_cell rejects them cleanly with "queued for a future
1309
+ # release", and this guard does not crash.)
1107
1310
  from swarph_shared.cell import VALID_PROVIDERS as _VALID_PROVIDERS # noqa: E402
1108
1311
 
1109
- if set(MEMBRANES) != _VALID_PROVIDERS:
1312
+ _unmembraned = _VALID_PROVIDERS - set(MEMBRANES)
1313
+ if _unmembraned:
1110
1314
  raise RuntimeError(
1111
- f"MEMBRANES {sorted(MEMBRANES)} out of sync with VALID_PROVIDERS "
1112
- f"{sorted(_VALID_PROVIDERS)} add the missing provider membrane."
1315
+ f"VALID_PROVIDERS {sorted(_VALID_PROVIDERS)} has providers with no "
1316
+ f"membrane: {sorted(_unmembraned)} (MEMBRANES: {sorted(MEMBRANES)}) "
1317
+ f"add the missing provider membrane."
1113
1318
  )
1114
1319
 
1115
1320
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -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.1
28
+ Requires-Dist: swarph-shared>=0.3.3
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.1
2
+ swarph-shared>=0.3.3
3
3
  PyYAML>=6.0
4
4
 
5
5
  [dev]
@@ -54,3 +54,17 @@ def test_main_routes_cell_verb(monkeypatch):
54
54
  # `swarph cell verify droplet` dispatches through main._VERB_HANDLERS
55
55
  from swarph_cli import main
56
56
  assert "cell" in main._VERB_HANDLERS
57
+
58
+
59
+ def test_verify_warnings_print_to_stderr(monkeypatch, capsys):
60
+ # The loud-warning render: an OK-with-warnings verdict must still surface the
61
+ # warning on stderr (journal-visible from the @.service ExecStart).
62
+ monkeypatch.setattr(
63
+ verify, "verify_cell",
64
+ lambda role: verify.VerifyResult(True, 0, "ok", warnings=[
65
+ "no capture manifest for 'science-claude' — run swarph cell harden"]),
66
+ )
67
+ rc = cell_cmd.run_cell(["verify", "science-claude"])
68
+ assert rc == 0
69
+ err = capsys.readouterr().err
70
+ assert "WARN" in err and "harden" in err
@@ -119,3 +119,75 @@ def test_cross_name_dead_holder_cleared_and_allowed(tmp_path, monkeypatch):
119
119
  assert r.ok and r.code == 0
120
120
  # the OTHER role's stale poison-pin got cleared
121
121
  assert manifest.read_manifest("drop-mother")["head"]["live_pin_holder"] is None
122
+
123
+
124
+ def test_live_unhardened_cell_warns_but_allows(tmp_path, monkeypatch):
125
+ # science-claude's co-review case (mesh #2811): a cell that is demonstrably
126
+ # alive but was hand-deployed (never `harden`ed) has NO capture manifest, so
127
+ # the per-UUID liveness sweep finds nothing to probe. The pin is untouched
128
+ # (good) but verify used to pass MUTE — violating its own fail-LOUD contract.
129
+ # It must ALLOW (no manifest = no proof of double-resume) yet WARN loudly.
130
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
131
+ cwd = tmp_path / "right"
132
+ cwd.mkdir()
133
+ monkeypatch.setattr(verify, "_resolve_cell", lambda role: _cell(cwd))
134
+ monkeypatch.setattr(verify, "_read_pin", lambda role: ("uuid-1", str(cwd)))
135
+ good = Path(".claude/projects") / verify.expected_project_dir(cwd) / "uuid-1.jsonl"
136
+ monkeypatch.setattr(verify, "locate_session_jsonl", lambda u: [good])
137
+ # NO manifest written for this role -> un-hardened, unprotected
138
+ r = verify.verify_cell("science-claude")
139
+ assert r.ok and r.code == 0
140
+ assert r.warnings, "an un-hardened live cell must emit a loud warning, not pass mute"
141
+ assert any("harden" in w for w in r.warnings)
142
+
143
+
144
+ def test_hardened_cell_emits_no_unprotected_warning(tmp_path, monkeypatch):
145
+ # The inverse: a cell WITH a manifest is protected -> no unprotected warning.
146
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
147
+ cwd = tmp_path / "right"
148
+ cwd.mkdir()
149
+ monkeypatch.setattr(verify, "_resolve_cell", lambda role: _cell(cwd))
150
+ monkeypatch.setattr(verify, "_read_pin", lambda role: ("uuid-1", str(cwd)))
151
+ good = Path(".claude/projects") / verify.expected_project_dir(cwd) / "uuid-1.jsonl"
152
+ monkeypatch.setattr(verify, "locate_session_jsonl", lambda u: [good])
153
+ manifest.write_manifest("droplet", recipe="r", pin="p", service="s",
154
+ lineage="l", session_id="uuid-1", live_pin_holder="droplet")
155
+ monkeypatch.setattr(verify, "probe_holder_liveness", lambda h: False)
156
+ r = verify.verify_cell("droplet")
157
+ assert r.ok and r.code == 0
158
+ assert not r.warnings
159
+
160
+
161
+ def test_unstarted_pin_no_jsonl_does_not_warn(tmp_path, monkeypatch):
162
+ # A pin minted but never run (no jsonl) is benign fresh state, not an
163
+ # unprotected live cell -> no noise.
164
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
165
+ monkeypatch.setattr(verify, "_resolve_cell", lambda role: _cell(tmp_path))
166
+ monkeypatch.setattr(verify, "_read_pin", lambda role: ("uuid-1", str(tmp_path)))
167
+ monkeypatch.setattr(verify, "locate_session_jsonl", lambda u: [])
168
+ r = verify.verify_cell("science-claude")
169
+ assert r.ok and r.code == 0
170
+ assert not r.warnings
171
+
172
+
173
+ def test_corrupt_own_manifest_fails_closed_not_throws(tmp_path, monkeypatch):
174
+ # science-claude defensive co-review (mesh #2826): read_manifest RAISES on a
175
+ # corrupt manifest (json.loads). The un-hardened check must NOT parse — it
176
+ # runs in the @.service ExecStart gate, so a throw there = no spawn. A corrupt
177
+ # OWN manifest must route to the existing fail-CLOSED handling (code 5), never
178
+ # an unhandled exception, AND must not emit the "no manifest" warning (a
179
+ # manifest FILE exists — it's just damaged, not absent).
180
+ from swarph_cli.capture import paths as _paths
181
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
182
+ cwd = tmp_path / "right"
183
+ cwd.mkdir()
184
+ monkeypatch.setattr(verify, "_resolve_cell", lambda role: _cell(cwd))
185
+ monkeypatch.setattr(verify, "_read_pin", lambda role: ("uuid-1", str(cwd)))
186
+ good = Path(".claude/projects") / verify.expected_project_dir(cwd) / "uuid-1.jsonl"
187
+ monkeypatch.setattr(verify, "locate_session_jsonl", lambda u: [good])
188
+ mpath = _paths.manifest_path("science-claude")
189
+ mpath.parent.mkdir(parents=True, exist_ok=True)
190
+ mpath.write_text("{ this is not valid json", encoding="utf-8")
191
+ r = verify.verify_cell("science-claude") # MUST NOT raise
192
+ assert not r.ok and r.code == 5
193
+ assert not any("no capture manifest" in w for w in r.warnings)
@@ -1012,3 +1012,222 @@ def test_run_spawn_codex_assisted_memory_injects_agents_md(tmp_path, isolated_xd
1012
1012
  assert len(exec_args) == 1
1013
1013
  argv = exec_args[0][1]
1014
1014
  assert "--prompt-interactive" not in argv
1015
+
1016
+
1017
+ # --- per-OS ClaudeMembrane.launch (v0.12.0 Windows fix) ------------------
1018
+ #
1019
+ # On Windows os.exec* is spawn-and-exit, NOT a true replace: inside a tmux/psmux
1020
+ # pane it collapses the pane (the root process exits) and orphans claude, so the
1021
+ # create path's inner `swarph spawn` produced "tmux created but no claude" and a
1022
+ # dropped auto-attach. launch() must BLOCK on Windows (subprocess.run, claude as
1023
+ # a child of a stable pane root) and only os.execve on POSIX (true in-place
1024
+ # replace). Mirrors the per-OS tmux attach split.
1025
+
1026
+
1027
+ def test_claude_launch_windows_blocks_not_execve(monkeypatch, tmp_path):
1028
+ from swarph_cli.commands import spawn
1029
+ monkeypatch.setattr(spawn.sys, "platform", "win32")
1030
+ monkeypatch.setattr(spawn.os, "chdir", lambda p: None)
1031
+
1032
+ class _R:
1033
+ returncode = 0
1034
+
1035
+ ran = []
1036
+ monkeypatch.setattr(spawn.subprocess, "run",
1037
+ lambda cmd, **kw: ran.append((cmd, kw)) or _R())
1038
+ execve_called = []
1039
+ monkeypatch.setattr(spawn.os, "execve", lambda *a: execve_called.append(a))
1040
+
1041
+ cell = type("C", (), {"cwd": tmp_path})()
1042
+ rc = spawn.ClaudeMembrane().launch(
1043
+ cell, "/bin/claude", ["claude", "--name", "x", "--session-id", "u"])
1044
+
1045
+ assert rc == 0
1046
+ assert not execve_called # Windows must NOT execve (collapses the tmux pane)
1047
+ assert ran and ran[0][0] == ["/bin/claude", "--name", "x", "--session-id", "u"]
1048
+
1049
+
1050
+ @pytest.mark.parametrize("platform", ["linux", "darwin"])
1051
+ def test_claude_launch_posix_uses_execve(monkeypatch, tmp_path, platform):
1052
+ from swarph_cli.commands import spawn
1053
+ monkeypatch.setattr(spawn.sys, "platform", platform)
1054
+ monkeypatch.setattr(spawn.os, "chdir", lambda p: None)
1055
+
1056
+ run_called = []
1057
+ monkeypatch.setattr(spawn.subprocess, "run",
1058
+ lambda cmd, **kw: run_called.append(cmd))
1059
+ execve_args = []
1060
+ monkeypatch.setattr(spawn.os, "execve",
1061
+ lambda p, a, e: execve_args.append((p, a, e)))
1062
+
1063
+ cell = type("C", (), {"cwd": tmp_path})()
1064
+ spawn.ClaudeMembrane().launch(cell, "/bin/claude", ["claude", "--name", "x"])
1065
+
1066
+ assert execve_args and execve_args[0][0] == "/bin/claude"
1067
+ assert execve_args[0][1] == ["claude", "--name", "x"] # full argv incl argv0
1068
+ assert not run_called # POSIX launch uses execve, not subprocess.run
1069
+
1070
+
1071
+
1072
+
1073
+ # ---------------------------------------------------------------------------
1074
+ # Grok membrane (local `grok` CLI as a durable PINNED cell — $0 OIDC)
1075
+ # ---------------------------------------------------------------------------
1076
+
1077
+
1078
+ @pytest.fixture
1079
+ def fake_grok_cell_yaml(tmp_path):
1080
+ """Drop a valid grok cell.yaml under tmp_path/cell.yaml and return its path."""
1081
+ payload = {
1082
+ "schema_version": SCHEMA_VERSION_V1,
1083
+ "name": "grok-researcher",
1084
+ "role": "grok-researcher",
1085
+ "cwd": str(tmp_path),
1086
+ "provider": "grok",
1087
+ }
1088
+ p = tmp_path / "cell.yaml"
1089
+ p.write_text(yaml.safe_dump(payload), encoding="utf-8")
1090
+ return p
1091
+
1092
+
1093
+ def test_membranes_lockstep_includes_grok():
1094
+ # The import-time lockstep guard only passes if every VALID_PROVIDERS entry
1095
+ # has a membrane. A bare import proves the guard did not raise; assert grok
1096
+ # is wired + pinned (regression for the GrokMembrane-missing crash that
1097
+ # bricked every `swarph spawn`, and for the pinned-session contract).
1098
+ from swarph_cli.commands.spawn import MEMBRANES, GrokMembrane
1099
+ from swarph_shared.cell import VALID_PROVIDERS
1100
+
1101
+ # Invariant is a SUBSET, not equality: every whitelisted provider has a
1102
+ # membrane, but MEMBRANES may carry one ahead of the shared whitelist (grok
1103
+ # ships here before swarph_shared whitelists it — must not crash import).
1104
+ assert VALID_PROVIDERS <= set(MEMBRANES)
1105
+ assert isinstance(MEMBRANES["grok"], GrokMembrane)
1106
+ assert MEMBRANES["grok"].uses_pinned_session() is True
1107
+
1108
+
1109
+ def test_build_grok_argv_pinned(fake_grok_cell_yaml):
1110
+ from swarph_cli.commands.spawn import _build_grok_argv
1111
+
1112
+ cell = load_cell(fake_grok_cell_yaml)
1113
+ argv = _build_grok_argv(
1114
+ cell,
1115
+ "15f1f31c-5a59-4162-9e4f-ecbed9c48403",
1116
+ "grok-researcher",
1117
+ [],
1118
+ )
1119
+ assert argv == [
1120
+ "grok",
1121
+ "--cwd",
1122
+ str(cell.cwd),
1123
+ "--resume",
1124
+ "15f1f31c-5a59-4162-9e4f-ecbed9c48403",
1125
+ "--agent",
1126
+ "grok-researcher",
1127
+ "--always-approve",
1128
+ ]
1129
+
1130
+
1131
+ def test_build_grok_argv_no_session_falls_back_to_role(fake_grok_cell_yaml):
1132
+ from swarph_cli.commands.spawn import _build_grok_argv
1133
+
1134
+ cell = load_cell(fake_grok_cell_yaml)
1135
+ # No pinned id yet (first genesis) → no --resume; effective_role None falls
1136
+ # back to cell.role for --agent.
1137
+ argv = _build_grok_argv(cell, None, None, [])
1138
+ assert "--resume" not in argv
1139
+ assert argv == [
1140
+ "grok",
1141
+ "--cwd",
1142
+ str(cell.cwd),
1143
+ "--agent",
1144
+ "grok-researcher",
1145
+ "--always-approve",
1146
+ ]
1147
+
1148
+
1149
+ def test_build_grok_argv_effective_role_and_passthrough(fake_grok_cell_yaml):
1150
+ from swarph_cli.commands.spawn import _build_grok_argv
1151
+
1152
+ cell = load_cell(fake_grok_cell_yaml)
1153
+ argv = _build_grok_argv(cell, "abc", "grok-researcher-2", ["--no-subagents"])
1154
+ # effective_role (sibling slot) wins for --agent; passthrough is appended
1155
+ # AFTER --always-approve.
1156
+ assert argv[argv.index("--agent") + 1] == "grok-researcher-2"
1157
+ assert argv[-1] == "--no-subagents"
1158
+ assert argv.index("--no-subagents") > argv.index("--always-approve")
1159
+
1160
+
1161
+ def test_build_grok_argv_always_approve_optout(fake_grok_cell_yaml):
1162
+ from swarph_cli.commands.spawn import _build_grok_argv
1163
+
1164
+ # Extra fields live at TOP LEVEL of cell.yaml; load_cell collects leftover
1165
+ # keys into cell.extra. (A nested `extra:` block lands as cell.extra['extra']
1166
+ # and would NOT be read here — top-level is the convention, matching agy's
1167
+ # cell.extra.get('sandbox').)
1168
+ payload = yaml.safe_load(fake_grok_cell_yaml.read_text())
1169
+ payload["always_approve"] = False
1170
+ fake_grok_cell_yaml.write_text(yaml.safe_dump(payload))
1171
+ cell = load_cell(fake_grok_cell_yaml)
1172
+
1173
+ argv = _build_grok_argv(cell, None, None, [])
1174
+ assert "--always-approve" not in argv
1175
+
1176
+
1177
+ def test_grok_env_isolates_home_scrubs_billing_and_token(
1178
+ fake_grok_cell_yaml, monkeypatch
1179
+ ):
1180
+ from swarph_cli.commands.spawn import _grok_env
1181
+
1182
+ # Metered xAI keys + the shared mesh token in the parent env must NOT reach
1183
+ # the spawned grok cell.
1184
+ monkeypatch.setenv("XAI_API_KEY", "sk-xai-LEAK")
1185
+ monkeypatch.setenv("XAI_API_BASE", "https://metered.example")
1186
+ monkeypatch.setenv("MESH_GATEWAY_TOKEN", "shared-token")
1187
+ cell = load_cell(fake_grok_cell_yaml)
1188
+
1189
+ env = _grok_env(cell)
1190
+
1191
+ # Isolated HOME inside the cell cwd (created), so the cell's grok state never
1192
+ # mixes with the operator's personal ~/.grok.
1193
+ assert env["HOME"] == str(cell.cwd / ".grok-cell")
1194
+ assert (cell.cwd / ".grok-cell").is_dir()
1195
+ # $0 OIDC preserved: metered endpoints scrubbed (incl the *_API_BASE the
1196
+ # suffix sweep misses).
1197
+ assert "XAI_API_KEY" not in env
1198
+ assert "XAI_API_BASE" not in env
1199
+ # Per-peer token CUTOVER: shared mesh token popped so the resolver loads the
1200
+ # cell's per-peer token file (the mint!=cutover fix the /tmp draft omitted).
1201
+ assert "MESH_GATEWAY_TOKEN" not in env
1202
+ # tmux re-entry / hook loop-guard marker.
1203
+ assert env["SWARPH_SPAWN"] == "1"
1204
+
1205
+
1206
+ def test_grok_env_symlinks_operator_auth(fake_grok_cell_yaml, monkeypatch, tmp_path):
1207
+ from swarph_cli.commands.spawn import _grok_env
1208
+
1209
+ # Operator ~/.grok/auth.json gets symlinked into the cell HOME for $0 OIDC.
1210
+ fake_home = tmp_path / "ophome"
1211
+ (fake_home / ".grok").mkdir(parents=True)
1212
+ (fake_home / ".grok" / "auth.json").write_text("{}")
1213
+ monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
1214
+ cell = load_cell(fake_grok_cell_yaml)
1215
+
1216
+ env = _grok_env(cell)
1217
+ link = Path(env["HOME"]) / "auth.json"
1218
+ assert link.is_symlink()
1219
+ assert link.resolve() == (fake_home / ".grok" / "auth.json")
1220
+
1221
+
1222
+ def test_grok_dry_run_emits_pinned_resume(
1223
+ fake_grok_cell_yaml, isolated_xdg, monkeypatch, capsys
1224
+ ):
1225
+ # `swarph spawn <grok-cell-path> --dry-run` prints grok with the pinned
1226
+ # --resume (R5 store mints the id on first run), matching the validated
1227
+ # manual launch.
1228
+ rc = run_spawn(["--dry-run", "--no-banner", str(fake_grok_cell_yaml)])
1229
+ out = capsys.readouterr().out
1230
+ assert rc == 0
1231
+ assert f"grok --cwd {fake_grok_cell_yaml.parent}" in out
1232
+ assert "--resume" in out
1233
+ assert "--agent grok-researcher --always-approve" in out
File without changes
File without changes
File without changes