The plugin is named 'iris' (IrisAdapter, IRIS_HOME_CHANNEL, label Iris), but several docs still referred to it as the android platform/plugin and to the product as 'the Android app'. Rename name-mentions to iris/IRIS and product-mentions to 'Iris app'; keep legitimate OS references (androidApp, Android SDK, Android 10, androidx, test_android.py, ...). Also includes pi-lens markdown-lint autofixes (table spacing, trailing newlines) in the touched files.
15 KiB
18 — Code Review & Lint/LSP Cleanup (alpha → stable)
Comprehensive review of all three components — gateway plugin, Iris app (Android), and Desktop app — performed to take the project from alpha to a clean, stable baseline. Each section records what was found, what was fixed, how it was verified, and what was deliberately left (with rationale).
Companion file:
DECISIONS.mdat the repo root records the judgment calls made during this pass (rule thresholds, suppressed findings, config additions). This doc is the findings; that file is the decisions.
Verification tooling used throughout:
- Ruff (via the
hermes-agent/.venvinterpreter) — seegateway-plugin/ruff.tomlfor the rule set. - pi-lens (
lens_diagnostics mode=full) — LSP + tree-sitter + ast-grep + opengrep + jscpd + gitleaks. - Python test suite —
hermes-agent/scripts/run_tests.sh tests/gateway/test_android.py(64 tests). - Kotlin —
./gradlew :shared:testDebugUnitTest/:shared:desktopTestand./gradlew lint(Android/Desktop).
18.1 Gateway plugin (gateway-plugin/)
18.1.1 Findings (before)
A fresh lens_diagnostics mode=full over gateway-plugin/ reported 30
blocking errors and ~47 warnings. Ruff (broad rule set) reported 450
findings. Categories:
| Category | Count | Severity | Resolution |
|---|---|---|---|
Empty except: pass blocks |
14 | blocking | Rewritten as contextlib.suppress(...) with a rationale comment (or a logger.debug where the swallow is worth tracing). |
Unreachable except clause |
2 | blocking | False positive from an over-broad tree-sitter rule, but the two-clause try was restructured into a single except (A, B) as e: + isinstance so the code is unambiguous and the rule no longer fires. |
| SQL-injection sink (parameterized) | 4 | blocking | False positive — every value is bound via ? placeholders. Suppressed inline (pi-lens-ignore: python-sql-injection) with a justification; the opengrep SQLAlchemy variant (misfiring on raw sqlite3) disabled project-wide. |
Hardcoded secret (token_field) |
3 | blocking | False positive — token_field is a DB column name string, not a credential. Suppressed inline. |
Path traversal (open(path)) |
1 | blocking | False positive — path is produced by hermes cache_*_from_bytes (hermes's own media dir), never raw user input. Suppressed inline. |
| Unresolved hermes imports | many | blocking (LSP) | Not a code bug — the plugin imports hermes-runtime modules (websockets, gateway.platforms.base, hermes_state_search, …) that live in the read-only hermes-agent/ tree + its venv. Fixed by adding pyrightconfig.json pointing the Python LSP at that venv + source root. |
int()/float()/open() "unchecked" |
38+ | warning | Noisy heuristic on validated internal data. Disabled project-wide in .pi-lens.json (see DECISIONS). |
| Logger "credential leak" | 3 | warning | False positive — the word token in a log message; the logged values (peer addr, device id, HTTP status) are not secrets. Disabled project-wide. |
| Ruff: line length / type annotations / imports / magic values / complexity | 450 | lint | All fixed (see 18.1.3). |
| gitleaks (git-ignored paths) | several | warning | Allowlisted in .gitleaks.toml — the hits were the read-only hermes-agent/ tree, build/ artifacts, and the standard (git-ignored) google-services.json. |
18.1.2 Real bugs fixed
adapter.pyinteractive_setupbroken imports (regression, silently masked): the setup flow importedprint_info/print_success/print_warning/promptfromhermes_cli.config, but those live inhermes_cli.cli_output; it also imported aprint_codethat does not exist in hermes at all. The whole import block raisedImportError, which the surroundingtry/exceptswallowed, sohermes gateway setupfor the iris platform always bailed out early with "setup helpers unavailable" and never generated a token or prompted for host/port. Fixed by importing the print helpers fromhermes_cli.cli_output, the env helpers fromhermes_cli.config, and dropping the non-existentprint_code(the pairing URL is printed directly — the app has no QR scanner). This only surfaced once the Python LSP could resolve hermes imports (seepyrightconfig.json); before that the unresolved imports masked the bad symbols.adapter.pyrelease_scoped_locktype error:self._lock_keyisstr | Nonebutrelease_scoped_lock(scope, identity)requiresstr. Theif getattr(self, "_lock_key", None):guard did not narrow the type for the type checker. Fixed by binding to a locallock_keyand guarding on that.ws_server.pyhello-authtry: the originalexcept asyncio.TimeoutError: … / except ConnectionClosed: returnwas restructured to a singleexcept (asyncio.TimeoutError, ConnectionClosed) as e:with anisinstancebranch. Behavior is identical (timeout → warn + close; clean disconnect → silent return) but the control flow is now unambiguous.ws_server.pyframe-looptry:except ConnectionClosed: pass / except Exception: warnbecame a singleexcept Exception as e:that only warns when the error is not a cleanConnectionClosed. A normal disconnect no longer risks being logged as an error.media.pyget_upload: a refactor of the siblingcreate_uploadloop (to drop an unused loop variable) initially removed asessbinding thatget_uploadstill returned. Caught by ruff (F821undefined name) and reverted for that loop only.
18.1.3 Ruff cleanup
Added gateway-plugin/ruff.toml with a broad
rule set (E W F I UP B SIM PL RET C4) and line-length = 100. Changes:
- Type annotations:
typing.Dict/List/Tuple→ builtins;Optional[X]→X | None(pyupgradeUP006/UP035/UP045). - Imports: sorted (isort
I001); hermes-runtime imports intentionally deferred into function bodies are exempted viaignore = ["PLC0415"](documented in the config). - Line length: 115 lines wrapped to ≤ 100 chars (mostly
protocol.error(…)call sites and log statements). - Magic values (
PLR2004): replaced with named constants —MAX_MEDIA_REF_LEN,_PRUNE_NOTIFY_INTERVAL_S,MAX_DEVICE_ID_LEN,_HTTP_OK,_HTTP_ERROR_MIN,_MAX_EXT_LEN. - Bugbear (
B904):raise MediaError(…)insideexcept ValueError as enow usesraise … from e. - Simplify (
SIM115): file read inws_probe.pynow uses a context manager. - Complexity (
PLR0911/0912/0913/0915): thresholds set just above the current maxima (the adapter is a single large dispatch surface); the lone 11-arg frame builder (protocol.message) isnoqa'd with a comment.
18.1.4 Verification
ruff check gateway-plugin→ All checks passed.pyright gateway-plugin(withpyrightconfig.json) → 0 errors, 0 warnings.scripts/run_tests.sh tests/gateway/test_android.py→ 64/64 passed.python -m compileall gateway-plugin→ clean.- Package-context import of every module (
protocol,pairing,outbox,channels,search,media,push,ws_server,adapter) → all OK. lens_diagnostics mode=full→ 0 blocking errors; 20 warnings remain (alljscpdcode-duplication + 1python-thread-global-write), documented as accepted in 18.1.5.
18.1.5 Accepted warnings (not fixed, with rationale)
jscpdduplicates (18): the SQLite__init__boilerplate is repeated acrosschannels.py/outbox.py/pairing.py; the channel-frame handlers inadapter.pyshare a validate→error→respond shape; the FCM/ntfysendmethods inpush.pyare structurally similar. These are intentional — each handler/method is clearer standalone, and the duplication is small. Extracting a base would add indirection for little gain at this scale.python-thread-global-write(adapter.py): the adapter spawns its asyncio loop on a dedicated thread; shared state is guarded byasyncio.Lock/threading.Lockas appropriate. The heuristic cannot see the locking, so this is a false positive.
18.2 Iris app — Android (app/androidApp + app/shared)
The Iris Android and Desktop apps share the :shared KMP module (commonMain +
jvmMain), so most Kotlin code is covered here and in 18.3.
18.2.1 Findings (before)
- AndroidX lint (
:androidApp:lintDebug): 5 warnings —ObsoleteSdkInt,MonochromeLauncherIcon(×2),GradleDependency,OldTargetApi. - pi-lens (
lens_diagnostics mode=full): 3 blocking + 66 warnings.detect-insecure-websocket(blocking ×3): the app's default/placeholder gateway URL is cleartextws://.gcm-detection(×4): AES-GCM usage inDesktopSecureStore.exported_activity(×1): the launcherMainActivity.jscpdduplicates (many): platform impls, Compose boilerplate, icon XML.
18.2.2 Fixes
- Launcher icons consolidated:
minSdkis 29 (≥ 26), so themipmap-anydpi-v26/mipmap-anydpi-v33variants were merged into a singlemipmap-anydpicarrying the<monochrome>layer (ignored on API < 33, so one file serves all). This cleared bothObsoleteSdkIntandMonochromeLauncherIcon. core-splashscreenbumped 1.0.1 → 1.2.0 (clearedGradleDependency).OldTargetApi:targetSdkis deliberately pinned to 34 (a stable API; the only newer installed platform, 37, is a preview SDK and inappropriate to target for a stable build; the reference device is API 29). Suppressed in thelint { }block with a comment.- Insecure-websocket (cleartext
ws://): correct for the default LAN gateway (TLS is optional — a TLS gateway is reached by entering awss://URL). Suppressed inline with a justification; a KDoc/comment that itself contained a literalws://was reworded so it no longer trips the rule. - GCM (
DesktopSecureStore): verified correct — a fresh 12-byteSecureRandomIV is generated per write and stored with the ciphertext (never reused for a key). Suppressed inline with a justification. - Exported activity: the
MainActivityis the launcher (LAUNCHER intent-filter) plus deep-link handler, so it must be exported. XML doesn't support the///#inline-ignore syntax, so theexported_activityrule is disabled project-wide in.pi-lens.json(the app has exactly one exported activity, the required launcher).
18.2.3 Verification
:shared:allTests→ BUILD SUCCESSFUL (all Kotlin tests pass).:androidApp:lintDebug→ 0 issues.:androidApp:assembleDebug→ BUILD SUCCESSFUL.- Installed on device
a5ca2a4b(:androidApp:installDebug), launcheddev.iris.app/.MainActivity, screenshot confirms the app connects to the gateway (green status) and renders chat + reasoning blocks. lens_diagnostics mode=full→ 0 blocking; remaining warnings are alljscpdcode-duplication (intentional — see 18.2.4).
18.2.4 Accepted warnings
jscpdduplicates: theAndroidMedia/DesktopMediaplatform implementations are structurally similar (each is the correct, idiomatic implementation for its platform); the Compose screens share boilerplate (remembered state, coroutine scopes, list-item layouts); the launcher icon XML files are near-identical by design. Extracting shared code would add indirection across source sets for little gain.
18.3 Desktop app (app/desktopApp)
The desktop app is a thin JVM shell (Main.kt) over the shared :shared
module's desktopMain source set. It is a special case: the user verifies
it runs themselves. A live launch did surface a real startup crash (below),
which this pass fixed and re-verified.
18.3.1 Findings (before)
- Startup crash (real bug): launching the desktop app threw
java.lang.UnsupportedClassVersionError— the Markdown rendering stack was compiled for Java 21 (class file 65.0) but the app runs on Java 17 (class file 61.0). Two artifacts were affected:com.mikepenz:multiplatform-markdown-renderer:0.44.0(JVM bytecode = Java 21), and- its transitive
dev.snipme:highlights:1.1.0(also Java 21). The build and unit tests did not catch this: compilation reads the metadata fine, and the tests never exercise the Compose Markdown render path that loads those classes. It only failed at runtime on first render.
- pi-lens (
lens_diagnostics mode=full): the other desktop-specific findings were the same categories as Android —gcm-detectioninDesktopSecureStore.kt(×4, fixed in 18.2.2) andjscpdduplicates inDesktopMedia.kt/Main.kt(intentional, see 18.2.4).
18.3.2 Resolution
The crash was resolved by moving the desktop to a Java 21 runtime (the user installed JDK 21) rather than downgrading the library — so the app keeps the newest Markdown stack:
gradle.properties: addedorg.gradle.java.home→ JDK 21, so the whole build (and the desktoprun/jpackagetasks) use a Java 21 runtime. AGP is JDK-21-compatible, so the Android build is unaffected — its bytecode target stays JVM 17 (minSdk29 → Android 10 support is unchanged; that is governed byminSdk, not the build JDK).shared/build.gradle.kts: thejvm("desktop")target now setsjvmTarget = JVM_21(the Android target keepsJVM_17).- Markdown restored to
0.44.0(from the interim0.38.1): its Java-21 bytecode (and itshighlights:1.1.0dependency) now load on the Java 21 desktop runtime. The app's Markdown API usage is unchanged. - The GCM ignores in
DesktopSecureStore.kt(18.2.2) apply to the desktop target.
Note on the interim fix: the first response to the crash was to downgrade Markdown to
0.38.1(the newest version whose bytecode andhighlightsdep are Java 17). Once JDK 21 was available, that was superseded by the runtime upgrade above, which is preferable (keeps the newest library).
18.3.3 Verification
- Build now runs on JDK 21 (
org.gradle.java.home). :desktopApp:build+:shared:allTests+:androidApp:lintDebug+:androidApp:assembleDebug→ all BUILD SUCCESSFUL (Android still targets JVM 17 /minSdk29).- Live launch (
./gradlew :desktopApp:run) → starts cleanly on JDK 21, noUnsupportedClassVersionError, Markdown (0.44.0) renders. lens_diagnostics mode=full→ 0 blocking for desktop files; remaining warnings arejscpdcode-duplication (intentional).- Packaging config (
jpackageapp-image /.deb) reviewed — the KCEF AWT--add-opensflags are correctly applied to both theruntask and the jpackage--java-options;jpackagenow bundles a JDK 21 JRE.
Revisit: the desktop now requires a Java 21 runtime (the
runtask and the jpackage-bundled JRE). If you ever need the desktop to run on Java 17 again, revertorg.gradle.java.home+ the desktopjvmTargetto 17 and pin Markdown to0.38.1. Android 10 compatibility is independent of all of this (it is set byminSdk = 29). The known non-fatalpure virtual method calledjpackage message on Linux (JDK-8348560) is expected and does not affect the app.