mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
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:
parent
99b3630e72
commit
06e2faeae9
42 changed files with 1681 additions and 94 deletions
2
.github/workflows/android-emulator-smoke.yml
vendored
2
.github/workflows/android-emulator-smoke.yml
vendored
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "История телеметрии",
|
||||
|
|
|
|||
|
|
@ -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": "无活动聊天",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
85
tests/backend/landlock_integration_support.py
Normal file
85
tests/backend/landlock_integration_support.py
Normal 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 "")
|
||||
419
tests/backend/test_landlock_integration_surfaces.py
Normal file
419
tests/backend/test_landlock_integration_surfaces.py
Normal 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})"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
147
tests/backend/test_lxmf_local_self_message.py
Normal file
147
tests/backend/test_lxmf_local_self_message.py
Normal 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",
|
||||
)
|
||||
215
tests/backend/test_lxmf_local_self_oracle.py
Normal file
215
tests/backend/test_lxmf_local_self_oracle.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
53
tests/e2e/self-message.spec.js
Normal file
53
tests/e2e/self-message.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
79
tests/frontend/conversationViewerSelfPeer.test.js
Normal file
79
tests/frontend/conversationViewerSelfPeer.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
120
tests/frontend/messagesPageOutboundCompose.oracle.test.js
Normal file
120
tests/frontend/messagesPageOutboundCompose.oracle.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue