feat: improve Linux Landlock support for user-local tools and subprocesses, improve messaging UI for self-notes, and update documentation

This commit is contained in:
Ivan 2026-07-26 06:18:21 -05:00
parent 99b3630e72
commit 06e2faeae9
No known key found for this signature in database
42 changed files with 1681 additions and 94 deletions

View file

@ -1,5 +1,5 @@
# Nightly / manual Android emulator smoke: build x86_64 debug APK, install on
# an AVD, launch MainActivity, and require /api/v1/status == ok.
# an AVD, launch MainActivity, and require /api/v1/status to respond (ok or starting).
# Catches Chaquopy boot failures (missing modules, storage lock SystemExit, etc.)
# that assembleDebug alone cannot see.
#

View file

@ -4,3 +4,12 @@ Start at [docs/agents/README.md](docs/agents/README.md).
For architecture and invariants, read [docs/agents/overview.md](docs/agents/overview.md).
For mesh design, read [docs/agents/conventions/reticulum-zen.md](docs/agents/conventions/reticulum-zen.md).
## Linux Landlock (quick)
MeshChatX applies an optional filesystem sandbox on Linux (`MESHCHAT_LANDLOCK`, see overview). Besides SQLite `temp_store`, it affects **subprocesses** and **user-local tools** (pipx Argos Translate, `~/.local/bin` wrappers, rnsh/rnx when launched as PATH scripts).
- Implementation: `meshchatx/src/backend/landlock_sandbox.py`
- SQLite symptoms and pragmas: [docs/agents/skills/landlock-sqlite/SKILL.md](docs/agents/skills/landlock-sqlite/SKILL.md)
- Integration probes (subprocess spawn, translator Argos, home write denial): `tests/backend/test_landlock_integration_surfaces.py` and `tests/backend/landlock_integration_support.py`
- After changing Landlock rules or any code that `subprocess`/`Popen`s external binaries, extend read/RW roots and add a probe test in that integration file.

View file

@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file.
### Fixed
- **Messages (personal notes)**: Sending to your own LXMF address or identity is stored locally as delivered (method `local`) without LXMF router outbound, so self-chat no longer hangs in a waiting state.
- **Messages sidebar**: Conversation list updates optimistically when you press Send, before the server acknowledges the message.
- **E2E**: Playwright API helpers attach CSRF tokens for direct backend POSTs; `pretest:e2e` installs Chromium before the suite runs.
- **Android LXST / Codec2**: When the Chaquopy `pycodec2.so` extension is an empty stub, fall back to a ctypes Codec2 binding over the bundled `libcodec2.so` so LXST Codec2 voice profiles work on device. Still preload jniLibs Codec2 and reload soft-imported LXST bindings after probe.
- **Android RNode flasher**: Open native flasher returns a real status, keeps USB-serial classes through R8, uses an ActionBar theme, and surfaces startup failures instead of silently doing nothing. Bluetooth Open settings tries GrapheneOS-friendly fallbacks (app details, Bluetooth settings, general Settings) instead of toasting unavailable.
- **Connection banners**: Do not flash disconnected on startup before the first successful WebSocket open. Debounce disconnect UI for 2.5s and only show reconnected when the disconnect banner was actually shown. Foreground recovery prefers a ping for longer before forcing a reconnect.
@ -14,8 +17,8 @@ All notable changes to this project will be documented in this file.
- **HTTP security headers**: Send Permissions-Policy allowing microphone and camera for this origin so reverse proxies that omit the header do not block capture by default.
- **UI language**: Persist language changes over the config HTTP API (not WebSocket-only), normalize legacy locale codes, and stop the Reticulum manual language picker from overwriting app UI language.
- **Network visualizer**: WebGL background follows light theme and clears while the WASM scene is still loading. Boot theme removes stale dark class when light is selected.
## [4.8.1] - 2026-07-25
- **Translator (Landlock)**: On Linux, allow read/execute for user-local pipx CLIs (`~/.local/bin`, `~/.local/share/pipx`) and read-write for Argos Translate data under `~/.local/share/argos-translate`, so `argospm` language lists and local Argos translation work with the filesystem sandbox enabled.
- **Tests**: Landlock integration probes for subprocess spawn, translator Argos language listing, user-local CLI execution, and home write denial outside RW roots (`tests/backend/test_landlock_integration_surfaces.py`).
### Fixed

View file

@ -54,12 +54,12 @@ Optional editor rules (if present under `.cursor/rules/` or similar):
### Identity and data
| Skill | Use when |
| -------------------------------------------------------------------------- | ------------------------------------------------- |
| [identity-restore](skills/identity-restore/SKILL.md) | Identity key vs database zip restore |
| [identity-switch-teardown](skills/identity-switch-teardown/SKILL.md) | Live identity switch without cross-identity leaks |
| [database-migrations-backups](skills/database-migrations-backups/SKILL.md) | Schema bumps, backups, snapshots, restore safety |
| [landlock-sqlite](skills/landlock-sqlite/SKILL.md) | Landlock + SQLite conversation failures |
| Skill | Use when |
| -------------------------------------------------------------------------- | --------------------------------------------------- |
| [identity-restore](skills/identity-restore/SKILL.md) | Identity key vs database zip restore |
| [identity-switch-teardown](skills/identity-switch-teardown/SKILL.md) | Live identity switch without cross-identity leaks |
| [database-migrations-backups](skills/database-migrations-backups/SKILL.md) | Schema bumps, backups, snapshots, restore safety |
| [landlock-sqlite](skills/landlock-sqlite/SKILL.md) | Landlock + SQLite, subprocess/user-local CLI probes |
### Security and plugins

View file

@ -11,6 +11,7 @@ Applies when editing `meshchatx/**/*.py`.
- Multipart parsers must not assume field order.
- SQLite worker connections must set `temp_store=MEMORY` in `DatabaseProvider` (Landlock-safe).
- Under Landlock, memory-pressure must not force FILE temp for conversation queries.
- Subprocess or user-local CLI features must remain usable under Landlock. Extend `landlock_sandbox.py` read/RW roots and add a probe in `tests/backend/test_landlock_integration_surfaces.py` (see `landlock-sqlite` skill).
- Keep conversation list queries slim: truncate content, derive attachment flags in SQL, avoid shipping full `fields` blobs.
- Identity restore validates size and empty payloads. Preserve existing identity metadata on re-import.
- No backticks in code comments. Prefer plain words or quoted identifiers.

View file

@ -7,6 +7,9 @@ Applies when editing `tests/**/*.{py,js}`.
- Mock `window.api` for page tests. Assert toasts when outcomes are user-visible.
- Prefer focused files over full suite unless the user asks for broad runs.
- Landlock tests that apply the sandbox must run in a subprocess (one restrict per process).
- Shared runner: `tests/backend/landlock_integration_support.py` (`run_python_under_landlock`).
- Subprocess and user-local CLI probes: `tests/backend/test_landlock_integration_surfaces.py`.
- Unit tests for rules and ABI: `tests/backend/test_landlock_sandbox.py`.
- Long-running / notification soak suites can hang. Prefer timeouts and avoid piping pytest through `tail` in agent shells.
## Oracle style (no soft fuzz)

View file

@ -7,23 +7,23 @@ Schema contracts live under `tests/backend/` (not production route modules).
## Backend
| Domain | Manager modules | HTTP route module | WS module | Primary tests | Frontend page |
| ----------------- | ------------------------------------------------------------- | ------------------------------------------- | ---------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------- |
| Identity | `identity_context.py`, `identity_manager.py` | `http/routes/identities.py` | `http/ws/dispatch.py` | `test_identity_*`, identity-switch skills | Identities / tutorial |
| Status / startup | `lifecycle/deferred_network.py`, `rns_startup_recovery.py` | `http/routes/status.py` | (status via REST) | `test_deferred_network_startup.py`, `test_api_json_contracts.py` | App shell banners |
| Auth / CSRF | `csrf.py`, `app_security_settings.py`, `ip_allowlist.py` | `http/routes/auth.py` | `http/ws/dispatch.py` | `test_csrf*`, `test_access_attempts_*` | Auth gate in `App.vue` |
| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `messages/MessagesPage.vue`, `ConversationViewer.vue` |
| Telephone / LXST | `telephone_manager.py`, `database/telephone.py` | `http/routes/telephone.py`, `contacts.py` | `http/ws/dispatch.py` | telephone contract tests | `call/CallPage.vue` |
| RRC | `rrc/manager.py`, `rrc/server.py` | `http/routes/rrc.py` | `http/ws/dispatch.py` | RRC skill tests | `relay/RelayChatPage.vue` |
| Map | `map_manager.py`, `map_overlay_manager.py` | `http/routes/map.py` | marker updates via broadcast | map manager tests | `map/MapPage.vue` |
| Plugins | `plugin_manager.py`, `plugin_permissions.py` | `http/routes/plugins.py`, `sideband.py` | `http/ws/dispatch.py` | plugin install security tests | `settings/PluginsSettingsSection.vue` |
| FileSync | `rns_filesync_handler.py` | `http/routes/filesync.py` | (REST-heavy) | filesync security tests | `filesync/` pages |
| Interfaces | `interface_editor.py`, `interface_config_parser.py` | `http/routes/interfaces.py` | (announce loops on app) | interface stats tests | `interfaces/InterfacesPage.vue` |
| Database / backup | `database/*`, `recovery/*` | `http/routes/database.py`, `maintenance.py` | (broadcasts on restore) | backup/restore skill tests | About / maintenance settings |
| Docs | `docs_manager.py` | `http/routes/docs.py` | (none) | docs manager tests | `docs/DocsPage.vue` |
| Sandbox | `landlock_sandbox.py`, `seccomp_sandbox.py` | `http/routes/status.py` | (none) | landlock/seccomp tests | Web exposure settings |
| RNS Link API | `rns_link_manager.py` | (WS only) | `http/ws/dispatch.py` | `test_rns_link_*` | plugins / external tools |
| Config | `config_manager.py` | `http/routes/config.py` | `http/ws/dispatch.py` | settings config services tests | `settings/SettingsPage.vue` |
| Domain | Manager modules | HTTP route module | WS module | Primary tests | Frontend page |
| ----------------- | ------------------------------------------------------------- | ------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| Identity | `identity_context.py`, `identity_manager.py` | `http/routes/identities.py` | `http/ws/dispatch.py` | `test_identity_*`, identity-switch skills | Identities / tutorial |
| Status / startup | `lifecycle/deferred_network.py`, `rns_startup_recovery.py` | `http/routes/status.py` | (status via REST) | `test_deferred_network_startup.py`, `test_api_json_contracts.py` | App shell banners |
| Auth / CSRF | `csrf.py`, `app_security_settings.py`, `ip_allowlist.py` | `http/routes/auth.py` | `http/ws/dispatch.py` | `test_csrf*`, `test_access_attempts_*` | Auth gate in `App.vue` |
| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `messages/MessagesPage.vue`, `ConversationViewer.vue` |
| Telephone / LXST | `telephone_manager.py`, `database/telephone.py` | `http/routes/telephone.py`, `contacts.py` | `http/ws/dispatch.py` | telephone contract tests | `call/CallPage.vue` |
| RRC | `rrc/manager.py`, `rrc/server.py` | `http/routes/rrc.py` | `http/ws/dispatch.py` | RRC skill tests | `relay/RelayChatPage.vue` |
| Map | `map_manager.py`, `map_overlay_manager.py` | `http/routes/map.py` | marker updates via broadcast | map manager tests | `map/MapPage.vue` |
| Plugins | `plugin_manager.py`, `plugin_permissions.py` | `http/routes/plugins.py`, `sideband.py` | `http/ws/dispatch.py` | plugin install security tests | `settings/PluginsSettingsSection.vue` |
| FileSync | `rns_filesync_handler.py` | `http/routes/filesync.py` | (REST-heavy) | filesync security tests | `filesync/` pages |
| Interfaces | `interface_editor.py`, `interface_config_parser.py` | `http/routes/interfaces.py` | (announce loops on app) | interface stats tests | `interfaces/InterfacesPage.vue` |
| Database / backup | `database/*`, `recovery/*` | `http/routes/database.py`, `maintenance.py` | (broadcasts on restore) | backup/restore skill tests | About / maintenance settings |
| Docs | `docs_manager.py` | `http/routes/docs.py` | (none) | docs manager tests | `docs/DocsPage.vue` |
| Sandbox | `landlock_sandbox.py`, `seccomp_sandbox.py` | `http/routes/status.py` | (none) | `test_landlock_sandbox.py`, `test_landlock_integration_surfaces.py`, sqlite landlock tests | Web exposure settings |
| RNS Link API | `rns_link_manager.py` | (WS only) | `http/ws/dispatch.py` | `test_rns_link_*` | plugins / external tools |
| Config | `config_manager.py` | `http/routes/config.py` | `http/ws/dispatch.py` | settings config services tests | `settings/SettingsPage.vue` |
## HTTP package layout

View file

@ -186,7 +186,21 @@ Critical SQLite interaction:
- Memory-pressure mode may shrink cache/mmap. While Landlock is active, keep MEMORY temp.
- Without Landlock, FILE temp plus a storage-local `sqlite-tmp` TMPDIR is acceptable.
Landlock apply is process-wide and one-shot. Tests that enable it must run in a subprocess.
Subprocess and user-local tools:
- Landlock is not only SQLite. Features that **exec** binaries or read data outside `/usr`, the Python prefix, and MeshChatX storage can fail with `Permission denied` while the UI still "detects" the tool on PATH.
- Read/execute roots include typical pipx layouts: `~/.local/bin`, `~/.local/share/pipx`, and Argos package metadata under `~/.local/share/argos-translate` (RW for Stanza temp files during translation).
- **Translator (Argos)**: language lists use `argospm list` when the CLI is present. Local translation runs `argos-translate` or the `argostranslate` Python module.
- **rnsh / rnx**: prefer `python -m …` so installs are not blocked when `~/.local/bin` wrappers are not executable under Landlock. rnsh uses a storage-scoped `HOME`, not the real home directory.
- **Not covered by default**: symlinks in `~/.local/bin` that point outside allowed trees (for example IDE agent shims), tools installed only under `~/.nvm`, and arbitrary `location_cmd` paths in interface config. Use `MESHCHAT_LANDLOCK=0` to debug or widen rules deliberately in `landlock_sandbox.py`.
Landlock apply is process-wide and one-shot. Tests that enable it must run in a subprocess (`tests/backend/landlock_integration_support.py`).
Verification after Landlock or subprocess-facing edits:
```bash
uv run pytest tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_sqlite_landlock_temp_store.py -q
```
Also see `docs/en/platform-guides/linux-sandbox.md` for Firejail / Bubblewrap host examples.

View file

@ -1,6 +1,6 @@
# Skill: landlock-sqlite
Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, slim queries, memory pressure).
Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, slim queries, memory pressure). Also covers subprocess and user-local CLI breakage under Linux Landlock.
# MeshChatX FS sandbox + SQLite
@ -25,6 +25,27 @@ Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store,
- List queries: `substr(content, 1, 240)` and SQL `instr` flags for attachments
- API: map OperationalError / unable-to-open / locked to **503** with retryable message
## Subprocess and user-local tools (Linux Landlock)
### Symptoms
- Translator shows Argos as available but **no languages** after Refresh, or translation fails with `Permission denied`
- `argospm list` or `argos-translate` works in a normal shell but not inside MeshChatX
- PATH tools in `~/.local/bin` fail while `/usr/bin` tools work
### Root causes
1. Landlock read roots did not include pipx or user-local install paths (`~/.local/bin`, `~/.local/share/pipx`)
2. Argos Stanza needs **write** under `~/.local/share/argos-translate` (not read-only)
3. `TranslatorHandler` used Python `argostranslate` with zero packages and did not fall back to `argospm list`
4. Symlink wrappers in `~/.local/bin` that point outside allowed trees (not fixable by widening `~/.local/bin` alone)
### Required behavior
- `_collect_read_roots()` includes user-local CLI roots when present (`landlock_sandbox._collect_user_local_cli_roots`)
- `_collect_rw_roots()` includes `~/.local/share/argos-translate` when present
- New external-tool integrations: add roots and a probe in `tests/backend/test_landlock_integration_surfaces.py`
## Windows counterpart
- Module: `meshchatx/src/backend/appcontainer_sandbox.py`
@ -35,7 +56,7 @@ Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store,
## Verification
```bash
uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py tests/backend/test_appcontainer_sandbox.py tests/backend/test_self_check.py -q
uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_appcontainer_sandbox.py tests/backend/test_self_check.py -q
pnpm exec vitest run tests/frontend/i18n.test.js
bash scripts/ci/github-verify-frozen-sandbox.sh build/exe
```
@ -51,4 +72,6 @@ For live stress, run Landlock in a **subprocess** (sandbox applies once per proc
- `meshchatx/src/backend/appcontainer_sandbox.py`
- `meshchatx/src/backend/appcontainer_launcher.py`
- `meshchatx/src/backend/seccomp_sandbox.py` (syscall denylist after Landlock)
- `meshchatx/meshchat.py` (conversations/notifications error mapping)
- `meshchatx/src/backend/translator_handler.py` (Argos CLI and lib language listing)
- `tests/backend/landlock_integration_support.py`
- `tests/backend/test_landlock_integration_surfaces.py`

View file

@ -49,3 +49,7 @@ pnpm exec vitest run tests/frontend/<related>.test.js
## After identity / Landlock edits
Run the matching skill's verification section before claiming done.
```bash
uv run pytest tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_sqlite_landlock_temp_store.py -q
```

View file

@ -59,7 +59,7 @@ Privacy mode does not disable Reticulum mesh traffic. It limits clearnet fetches
On Linux, MeshChatX can enable two complementary in-process sandboxes when supported:
- **Landlock** restricts filesystem paths the backend may use
- **Landlock** restricts filesystem paths the backend may use. User-local pipx tools (for example Argos Translate under `~/.local`) need explicit read and sometimes write roots. See **Linux sandboxing** in Platform guides.
- **Seccomp-BPF** installs a syscall denylist (via libseccomp) that blocks kernel-admin and related calls a mesh client does not need
Both auto-enable when available and fall back to a no-op when the platform, kernel, or libraries cannot support them. Override with:

View file

@ -11,6 +11,8 @@ MeshChatX also applies optional **in-process** Linux sandboxes when available:
Those layers fall back cleanly when unsupported. Firejail and Bubblewrap remain useful as an outer wrapper.
**Landlock and user-local tools:** When Landlock is active, MeshChatX whitelists common pipx paths (`~/.local/bin`, `~/.local/share/pipx`) and Argos Translate data under `~/.local/share/argos-translate` so local translation and similar CLIs keep working. Tools installed elsewhere (for example only under `~/.nvm`) or symlink shims that point outside those trees may still fail with permission errors. Disable Landlock temporarily with `MESHCHAT_LANDLOCK=0` while debugging PATH-only failures.
**Containers:** If you already run MeshChatX with Docker or Podman, that is a different isolation model, this document is aimed at **host-installed** `meshchatx` (or `meshchat`).
## Prerequisites

Binary file not shown.

View file

@ -5207,12 +5207,15 @@ class ReticulumMeshChat:
raise FileNotFoundError(msg)
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(cert_path, key_path)
print(f"HTTPS enabled with custom certificate at {cert_path}")
print(
f"HTTPS enabled with custom certificate at {cert_path}",
flush=True,
)
else:
generate_ssl_certificate(cert_path, key_path)
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(cert_path, key_path)
print(f"HTTPS enabled with certificate at {cert_path}")
print(f"HTTPS enabled with certificate at {cert_path}", flush=True)
except Exception as e:
if custom_ssl:
print(f"Failed to load custom SSL certificate: {e}")
@ -5357,7 +5360,7 @@ class ReticulumMeshChat:
app.on_startup.append(on_startup)
protocol = "https" if use_https else "http"
print(f"Starting web server on {protocol}://{host}:{port}")
print(f"Starting web server on {protocol}://{host}:{port}", flush=True)
# Start memory diagnostics if enabled
if self._memory_diag_enabled:
@ -8997,6 +9000,29 @@ class ReticulumMeshChat:
router.handle_outbound(lxmf_message)
# upserts the provided lxmf message to the database
def _is_self_lxmf_destination(self, destination_hash: str, context=None) -> bool:
"""True when destination_hash refers to this identity's own LXMF peer."""
ctx = context or self.current_context
if not ctx or not ctx.local_lxmf_destination:
return False
norm_dest = normalize_hex_identifier(destination_hash)
if not norm_dest:
return False
local_lxmf = normalize_hex_identifier(ctx.local_lxmf_destination.hexhash)
if norm_dest == local_lxmf:
return True
if ctx.identity:
local_id = normalize_hex_identifier(ctx.identity.hash.hex())
if norm_dest == local_id:
return True
try:
resolved = self.get_lxmf_destination_hash_for_identity_hash(norm_dest)
if resolved and normalize_hex_identifier(resolved) == local_lxmf:
return True
except Exception:
pass
return False
def db_upsert_lxmf_message(
self,
lxmf_message: LXMF.LXMessage,
@ -9005,6 +9031,8 @@ class ReticulumMeshChat:
context=None,
path_finding_measure: str | None = None,
path_row_hash_hex: str | None = None,
state_override: str | None = None,
method_override: str | None = None,
):
ctx = context or self.current_context
if not ctx:
@ -9016,6 +9044,10 @@ class ReticulumMeshChat:
reticulum=self.reticulum,
message_router=ctx.message_router,
)
if state_override is not None:
lxmf_message_dict["state"] = state_override
if method_override is not None:
lxmf_message_dict["method"] = method_override
lxmf_message_dict["is_spam"] = 1 if is_spam else 0
lxmf_message_dict["attachments_stripped"] = 1 if attachments_stripped else 0
if path_finding_measure is not None:
@ -9106,10 +9138,20 @@ class ReticulumMeshChat:
raise ValueError(msg) from exc
wants_propagated = delivery_method == "propagated"
is_local_self = self._is_self_lxmf_destination(destination_hash, ctx)
if is_local_self and wants_propagated:
msg = "Propagated delivery is not available for messages to yourself."
raise ValueError(msg)
# Direct/opportunistic need a live peer path. Propagated uses the
# propagation node, so skip the peer path wait (still record outcome).
if wants_propagated:
if is_local_self:
path_outcome = reticulum_pathfinding.OutboundPathOutcome(
True,
"local_self",
False,
)
elif wants_propagated:
path_outcome = reticulum_pathfinding.OutboundPathOutcome(
False,
"skipped_for_propagated",
@ -9121,6 +9163,8 @@ class ReticulumMeshChat:
path_outcome = await self._await_transport_path(destination_hash_bytes)
destination_identity = self.recall_identity(destination_hash)
if destination_identity is None and is_local_self and ctx.identity:
destination_identity = ctx.identity
if destination_identity is None:
msg = (
"Could not recall destination identity. "
@ -9130,7 +9174,11 @@ class ReticulumMeshChat:
# Direct/opportunistic delivery needs a live transport path. Propagated
# delivery can proceed without a peer path (it uses the propagation node).
if not wants_propagated and not path_outcome.path_available:
if (
not is_local_self
and not wants_propagated
and not path_outcome.path_available
):
msg = (
"No path to destination. "
"Use Path Finder or wait for a route, then try again."
@ -9288,6 +9336,40 @@ class ReticulumMeshChat:
current_icon_hash,
)
if is_local_self:
lxmf_message.state = LXMF.LXMessage.DELIVERED
lxmf_message.progress = 1.0
local_peer = ctx.local_lxmf_destination.hexhash
if not no_display:
self.db_upsert_lxmf_message(
lxmf_message,
context=ctx,
path_finding_measure=reticulum_pathfinding.format_outbound_path_finding_measure(
path_outcome,
),
path_row_hash_hex=local_peer,
state_override="delivered",
method_override="local",
)
ws_payload = convert_lxmf_message_to_dict(
lxmf_message,
include_attachments=False,
reticulum=self.reticulum,
message_router=ctx.message_router,
)
ws_payload["state"] = "delivered"
ws_payload["method"] = "local"
ws_payload["progress"] = 100.0
await self.websocket_broadcast(
json.dumps(
{
"type": "lxmf_message_created",
"lxmf_message": ws_payload,
},
),
)
return lxmf_message
# register delivery callbacks
lxmf_message.register_delivery_callback(
lambda msg: self.on_lxmf_sending_state_updated(msg, context=ctx),

View file

@ -322,6 +322,24 @@ def _existing_dir(path: str | None) -> str | None:
return None
def _collect_user_local_cli_roots() -> list[str]:
"""User-installed CLIs (pipx Argos Translate, rnsh/rnx wrappers, git-remote-rns, etc.)."""
home = os.path.expanduser("~")
if not home or home == "~":
return []
candidates = (
os.path.join(home, ".local", "bin"),
os.path.join(home, ".local", "share", "argos-translate"),
os.path.join(home, ".local", "share", "pipx"),
)
paths: list[str] = []
for candidate in candidates:
existing = _existing_dir(candidate)
if existing and existing not in paths:
paths.append(existing)
return paths
def _collect_read_roots() -> list[str]:
roots = {
"/usr",
@ -369,6 +387,8 @@ def _collect_read_roots() -> list[str]:
prefix = _existing_dir(prefix_candidate)
if prefix:
roots.add(prefix)
for root in _collect_user_local_cli_roots():
roots.add(root)
return sorted(roots)
@ -391,6 +411,11 @@ def _collect_rw_roots(
paths.append(existing)
if os.path.isdir("/dev"):
paths.append("/dev")
argos_share = _existing_dir(
os.path.join(os.path.expanduser("~"), ".local", "share", "argos-translate"),
)
if argos_share and argos_share not in paths:
paths.append(argos_share)
return paths

View file

@ -223,12 +223,14 @@ class TranslatorHandler:
)
except Exception as e:
print(f"Failed to fetch Argos languages: {e}")
elif self.has_argos_cli:
try:
cli_langs = self._get_argos_languages_cli()
languages.extend(cli_langs)
except Exception as e:
print(f"Failed to fetch Argos languages via CLI: {e}")
if self.has_argos_cli:
has_argos_langs = any(lang.get("source") == "argos" for lang in languages)
if not has_argos_langs:
try:
cli_langs = self._get_argos_languages_cli()
languages.extend(cli_langs)
except Exception as e:
print(f"Failed to fetch Argos languages via CLI: {e}")
return {
"languages": languages,

View file

@ -1789,7 +1789,13 @@ export default {
required: true,
},
},
emits: ["close", "reload-conversations", "update:selectedPeer", "update-peer-tracking"],
emits: [
"close",
"reload-conversations",
"update:selectedPeer",
"update-peer-tracking",
"outbound-compose-enqueued",
],
data() {
return {
GlobalState,
@ -5619,6 +5625,15 @@ export default {
);
await this._sendMessageChain;
},
isSelfLxmfDestination(peerHash) {
if (!peerHash || typeof peerHash !== "string") {
return false;
}
const mine = (this.myLxmfAddressHash || "").toLowerCase();
const identity = (this.config?.identity_hash || "").toLowerCase();
const peer = peerHash.toLowerCase();
return (mine && peer === mine) || (identity && peer === identity);
},
async _enqueueOutboundFromCompose() {
try {
const job = await this.buildOutboundJobSnapshot();
@ -5626,6 +5641,16 @@ export default {
return;
}
this._outboundQueue.enqueue(job);
if (this.isSelfLxmfDestination(job.destinationHash)) {
ToastUtils.info(this.$t("messages.self_notes_toast"));
}
this.$emit("outbound-compose-enqueued", {
peerHash: job.destinationHash,
previewText: job.text,
title: "",
fields: job.fields || {},
pendingHash: job.pendingHash,
});
this.clearComposeAfterEnqueue();
this.$nextTick(() => {
this.adjustTextareaHeight();

View file

@ -97,6 +97,7 @@
@update-peer-tracking="onUpdatePeerTracking"
@close="onPaneClose(pane.id)"
@reload-conversations="requestConversationsRefresh"
@outbound-compose-enqueued="onOutboundComposeEnqueued"
/>
<div
v-if="!pane.peer"
@ -982,6 +983,54 @@ export default {
const peer = this.peers[peerHash];
return peer?.custom_display_name ?? peer?.display_name ?? "Anonymous Peer";
},
onOutboundComposeEnqueued(payload) {
if (!payload || !payload.peerHash) {
return;
}
const peerHash = payload.peerHash;
const nowSec = Math.floor(Date.now() / 1000);
const stub = {
content: payload.previewText || "",
title: payload.title || "",
fields: payload.fields || {},
hash: payload.pendingHash || "",
timestamp: nowSec,
is_incoming: false,
};
const peerDisplay = this.peerDisplayNameForConversationSidebar(peerHash);
const preview = lxmfConversationListPreview(stub, {
myLxmfAddressHash: this.config?.lxmf_address_hash || "",
peerDisplayName: peerDisplay,
t: this.$t.bind(this),
});
const idx = this.conversations.findIndex((c) => c.destination_hash === peerHash);
if (idx !== -1) {
const conv = this.conversations[idx];
conv.latest_message_preview = preview;
conv.latest_message_title = stub.title;
conv.latest_message_created_at = nowSec;
conv.updated_at = new Date(nowSec * 1000).toISOString();
} else {
const peer = this.peers[peerHash];
this.conversations.unshift({
destination_hash: peerHash,
display_name: peer?.display_name ?? this.selectedPeer?.display_name ?? "Anonymous Peer",
custom_display_name: peer?.custom_display_name ?? this.selectedPeer?.custom_display_name ?? null,
contact_image: peer?.contact_image ?? null,
lxmf_user_icon: peer?.lxmf_user_icon ?? null,
is_unread: false,
is_tracking: peer?.is_tracking ?? false,
failed_messages_count: 0,
has_attachments: false,
latest_message_preview: preview,
latest_message_title: stub.title,
latest_message_created_at: nowSec,
updated_at: new Date(nowSec * 1000).toISOString(),
is_contact: false,
});
this.resolvePeerDisplayName(peerHash);
}
},
onOutboundMessageCreated(msg) {
const peerHash = this.peerHashFromMessage(msg);
const peerDisplay = this.peerDisplayNameForConversationSidebar(peerHash);

View file

@ -22,7 +22,7 @@ export function outboundBubbleStatusIconName(lxmfMessage) {
if (method === "propagated") {
return "email-check-outline";
}
if (method === "paper") {
if (method === "paper" || method === "local") {
return "note-check-outline";
}
return "check-all";
@ -31,7 +31,7 @@ export function outboundBubbleStatusIconName(lxmfMessage) {
if (method === "propagated") {
return "email-outline";
}
if (method === "paper") {
if (method === "paper" || method === "local") {
return "note-outline";
}
return "check";
@ -51,8 +51,14 @@ export function outboundBubbleStatusTitleKey(lxmfMessage) {
if (lxmfMessage.method === "propagated") {
return "messages.outbound_delivered_propagated";
}
if (lxmfMessage.method === "local") {
return "messages.outbound_delivered_local";
}
return "messages.outbound_delivered";
}
if (lxmfMessage.method === "local") {
return "messages.outbound_saved_local";
}
if (lxmfMessage.method === "propagated") {
return "messages.outbound_on_propagation_node";
}

View file

@ -1734,6 +1734,9 @@
"outbound_sent_network": "Gesendet",
"outbound_delivered": "Zugestellt",
"outbound_delivered_propagated": "Über Propagation zugestellt",
"outbound_delivered_local": "Auf diesem Gerät gespeichert",
"outbound_saved_local": "Persönliche Notiz auf diesem Gerät",
"self_notes_toast": "Persönliche Notizen bleiben auf diesem Gerät und werden nicht über das Mesh gesendet.",
"more_actions": "Weitere Aktionen",
"retry_failed": "Fehlgeschlagene erneut senden",
"telemetry_history": "Telemetrie-Verlauf",

View file

@ -1702,6 +1702,9 @@
"outbound_sent_network": "Sent",
"outbound_delivered": "Delivered",
"outbound_delivered_propagated": "Delivered via propagation",
"outbound_delivered_local": "Saved on this device",
"outbound_saved_local": "Personal note on this device",
"self_notes_toast": "Personal notes stay on this device. They are not sent over the mesh.",
"no_messages_in_conversation": "No messages in this conversation yet.",
"say_hello": "Say hello!",
"no_active_chat": "No Active Chat",

View file

@ -1702,6 +1702,9 @@
"outbound_sent_network": "Enviado",
"outbound_delivered": "Entregado",
"outbound_delivered_propagated": "Entregado vía propagación",
"outbound_delivered_local": "Guardado en este dispositivo",
"outbound_saved_local": "Nota personal en este dispositivo",
"self_notes_toast": "Las notas personales permanecen en este dispositivo y no se envían por la mesh.",
"no_messages_in_conversation": "Aún no hay mensajes en esta conversación.",
"say_hello": "¡Saluda!",
"no_active_chat": "Sin chat activo",

View file

@ -1702,6 +1702,9 @@
"outbound_sent_network": "Lähetetty",
"outbound_delivered": "Toimitettu",
"outbound_delivered_propagated": "Toimitettu levityksen kautta",
"outbound_delivered_local": "Tallennettu tälle laitteelle",
"outbound_saved_local": "Henkilökohtainen muistiinpano tällä laitteella",
"self_notes_toast": "Henkilökohtaiset muistiinpanot pysyvät tällä laitteella eivätkä lähde mesh-verkkoon.",
"no_messages_in_conversation": "Tässä keskustelussa ei ole viestejä.",
"say_hello": "Sano hei!",
"no_active_chat": "Ei valittua keskustelua",

View file

@ -1702,6 +1702,9 @@
"outbound_sent_network": "Envoyé",
"outbound_delivered": "Distribué",
"outbound_delivered_propagated": "Distribué via propagation",
"outbound_delivered_local": "Enregistré sur cet appareil",
"outbound_saved_local": "Note personnelle sur cet appareil",
"self_notes_toast": "Les notes personnelles restent sur cet appareil et ne sont pas envoyées sur le mesh.",
"no_messages_in_conversation": "Pas encore de messages dans cette conversation.",
"say_hello": "Dis bonjour !",
"no_active_chat": "Pas de chat actif",

View file

@ -1734,6 +1734,9 @@
"outbound_sent_network": "Inviato",
"outbound_delivered": "Consegnato",
"outbound_delivered_propagated": "Consegnato via propagazione",
"outbound_delivered_local": "Salvato su questo dispositivo",
"outbound_saved_local": "Nota personale su questo dispositivo",
"self_notes_toast": "Le note personali restano su questo dispositivo e non vengono inviate sulla mesh.",
"more_actions": "Altre azioni",
"retry_failed": "Riprova messaggi non inviati",
"telemetry_history": "Cronologia telemetria",

View file

@ -1702,6 +1702,9 @@
"outbound_sent_network": "Verzonden",
"outbound_delivered": "Afgeleverd",
"outbound_delivered_propagated": "Afgeleverd via propagatie",
"outbound_delivered_local": "Opgeslagen op dit apparaat",
"outbound_saved_local": "Persoonlijke notitie op dit apparaat",
"self_notes_toast": "Persoonlijke notities blijven op dit apparaat en gaan niet over het mesh.",
"no_messages_in_conversation": "Nog geen berichten in dit gesprek.",
"say_hello": "Zeg hallo!",
"no_active_chat": "Geen actief gesprek",

View file

@ -1734,6 +1734,9 @@
"outbound_sent_network": "Отправлено",
"outbound_delivered": "Доставлено",
"outbound_delivered_propagated": "Доставлено через распространение",
"outbound_delivered_local": "Сохранено на этом устройстве",
"outbound_saved_local": "Личная заметка на этом устройстве",
"self_notes_toast": "Личные заметки остаются на этом устройстве и не отправляются в сеть.",
"more_actions": "Другие действия",
"retry_failed": "Повторить неудачные",
"telemetry_history": "История телеметрии",

View file

@ -1702,6 +1702,9 @@
"outbound_sent_network": "已发送",
"outbound_delivered": "已送达",
"outbound_delivered_propagated": "已通过传播送达",
"outbound_delivered_local": "已保存在本设备",
"outbound_saved_local": "本设备个人备忘",
"self_notes_toast": "个人备忘仅保存在本设备,不会通过 mesh 发送。",
"no_messages_in_conversation": "对话中尚无消息",
"say_hello": "打个招呼!",
"no_active_chat": "无活动聊天",

View file

@ -35,6 +35,7 @@
"test:i18n": "vitest run tests/frontend/i18n.test.js",
"test:loadtime": "vitest run tests/frontend/LoadTimePerformance.test.js",
"test:ui": "vitest --ui --open --exclude tests/frontend/LoadTimePerformance.test.js",
"pretest:e2e": "playwright install chromium",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install chromium",

View file

@ -10,6 +10,10 @@ ACTIVITY="${MESHCHATX_ANDROID_ACTIVITY:-com.meshchatx/.MainActivity}"
STATUS_PATH="${MESHCHATX_SMOKE_STATUS_PATH:-/api/v1/status}"
TIMEOUT_SEC="${MESHCHATX_SMOKE_TIMEOUT_SEC:-180}"
LOGCAT_TAG_FILTER="${MESHCHATX_SMOKE_LOGCAT_FILTER:-Python|meshchat|MeshChat|chaquo|AndroidRuntime}"
DEVICE_HTTP_PORT="${MESHCHATX_SMOKE_DEVICE_PORT:-8000}"
FORWARD_LOCAL_PORT="${MESHCHATX_SMOKE_FORWARD_PORT:-18080}"
PROBE_CURL_MAX_TIME="${MESHCHATX_SMOKE_PROBE_MAX_TIME:-5}"
last_probe_body=""
APK="${1:-${MESHCHATX_APK:-}}"
if [[ -z "${APK}" ]]; then
@ -53,11 +57,25 @@ cleanup_logcat() {
kill "${logcat_pid}" >/dev/null 2>&1 || true
wait "${logcat_pid}" >/dev/null 2>&1 || true
}
trap 'cleanup_logcat; rm -rf "${workdir}"' EXIT
teardown_adb_forward() {
adb forward --remove "tcp:${FORWARD_LOCAL_PORT}" >/dev/null 2>&1 || true
}
setup_adb_forward() {
teardown_adb_forward
adb forward "tcp:${FORWARD_LOCAL_PORT}" "tcp:${DEVICE_HTTP_PORT}"
}
trap 'teardown_adb_forward; cleanup_logcat; rm -rf "${workdir}"' EXIT
fail_with_logs() {
local reason="$1"
echo "::error::Android emulator smoke failed: ${reason}"
if [[ -n "${last_probe_body}" ]]; then
echo "---- last /api/v1/status probe body ----"
printf '%s\n' "${last_probe_body}"
fi
echo "---- logcat (filtered) ----"
grep -E "${LOGCAT_TAG_FILTER}|PyException|SystemExit|ModuleNotFoundError|StorageLock|backend failed|Error starting MeshChatX" \
"${logcat_file}" | tail -n 200 || true
@ -66,22 +84,67 @@ fail_with_logs() {
exit 1
}
probe_status_ok() {
# Probe from inside the emulator (server binds 127.0.0.1 on-device).
# Prefer toybox wget (API 30+ images). Fall back to python if present.
status_body_is_healthy() {
local body="$1"
if [[ -z "${body}" ]]; then
return 1
fi
if printf '%s' "${body}" | grep -qE '"status"[[:space:]]*:[[:space:]]*"failed"'; then
return 1
fi
# ok: network ready. starting: HTTP up while RNS finishes (normal on Android boot).
printf '%s' "${body}" | grep -qE '"status"[[:space:]]*:[[:space:]]*"(ok|starting)"'
}
probe_status_via_host_forward() {
local body=""
if adb shell "command -v wget >/dev/null 2>&1" >/dev/null 2>&1; then
body="$(adb shell "wget -qO- --no-check-certificate https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
if ! command -v curl >/dev/null 2>&1; then
return 1
fi
body="$(
curl -ksS --max-time "${PROBE_CURL_MAX_TIME}" \
"https://127.0.0.1:${FORWARD_LOCAL_PORT}${STATUS_PATH}" 2>/dev/null || true
)"
if [[ -z "${body}" ]]; then
body="$(
curl -fsS --max-time "${PROBE_CURL_MAX_TIME}" \
"http://127.0.0.1:${FORWARD_LOCAL_PORT}${STATUS_PATH}" 2>/dev/null || true
)"
fi
if [[ -z "${body}" ]]; then
return 1
fi
last_probe_body="${body}"
status_body_is_healthy "${body}"
}
probe_status_via_device_shell() {
# Fallback when host curl or adb forward is unavailable.
local body=""
local device_base="127.0.0.1:${DEVICE_HTTP_PORT}"
if adb shell "command -v curl >/dev/null 2>&1" >/dev/null 2>&1; then
body="$(
adb shell "curl -ksS --max-time ${PROBE_CURL_MAX_TIME} https://${device_base}${STATUS_PATH}" \
2>/dev/null | tr -d '\r' || true
)"
if [[ -z "${body}" ]]; then
body="$(adb shell "wget -qO- http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
body="$(
adb shell "curl -fsS --max-time ${PROBE_CURL_MAX_TIME} http://${device_base}${STATUS_PATH}" \
2>/dev/null | tr -d '\r' || true
)"
fi
elif adb shell "command -v curl >/dev/null 2>&1" >/dev/null 2>&1; then
body="$(adb shell "curl -ksS --max-time 3 https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
elif adb shell "command -v wget >/dev/null 2>&1" >/dev/null 2>&1; then
body="$(
adb shell "wget -qO- -T ${PROBE_CURL_MAX_TIME} --no-check-certificate https://${device_base}${STATUS_PATH}" \
2>/dev/null | tr -d '\r' || true
)"
if [[ -z "${body}" ]]; then
body="$(adb shell "curl -fsS --max-time 3 http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
body="$(
adb shell "wget -qO- -T ${PROBE_CURL_MAX_TIME} http://${device_base}${STATUS_PATH}" \
2>/dev/null | tr -d '\r' || true
)"
fi
else
# Escape path for embedding in a double-quoted adb shell command.
local py_path
py_path="$(printf '%s' "${STATUS_PATH}" | sed 's/\\/\\\\/g; s/"/\\"/g')"
body="$(
@ -89,12 +152,12 @@ probe_status_ok() {
import ssl, urllib.request
ctx = ssl._create_unverified_context()
urls = (
('https://127.0.0.1:8000${py_path}', ctx),
('http://127.0.0.1:8000${py_path}', None),
('https://${device_base}${py_path}', ctx),
('http://${device_base}${py_path}', None),
)
for url, context in urls:
try:
kwargs = {'timeout': 3}
kwargs = {'timeout': ${PROBE_CURL_MAX_TIME}}
if context is not None:
kwargs['context'] = context
print(urllib.request.urlopen(url, **kwargs).read().decode())
@ -104,9 +167,22 @@ for url, context in urls:
\"" 2>/dev/null | tr -d '\r' || true
)"
fi
printf '%s' "${body}" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'
if [[ -z "${body}" ]]; then
return 1
fi
last_probe_body="${body}"
status_body_is_healthy "${body}"
}
probe_status_ok() {
if probe_status_via_host_forward; then
return 0
fi
probe_status_via_device_shell
}
setup_adb_forward
echo "Waiting up to ${TIMEOUT_SEC}s for backend health (${STATUS_PATH})"
deadline=$((SECONDS + TIMEOUT_SEC))
healthy=0
@ -125,8 +201,8 @@ while (( SECONDS < deadline )); do
done
if [[ "${healthy}" -ne 1 ]]; then
fail_with_logs "backend /api/v1/status did not become ok within ${TIMEOUT_SEC}s"
fail_with_logs "backend ${STATUS_PATH} did not return ok or starting within ${TIMEOUT_SEC}s"
fi
echo "Android emulator smoke OK: ${STATUS_PATH} returned status=ok"
echo "Android emulator smoke OK: ${STATUS_PATH} -> ${last_probe_body}"
grep -E "${LOGCAT_TAG_FILTER}" "${logcat_file}" | tail -n 40 || true

View file

@ -0,0 +1,85 @@
# SPDX-License-Identifier: 0BSD
"""Helpers for Landlock integration probes (always run in a subprocess)."""
from __future__ import annotations
import os
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
from meshchatx.src.backend.landlock_sandbox import landlock_kernel_supported
REPO_ROOT = Path(__file__).resolve().parents[2]
APPLY_FAILED_MARKER = "APPLY_FAILED"
def landlock_integration_available() -> bool:
return sys.platform == "linux" and landlock_kernel_supported()
requires_landlock_integration = pytest.mark.skipif(
not landlock_integration_available(),
reason="Landlock apply requires a supported Linux kernel",
)
def run_python_under_landlock(
body: str,
*,
storage: str | Path,
reticulum_config_dir: str | Path | None = None,
extra_env: dict[str, str] | None = None,
timeout: float = 90,
) -> subprocess.CompletedProcess[str]:
"""Execute body in a fresh Python process after apply_landlock_sandbox."""
storage_s = str(storage)
rns_s = str(reticulum_config_dir) if reticulum_config_dir is not None else None
rns_kw = f", reticulum_config_dir={rns_s!r}" if rns_s is not None else ""
body_clean = textwrap.dedent(body).strip()
script = (
"import os\n"
"import sys\n"
'os.environ["MESHCHAT_LANDLOCK"] = "1"\n'
"from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox\n"
f"storage = {storage_s!r}\n"
"ok = apply_landlock_sandbox(\n"
f" storage_dir=storage,\n"
f" log_dir=storage{rns_kw},\n"
")\n"
"if not ok:\n"
f" print({APPLY_FAILED_MARKER!r})\n"
" sys.exit(2)\n"
f"{body_clean}\n"
)
env = {**os.environ, **(extra_env or {})}
return subprocess.run(
[sys.executable, "-c", script],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
stdin=subprocess.DEVNULL,
)
def skip_if_landlock_not_applied(result: subprocess.CompletedProcess[str]) -> None:
if APPLY_FAILED_MARKER in (result.stdout or ""):
pytest.skip("Landlock could not be applied in this environment")
def assert_probe_ok(
result: subprocess.CompletedProcess[str],
*,
ok_marker: str = "OK",
) -> None:
skip_if_landlock_not_applied(result)
assert result.returncode == 0, (result.stdout, result.stderr)
assert ok_marker in (result.stdout or "")

View file

@ -0,0 +1,419 @@
# SPDX-License-Identifier: 0BSD
"""Landlock regression probes for subprocess and user-local tool surfaces."""
from __future__ import annotations
import os
import shutil
import tempfile
import pytest
from meshchatx.src.backend import landlock_sandbox as ll
from tests.backend.landlock_integration_support import (
assert_probe_ok,
requires_landlock_integration,
run_python_under_landlock,
skip_if_landlock_not_applied,
)
def test_collect_user_local_cli_roots_expected_layout(tmp_path, monkeypatch):
home = tmp_path / "home"
local_bin = home / ".local" / "bin"
pipx = home / ".local" / "share" / "pipx"
argos = home / ".local" / "share" / "argos-translate"
local_bin.mkdir(parents=True)
pipx.mkdir(parents=True)
argos.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
roots = set(ll._collect_user_local_cli_roots())
assert str(local_bin) in roots
assert str(pipx) in roots
assert str(argos) in roots
def test_collect_rw_roots_includes_argos_share_when_present(tmp_path, monkeypatch):
home = tmp_path / "home"
argos = home / ".local" / "share" / "argos-translate"
argos.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
storage = tmp_path / "storage"
storage.mkdir()
rw = ll._collect_rw_roots(str(storage), None, str(storage))
assert str(argos) in rw
@requires_landlock_integration
def test_landlock_denies_write_directly_under_home(tmp_path):
"""HOME is not an RW root; only storage, temp, and explicit shares are."""
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
"""
import os
import sys
path = os.path.join(
os.path.expanduser("~"),
".meshchatx_landlock_write_probe",
)
try:
with open(path, "w", encoding="utf-8") as handle:
handle.write("x")
except PermissionError:
print("OK")
sys.exit(0)
try:
os.remove(path)
except OSError:
pass
print("WRITE_SHOULD_HAVE_FAILED")
sys.exit(3)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_allows_storage_and_temp_writes(tmp_path):
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
f"""
import os
import sys
import tempfile
storage = {str(storage)!r}
path = os.path.join(storage, "rw-check.txt")
with open(path, "w", encoding="utf-8") as handle:
handle.write("storage")
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
delete=True,
) as tmp:
tmp.write("temp")
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_child_interpreter_spawn():
"""Same pattern as self_check.check_subprocess_spawn."""
storage = tempfile.mkdtemp(prefix="meshchat_ll_spawn_")
result = run_python_under_landlock(
"""
import subprocess
import sys
child = subprocess.run(
[sys.executable, "-c", "print('meshchatx-spawn-ok', flush=True)"],
capture_output=True,
text=True,
timeout=30,
check=False,
stdin=subprocess.DEVNULL,
)
if child.returncode != 0:
print("SPAWN_RC", child.returncode, child.stderr)
sys.exit(4)
if "meshchatx-spawn-ok" not in (child.stdout or ""):
print("SPAWN_BAD_OUT", child.stdout)
sys.exit(5)
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_self_check_subprocess_spawn_helper():
storage = tempfile.mkdtemp(prefix="meshchat_ll_selfcheck_")
result = run_python_under_landlock(
"""
import sys
from meshchatx.src.backend.self_check import check_subprocess_spawn
out = check_subprocess_spawn()
if out.get("status") != "ok":
print("SELF_CHECK_FAIL", out.get("reason", ""))
sys.exit(3)
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_translator_handler_lists_argos_languages(tmp_path):
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
"""
import sys
from meshchatx.src.backend.translator_handler import TranslatorHandler
handler = TranslatorHandler(translator_argos_enabled=True)
if not handler.has_argos:
print("SKIP_NO_ARGOS")
sys.exit(0)
resp = handler.get_translator_languages_response()
argos = [x for x in resp["languages"] if x.get("source") == "argos"]
if not argos:
print("EMPTY_ARGOS_LANGS has_lib", handler.has_argos_lib, "has_cli", handler.has_argos_cli)
sys.exit(4)
print("OK", len(argos))
sys.exit(0)
""",
storage=storage,
timeout=120,
)
skip_if_landlock_not_applied(result)
if "SKIP_NO_ARGOS" in (result.stdout or ""):
pytest.skip("Argos Translate not installed on this host")
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_argospm_list_when_installed(tmp_path):
if not shutil.which("argospm"):
pytest.skip("argospm not on PATH")
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
"""
import subprocess
import sys
proc = subprocess.run(
["argospm", "list"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if proc.returncode != 0:
print("ARGOSPM_FAIL", proc.stderr or proc.stdout)
sys.exit(3)
if not (proc.stdout or "").strip():
print("ARGOSPM_EMPTY")
sys.exit(4)
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_argos_translate_cli_when_installed(tmp_path):
executable = shutil.which("argos-translate")
if not executable:
pytest.skip("argos-translate not on PATH")
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
f"""
import subprocess
import sys
proc = subprocess.run(
[{executable!r}, "--from-lang", "en", "--to-lang", "ru", "hello"],
capture_output=True,
text=True,
timeout=120,
check=False,
)
if proc.returncode != 0:
print("TRANSLATE_FAIL", proc.stderr or proc.stdout)
sys.exit(3)
if not (proc.stdout or "").strip():
print("TRANSLATE_EMPTY")
sys.exit(4)
print("OK")
sys.exit(0)
""",
storage=storage,
timeout=180,
)
skip_if_landlock_not_applied(result)
if result.returncode != 0 and "translate-en_ru" in (
result.stdout or result.stderr or ""
):
pytest.skip("translate-en_ru package not installed")
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_argos_share_allows_stanza_style_temp_dir(tmp_path):
home = os.path.expanduser("~")
argos_share = os.path.join(home, ".local", "share", "argos-translate")
if not os.path.isdir(argos_share):
pytest.skip("Argos data directory not present")
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
f"""
import os
import sys
import tempfile
base = {argos_share!r}
with tempfile.TemporaryDirectory(dir=base) as td:
path = os.path.join(td, "probe.txt")
with open(path, "w", encoding="utf-8") as handle:
handle.write("ok")
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_user_local_bin_script_executable(tmp_path):
local_bin = os.path.join(os.path.expanduser("~"), ".local", "bin")
if not os.path.isdir(local_bin):
pytest.skip("~/.local/bin not present")
preferred = ("argospm", "argos-translate", "argostranslate", "git-remote-rns")
cmd = None
for name in preferred:
path = os.path.join(local_bin, name)
if os.path.isfile(path) and os.access(path, os.X_OK):
cmd = path
break
if cmd is None:
pytest.skip("no known pipx-style CLI in ~/.local/bin")
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
f"""
import subprocess
import sys
proc = subprocess.run(
[{cmd!r}],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if proc.returncode != 0:
print("EXEC_FAIL", proc.stderr)
sys.exit(3)
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_git_version_when_git_on_path(tmp_path):
git = shutil.which("git")
if not git:
pytest.skip("git not on PATH")
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
f"""
import subprocess
import sys
proc = subprocess.run(
[{git!r}, "--version"],
capture_output=True,
text=True,
timeout=15,
check=False,
)
if proc.returncode != 0:
print("GIT_FAIL", proc.stderr)
sys.exit(3)
if "git version" not in (proc.stdout or ""):
print("GIT_BAD_OUT", proc.stdout)
sys.exit(4)
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@requires_landlock_integration
def test_landlock_rnsh_module_spawn_when_installed(tmp_path):
try:
import importlib.util
if importlib.util.find_spec("RNS.Utilities.rnsh.rnsh") is None:
pytest.skip("rnsh module not installed")
except (ImportError, ValueError, AttributeError):
pytest.skip("rnsh module not installed")
storage = tmp_path / "storage"
rns_dir = tmp_path / "rns"
rns_dir.mkdir()
storage.mkdir()
result = run_python_under_landlock(
"""
import subprocess
import sys
proc = subprocess.run(
[sys.executable, "-m", "RNS.Utilities.rnsh.rnsh", "--help"],
capture_output=True,
text=True,
timeout=20,
check=False,
)
out = (proc.stdout or "") + (proc.stderr or "")
if proc.returncode not in (0, 1, 2) and "rnsh" not in out.lower():
print("RNSH_HELP_FAIL", proc.returncode, out[-500:])
sys.exit(3)
print("OK")
sys.exit(0)
""",
storage=storage,
reticulum_config_dir=rns_dir,
)
assert_probe_ok(result)
@pytest.mark.parametrize(
("path_name", "collector"),
[
("local_bin", lambda home: os.path.join(home, ".local", "bin")),
("pipx", lambda home: os.path.join(home, ".local", "share", "pipx")),
(
"argos_share",
lambda home: os.path.join(home, ".local", "share", "argos-translate"),
),
],
)
def test_read_roots_cover_user_local_paths_when_present(path_name, collector):
home = os.path.expanduser("~")
target = collector(home)
if not os.path.isdir(target):
pytest.skip(f"{path_name} not present on this host")
roots = ll._collect_read_roots()
assert any(
target == root or target.startswith(root.rstrip("/") + "/") for root in roots
), f"{target!r} not covered by landlock read roots ({path_name})"

View file

@ -97,6 +97,17 @@ def test_collect_read_roots_includes_proc_for_psutil():
assert "/proc" in roots
def test_collect_read_roots_includes_user_local_cli_when_present():
home = os.path.expanduser("~")
local_bin = os.path.join(home, ".local", "bin")
roots = ll._collect_read_roots()
if os.path.isdir(local_bin):
assert any(
local_bin == root or local_bin.startswith(root.rstrip("/") + "/")
for root in roots
), f"{local_bin!r} not covered by landlock read roots"
def test_collect_read_roots_includes_interpreter_prefix():
roots = ll._collect_read_roots()
exe = os.path.realpath(sys.executable)
@ -210,24 +221,19 @@ def test_landlock_abi_version_on_linux():
)
def test_apply_landlock_preserves_storage_write_and_truncate(tmp_path):
"""Apply sandbox in a subprocess and confirm RW + truncate still work."""
import subprocess
import textwrap
from pathlib import Path
from tests.backend.landlock_integration_support import (
assert_probe_ok,
run_python_under_landlock,
)
storage = tmp_path / "storage"
storage.mkdir()
script = textwrap.dedent(
result = run_python_under_landlock(
f"""
import os
import sys
from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox
storage = {str(storage)!r}
os.environ["MESHCHAT_LANDLOCK"] = "1"
ok = apply_landlock_sandbox(storage_dir=storage, log_dir=storage)
if not ok:
print("APPLY_FAILED")
sys.exit(2)
path = os.path.join(storage, "landlock-abi-check.txt")
with open(path, "w", encoding="utf-8") as handle:
handle.write("hello")
@ -239,17 +245,52 @@ def test_apply_landlock_preserves_storage_write_and_truncate(tmp_path):
print("TRUNCATE_FAILED", repr(data))
sys.exit(3)
print("OK")
sys.exit(0)
""",
storage=storage,
)
assert_probe_ok(result)
@pytest.mark.skipif(
sys.platform != "linux" or not ll.landlock_kernel_supported(),
reason="Landlock apply requires a supported Linux kernel",
)
def test_apply_landlock_allows_user_local_argospm_list(tmp_path):
"""Pipx Argos under ~/.local must remain usable for translator language lists."""
import shutil
from tests.backend.landlock_integration_support import (
assert_probe_ok,
run_python_under_landlock,
)
if not shutil.which("argospm"):
pytest.skip("argospm not on PATH")
storage = tmp_path / "storage"
storage.mkdir()
result = run_python_under_landlock(
"""
import subprocess
import sys
proc = subprocess.run(
["argospm", "list"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if proc.returncode != 0:
print("ARGOSPM_FAILED", proc.stderr or proc.stdout)
sys.exit(3)
if not (proc.stdout or "").strip():
print("ARGOSPM_EMPTY")
sys.exit(4)
print("OK")
sys.exit(0)
""",
storage=storage,
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=str(Path(__file__).resolve().parents[2]),
capture_output=True,
text=True,
timeout=30,
check=False,
)
if "APPLY_FAILED" in result.stdout:
pytest.skip("Landlock could not be applied in this environment")
assert result.returncode == 0, (result.stdout, result.stderr)
assert "OK" in result.stdout
assert_probe_ok(result)

View file

@ -0,0 +1,147 @@
# SPDX-License-Identifier: 0BSD
from unittest.mock import AsyncMock, MagicMock, patch
import LXMF
import pytest
LOCAL_LXMF = "a1" * 16
REMOTE_PEER = "c3" * 16
def _fake_lxmf_message():
fake_lxm = MagicMock()
fake_lxm.fields = {}
fake_lxm.hash = MagicMock(hex=lambda: "d4" * 16)
fake_lxm.source_hash = MagicMock(hex=lambda: LOCAL_LXMF)
fake_lxm.destination_hash = MagicMock(hex=lambda: LOCAL_LXMF)
fake_lxm.incoming = False
fake_lxm.progress = 0
fake_lxm.delivery_attempts = 0
fake_lxm.next_delivery_attempt = None
fake_lxm.rssi = None
fake_lxm.snr = None
fake_lxm.q = None
fake_lxm.title = b""
fake_lxm.timestamp = 1_700_000_000
fake_lxm.state = LXMF.LXMessage.OUTBOUND
fake_lxm.method = LXMF.LXMessage.DIRECT
fake_lxm.get_fields = MagicMock(return_value={})
fake_lxm.content = ""
fake_lxm.include_ticket = False
return fake_lxm
def _lxmf_message_factory(*args, **kwargs):
msg = _fake_lxmf_message()
if len(args) >= 3:
raw = args[2]
if isinstance(raw, bytes):
msg.content = raw
else:
msg.content = (raw or "").encode("utf-8")
return msg
@pytest.fixture
def local_self_app(mock_app):
ctx = mock_app.current_context
ctx.config.auto_send_failed_messages_to_propagation_node.set(False)
ctx.message_router.delivery_link_available.return_value = True
ctx.message_router.handle_outbound = MagicMock()
ctx.local_lxmf_destination = MagicMock()
ctx.local_lxmf_destination.hexhash = LOCAL_LXMF
ctx.local_lxmf_destination.hash = bytes.fromhex(LOCAL_LXMF)
mock_app.recall_identity = MagicMock(return_value=ctx.identity)
mock_app.get_current_icon_hash = MagicMock(return_value=None)
mock_app._await_transport_path = AsyncMock()
mock_app._convert_webm_opus_to_ogg = MagicMock(side_effect=lambda b: b)
mock_app.websocket_broadcast = AsyncMock()
if getattr(mock_app, "reticulum", None) is not None:
mock_app.reticulum.get_packet_rssi = MagicMock(return_value=None)
mock_app.reticulum.get_packet_snr = MagicMock(return_value=None)
mock_app.reticulum.get_packet_q = MagicMock(return_value=None)
return mock_app
def test_is_self_lxmf_destination_by_lxmf_hash(local_self_app):
app = local_self_app
assert app._is_self_lxmf_destination(LOCAL_LXMF) is True
assert app._is_self_lxmf_destination(LOCAL_LXMF.upper()) is True
assert app._is_self_lxmf_destination(REMOTE_PEER) is False
def test_is_self_lxmf_destination_by_identity_hash(local_self_app):
app = local_self_app
identity_hex = app.current_context.identity.hash.hex()
assert app._is_self_lxmf_destination(identity_hex) is True
@pytest.mark.asyncio
async def test_send_to_self_persists_delivered_local_without_router(local_self_app):
app = local_self_app
fake_destination = MagicMock()
fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
app.db_upsert_lxmf_message = MagicMock()
with (
patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
patch(
"meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
return_value=False,
),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
await app.send_message(
destination_hash=LOCAL_LXMF,
content="note to self",
)
app._await_transport_path.assert_not_called()
app.current_context.message_router.handle_outbound.assert_not_called()
app.db_upsert_lxmf_message.assert_called_once()
upsert_kw = app.db_upsert_lxmf_message.call_args.kwargs
assert upsert_kw.get("state_override") == "delivered"
assert upsert_kw.get("method_override") == "local"
app.websocket_broadcast.assert_awaited()
payload = app.websocket_broadcast.await_args[0][0]
assert "local" in payload and "delivered" in payload
@pytest.mark.asyncio
async def test_send_to_self_by_identity_hash(local_self_app):
app = local_self_app
identity_hex = app.current_context.identity.hash.hex()
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LOCAL_LXMF)
fake_destination = MagicMock()
fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
app.db_upsert_lxmf_message = MagicMock()
with (
patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
patch(
"meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
return_value=False,
),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
await app.send_message(
destination_hash=identity_hex,
content="via identity hash",
)
app.current_context.message_router.handle_outbound.assert_not_called()
assert app.db_upsert_lxmf_message.call_args.kwargs.get("method_override") == "local"
@pytest.mark.asyncio
async def test_send_to_self_rejects_propagated(local_self_app):
app = local_self_app
with pytest.raises(ValueError, match="yourself"):
await app.send_message(
destination_hash=LOCAL_LXMF,
content="nope",
delivery_method="propagated",
)

View file

@ -0,0 +1,215 @@
# SPDX-License-Identifier: 0BSD
"""Oracles and adversarial cases for local (self) LXMF messaging.
Invariant: destination equal to local LXMF, identity, or resolving to local LXMF
must persist as delivered/local without LXMF router outbound. Any other
destination must use the mesh path when send succeeds.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend import reticulum_pathfinding
from tests.backend.test_lxmf_local_self_message import (
LOCAL_LXMF,
REMOTE_PEER,
_fake_lxmf_message,
_lxmf_message_factory,
local_self_app,
)
@pytest.mark.parametrize(
("dest", "expected"),
[
(LOCAL_LXMF, True),
(LOCAL_LXMF.upper(), True),
(REMOTE_PEER, False),
("", False),
],
)
def test_is_self_lxmf_param_oracle(local_self_app, dest, expected):
app = local_self_app
assert app._is_self_lxmf_destination(dest) is expected
def test_is_self_lxmf_identity_hash_oracle(local_self_app):
app = local_self_app
identity_hex = app.current_context.identity.hash.hex()
assert app._is_self_lxmf_destination(identity_hex) is True
@pytest.mark.parametrize(
"dest",
[
REMOTE_PEER,
"f0" * 16,
"deadbeef" * 4,
"0123456789abcdef0123456789abcdef",
],
)
def test_is_self_false_for_unrelated_peers(local_self_app, dest):
app = local_self_app
identity = app.current_context.identity.hash.hex().lower()
assert dest.lower() not in {LOCAL_LXMF.lower(), identity}
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=None)
assert app._is_self_lxmf_destination(dest) is False
@pytest.mark.asyncio
async def test_local_self_sqlite_row_delivered_local(local_self_app):
"""End-to-end persist: real db_upsert, not a mock."""
app = local_self_app
fake_destination = MagicMock()
fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
with (
patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
patch(
"meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
return_value=False,
),
patch("meshchatx.meshchat.AsyncUtils.run_async") as run_async,
):
await app.send_message(destination_hash=LOCAL_LXMF, content="oracle persist")
run_async.assert_not_called()
row = app.database.provider.fetchone(
"SELECT state, method, peer_hash, content FROM lxmf_messages WHERE content = ?",
("oracle persist",),
)
assert row is not None
assert row["state"] == "delivered"
assert row["method"] == "local"
assert row["peer_hash"] == LOCAL_LXMF
@pytest.mark.asyncio
async def test_local_self_conversation_summary_updated(local_self_app):
app = local_self_app
fake_destination = MagicMock()
fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
with (
patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
patch(
"meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
return_value=False,
),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
await app.send_message(destination_hash=LOCAL_LXMF, content="summary row")
summary = app.database.provider.fetchone(
"SELECT state, content_preview FROM lxmf_conversation_summaries WHERE peer_hash = ?",
(LOCAL_LXMF,),
)
assert summary is not None
assert summary["state"] == "delivered"
assert "summary row" in (summary["content_preview"] or "")
@pytest.mark.asyncio
async def test_remote_peer_still_invokes_mesh_outbound(local_self_app):
app = local_self_app
fake_destination = MagicMock()
fake_destination.hash = bytes.fromhex(REMOTE_PEER)
app._await_transport_path = AsyncMock(
return_value=reticulum_pathfinding.OutboundPathOutcome(
True,
"reused_valid_path",
False,
),
)
app.db_upsert_lxmf_message = MagicMock()
progress = AsyncMock()
app.handle_lxmf_message_progress = progress
with (
patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
patch(
"meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
return_value=False,
),
patch("meshchatx.meshchat.AsyncUtils.run_async") as run_async,
):
await app.send_message(destination_hash=REMOTE_PEER, content="to peer")
app._await_transport_path.assert_awaited()
app.current_context.message_router.handle_outbound.assert_called_once()
run_async.assert_called_once()
upsert_kw = app.db_upsert_lxmf_message.call_args.kwargs
assert upsert_kw.get("method_override") is None
assert upsert_kw.get("state_override") is None
def test_db_upsert_override_writes_local_method(local_self_app):
app = local_self_app
msg = _fake_lxmf_message()
msg.content = b"stored"
ReticulumMeshChat.db_upsert_lxmf_message(
app,
msg,
state_override="delivered",
method_override="local",
)
row = app.database.provider.fetchone(
"SELECT state, method FROM lxmf_messages WHERE hash = ?",
("d4" * 16,),
)
assert row is not None
assert row["state"] == "delivered"
assert row["method"] == "local"
@pytest.mark.asyncio
async def test_local_self_websocket_payload_is_delivered_local(local_self_app):
app = local_self_app
fake_destination = MagicMock()
fake_destination.hash = bytes.fromhex(LOCAL_LXMF)
with (
patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination),
patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory),
patch(
"meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps",
return_value=False,
),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
await app.send_message(destination_hash=LOCAL_LXMF, content="ws")
raw = app.websocket_broadcast.await_args[0][0]
payload = json.loads(raw)
assert payload["type"] == "lxmf_message_created"
lm = payload["lxmf_message"]
assert lm["state"] == "delivered"
assert lm["method"] == "local"
assert lm["content"] == "ws"
@pytest.mark.asyncio
async def test_identity_resolved_to_other_lxmf_is_not_self(local_self_app):
app = local_self_app
other_lxmf = REMOTE_PEER
identity_hex = app.current_context.identity.hash.hex()
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=other_lxmf)
assert app._is_self_lxmf_destination(identity_hex) is True
stranger = "f0" * 16
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=other_lxmf)
assert app._is_self_lxmf_destination(stranger) is False
def test_oracle_rejects_if_implementation_always_returns_true(local_self_app):
"""Guard: mutating oracle expectation must fail (documents test strength)."""
app = local_self_app
assert app._is_self_lxmf_destination(REMOTE_PEER) is False

View file

@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshchatx.src.backend.translator_handler import (
HAS_ARGOS_LIB,
TranslatorHandler,
_normalize_optional_libretranslate_api_key,
)
@ -357,3 +358,25 @@ def test_translate_explicit_api_key_overrides_handler_default(mock_session_cls):
)
body = mock_session.post.call_args.kwargs.get("json")
assert body.get("api_key") == "one-off"
@pytest.mark.skipif(not HAS_ARGOS_LIB, reason="argostranslate not installed")
def test_get_supported_languages_falls_back_to_cli_when_lib_has_no_packages():
handler = TranslatorHandler(translator_argos_enabled=True)
handler.has_argos_lib = True
handler.has_argos_cli = True
handler.has_argos = True
handler.has_requests = False
handler.translator_libretranslate_enabled = False
with (
patch("meshchatx.src.backend.translator_handler.package") as mock_pkg,
patch.object(handler, "_get_argos_languages_cli") as mock_cli,
):
mock_pkg.get_installed_packages.return_value = []
mock_cli.return_value = [{"code": "en", "name": "English", "source": "argos"}]
langs = handler.get_supported_languages()
assert any(
lang.get("code") == "en" and lang.get("source") == "argos" for lang in langs
)
mock_cli.assert_called_once()

View file

@ -3,10 +3,47 @@ const { expect } = require("@playwright/test");
const E2E_BACKEND_PORT = process.env.E2E_BACKEND_PORT || "18079";
const E2E_BACKEND_ORIGIN = `http://127.0.0.1:${E2E_BACKEND_PORT}`;
const CSRF_HEADER = "X-CSRF-Token";
const E2E_SCROLL_PEER_HASH = `e2e0${"0".repeat(28)}`;
const E2E_SCROLL_ALT_PEER_HASH = `e2e1${"0".repeat(28)}`;
const _csrfByOrigin = new Map();
/**
* @param {import('@playwright/test').APIRequestContext} request
* @param {string} origin e.g. http://127.0.0.1:18079
*/
async function ensureE2eCsrf(request, origin) {
const cached = _csrfByOrigin.get(origin);
if (cached) {
return cached;
}
const res = await request.get(`${origin}/api/v1/auth/csrf`);
expect(res.ok()).toBeTruthy();
const body = await res.json();
const token = body.csrf_token;
expect(token && typeof token === "string").toBeTruthy();
_csrfByOrigin.set(origin, token);
return token;
}
/**
* POST with session cookie and CSRF header (Playwright request keeps cookies per context).
* @param {import('@playwright/test').APIRequestContext} request
* @param {string} url absolute URL
* @param {object} [data] JSON body
*/
async function e2ePost(request, url, data) {
const origin = new URL(url).origin;
const token = await ensureE2eCsrf(request, origin);
const headers = { [CSRF_HEADER]: token };
if (data === undefined) {
return request.post(url, { headers });
}
return request.post(url, { headers, data });
}
function buildE2eLxmfRow({ peerHash, localHash, index, total, inbound }) {
const hash = crypto.randomBytes(16).toString("hex");
const baseTs = Math.floor(Date.now() / 1000) - total;
@ -70,8 +107,8 @@ async function seedE2eLongConversationThread(request, opts = {}) {
})
);
}
const imp = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
data: { messages },
const imp = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
messages,
});
expect(imp.ok()).toBeTruthy();
return { peerHash, localHash };
@ -98,8 +135,8 @@ async function seedE2eAltShortConversationThread(request, opts = {}) {
row.content = `E2E alt short ${String(i).padStart(3, "0")} ${"x".repeat(48)}`;
messages.push(row);
}
const imp = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
data: { messages },
const imp = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
messages,
});
expect(imp.ok()).toBeTruthy();
return { peerHash, localHash };
@ -112,10 +149,10 @@ const PALETTE_PLACEHOLDER = /Search commands,\s*(routes|navigate),\s*or peers\.{
* (v-overlay scrim) do not block pointer clicks on the shell.
*/
async function prepareE2eSession(request) {
const tutorial = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`);
const tutorial = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`);
expect(tutorial.ok()).toBeTruthy();
const changelog = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/changelog/seen`, {
data: { version: "999.999.999" },
const changelog = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/app/changelog/seen`, {
version: "999.999.999",
});
expect(changelog.ok()).toBeTruthy();
}
@ -157,6 +194,8 @@ module.exports = {
E2E_SCROLL_ALT_PEER_HASH,
PALETTE_PLACEHOLDER,
dismissMapOnboardingTooltip,
e2ePost,
ensureE2eCsrf,
openCommandPalette,
prepareE2eSession,
getE2eLocalLxmfHash,

View file

@ -0,0 +1,53 @@
const { test, expect } = require("@playwright/test");
const { E2E_BACKEND_ORIGIN, e2ePost, prepareE2eSession } = require("./helpers");
test.describe("E2E CSRF API helpers", () => {
test("POST /app/tutorial/seen without CSRF token is rejected", async ({ request }) => {
const res = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`);
expect(res.status()).toBe(403);
});
test("e2ePost succeeds for tutorial and changelog seen", async ({ request }) => {
await prepareE2eSession(request);
});
});
test.describe("Local self-message API", () => {
test.beforeEach(async ({ request }) => {
await prepareE2eSession(request);
});
test("POST lxmf-messages/send to own LXMF hash returns delivered local", async ({ request }) => {
const cfg = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/config`);
expect(cfg.ok()).toBeTruthy();
const localHash = (await cfg.json()).config?.lxmf_address_hash;
expect(localHash && String(localHash).length).toBe(32);
const res = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/lxmf-messages/send`, {
lxmf_message: {
destination_hash: localHash,
content: "E2E personal note",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const body = await res.json();
const lm = body.lxmf_message;
expect(lm.state).toBe("delivered");
expect(lm.method).toBe("local");
expect(lm.content).toBe("E2E personal note");
});
test("propagated send to self is rejected", async ({ request }) => {
const cfg = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/config`);
const localHash = (await cfg.json()).config?.lxmf_address_hash;
const res = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/lxmf-messages/send`, {
delivery_method: "propagated",
lxmf_message: {
destination_hash: localHash,
content: "nope",
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBeGreaterThanOrEqual(400);
});
});

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: 0BSD AND MIT
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import ConversationViewer from "@/components/messages/ConversationViewer.vue";
import ToastUtils from "@/js/ToastUtils";
vi.mock("@/js/ToastUtils", () => ({
default: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
vi.mock("@/js/WebSocketConnection", () => ({
default: { on: vi.fn(), off: vi.fn() },
}));
vi.mock("@/js/GlobalEmitter", () => ({
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}));
describe("ConversationViewer self peer detection", () => {
const mountViewer = (myLxmf = "aa".repeat(16), identityHash = "bb".repeat(16)) =>
mount(ConversationViewer, {
props: {
selectedPeer: { destination_hash: myLxmf, display_name: "Me" },
myLxmfAddressHash: myLxmf,
config: { identity_hash: identityHash, lxmf_address_hash: myLxmf },
},
global: {
mocks: {
$t: (key) => key,
$route: { query: {} },
$router: { push: vi.fn() },
},
stubs: {
MaterialDesignIcon: true,
ConversationPeerHeader: true,
ConversationMessageListVirtual: true,
SendMessageButton: true,
AddImageButton: true,
AddAudioButton: true,
ContextMenuPanel: true,
ContextMenuItem: true,
ContextMenuDivider: true,
Modal: true,
PaperMessageModal: true,
ShareContactModal: true,
ConversationImageModal: true,
LxmfUserIcon: true,
AudioWaveformPlayer: true,
StickerView: true,
InViewAnimatedImg: true,
TelemetryHistoryModal: true,
ConversationMessageEntry: true,
},
},
});
it("isSelfLxmfDestination is true for own LXMF hash", async () => {
const my = "cc".repeat(16);
const wrapper = mountViewer(my, "dd".repeat(16));
await flushPromises();
expect(wrapper.vm.isSelfLxmfDestination(my)).toBe(true);
expect(wrapper.vm.isSelfLxmfDestination(my.toUpperCase())).toBe(true);
});
it("isSelfLxmfDestination is true for own identity hash", async () => {
const my = "cc".repeat(16);
const ident = "dd".repeat(16);
const wrapper = mountViewer(my, ident);
await flushPromises();
expect(wrapper.vm.isSelfLxmfDestination(ident)).toBe(true);
});
it("isSelfLxmfDestination is false for unrelated peer", async () => {
const wrapper = mountViewer();
await flushPromises();
expect(wrapper.vm.isSelfLxmfDestination("ee".repeat(16))).toBe(false);
expect(wrapper.vm.isSelfLxmfDestination("")).toBe(false);
});
});

View file

@ -0,0 +1,120 @@
// SPDX-License-Identifier: 0BSD AND MIT
/**
* Oracle: optimistic sidebar bump on compose enqueue must appear before WS ack.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import MessagesPage from "@/components/messages/MessagesPage.vue";
vi.mock("@/js/GlobalEmitter", () => ({
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}));
vi.mock("@/js/NotificationUtils", () => ({
default: {
clearMessageNotifications: vi.fn(),
clearAllMessageNotifications: vi.fn(),
showNewMessageNotification: vi.fn(),
syncAndroidNotificationContext: vi.fn(),
},
}));
const PEER = "bb".repeat(16);
describe("MessagesPage outbound compose oracle", () => {
let axiosMock;
beforeEach(() => {
axiosMock = {
get: vi.fn().mockImplementation((url) => {
if (url === "/api/v1/config") {
return Promise.resolve({
data: { config: { lxmf_address_hash: "aa".repeat(16) } },
});
}
if (url === "/api/v1/lxmf/conversations") {
return Promise.resolve({ data: { conversations: [] } });
}
if (url === "/api/v1/announces") {
return Promise.resolve({ data: { announces: [] } });
}
return Promise.resolve({ data: {} });
}),
post: vi.fn().mockResolvedValue({ data: {} }),
};
window.api = axiosMock;
});
afterEach(() => {
delete window.api;
});
const mountPage = () =>
mount(MessagesPage, {
props: { destinationHash: "" },
global: {
mocks: {
$t: (key) => key,
$route: { query: {} },
$router: { replace: vi.fn() },
},
stubs: {
MaterialDesignIcon: true,
LoadingSpinner: true,
MessagesSidebar: true,
ConversationViewer: true,
Modal: true,
},
},
});
it("onOutboundComposeEnqueued inserts conversation row with preview before server ack", async () => {
const wrapper = mountPage();
await flushPromises();
expect(wrapper.vm.conversations).toHaveLength(0);
wrapper.vm.onOutboundComposeEnqueued({
peerHash: PEER,
previewText: "hello optimistic",
title: "",
fields: {},
});
expect(wrapper.vm.conversations).toHaveLength(1);
expect(wrapper.vm.conversations[0].destination_hash).toBe(PEER);
expect(wrapper.vm.conversations[0].latest_message_preview).toContain("hello optimistic");
expect(wrapper.vm.conversations[0].latest_message_created_at).toBeGreaterThan(0);
});
it("onOutboundComposeEnqueued updates existing row instead of duplicating", async () => {
const wrapper = mountPage();
await flushPromises();
wrapper.vm.conversations = [
{
destination_hash: PEER,
display_name: "Peer",
latest_message_preview: "old",
latest_message_created_at: 1,
updated_at: new Date(1000).toISOString(),
},
];
wrapper.vm.onOutboundComposeEnqueued({
peerHash: PEER,
previewText: "new text",
title: "t",
fields: {},
});
expect(wrapper.vm.conversations).toHaveLength(1);
expect(wrapper.vm.conversations[0].latest_message_preview).toContain("new text");
expect(wrapper.vm.conversations[0].latest_message_title).toBe("t");
expect(wrapper.vm.conversations[0].latest_message_created_at).toBeGreaterThan(1);
});
it("onOutboundComposeEnqueued ignores empty peerHash (must not create orphan row)", () => {
const wrapper = mountPage();
wrapper.vm.onOutboundComposeEnqueued({ peerHash: "", previewText: "x" });
expect(wrapper.vm.conversations).toHaveLength(0);
});
});

View file

@ -26,7 +26,7 @@ const API_STATES = [
"unknown",
];
const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown"];
const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "local", "unknown"];
/**
* Independent UI oracle. Must stay in sync with product intent, not by
@ -35,12 +35,12 @@ const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown"
function oracleIconName({ state, method }) {
if (state === "delivered") {
if (method === "propagated") return "email-check-outline";
if (method === "paper") return "note-check-outline";
if (method === "paper" || method === "local") return "note-check-outline";
return "check-all";
}
if (state === "sent" || state === "propagated" || state === "unknown") {
if (method === "propagated") return "email-outline";
if (method === "paper") return "note-outline";
if (method === "paper" || method === "local") return "note-outline";
return "check";
}
return "check";
@ -49,8 +49,10 @@ function oracleIconName({ state, method }) {
function oracleTitleKey({ state, method }) {
if (state === "delivered") {
if (method === "propagated") return "messages.outbound_delivered_propagated";
if (method === "local") return "messages.outbound_delivered_local";
return "messages.outbound_delivered";
}
if (method === "local") return "messages.outbound_saved_local";
if (method === "propagated") return "messages.outbound_on_propagation_node";
return "messages.outbound_sent_network";
}
@ -113,6 +115,17 @@ describe("outboundMessageStatus LXMF oracle", () => {
expect(outboundBubbleStatusIconName({ state: "delivered", method: "paper" })).toBe("note-check-outline");
});
it("local method uses note-check icon when delivered", () => {
const msg = { state: "delivered", method: "local" };
expect(outboundBubbleStatusIconName(msg)).toBe("note-check-outline");
expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_delivered_local");
});
it("local method while sending uses note outline title for saved local", () => {
const msg = { state: "sending", method: "local" };
expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_saved_local");
});
it("backend never emits state=propagated but UI still treats it as sent-like", () => {
expect(outboundBubbleStatusIconName({ state: "propagated", method: "direct" })).toBe("check");
expect(outboundBubbleStatusIconName({ state: "propagated", method: "propagated" })).toBe("email-outline");

View file

@ -46,3 +46,4 @@ def test_workflow_references_smoke_script():
smoke_script = smoke_step.split("script:", 1)[1].split("\n", 1)[0]
assert "bash scripts/ci/android-emulator-smoke.sh" in smoke_script
assert "set -euo pipefail" not in smoke_script
assert "adb forward" in _SCRIPT.read_text(encoding="utf-8")