swarph-cli 0.19.0__tar.gz → 0.20.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.19.0/src/swarph_cli.egg-info → swarph_cli-0.20.0}/PKG-INFO +4 -1
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/README.md +3 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/pyproject.toml +1 -1
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/__init__.py +1 -1
- swarph_cli-0.20.0/src/swarph_cli/commands/install_multiplexer.py +59 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/spawn.py +8 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/main.py +1 -0
- swarph_cli-0.20.0/src/swarph_cli/multiplexer.py +138 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0/src/swarph_cli.egg-info}/PKG-INFO +4 -1
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/SOURCES.txt +4 -0
- swarph_cli-0.20.0/tests/test_install_multiplexer_command.py +77 -0
- swarph_cli-0.20.0/tests/test_multiplexer.py +144 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/LICENSE +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/setup.cfg +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/__init__.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/harden.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/lineage.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/liveness.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/manifest.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/paths.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/verify.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/add.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/brain.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/brain_ask.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/cell.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/channel.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/compress.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/gateway.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/highlight.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/hooks.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/lane.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/mcp_server.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/schedule.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/security.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/service.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/watchdog.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/__init__.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/levers.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/marker.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/verify.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/__init__.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/schema.sql +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/server.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/services_control.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/service/__init__.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/service/app.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/service/providers.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/requires.txt +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_add_security_gate.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_add.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_hash.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_lib.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_mcp.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_skill.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_tool.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_uri.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_brain_ask_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_brain_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_caller_meta_guard.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_lineage.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_liveness.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_manifest.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_paths.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_security.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_command_dispatch.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_harden.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_verify.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_channel_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_claude_tmux_template.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_caller_convention.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_levers.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_marker.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_verify.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_feature_to_uri.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_gateway_client.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_gateway_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_highlight_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_add.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_bundle.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_init.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_lifecycle.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_merge.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_init_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_lane_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_main.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_mcp_server.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_protocol_handler.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_repl_caller_convention.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_schedule_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_security.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_service_app.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_service_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_service_providers.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_session_path_role_gate.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_command.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_live_pin.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_mitosis_lineage.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_tmux_session.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_windows_relaunch.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_version_consistency.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_dm_wake.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_model_rung.py +0 -0
- {swarph_cli-0.19.0 → swarph_cli-0.20.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.20.0
|
|
4
4
|
Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
@@ -51,6 +51,8 @@ pip install swarph-cli
|
|
|
51
51
|
swarph --version
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
> **System requirement — a terminal multiplexer.** The session verbs (`spawn`, `cell`, `watchdog`) drive a `tmux`-compatible multiplexer. On **Linux/macOS** install `tmux` (`apt install tmux` / `brew install tmux`). On **Windows** there is no native tmux — install [**psmux**](https://github.com/psmux/psmux) (a Rust tmux-for-Windows, MIT) with `swarph install-multiplexer` (fetches the pinned, checksum-verified binary into `~/.swarph/bin`) or `winget install marlocarlo.psmux`.
|
|
55
|
+
|
|
54
56
|
**What it is:** a multi-provider LLM command line. Run one-shot prompts or an interactive REPL against any supported provider; spawn long-lived agent *cells* that persist across restarts and coordinate over a mesh; install hooks, MCP servers, and skills by content-addressed URI. An open, inspectable substrate — not a closed orchestration platform.
|
|
55
57
|
|
|
56
58
|
**Who it's for:** builders running more than one LLM who want them to *cooperate* instead of sitting in separate tabs — multi-agent systems across vendors, agnostic by design. A thin client over the [`swarph-mesh`](https://github.com/BrainSurfing-tech/swarph-mesh) substrate library.
|
|
@@ -79,6 +81,7 @@ swarph highlight "<x>" log a highlight to the shared git-backed swarph timelin
|
|
|
79
81
|
swarph spawn <role> launch a long-lived agent session as a named mesh cell
|
|
80
82
|
swarph daemon foreground inbox-drain loop (the mesh doorbell)
|
|
81
83
|
swarph watchdog detect + recover stranded agent sessions (cron- or systemd-driven)
|
|
84
|
+
swarph install-multiplexer fetch the Windows tmux multiplexer (psmux) — pinned + checksum-verified
|
|
82
85
|
swarph add <uri> install a hook / MCP server / skill by content-addressed URI
|
|
83
86
|
swarph hooks install Claude Code hooks as reusable artifacts
|
|
84
87
|
swarph onboard / ratify bring a new peer into the mesh (mechanics + witness ratification)
|
|
@@ -7,6 +7,8 @@ pip install swarph-cli
|
|
|
7
7
|
swarph --version
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
+
> **System requirement — a terminal multiplexer.** The session verbs (`spawn`, `cell`, `watchdog`) drive a `tmux`-compatible multiplexer. On **Linux/macOS** install `tmux` (`apt install tmux` / `brew install tmux`). On **Windows** there is no native tmux — install [**psmux**](https://github.com/psmux/psmux) (a Rust tmux-for-Windows, MIT) with `swarph install-multiplexer` (fetches the pinned, checksum-verified binary into `~/.swarph/bin`) or `winget install marlocarlo.psmux`.
|
|
11
|
+
|
|
10
12
|
**What it is:** a multi-provider LLM command line. Run one-shot prompts or an interactive REPL against any supported provider; spawn long-lived agent *cells* that persist across restarts and coordinate over a mesh; install hooks, MCP servers, and skills by content-addressed URI. An open, inspectable substrate — not a closed orchestration platform.
|
|
11
13
|
|
|
12
14
|
**Who it's for:** builders running more than one LLM who want them to *cooperate* instead of sitting in separate tabs — multi-agent systems across vendors, agnostic by design. A thin client over the [`swarph-mesh`](https://github.com/BrainSurfing-tech/swarph-mesh) substrate library.
|
|
@@ -35,6 +37,7 @@ swarph highlight "<x>" log a highlight to the shared git-backed swarph timelin
|
|
|
35
37
|
swarph spawn <role> launch a long-lived agent session as a named mesh cell
|
|
36
38
|
swarph daemon foreground inbox-drain loop (the mesh doorbell)
|
|
37
39
|
swarph watchdog detect + recover stranded agent sessions (cron- or systemd-driven)
|
|
40
|
+
swarph install-multiplexer fetch the Windows tmux multiplexer (psmux) — pinned + checksum-verified
|
|
38
41
|
swarph add <uri> install a hook / MCP server / skill by content-addressed URI
|
|
39
42
|
swarph hooks install Claude Code hooks as reusable artifacts
|
|
40
43
|
swarph onboard / ratify bring a new peer into the mesh (mechanics + witness ratification)
|
|
@@ -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.20.0"
|
|
8
8
|
description = "The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""``swarph install-multiplexer`` — fetch the checksum-verified psmux binary.
|
|
2
|
+
|
|
3
|
+
swarph's session machinery needs a ``tmux``-compatible multiplexer on PATH. On
|
|
4
|
+
Linux/macOS that's native ``tmux`` (install via apt/brew). On Windows there's no
|
|
5
|
+
pip/npm package, so this verb fetches the pinned, SHA-256-verified **psmux**
|
|
6
|
+
release zip and drops its ``tmux.exe`` into ``--dir`` (default
|
|
7
|
+
``~/.swarph/bin``). The download is verified before anything is written.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
swarph install-multiplexer [--dir DIR] [--force] [--version 3.3.6]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from swarph_cli.multiplexer import (
|
|
20
|
+
PSMUX_VERSION,
|
|
21
|
+
find_multiplexer,
|
|
22
|
+
install_psmux,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_install_multiplexer(argv: list) -> int:
|
|
27
|
+
p = argparse.ArgumentParser(
|
|
28
|
+
prog="swarph install-multiplexer",
|
|
29
|
+
description="Fetch the checksum-verified psmux (tmux-for-Windows) binary.")
|
|
30
|
+
p.add_argument("--dir", default=os.path.expanduser("~/.swarph/bin"),
|
|
31
|
+
help="install directory (default ~/.swarph/bin)")
|
|
32
|
+
p.add_argument("--force", action="store_true",
|
|
33
|
+
help="reinstall even if a multiplexer is already on PATH")
|
|
34
|
+
p.add_argument("--version", default=PSMUX_VERSION,
|
|
35
|
+
help=f"psmux version to fetch (default {PSMUX_VERSION})")
|
|
36
|
+
args = p.parse_args(argv)
|
|
37
|
+
|
|
38
|
+
if sys.platform != "win32":
|
|
39
|
+
print("swarph install-multiplexer fetches the Windows psmux binary. On "
|
|
40
|
+
"Linux/macOS, tmux is the native multiplexer — install it via your "
|
|
41
|
+
"package manager (`apt install tmux` / `brew install tmux`).",
|
|
42
|
+
file=sys.stderr)
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
if not args.force:
|
|
46
|
+
present = find_multiplexer()
|
|
47
|
+
if present:
|
|
48
|
+
print(f"multiplexer already present at {present}")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
path = install_psmux(args.dir, version=args.version)
|
|
53
|
+
except (RuntimeError, ValueError, OSError) as err:
|
|
54
|
+
print(f"swarph install-multiplexer: {err}", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
print(f"installed psmux {args.version} -> {path}; "
|
|
58
|
+
f"add {args.dir} to your PATH")
|
|
59
|
+
return 0
|
|
@@ -1029,6 +1029,14 @@ def _launch_via_tmux(
|
|
|
1029
1029
|
return False
|
|
1030
1030
|
tmux = shutil.which("tmux")
|
|
1031
1031
|
if not tmux:
|
|
1032
|
+
# No tmux-compatible multiplexer on PATH — fall back (Windows: WT rescue
|
|
1033
|
+
# / in-place). Surface a soft, actionable hint when interactive (Windows
|
|
1034
|
+
# users typically need psmux); stay silent on headless/respawn paths so
|
|
1035
|
+
# the watchdog/CI logs aren't spammed.
|
|
1036
|
+
if sys.stdout.isatty():
|
|
1037
|
+
from swarph_cli.multiplexer import find_multiplexer, multiplexer_hint
|
|
1038
|
+
if find_multiplexer() is None:
|
|
1039
|
+
print(f"swarph spawn: {multiplexer_hint()}", file=sys.stderr)
|
|
1032
1040
|
return False
|
|
1033
1041
|
|
|
1034
1042
|
interactive = sys.stdout.isatty()
|
|
@@ -98,6 +98,7 @@ _VERB_HANDLERS: dict[str, str] = {
|
|
|
98
98
|
"channel": "swarph_cli.commands.channel.run_channel",
|
|
99
99
|
"schedule": "swarph_cli.commands.schedule.run_schedule",
|
|
100
100
|
"lane": "swarph_cli.commands.lane.run_lane",
|
|
101
|
+
"install-multiplexer": "swarph_cli.commands.install_multiplexer.run_install_multiplexer",
|
|
101
102
|
"highlight": "swarph_cli.commands.highlight.run_highlight",
|
|
102
103
|
# Future: "list-peers", "list-adapters", etc.
|
|
103
104
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Multiplexer detection + the checksum-verified psmux fetcher.
|
|
2
|
+
|
|
3
|
+
swarph's session machinery (spawn/cell/watchdog) shells out to a ``tmux``-
|
|
4
|
+
compatible binary. On Linux/macOS that's ``tmux`` from the package manager; on
|
|
5
|
+
Windows it's **psmux** (https://github.com/psmux/psmux — a Rust tmux-for-Windows,
|
|
6
|
+
MIT) which installs a ``tmux`` command on PATH. psmux isn't on pip/npm — it ships
|
|
7
|
+
as per-arch GitHub release zips, so we fetch the pinned, **checksum-verified**
|
|
8
|
+
binary ourselves.
|
|
9
|
+
|
|
10
|
+
Security core (``install_psmux``): the order is download -> verify SHA-256 ->
|
|
11
|
+
(reject on mismatch, OR extract). A bad download NEVER reaches extraction, and a
|
|
12
|
+
malicious archive member name can never escape ``dest_dir`` (zip-slip guard:
|
|
13
|
+
only ``os.path.basename`` is ever honored). Nothing is executed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import io
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import urllib.request
|
|
26
|
+
import zipfile
|
|
27
|
+
|
|
28
|
+
# --- pinned psmux release (v3.3.6, GitHub-attested digests) -----------------
|
|
29
|
+
|
|
30
|
+
PSMUX_VERSION = "3.3.6"
|
|
31
|
+
_URL = "https://github.com/psmux/psmux/releases/download/v3.3.6/{filename}"
|
|
32
|
+
_ASSETS = { # arch -> (filename, sha256)
|
|
33
|
+
"x64": ("psmux-v3.3.6-windows-x64.zip", "a56a890ea0829567818b9a368f16dcbd39c087f27328573df17c10dd39618947"),
|
|
34
|
+
"arm64": ("psmux-v3.3.6-windows-arm64.zip", "bf1e8bb9d624a2fe5cb09f6b5bb84e7f516cfd022efb677a2439f846a39f8f92"),
|
|
35
|
+
"x86": ("psmux-v3.3.6-windows-x86.zip", "9b387fe03cbcc1ef671a5c3fbddaafe383089d82cea03c567980d8ed3de9ba82"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# binaries we accept out of the archive (basename, case-insensitive); the first
|
|
39
|
+
# is the primary whose path we return.
|
|
40
|
+
_BINARIES = ("tmux.exe", "psmux.exe", "pmux.exe")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_multiplexer() -> str | None:
|
|
44
|
+
"""Return the path of the first tmux-compatible multiplexer on PATH, else None."""
|
|
45
|
+
for name in ("tmux", "psmux", "pmux"):
|
|
46
|
+
found = shutil.which(name)
|
|
47
|
+
if found:
|
|
48
|
+
return found
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def multiplexer_hint() -> str:
|
|
53
|
+
"""A one-line, OS-aware install hint for a missing multiplexer."""
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
return ("no tmux-compatible multiplexer found — run "
|
|
56
|
+
"`swarph install-multiplexer` (fetches psmux) or "
|
|
57
|
+
"`winget install marlocarlo.psmux`")
|
|
58
|
+
return ("no tmux found — install it via your package manager "
|
|
59
|
+
"(e.g. `apt install tmux` / `brew install tmux`)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _detect_arch() -> str:
|
|
63
|
+
"""Map ``platform.machine()`` to an ``_ASSETS`` key; raise ValueError otherwise."""
|
|
64
|
+
machine = platform.machine()
|
|
65
|
+
m = machine.lower()
|
|
66
|
+
if m in ("amd64", "x86_64"):
|
|
67
|
+
return "x64"
|
|
68
|
+
if m in ("arm64", "aarch64"):
|
|
69
|
+
return "arm64"
|
|
70
|
+
if m in ("x86", "i686", "i386"):
|
|
71
|
+
return "x86"
|
|
72
|
+
raise ValueError(f"unsupported architecture for psmux: {machine!r}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _default_opener(url: str) -> bytes:
|
|
76
|
+
with urllib.request.urlopen(url) as resp: # noqa: S310 (pinned github.com URL)
|
|
77
|
+
return resp.read()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def install_psmux(dest_dir, *, version=PSMUX_VERSION, opener=None) -> str:
|
|
81
|
+
"""Fetch + verify + extract the pinned psmux binary into ``dest_dir``.
|
|
82
|
+
|
|
83
|
+
Order is load-bearing: download -> SHA-256 verify -> (reject OR extract). A
|
|
84
|
+
checksum mismatch raises BEFORE anything is written, and only the basename of
|
|
85
|
+
an archive member is ever honored (zip-slip guard). Returns the path to the
|
|
86
|
+
extracted primary binary (``tmux.exe``). Nothing is executed.
|
|
87
|
+
"""
|
|
88
|
+
opener = opener or _default_opener
|
|
89
|
+
|
|
90
|
+
# 1. resolve the pinned asset for this arch
|
|
91
|
+
arch = _detect_arch()
|
|
92
|
+
filename, expected_sha = _ASSETS[arch]
|
|
93
|
+
|
|
94
|
+
# 2. download
|
|
95
|
+
data = opener(_URL.format(filename=filename))
|
|
96
|
+
|
|
97
|
+
# 3. verify BEFORE touching the filesystem — a bad download stops here
|
|
98
|
+
actual_sha = hashlib.sha256(data).hexdigest()
|
|
99
|
+
if actual_sha != expected_sha:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
f"psmux checksum mismatch for {filename}: "
|
|
102
|
+
f"expected {expected_sha}, got {actual_sha} — refusing to extract")
|
|
103
|
+
|
|
104
|
+
# 4. only now open the verified archive and extract the binaries
|
|
105
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
106
|
+
written: dict[str, str] = {}
|
|
107
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
108
|
+
for member in zf.namelist():
|
|
109
|
+
base = os.path.basename(member)
|
|
110
|
+
# zip-slip guard: never honor a path component; skip anything that
|
|
111
|
+
# isn't a plain basename (no separators, no traversal).
|
|
112
|
+
if not base or base != member or ".." in member \
|
|
113
|
+
or "/" in member or "\\" in member:
|
|
114
|
+
continue
|
|
115
|
+
if base.lower() not in _BINARIES:
|
|
116
|
+
continue
|
|
117
|
+
target = os.path.join(dest_dir, base)
|
|
118
|
+
# atomic write: temp file in dest_dir, then os.replace
|
|
119
|
+
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".psmux-")
|
|
120
|
+
try:
|
|
121
|
+
with os.fdopen(fd, "wb") as fh:
|
|
122
|
+
fh.write(zf.read(member))
|
|
123
|
+
os.replace(tmp, target)
|
|
124
|
+
except BaseException:
|
|
125
|
+
if os.path.exists(tmp):
|
|
126
|
+
os.remove(tmp)
|
|
127
|
+
raise
|
|
128
|
+
written[base.lower()] = target
|
|
129
|
+
|
|
130
|
+
if not written:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
f"no psmux binary ({'/'.join(_BINARIES)}) found in {filename}")
|
|
133
|
+
|
|
134
|
+
# return the primary (tmux.exe) if present, else whatever we extracted
|
|
135
|
+
for name in _BINARIES:
|
|
136
|
+
if name in written:
|
|
137
|
+
return written[name]
|
|
138
|
+
return next(iter(written.values()))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swarph-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.20.0
|
|
4
4
|
Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
@@ -51,6 +51,8 @@ pip install swarph-cli
|
|
|
51
51
|
swarph --version
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
> **System requirement — a terminal multiplexer.** The session verbs (`spawn`, `cell`, `watchdog`) drive a `tmux`-compatible multiplexer. On **Linux/macOS** install `tmux` (`apt install tmux` / `brew install tmux`). On **Windows** there is no native tmux — install [**psmux**](https://github.com/psmux/psmux) (a Rust tmux-for-Windows, MIT) with `swarph install-multiplexer` (fetches the pinned, checksum-verified binary into `~/.swarph/bin`) or `winget install marlocarlo.psmux`.
|
|
55
|
+
|
|
54
56
|
**What it is:** a multi-provider LLM command line. Run one-shot prompts or an interactive REPL against any supported provider; spawn long-lived agent *cells* that persist across restarts and coordinate over a mesh; install hooks, MCP servers, and skills by content-addressed URI. An open, inspectable substrate — not a closed orchestration platform.
|
|
55
57
|
|
|
56
58
|
**Who it's for:** builders running more than one LLM who want them to *cooperate* instead of sitting in separate tabs — multi-agent systems across vendors, agnostic by design. A thin client over the [`swarph-mesh`](https://github.com/BrainSurfing-tech/swarph-mesh) substrate library.
|
|
@@ -79,6 +81,7 @@ swarph highlight "<x>" log a highlight to the shared git-backed swarph timelin
|
|
|
79
81
|
swarph spawn <role> launch a long-lived agent session as a named mesh cell
|
|
80
82
|
swarph daemon foreground inbox-drain loop (the mesh doorbell)
|
|
81
83
|
swarph watchdog detect + recover stranded agent sessions (cron- or systemd-driven)
|
|
84
|
+
swarph install-multiplexer fetch the Windows tmux multiplexer (psmux) — pinned + checksum-verified
|
|
82
85
|
swarph add <uri> install a hook / MCP server / skill by content-addressed URI
|
|
83
86
|
swarph hooks install Claude Code hooks as reusable artifacts
|
|
84
87
|
swarph onboard / ratify bring a new peer into the mesh (mechanics + witness ratification)
|
|
@@ -5,6 +5,7 @@ src/swarph_cli/__init__.py
|
|
|
5
5
|
src/swarph_cli/caller.py
|
|
6
6
|
src/swarph_cli/cell.py
|
|
7
7
|
src/swarph_cli/main.py
|
|
8
|
+
src/swarph_cli/multiplexer.py
|
|
8
9
|
src/swarph_cli.egg-info/PKG-INFO
|
|
9
10
|
src/swarph_cli.egg-info/SOURCES.txt
|
|
10
11
|
src/swarph_cli.egg-info/dependency_links.txt
|
|
@@ -35,6 +36,7 @@ src/swarph_cli/commands/hooks.py
|
|
|
35
36
|
src/swarph_cli/commands/import_session.py
|
|
36
37
|
src/swarph_cli/commands/init.py
|
|
37
38
|
src/swarph_cli/commands/install_hook.py
|
|
39
|
+
src/swarph_cli/commands/install_multiplexer.py
|
|
38
40
|
src/swarph_cli/commands/lane.py
|
|
39
41
|
src/swarph_cli/commands/mcp_server.py
|
|
40
42
|
src/swarph_cli/commands/memory_sync.py
|
|
@@ -108,12 +110,14 @@ tests/test_hooks_merge.py
|
|
|
108
110
|
tests/test_import_command.py
|
|
109
111
|
tests/test_init_command.py
|
|
110
112
|
tests/test_install_hook.py
|
|
113
|
+
tests/test_install_multiplexer_command.py
|
|
111
114
|
tests/test_lane_command.py
|
|
112
115
|
tests/test_main.py
|
|
113
116
|
tests/test_mcp_server.py
|
|
114
117
|
tests/test_memory_sync.py
|
|
115
118
|
tests/test_mesh_command.py
|
|
116
119
|
tests/test_mesh_sidecar.py
|
|
120
|
+
tests/test_multiplexer.py
|
|
117
121
|
tests/test_onboard_command.py
|
|
118
122
|
tests/test_protocol_handler.py
|
|
119
123
|
tests/test_ratify_command.py
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for ``swarph install-multiplexer`` — fetch the checksum-verified psmux
|
|
2
|
+
binary on Windows; on Linux/macOS it just points at the native tmux.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from swarph_cli.commands import install_multiplexer as im
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_install_multiplexer_registered_in_verb_handlers():
|
|
15
|
+
from swarph_cli.main import _VERB_HANDLERS
|
|
16
|
+
handler = _VERB_HANDLERS.get("install-multiplexer")
|
|
17
|
+
if handler is None:
|
|
18
|
+
pytest.skip("verb not yet registered by parent (main.py owned externally)")
|
|
19
|
+
assert handler == "swarph_cli.commands.install_multiplexer.run_install_multiplexer"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_non_windows_prints_native_tmux_hint(monkeypatch, capsys):
|
|
23
|
+
# FORCE the non-Windows branch regardless of host — CI runs on Windows too,
|
|
24
|
+
# where the unforced verb would take the Windows path (host-dependent green).
|
|
25
|
+
monkeypatch.setattr(im.sys, "platform", "linux")
|
|
26
|
+
rc = im.run_install_multiplexer([])
|
|
27
|
+
assert rc == 0
|
|
28
|
+
err = capsys.readouterr().err.lower()
|
|
29
|
+
assert "tmux" in err
|
|
30
|
+
assert "apt" in err or "brew" in err
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_windows_already_present_returns_0(monkeypatch, capsys):
|
|
34
|
+
monkeypatch.setattr(im.sys, "platform", "win32")
|
|
35
|
+
monkeypatch.setattr(im, "find_multiplexer", lambda: "C:/tools/tmux.exe")
|
|
36
|
+
rc = im.run_install_multiplexer([])
|
|
37
|
+
assert rc == 0
|
|
38
|
+
assert "already present" in capsys.readouterr().out.lower()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_windows_installs_when_absent(monkeypatch, capsys):
|
|
42
|
+
monkeypatch.setattr(im.sys, "platform", "win32")
|
|
43
|
+
monkeypatch.setattr(im, "find_multiplexer", lambda: None)
|
|
44
|
+
monkeypatch.setattr(im, "install_psmux",
|
|
45
|
+
lambda d, **kw: "C:/Users/x/.swarph/bin/tmux.exe")
|
|
46
|
+
rc = im.run_install_multiplexer([])
|
|
47
|
+
assert rc == 0
|
|
48
|
+
assert "installed" in capsys.readouterr().out.lower()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_windows_force_reinstalls_even_if_present(monkeypatch, capsys):
|
|
52
|
+
calls = {}
|
|
53
|
+
monkeypatch.setattr(im.sys, "platform", "win32")
|
|
54
|
+
monkeypatch.setattr(im, "find_multiplexer", lambda: "C:/tools/tmux.exe")
|
|
55
|
+
|
|
56
|
+
def fake_install(d, **kw):
|
|
57
|
+
calls["hit"] = True
|
|
58
|
+
return "C:/Users/x/.swarph/bin/tmux.exe"
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(im, "install_psmux", fake_install)
|
|
61
|
+
rc = im.run_install_multiplexer(["--force"])
|
|
62
|
+
assert rc == 0
|
|
63
|
+
assert calls.get("hit") is True
|
|
64
|
+
assert "installed" in capsys.readouterr().out.lower()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_windows_install_error_returns_1(monkeypatch, capsys):
|
|
68
|
+
monkeypatch.setattr(im.sys, "platform", "win32")
|
|
69
|
+
monkeypatch.setattr(im, "find_multiplexer", lambda: None)
|
|
70
|
+
|
|
71
|
+
def boom(d, **kw):
|
|
72
|
+
raise RuntimeError("psmux checksum mismatch")
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(im, "install_psmux", boom)
|
|
75
|
+
rc = im.run_install_multiplexer([])
|
|
76
|
+
assert rc == 1
|
|
77
|
+
assert "checksum mismatch" in capsys.readouterr().err.lower()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Tests for the multiplexer helper — detect tmux/psmux + the checksum-verified
|
|
2
|
+
psmux fetcher.
|
|
3
|
+
|
|
4
|
+
The load-bearing security property: ``install_psmux`` MUST verify the SHA-256 of
|
|
5
|
+
the downloaded bytes BEFORE it writes or extracts anything, and it MUST never
|
|
6
|
+
honor a path component inside the zip (zip-slip). A bad download or a malicious
|
|
7
|
+
archive name therefore never reaches the filesystem outside ``dest_dir``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import io
|
|
14
|
+
import os
|
|
15
|
+
import zipfile
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from swarph_cli import multiplexer as mx
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- find_multiplexer -------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def test_find_multiplexer_returns_path_when_tmux_present(monkeypatch):
|
|
25
|
+
monkeypatch.setattr(mx.shutil, "which",
|
|
26
|
+
lambda name: "/usr/bin/tmux" if name == "tmux" else None)
|
|
27
|
+
assert mx.find_multiplexer() == "/usr/bin/tmux"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_find_multiplexer_returns_none_when_nothing_found(monkeypatch):
|
|
31
|
+
monkeypatch.setattr(mx.shutil, "which", lambda name: None)
|
|
32
|
+
assert mx.find_multiplexer() is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_find_multiplexer_falls_back_to_psmux(monkeypatch):
|
|
36
|
+
monkeypatch.setattr(mx.shutil, "which",
|
|
37
|
+
lambda name: "/c/psmux.exe" if name == "psmux" else None)
|
|
38
|
+
assert mx.find_multiplexer() == "/c/psmux.exe"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- multiplexer_hint -------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def test_hint_windows_mentions_install_verb(monkeypatch):
|
|
44
|
+
monkeypatch.setattr(mx.sys, "platform", "win32")
|
|
45
|
+
hint = mx.multiplexer_hint()
|
|
46
|
+
assert "install-multiplexer" in hint and "psmux" in hint
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_hint_posix_mentions_package_manager(monkeypatch):
|
|
50
|
+
monkeypatch.setattr(mx.sys, "platform", "linux")
|
|
51
|
+
hint = mx.multiplexer_hint()
|
|
52
|
+
assert "tmux" in hint and ("apt" in hint or "brew" in hint)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- _detect_arch -----------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("machine,expected", [
|
|
58
|
+
("AMD64", "x64"), ("x86_64", "x64"),
|
|
59
|
+
("ARM64", "arm64"), ("aarch64", "arm64"),
|
|
60
|
+
("x86", "x86"), ("i686", "x86"), ("i386", "x86"),
|
|
61
|
+
])
|
|
62
|
+
def test_detect_arch_maps_known_machines(monkeypatch, machine, expected):
|
|
63
|
+
monkeypatch.setattr(mx.platform, "machine", lambda: machine)
|
|
64
|
+
assert mx._detect_arch() == expected
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_detect_arch_raises_on_unknown(monkeypatch):
|
|
68
|
+
monkeypatch.setattr(mx.platform, "machine", lambda: "sparc")
|
|
69
|
+
with pytest.raises(ValueError):
|
|
70
|
+
mx._detect_arch()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- install_psmux helpers --------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _zip_bytes(members):
|
|
76
|
+
"""Build an in-memory zip: members is a list of (name, data)."""
|
|
77
|
+
buf = io.BytesIO()
|
|
78
|
+
with zipfile.ZipFile(buf, "w") as zf:
|
|
79
|
+
for name, data in members:
|
|
80
|
+
zf.writestr(name, data)
|
|
81
|
+
return buf.getvalue()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _pin_arch(monkeypatch, filename, data):
|
|
85
|
+
"""Force _detect_arch -> 'x64' and pin _ASSETS so the real sha matches."""
|
|
86
|
+
monkeypatch.setattr(mx, "_detect_arch", lambda: "x64")
|
|
87
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
88
|
+
monkeypatch.setattr(mx, "_ASSETS", {"x64": (filename, sha)})
|
|
89
|
+
return sha
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- install_psmux: success -------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def test_install_psmux_success_writes_binary(monkeypatch, tmp_path):
|
|
95
|
+
data = _zip_bytes([("tmux.exe", b"FAKE")])
|
|
96
|
+
_pin_arch(monkeypatch, "psmux.zip", data)
|
|
97
|
+
|
|
98
|
+
out = mx.install_psmux(str(tmp_path), opener=lambda url: data)
|
|
99
|
+
|
|
100
|
+
assert os.path.basename(out).lower() == "tmux.exe"
|
|
101
|
+
assert os.path.isfile(out)
|
|
102
|
+
assert (tmp_path / "tmux.exe").read_bytes() == b"FAKE"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- install_psmux: checksum mismatch (the load-bearing security test) ------
|
|
106
|
+
|
|
107
|
+
def test_install_psmux_checksum_mismatch_writes_nothing(monkeypatch, tmp_path):
|
|
108
|
+
good = _zip_bytes([("tmux.exe", b"FAKE")])
|
|
109
|
+
_pin_arch(monkeypatch, "psmux.zip", good)
|
|
110
|
+
# opener returns DIFFERENT bytes whose hash won't match the pinned sha
|
|
111
|
+
tampered = _zip_bytes([("tmux.exe", b"EVIL-PAYLOAD")])
|
|
112
|
+
|
|
113
|
+
with pytest.raises(RuntimeError):
|
|
114
|
+
mx.install_psmux(str(tmp_path), opener=lambda url: tampered)
|
|
115
|
+
|
|
116
|
+
# nothing extracted/written: a bad download never reaches the filesystem
|
|
117
|
+
assert list(tmp_path.iterdir()) == []
|
|
118
|
+
assert not (tmp_path / "tmux.exe").exists()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- install_psmux: zip-slip ------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def test_install_psmux_zip_slip_stays_in_dest(monkeypatch, tmp_path):
|
|
124
|
+
dest = tmp_path / "bin"
|
|
125
|
+
dest.mkdir()
|
|
126
|
+
# malicious member tries to escape dest_dir; hash matches so we test extract
|
|
127
|
+
data = _zip_bytes([("../evil.exe", b"FAKE"), ("tmux.exe", b"GOOD")])
|
|
128
|
+
_pin_arch(monkeypatch, "psmux.zip", data)
|
|
129
|
+
|
|
130
|
+
out = mx.install_psmux(str(dest), opener=lambda url: data)
|
|
131
|
+
|
|
132
|
+
# the escaped path was never honored
|
|
133
|
+
assert not (tmp_path / "evil.exe").exists()
|
|
134
|
+
assert not (dest.parent / "evil.exe").exists()
|
|
135
|
+
# the legitimate binary still lands inside dest
|
|
136
|
+
assert os.path.isfile(out)
|
|
137
|
+
assert (dest / "tmux.exe").exists()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_install_psmux_no_binary_in_zip_raises(monkeypatch, tmp_path):
|
|
141
|
+
data = _zip_bytes([("README.txt", b"hi")])
|
|
142
|
+
_pin_arch(monkeypatch, "psmux.zip", data)
|
|
143
|
+
with pytest.raises(RuntimeError):
|
|
144
|
+
mx.install_psmux(str(tmp_path), opener=lambda url: data)
|
|
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
|
|
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
|