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.
- {swarph_cli-0.12.0/src/swarph_cli.egg-info → swarph_cli-0.13.0}/PKG-INFO +2 -2
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/pyproject.toml +2 -2
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/verify.py +30 -3
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/cell.py +4 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/spawn.py +219 -14
- {swarph_cli-0.12.0 → swarph_cli-0.13.0/src/swarph_cli.egg-info}/PKG-INFO +2 -2
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/requires.txt +1 -1
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_command_dispatch.py +14 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_verify.py +72 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_command.py +219 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/LICENSE +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/README.md +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/setup.cfg +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/__init__.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/harden.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/lineage.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/liveness.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/manifest.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/capture/paths.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/add.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/compress.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/hooks.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/mcp_server.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/security.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/commands/watchdog.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/__init__.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/levers.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/marker.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/compress/verify.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_add_security_gate.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_add.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_hash.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_lib.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_mcp.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_skill.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_tool.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_artifact_uri.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_lineage.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_liveness.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_manifest.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_paths.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_capture_security.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_harden.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_claude_tmux_template.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_levers.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_marker.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_compress_verify.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_feature_to_uri.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_add.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_bundle.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_init.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_lifecycle.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_hooks_merge.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_init_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_main.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_mcp_server.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_protocol_handler.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_security.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_session_path_role_gate.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_live_pin.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_mitosis_lineage.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_tmux_session.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_spawn_windows_relaunch.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_dm_wake.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
- {swarph_cli-0.12.0 → swarph_cli-0.13.0}/tests/test_watchdog_model_rung.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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
|
-
#
|
|
1014
|
-
# a parent-set ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN can't silently
|
|
1015
|
-
#
|
|
1016
|
-
#
|
|
1017
|
-
#
|
|
1018
|
-
#
|
|
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,
|
|
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
|
|
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:
|
|
1105
|
-
#
|
|
1106
|
-
#
|
|
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
|
-
|
|
1312
|
+
_unmembraned = _VALID_PROVIDERS - set(MEMBRANES)
|
|
1313
|
+
if _unmembraned:
|
|
1110
1314
|
raise RuntimeError(
|
|
1111
|
-
f"
|
|
1112
|
-
f"{sorted(
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|