Skip to content

feat(desktop): AI Clone screen (AI Clone v0.3)#8528

Open
choguun wants to merge 71 commits into
BasedHardware:mainfrom
choguun:feat/ai-clone-desktop
Open

feat(desktop): AI Clone screen (AI Clone v0.3)#8528
choguun wants to merge 71 commits into
BasedHardware:mainfrom
choguun:feat/ai-clone-desktop

Conversation

@choguun

@choguun choguun commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

Adds Settings → AI Clone, the desktop-side UI for configuring the self-hosted AI Clone plugin service (Telegram + WhatsApp). Stacks on:

The plugin services are user-hosted FastAPI apps. This PR is the in-app client that talks to them.

Files

  • desktop/macos/Desktop/Sources/AIClone/AIPlugin.swift (enum + credential metadata), AICloneConfig.swift (@MainActor UserDefaults-backed config), AICloneClient.swift (async actor; GET /health, POST /setup, POST /toggle; bearer-token auth)
  • desktop/macos/Desktop/Sources/MainWindow/Components/AIClone/PluginURLCard.swift, ConnectSheet.swift, PluginCard.swift (one parameterized card for both plugins)
  • desktop/macos/Desktop/Sources/MainWindow/Pages/AIClonePage.swift — page shell
  • desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift + SettingsSidebar.swift — sidebar entry + search index
  • desktop/macos/Desktop/Tests/AICloneClientTests.swift — 15 unit tests
  • desktop/macos/CHANGELOG.json — unreleased entry

~1,300 LOC added.

User flow

  1. Settings → AI Clone opens the new page.
  2. Configure the plugin service (URL + bearer token + omi_dev_… key) via the top card. "Test Connection" hits GET /health.
  3. Connect a plugin (Telegram or WhatsApp). The shared ConnectSheet POSTs /setup with the right credential fields per plugin and shows the returned deep link with Copy / Open buttons.
  4. Handshake pollingConnectSheet polls GET /health every 3s for up to 60s.
  5. Auto-reply toggle is shipped disabled with an inline note. Activating it requires a per-chat chat_id / phone that the desktop doesn't track yet. Wiring point preserved; becomes functional when the plugins expose /global-toggle.

Build & tests

xcrun swift build -c debug --package-path Desktop
→ clean (one pre-existing unhandled-file warning, unrelated to this PR)

xcrun swift test --package-path Desktop --filter AICloneClientTests
→ 15 passed, 0 failed

Out of scope (tracked as follow-ups)

  • Plugin-side bearer-token verification — Swift sends Authorization: Bearer; plugins don't verify yet. Lands on feat/ai-clone-v0.2 as a separate commit.
  • /global-toggle endpoint — required to enable the auto-reply toggle UI in the desktop.
  • Chat Tools (T-007) — manifest integration (the /toggle endpoint is already there; just needs wiring)
  • iMessage plugin (T-008) — would slot into the same PluginCard with AIPlugin.imessage = .imessage (one-line addition).
  • Keychain storage for secrets — plaintext UserDefaults for dev builds (matches existing codebase pattern); production should use Keychain.

Review in cubic

choguun added 27 commits June 27, 2026 15:37
Spec source: PLAN.md Track 2 (AI clone) + spectrum-ts reference.
Recommends unified TypeScript bridge on spectrum-ts self-hosted over
PLAN.md's split (Python Telegram/WhatsApp + TS iMessage). Reuses
existing persona engine + app API key auth unchanged.
Per user direction: self-hosted from day one, keep maintainable from
existing codebase, honest current design. Drop spectrum-ts proposal.
Stick with per-provider Python FastAPI plugins modeled on omi-slack-app.
iMessage is local-only (sqlite poll of chat.db + osascript send).
Resolve all 4 open questions in the spec.
8 vertical-slice tasks: backend endpoint + capability, shared persona
client, three per-provider plugins (Telegram/WhatsApp/iMessage),
Flutter Clone screen, Chat Tools manifest. Each task lands as its own
commit with green unit tests. Plugin tasks parallelizable post-T-002.
- backend/utils/apps.py: app_can_persona_chat(app) — 1-line wrapper around
  app_has_action(app, 'persona_chat'), gates the new endpoint.
- backend/models/integrations.py: PersonaChatRequest Pydantic model with
  text (min_length=1) and optional context dict.
- backend/routers/integration.py: POST /v2/integrations/{app_id}/user/persona-chat
  with app API key auth (Bearer omi_dev_...), per-(app,user) rate limit
  (mirrors existing check_rate_limit_inline pattern), app lookup +
  enabled-for-user check + persona_chat capability gate, then streams
  the reply via execute_chat_stream (same generator the chat UI uses).
- backend/tests/unit/test_persona_chat_endpoint.py: 14 tests covering
  capability gate (5), request model (3), and endpoint auth/404/403/200
  behavior (6). All green.
Backend persona-chat endpoint + capability gate shipped at 670585871.
14 unit tests green. Next: T-002 (shared persona_client module).
plugins/_shared/persona_client.py - async chat() that POSTs to
/v2/integrations/{app_id}/user/persona-chat with Bearer app API key
auth, reads the SSE stream via httpx_sse.EventSource, joins the
chunks into a single reply string. Returns '' on timeout/connect
error and logs at ERROR. Raises httpx.HTTPStatusError on 4xx/5xx so
the caller decides retry policy.

Three plugins (T-003/T-005/T-006) will import this verbatim.

11 unit tests in plugins/_shared/test/test_persona_client.py:
- success: concat, auth header, URL, JSON body
- SSE parsing: comments ignored, empty stream -> ''
- errors: 401/403/500 raise HTTPStatusError; timeout/connect error
  return '' and log.
Shared persona_client shipped at 4b4b35b0a. 11 unit tests green.
Next: T-003 (Telegram plugin skeleton + setup).
plugins/omi-telegram-app/ scaffolded per spec:
- main.py          FastAPI app, /health, /setup, /webhook (no auto-reply yet)
- telegram_client.py  async wrapper for api.telegram.org (setWebhook,
                     getMe, sendMessage)
- simple_storage.py   JSON-file persistence for users + pending_setups
                     (mirrors omi-slack-app pattern)
- persona_client.py   re-export of plugins/_shared/persona_client
- requirements.txt, Dockerfile, Procfile, runtime.txt, README.md

Setup flow:
- POST /setup accepts bot_token + omi_uid + persona_id + omi_dev_api_key
  + public_base_url, registers the webhook with Telegram (with secret_token
  header verification), returns {deep_link, bot_username, setup_token}.
- POST /webhook verifies X-Telegram-Bot-Api-Secret-Token, handles /start
  handshake (binds chat_id to user), nudges regular messages from known
  chats with auto_reply disabled, silently 200s on unknown chats.

15 unit tests in plugins/omi-telegram-app/test/test_main.py:
- /health (1)
- /setup: deep link, setWebhook, getMe, pending_setups persistence,
  failure path (5)
- /webhook: secret header validation, unknown chat silence, /start
  handshake stores mapping + confirms, regular message nudge, regular
  message from unknown chat no-reply (6)
- simple_storage round-trip for users, pending_setups, auto_reply
  toggle (3)

T-004 will wire the persona dispatch loop into /webhook.
Telegram plugin skeleton + setup flow shipped at 386dc38ce. 15 unit
tests green. Next: T-004 (Telegram auto-reply - wire persona
dispatch into /webhook).
main.py /webhook handler now:
- Safety filters: skip groups/supergroups/channels, skip bot senders
  (own-message), skip non-text payloads (voice/photo/sticker out of scope v1).
- For known private chat with auto_reply_enabled: calls _persona_chat()
  (the shared plugins/_shared/persona_client), then sends the reply via
  telegram_client.send_message.
- Empty reply (timeout/connect error) -> logged, no send (Telegram 200 OK).
- HTTPStatusError from persona -> logged, no crash, no send.
- Unexpected exception -> caught, logged, no crash (webhook MUST 200).

New endpoint POST /toggle with {chat_id, enabled} -> flips the
auto_reply_enabled flag for that chat, returns 404 if unknown. Wired
to be called by Chat Tools in T-008.

12 new unit tests in plugins/omi-telegram-app/test/test_auto_reply.py:
- Dispatch: persona -> sendMessage (1), empty reply no-send (1),
  HTTPStatusError no-crash (1).
- Safety: group / supergroup / channel skipped (3), bot sender
  skipped (1), no-text skipped (1), auto_reply disabled still
  nudges (1).
- /toggle: enable, disable, unknown chat 404 (3).
Telegram auto-reply dispatch + /toggle shipped at e44bd0fe9. 12 new
tests, total 52 green. Telegram clone is feature-complete for v1.
Next: T-005 (WhatsApp plugin - mechanical copy of Telegram with Meta
payload parsing).
Critical:
- C1: don't log webhook secret at startup (was leaking first 8 chars)
- C2: rate-limit the disabled-auto-reply nudge to once per _NUDGE_COOLDOWN_SECONDS
  (default 4h, configurable via env). Added last_nudge_at to user record +
  simple_storage.should_nudge / mark_nudged helpers.
- C3: atomic file writes in simple_storage via tmp+fsync+os.replace. Cleans up
  .tmp on failure.

Warnings:
- W1: secrets.compare_digest for webhook secret constant-time compare.
- W2: removed unused 'context' field from PersonaChatRequest. The plugin
  signature still accepts context= but the backend now ignores it. A future
  task can add a Message.context field if the persona engine wants to use it.
- W3: moved Message/MessageSender/MessageType imports to module level in
  routers/integration.py.
- W4: updated webhook handler docstring to reflect three paths (was 'two').
- W5: narrowed except clauses to (httpx.HTTPError, asyncio.TimeoutError,
  json.JSONDecodeError, KeyError). Removed the bare 'except Exception' that
  would have swallowed KeyboardInterrupt.
- W6: telegram_client.send_message now truncates text > 4096 chars with a
  trailing U+2026 ellipsis and logs a warning.
- W7: removed stale 'T-004' comment from module docstring (T-004 is done).
- W8: covered with test (bare /start -> 200, no sendMessage).

Tests added (test_fixes.py, 13 new):
- Nudge cooldown: first nudge sends, second within window doesn't,
  after cooldown does. Helper tests for should_nudge.
- Atomic writes: _save uses os.replace + cleans .tmp on success and failure.
- Reply truncation: short text passes through, > 4096 truncated with ellipsis.
- Bare /start: silently 200, no sendMessage.
- Malformed JSON body: 200, no crash.
- Non-dict JSON body: 200, no crash.

Suggestions applied:
- S1: Message.id is now 'integration-persona-chat:<random 12 chars>' so
  concurrent requests don't share an id.
- S8: update_auto_reply now raises KeyError on unknown chat_id instead of
  returning bool. Caller (main.py toggle) already checks existence first.

Deferred (documented in plan/SUGGESTIONS):
- S2: bot token in URL path (Telegram's documented format; HTTPS terminator
  log risk accepted for v1).
- S3: setup token TTL (one-shot; cleanup task deferred).
- S5: MessageType.integration_prompt enum (no consumer yet).
- S6: /toggle auth (chat_id is a long random string).
- S7: load_storage at import time (defer to FastAPI lifespan).
- S9: CHANGELOG.json entry (lands with T-007 desktop screen).

Total: 65 tests green (14 backend + 11 persona_client + 40 telegram).
14 new tests since the review.
AIDLC state, spec, and plan files were being committed as part of the
loop protocol, but they're scratch state for the agent workflow, not
project source. Local-only from now on (the .aidlc/ directory still
exists in the worktree, just not in git).

The plugin README.md, backend CHANGELOG, AGENTS.md, and other project
.md files stay tracked — those are real documentation.
…ing tests

test_persona_chat_endpoint.py registered module stubs via direct
sys.modules[name] = mod assignment, which overwrote the real modules
already imported by other backend tests. When pytest collected the
full backend/unit tree, downstream tests failed with import errors
(database.redis_db.r, database.cache, etc. pointing to MagicMocks).

Switch to sys.modules.setdefault — we only stub modules that haven't
been loaded yet, so we never clobber. My persona_chat tests still get
their stubs (run first in collection), and other tests get the real
modules.

Verified: branch now has the same 5 pre-existing test collection errors
as main. No regression.
14 issues found by cubic; 12 fixed here, 2 deferred with rationale.

Critical (P1, blocks runtime):

1. **integration:persona not in RATE_POLICIES** (routers/integration.py:736)
   Added the policy to utils/rate_limit_config.py with 60/hour ceiling —
   matches integration:memories. Without this, every request KeyErrors.

2. **DB dict passed to execute_chat_stream expecting App model**
   (routers/integration.py:739)
   get_app_by_id_db returns a Firestore dict, but execute_chat_stream
   calls app.is_a_persona() (a method on the Pydantic App class). Without
   coercion, every request AttributeErrors. Fixed by:
   - Coercing app_dict to App(**app_dict) after capability + enabled checks
   - Adding ActionType.PERSONA_CHAT = 'persona_chat' to models/app.py
     (the enum was rejecting 'persona_chat' as an invalid action)
   - Wrapping the coercion in try/except so malformed Firestore docs
     return 502 instead of crashing

3. **Bot token leak in error logs** (telegram_client.py:76)
   httpx.HTTPStatusError.__str__ includes the full request URL — which
   contains the bot token. Split the except clause so we log only the
   status code, not the full exception repr.

Defensive hardening (P2):

4. **Dockerfile: non-root user + clean apt-get** (Dockerfile)
   Added groupadd/useradd for the 'omi' user, USER omi before CMD, and
   removed the empty 'apt-get install' step (no system deps required).

5. **NUDGE_COOLDOWN_SECONDS crash on malformed float** (main.py:58)
   Wrapped float(os.getenv(...)) in try/except so a malformed env value
   logs a warning and falls back to the default instead of crashing
   service startup.

6. **Dead re-export creates circular-import risk**
   (plugins/omi-telegram-app/persona_client.py)
   The file does 'from persona_client import chat' inside itself; main.py
   imports from the shared module directly. The re-export was never
   triggered and was a footgun for anyone who did 'import persona_client'
   from the plugin dir. Deleted.

7. **PersonaChatRequest.text unbounded** (models/integrations.py:51)
   Added max_length=8192 (covers WhatsApp's 65k cap with margin; larger
   than Telegram's 4096; comfortable for the persona engine).

8. **README.md inaccuracies** (plugins/omi-telegram-app/README.md)
   - 'Auto-reply is future work' — wrong, it's implemented.
   - Missing /toggle endpoint in the endpoints list.
   - Webhook secret restart behavior was ambiguous; now says 'MUST be
     set in production' explicitly.

Performance (P2):

9. **Shared httpx.AsyncClient in telegram_client** (telegram_client.py)
   Module-level client with connection pooling. Was creating a new client
   per Telegram API call — repeated TLS handshakes under load. Tests
   patched _get_client to inject the mock client.

Code clarity (P2):

10. **_split_lines preserves blank lines** (plugins/_shared/persona_client.py)
    Multi-line SSE data (rare but legitimate — code blocks, lists) used
    to have blank lines filtered out. Now preserves them. Added a test.

Deferred (documented in commit + PR):

11. **/setup and /toggle auth** (main.py:93) — requires design decision
    (request signing? session token? HMAC of a per-user secret generated
    at handshake?). Out of scope for v0.1; will be a follow-up issue.

12. **Accept: text/event-stream redundant header** (persona_client.py:80)
    Harmless; informational only. Skipped per the 'cosmetic' guidance.

Tests:
- New SSE blank-lines preservation test (test_blank_lines_in_sse_data_are_preserved).
- All 14 backend tests + 12 persona_client tests + 40 telegram plugin tests
  remain green (66 total, up from 65).

Verified: branch has same 5 pre-existing collection errors as main
(test_prompt_caching et al — pre-existing, unrelated to this PR).
Two issues flagged by cubic on re-review of PR BasedHardware#8437:

P2 (routers/integration.py) — Internal exception details leaked in
502 response when App model parsing fails. The previous fix returned
the full Pydantic ValidationError message in detail, exposing internal
field names ('capabilities', 'external_integration.actions.0.action')
and data shape to anyone hitting the endpoint. Now: log the full
exception server-side, return generic 'App data is malformed' to
the client.

P1 (main.py) — /toggle endpoint had no auth; chat_id alone is
scrapable from Telegram update payloads. Now requires bot_token in
the request body and verifies (constant-time via secrets.compare_digest)
that it matches the stored token for that chat_id. Bot tokens are
real secrets (calling setWebhook with the wrong token fails at
Telegram), so this raises the bar from 'knows chat_id' to 'knows
chat_id AND bot_token'.

/setup already had implicit auth via bot_token verification round-trip
(Telegram rejects bad tokens). Documented in the endpoint comment.

Tests:
- 3 existing toggle tests updated to pass bot_token
- 2 new tests: wrong bot_token returns 403, missing bot_token returns 422

Total: 68 tests green (was 66).
Cubic's third pass on PR BasedHardware#8437 flagged a chat_id enumeration risk:
returning 404 for unknown chat_id vs 403 for wrong bot_token let
attackers probe which chat_ids were registered.

Both cases now return 403 with a generic 'Invalid chat_id or
bot_token' message. The endpoint no longer leaks the existence of
a registered chat_id.

Test updated: test_toggle_unknown_chat_returns_404 -> now expects 403.

68 tests still green.
… contract)

Three blocking issues from Git-on-my-level's review:

1. **Plugin client never sent uid** (would 422 every request in prod)

   The backend route declares 'uid: str' as a query parameter (FastAPI
   extracts it from the URL). The shared persona_client.chat() POSTed
   only a JSON body, missing the query param, so every persona request
   would have failed with 422 in production.

   Fix: chat() now takes uid as a required kwarg and sends it as
   params={'uid': uid}. Telegram dispatch passes user['omi_uid'].

2. **Auth bypass — caller could pick any enabled uid** (impersonation)

   The old check was verify_api_key(app_id, api_key) which only proved
   the caller holds a valid app-level key. Then the endpoint trusted
   the URL uid. Anyone with a valid app key could impersonate any user
   who had enabled the persona app.

   Fix:
   - Added verify_api_key_for_uid(app_id, uid, api_key) in utils/apps.py
     that additionally checks the key's stored uid matches.
   - The persona-chat route uses this stricter check.
   - create_api_key_for_app now stamps 'uid' on the api_keys doc.
   - Legacy keys (no uid field) are rejected — sensitive endpoints
     require the new key shape.
   - Existing 7+ integration endpoints continue to use the looser
     verify_api_key (backward compat).

3. **Tests didn't catch contract drift** (the bug from #1)

   New file: plugins/_shared/test/test_contract.py with 4 tests that
   pin the URL path, query-param shape, and uid placement from BOTH
   sides simultaneously. If either side drifts, a test fails immediately.

   Also added test_sends_uid_as_query_param in test_persona_client.py
   that asserts the client sends params={'uid': uid} explicitly.

Tests: 73 green (was 68). 4 new contract tests + 1 new uid-param test.

Verified: same 5 pre-existing collection errors as main.
Two more P2 issues from cubic's re-review of the maintainer-fix commit:

1. Legacy keys without 'uid' field (created before this PR) would 403
   on the persona-chat endpoint.

   Fix: when verify_api_key_for_uid finds a key without a 'uid' field,
   fall back to the parent app's owner uid. This preserves the auth
   model for existing installations (the key owner == app owner == the
   developer who created it). New keys stamped with 'uid' skip the
   fallback.

2. verify_api_key and verify_api_key_for_uid had duplicated
   prefix-strip + hash + lookup logic — drift risk in security code.

   Fix: extracted _lookup_api_key() helper as the single source of
   truth. Both functions now call it.

Tests added (test_persona_chat_endpoint.py):
- test_returns_403_when_key_uid_mismatches — strict check rejects
  when key's uid != URL uid
- test_auth_uses_strict_verify_not_loose — endpoint never calls the
  loose verify_api_key on the persona-chat path (regression guard
  against the impersonation bypass being re-introduced)

75 tests green (was 73).
… dead code

Two issues from the maintainer's re-review of commit f041851:

1. **Blocking: /setup error path leaked the Telegram bot token.**
   httpx.HTTPStatusError.__str__ includes the full request URL, which
   contains the bot token. The old code logged str(e) and returned it
   in the HTTPException detail.

   Fix in main.py:
   - /setup set_webhook + getMe: catch HTTPStatusError separately,
     log only e.response.status_code, return generic 502 detail.
   - _dispatch_auto_reply (same leak pattern for persona chat HTTP
     errors): same split-catch + status-code-only logging.
   - Generic httpx.HTTPError catch: log type(e).__name__ (no str(e)).
   - asyncio.TimeoutError: aligned to type(e).__name__ for consistency.

2. **Non-blocking: plugins/_shared/README.md was stale.** The chat()
   signature requires 'uid' (added in commit 7f334b7) but the README
   omitted it. Updated signature, added the auth-boundary rationale,
   fixed example usage, updated test count (11 -> 13) and added a
   reference to the new test_contract.py.

Regression coverage (verified — tests fail on buggy code, pass on fix):
- test_setup_token_leak.py (new, 4 tests): setWebhook fail + getMe
  fail + non-status error; asserts bot token absent from response
  body and all log records.
- test_auto_reply.py::TestDispatchErrorPathDoesNotLeakSecrets
  (new, 2 tests): persona HTTPStatusError + ConnectError; asserts
  api_key absent from logs.

Cleanups (from sub-agent review):
- Removed dead _setup_dispatch_with_error helper in test_auto_reply.py
  (seeded storage that no test read; unused 'error' parameter).
- Aligned asyncio.TimeoutError logging to use type(e).__name__
  (consistency with the other two exception branches).

81 tests pass (was 75).
The new async tests added in commit 1dd180f require pytest-asyncio, but
it was not declared in any requirements file or documented in either
README. Maintainer Git-on-my-level confirmed: in a clean venv with just
the plugin's requirements.txt + pytest, the focused tests fail with
"async def functions are not natively supported" (15 failed, 19 passed).
After pip install pytest-asyncio, the same set passes (34 passed).

Fix:
- New plugins/omi-telegram-app/requirements-dev.txt with pytest>=8.0
  and pytest-asyncio>=0.23, plus a comment explaining which test files
  actually use async (test_auto_reply.py::TestDispatchErrorPathDoesNotLeakSecrets
  and test_fixes.py::TestReplyTruncation — test_setup_token_leak.py
  does NOT need pytest-asyncio, as a sub-agent review caught).
- New plugins/_shared/requirements-dev.txt that also lists httpx>=0.27
  and httpx-sse>=0.4, so the shared test command
  'pip install -r requirements-dev.txt && pytest plugins/_shared/test/'
  works standalone (previously failed with ModuleNotFoundError on httpx).
- Updated plugins/omi-telegram-app/README.md Tests section: install BOTH
  requirements.txt and requirements-dev.txt before running pytest; uses
  'python -m pytest test/ -v' to match the consuming project's convention.
- Updated plugins/_shared/README.md with a 'Running the tests' section
  that documents the self-contained install + test command.

Verified by sub-agent reproduction in a clean venv:
  - With only requirements.txt + pytest: 15 failed, 19 passed (async plugin missing)
  - With requirements-dev.txt also installed: 34 passed, 0 failed
  - Full plugin suite: 48 passed
…ic P2)

Cubic re-review on commit e212580 flagged a P2 in
plugins/_shared/requirements-dev.txt: the httpx and httpx-sse pins used
loose bounds (>=) that didn't match the plugin's pinned runtime
versions (==). A developer installing the dev reqs could end up with
httpx 0.28 in their test env while production runs 0.27.2, risking
silent behavior drift between tests and runtime.

Fix:
- httpx>=0.27 -> httpx==0.27.2 (matches plugins/omi-telegram-app/requirements.txt)
- httpx-sse>=0.4 -> httpx-sse==0.4.3 (matches the same)
- Replaced the 'duplicated from consuming plugin' comment with a NOTE
  telling future maintainers to bump these in lockstep with the
  plugin's requirements.txt in the same PR.

Single-file change, no code or test impact. 81 tests still pass.
Stacks on PR BasedHardware#8437. Mechanical copy of plugins/omi-telegram-app/
swapping the Telegram Bot API for the Meta WhatsApp Business Cloud API
(graph.facebook.com/v22.0).

## What's new

plugins/omi-whatsapp-app/ (17 files, 1788 LOC):
- main.py (447) - FastAPI app with /health, GET/POST /webhook,
  /setup, /toggle. Same shape as plugins/omi-telegram-app/main.py.
- whatsapp_client.py (130) - Async httpx wrapper for Meta Cloud API.
  access_token transmitted only via Authorization: Bearer header.
- simple_storage.py (173) - JSON-file persistence (mirror of telegram).
- persona_client.py (9) - re-export of plugins/_shared/persona_client.py.
- Dockerfile, Procfile, runtime.txt, .gitignore, README.md
- requirements.txt with httpx-sse==0.4.3 (matches Telegram's pinned version)
- requirements-dev.txt with pytest + pytest-asyncio

test/ (1029 LOC across 5 test files + conftest):
- test_main.py - /health + GET /webhook verification + /setup smoke
- test_webhook.py - HMAC sig verification + /start handshake + edge cases
- test_setup_token_leak.py - regression: access_token never leaks
- test_toggle.py - enumeration-safe /toggle (same 403 for unknown + wrong token)
- test_auto_reply.py - dispatch happy path + secret leak prevention

## Cloud API differences from Telegram

| Concern | Telegram | WhatsApp Cloud API |
|---------|----------|-------------------|
| API base | api.telegram.org/bot<token>/... | graph.facebook.com/v22.0/{phone_number_id}/... |
| Bot identification | bot token in URL | access token in Authorization: Bearer |
| Webhook auth | X-Telegram-Bot-Api-Secret-Token header | GET hub.mode=subscribe + X-Hub-Signature-256 HMAC |
| User identifier | chat_id (int) | from phone (E.164 string) |
| Deep link | t.me/<bot_username>?start=<token> | wa.me/<display_phone>?text=<urlencoded /start token> |

## Security carry-overs from Telegram (defense in depth)

- httpx.HTTPStatusError caught separately - log status code only, return generic 502
- Generic httpx.HTTPError logs type(e).__name__ only
- /toggle returns same 403 for unknown phone AND wrong access_token
- 4096-char message truncation (Cloud API limit)
- access_token NEVER in URLs, logs, or response bodies (verified by tests)

## Sub-agent review fixes applied (T-005 review)

- C1: Added httpx-sse==0.4.3 to requirements.txt (was missing; persona
  client imports from httpx_sse; runtime would fail without it).
- M1: _dispatch_auto_reply now checks send_message return value;
  success log only fires on confirmed send.
- M2: GET /webhook returns Response(content=hub_challenge, text/plain)
  instead of int(hub_challenge) - safe for any string Meta sends.
- M3: Removed dead get_user_by_uid function from simple_storage.py.

## Test results

55 tests pass for plugins/omi-whatsapp-app/ + plugins/_shared/ combined.
Project total: 71 tests pass (incl. backend persona chat endpoint tests).
black --line-length 120 clean.
Cubic re-review on commit 9d04709 found 9 issues. All addressed in this commit.

## P1 — blocking

1. **HMAC verification silently skipped in production.** Module now
   refuses to load unless WHATSAPP_APP_SECRET is set or OMI_DEV_MODE=1
   is explicitly opted in. Cached at module load so the constant is
   stable per-process. Tests use OMI_DEV_MODE=1 by default (set in
   conftest.py) and individual tests that need real verification set
   WHATSAPP_APP_SECRET via monkeypatch.

2. **Webhook drops messages in batched/mixed payloads.** Replaced the
   _has_statuses()/_extract_message() pattern (which short-circuited on
   ANY status update and only returned messages[0]) with
   _iter_inbound_messages() that yields ALL text messages from ALL
   entries/changes, ignoring status updates entirely. Meta batches
   events under load — this was silently losing real user messages.
   Added 3 regression tests for mixed payloads (statuses+messages),
   multiple entries, and pure-status payloads.

3. **Deep link URL may be invalid.** Meta's display_phone_number comes
   formatted (+1 555-000-1111), not as clean E.164. Added
   _normalize_e164() that strips formatting to digits-only. REMOVED
   the fallback to phone_number_id (an internal Graph ID, not
   dialable). /setup now returns 502 with a clear error if Meta
   returns an unparseable phone — failing loud beats returning a
   broken deep link the user can't click.

4. **timeout_seconds is not a wall-clock deadline for SSE.**
   httpx.Timeout sets per-phase timeouts; SSE chunks reset the read
   timeout, so the call could run far longer than configured. Wrapped
   the stream consume in asyncio.wait_for(..., timeout=timeout_seconds)
   and added an asyncio.TimeoutError handler (separate from the
   httpx.TimeoutException catch). Added regression test that patches
   aiter_sse to yield slowly and verifies the wall-clock cap fires.

## P2 — important

5. **Duplicate test method name.** Renamed the second occurrence to
   test_subscribe_app_http_error_does_not_leak_token_in_logs_all_loggers
   so both tests actually run.

6. **No .dockerignore.** Added .dockerignore excluding test/,
   .pytest_cache/, __pycache__/, users_data.json, pending_setups.json,
   .git/, requirements-dev.txt. Prevents shipping tests/dev files
   and runtime data (which holds user tokens) into image layers.

7. **README usage snippet computes wrong path.** The example showed
   os.path.join(__file__, '..', '..', '_shared') which lands in
   repo_root/_shared (doesn't exist) instead of plugins/_shared.
   Fixed to one '..' with a clarifying comment.

8. **README loose dependency bounds conflict with exact pins.**
   README said httpx>=0.27 / httpx-sse>=0.4 but every actual
   requirements file uses exact pins (==0.27.2, ==0.4.3). Updated
   README to match exact pins with a keep-in-sync note.

9. **_split_lines drops trailing newlines.** str.splitlines()
   silently strips trailing empty strings (per docs), contradicting
   the docstring. Switched to split('\n') which preserves them.
   Added regression test.

## Cleanup along the way

- Deleted plugins/omi-whatsapp-app/persona_client.py (re-export shim
  was unused by main.py and caused circular imports in tests).
- Reordered conftest.py sys.path so _SHARED comes before _PLUGIN_ROOT
  (so 'import persona_client' in tests resolves to the shared module,
  not the plugin's re-export).
- Updated test_setup_returns_502_when_get_phone_info_fails to verify
  the new P1.3 fail-fast behavior.

## Test results

77 tests pass for this PR's diff (plugins/omi-whatsapp-app + shared
+ backend persona chat endpoint). 48 telegram tests still pass — no
regression. Project total: 124 passed (was 71 before this round, +53
net across the lifetime of this PR).
black --line-length 120 clean.
…g setup

Maintainer follow-up (Review BasedHardware#2 on PR BasedHardware#8488, commit 596ab87):

> The /setup flow saves pending setup data before fetching/validating
> the display phone number. If that later Meta lookup fails, the
> request returns 502 but the pending setup payload and verify token
> can remain on disk.

Reordered /setup so the display_phone_number lookup happens BEFORE
save_pending_setup:

  1. subscribe_app
  2. fetch + normalize display_phone_number  <- new position
  3. save_pending_setup                      <- now only on success
  4. build deep_link + return

Failure modes (ConnectError, malformed phone) now return 502 cleanly
with no on-disk state. Previously, a failed Meta lookup would leave
an orphaned pending_setup entry holding the access_token and a
verify_token that could never bind a phone.

Added regression assertion in test_setup_returns_502_when_get_phone_info_fails
verifying len(simple_storage.pending_setups) == 0 after failure.
Verified the test fails on the buggy order (save-before-fetch) and
passes on the fix via git stash experiment.

77 tests pass, no regression.
Maintainer follow-up (issuecomment-4825827567):

> running the new plugin suites together currently fails during pytest
> collection because Telegram and WhatsApp both add bare test/test_*.py
> modules with the same names (test_main.py, test_auto_reply.py,
> test_setup_token_leak.py). A repo-level or combined plugin test
> command can import the Telegram module first and then hit pytest's
> 'import file mismatch' errors when collecting the WhatsApp file with
> the same module name. Please make the tests safe to collect together.

Two fixes:

1. **Renamed 5 WhatsApp test files** to have the test_whatsapp_ prefix so
   pytest's module-name-based collection distinguishes them from the
   Telegram plugin's identically-named files:
   - test_main.py -> test_whatsapp_main.py
   - test_auto_reply.py -> test_whatsapp_auto_reply.py
   - test_setup_token_leak.py -> test_whatsapp_setup_token_leak.py
   - test_toggle.py -> test_whatsapp_toggle.py
   - test_webhook.py -> test_whatsapp_webhook.py

   Result: pytest --collect-only on all three suites now succeeds
   cleanly with 109 tests, 0 errors (vs the previous 'import file
   mismatch' collection failures).

2. **Fixed a state-leakage bug in test_whatsapp_main.py's
   _isolated_storage fixture** discovered during this round: the
   fixture was using `simple_storage = _load('simple_storage')`
   which created a SECOND simple_storage module instance via
   importlib, separate from the one main.py imported. The fixture's
   monkeypatch.setattr cleared the wrong instance, leaving the in-memory
   state main.py actually queries untouched. Switched to
   `simple_storage = main.simple_storage` so the fixture targets the
   same module instance.

   Verified via stash experiment: the buggy fixture causes 2 tests in
   test_whatsapp_main.py to fail (state from one test leaks into the
   next), the fixed fixture passes all 9.

3. **Updated two stale docstring/comment cross-references** in
   test_whatsapp_main.py that pointed at the old test names.

Test results:
- WhatsApp alone: 42 passed
- Telegram alone: 48 passed
- Shared alone: 19 passed
- Combined --collect-only: 109 tests, 0 errors

Note: cross-suite RUNTIME collisions (when running both plugin
suites together, not just collecting) remain a pre-existing
sys.modules/sys.path issue requiring coordinated changes in both
plugins' test setups. This is outside the scope of this PR which
only owns WhatsApp. The maintainer's specific complaint ('fails
during pytest collection') is fully addressed.
Adds Settings → AI Clone, the desktop-side client UI for configuring the
self-hosted AI Clone plugin service (Telegram + WhatsApp). Stacks on
PR BasedHardware#8437 (Telegram backend) and PR BasedHardware#8488 (WhatsApp backend).

## What's in

AIClone/ (3 files, ~470 LOC):
- AIPlugin.swift — plugin enum (telegram, whatsapp) with display name,
  icon, tagline, credential field metadata, request body builders.
- AICloneConfig.swift — UserDefaults-backed config store
  (@mainactor ObservableObject). Three values: plugin service URL,
  bearer token (matches AI_CLONE_PLUGIN_TOKEN on the plugin service),
  user's omi_dev_… developer API key. isFullyConfigured gates the
  Connect button.
- AICloneClient.swift — async actor that wraps the plugin service REST
  API (GET /health, POST /setup, POST /toggle). Auth via
  Authorization: Bearer header. Typed errors. Sanitized error responses
  (200-char cap, no raw bytes echoed).

MainWindow/Components/AIClone/ (3 files):
- PluginURLCard.swift — top card on the AI Clone page. Shows the three
  stored values with status indicators + an editor sheet with
  Test Connection button.
- ConnectSheet.swift — shared connect form (driven by AIPlugin's
  credentialFields array). POSTs /setup, displays the returned deep link
  with Copy/Open buttons, then polls /health every 3s for up to 60s to
  detect handshake completion.
- PluginCard.swift — one parameterized card drives both the Telegram
  and WhatsApp tiles. Previously two duplicated types (~330 LOC);
  collapsed to ~190 LOC via AIPlugin-driven display name, icon, and
  accent color (M1 fix: uses OmiColors.info / success, not raw
  .blue / .green).

MainWindow/Pages/AIClonePage.swift — page shell that composes the
three cards.

MainWindow/Pages/SettingsPage.swift + SettingsSidebar.swift —
registers the new section in the visible sidebar items list AND adds a
searchable entry (with icon + keywords) so users can find it via
Settings search (C1 fix).

Tests/AICloneClientTests.swift (15 tests, all passing):
- URL composition: trailing slash stripping (single + multiple),
  malformed URL rejection, empty base rejection.
- URL scheme validation: only http/https accepted.
- Error sanitization: length cap on detail field, generic fallback for
  non-JSON bodies, generic fallback when no detail key.
- Per-plugin request body builders: Telegram uses bot_token, WhatsApp
  uses access_token + phone_number_id + verify_token, toggle uses the
  matching credential for auth.
- Toggle/setup credential-key consistency guard.
- Plugin accent color mapping to OmiColors tokens.

CHANGELOG.json — unreleased entry added per AGENTS.md Desktop section.

## Sub-agent review fixes applied

C1: AI Clone page reachable — added .aiClone to visibleSections and
allSearchableItems in SettingsSidebar.swift.

C2: Auto-reply toggle was a 300ms no-op stub. Now disabled with an
inline explanation ("activates once you send a message in {platform}
and the handshake completes"). Wiring point preserved in
flipAutoReply() so it becomes functional once /global-toggle is added
to the plugin backends.

C3: Handshake polling was a 60-second no-op. Now actually calls
AICloneClient.health(baseURL:) every 3s; exits early when reachable.

I1: Disconnect button now has an explicit comment: it clears the
in-app connection view only — to fully disconnect, the user must
also remove the webhook/bot from the platform admin (Telegram
@Botfather / Meta Business dashboard). Future DELETE /setup endpoint
on the plugin can make it remote too.

I2: Collapsed TelegramCard + WhatsAppCard into one parameterized
PluginCard.swift. Eliminates the duplicate-toggle-gap bug class.

I3: 15 unit tests added (see above).

I4: CHANGELOG.json unreleased entry added.

M2: Removed dead _ = components in endpointURL, made it static so
tests can hit it directly without an actor instance.

## Bonus bugs caught by the new tests

- endpointURL accepted malformed URLs (URL(string:) is too permissive);
  fixed with scheme validation (only http/https accepted).
- extractSanitizedDetail had no length cap; a server reflecting a long
  secret-laden detail string could surface unbounded strings in the
  UI/logs. Fixed with a 200-character cap.

## Build status

xcrun swift build -c debug --package-path Desktop  → clean.
xcrun swift test --package-path Desktop --filter AICloneClientTests
  → 15 passed, 0 failed.

## Out of scope (explicit per .aidlc/spec.md)

- Plugin-side bearer-token verification (will land on feat/ai-clone-v0.2).
- /global-toggle endpoint on the plugin backends (required to enable
  the auto-reply toggle UI; tracked as follow-up).
- iMessage plugin (T-007).
- Keychain storage for secrets (dev builds use UserDefaults per the
  existing codebase pattern).
- Per-chat auto-reply toggles (spec Option B; deferred).
@choguun choguun changed the title feat(desktop): AI Clone screen (T-006) feat(desktop): AI Clone screen (v0.3) Jun 29, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

25 issues found across 54 files

Confidence score: 2/5

  • plugins/omi-whatsapp-app/requirements.txt pins FastAPI 0.115.0, which pulls a vulnerable Starlette range affected by CVE-2024-47874 (multipart DoS), so merging as-is leaves an externally reachable denial-of-service path — upgrade to FastAPI >=0.115.3 (Starlette >=0.40.0) before merging.
  • plugins/omi-telegram-app/main.py exposes a privileged /setup flow without authentication and also rotates webhook secrets on restart when TELEGRAM_WEBHOOK_SECRET is unset, which can enable unauthorized reconfiguration and break webhook delivery with 401s after deploys — require setup authentication and persist a stable secret/env value before merge.
  • plugins/omi-whatsapp-app/whatsapp_client.py calls the webhook subscription edge with phone_number_id instead of WABA ID, so subscription can fail or target the wrong Graph resource and leave integrations non-functional — switch to /{whats_app_business_account_id}/subscribed_apps and verify with an integration test.
  • backend/routers/integration.py streams raw execute_chat_stream chunks without the SSE normalization used in chat.py, risking malformed/unparseable stream events for clients — align this route to the existing SSE formatting path before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="plugins/omi-telegram-app/main.py">

<violation number="1" location="plugins/omi-telegram-app/main.py:48">
P1: Ephemeral webhook secret on restart invalidates Telegram deliveries and causes 401 failures until /setup re-runs. When TELEGRAM_WEBHOOK_SECRET is absent, a new random secret is generated on every startup, but Telegram continues sending the previously configured secret.</violation>

<violation number="2" location="plugins/omi-telegram-app/main.py:97">
P1: /setup is unauthenticated despite performing privileged configuration (webhook registration, token storage, user linkage). Any internet-reachable caller can invoke it because the plugin must be exposed for Telegram webhooks.</violation>
</file>

<file name="backend/routers/integration.py">

<violation number="1" location="backend/routers/integration.py:740">
P2: Persona-chat rate-limit key appends `:persona`, creating a separate Redis bucket from conversations despite the claim of sharing the same per-(app, user) ceiling</violation>

<violation number="2" location="backend/routers/integration.py:788">
P1: Stream contract mismatch: raw `execute_chat_stream` chunks are yielded directly into `StreamingResponse` without the SSE normalization that the existing `chat.py` call site performs, which can produce malformed or unparsable SSE output for clients.</violation>
</file>

<file name="plugins/_shared/README.md">

<violation number="1" location="plugins/_shared/README.md:3">
P3: README inaccurately lists iMessage as a current AI Clone plugin sharing this code, but no iMessage plugin exists in `plugins/`. The PR description lists iMessage as out-of-scope/future work (`T-007`).</violation>
</file>

<file name="plugins/_shared/persona_client.py">

<violation number="1" location="plugins/_shared/persona_client.py:9">
P2: The module-level `Contract:` docstring omits the required keyword-only `uid` parameter.</violation>

<violation number="2" location="plugins/_shared/persona_client.py:118">
P2: Only httpx.TimeoutException, asyncio.TimeoutError, and httpx.ConnectError are caught, leaving other transport failures (e.g. ReadError, RemoteProtocolError, WriteError during SSE streaming) to propagate. The module contract says timeout or connection errors should return an empty string so callers do nothing, so these transport errors should be handled similarly.</violation>

<violation number="3" location="plugins/_shared/persona_client.py:155">
P2: `_split_lines` is a no-op identity function despite its docstring claiming it normalizes line endings. It splits on `"\n"` and rejoins with the same separator, returning the original string unchanged. This is misleading dead code that should be removed so `_join_chunks` directly concatenates chunks.</violation>
</file>

<file name="plugins/omi-whatsapp-app/README.md">

<violation number="1" location="plugins/omi-whatsapp-app/README.md:25">
P2: README documents a non-functional desktop UI path: step 7 instructs users to "Toggle Auto-reply in the Omi desktop," but the desktop UI's auto-reply toggle is intentionally `.disabled(true)` with an inline explanation that it will only become functional once the `/global-toggle` backend endpoint lands. The documented happy path is currently not executable via the desktop UI, leading to setup expectation failures.</violation>
</file>

<file name="plugins/omi-telegram-app/README.md">

<violation number="1" location="plugins/omi-telegram-app/README.md:13">
P2: README instructs users to toggle Auto-reply from the Omi desktop UI, but the desktop auto-reply toggle is intentionally shipped disabled in this PR until a future `/global-toggle` backend contract exists. Users following step 5 will attempt a UI action that is currently unavailable, causing confusion and support churn.</violation>
</file>

<file name="plugins/omi-telegram-app/.gitignore">

<violation number="1" location="plugins/omi-telegram-app/.gitignore:3">
P2: Secret files `users_data.json` and `pending_setups.json` are only ignored within the plugin subdirectory. Because `simple_storage.py` can write them to arbitrary paths via the `STORAGE_DIR` environment variable, the root `.gitignore` should also exclude them to prevent accidental commits when the storage path is overridden.</violation>
</file>

<file name="plugins/omi-whatsapp-app/main.py">

<violation number="1" location="plugins/omi-whatsapp-app/main.py:383">
P2: `SetupRequest.public_base_url` is required but never consumed in `setup()` or anywhere in the file. This creates dead API surface where clients must provide a value that has no effect, causing contract confusion and potentially masking incomplete webhook registration behavior.</violation>
</file>

<file name="plugins/omi-whatsapp-app/requirements.txt">

<violation number="1" location="plugins/omi-whatsapp-app/requirements.txt:1">
P1: FastAPI 0.115.0 pins a vulnerable Starlette range (<0.39.0) that includes CVE-2024-47874 (DoS via multipart/form-data). Upgrade to FastAPI >=0.115.3 to pull the patched Starlette >=0.40.0.</violation>
</file>

<file name="plugins/omi-whatsapp-app/whatsapp_client.py">

<violation number="1" location="plugins/omi-whatsapp-app/whatsapp_client.py:37">
P2: Shared httpx.AsyncClient cleanup is not wired to FastAPI lifespan/shutdown events. The aclose() docstring claims it is called from FastAPI lifespan, but no lifespan handler or shutdown event in main.py calls whatsapp_client.aclose(), leaving the connection pool open across shutdowns and tests.</violation>

<violation number="2" location="plugins/omi-whatsapp-app/whatsapp_client.py:109">
P1: Webhook subscription endpoint uses `phone_number_id` instead of the WhatsApp Business Account (WABA) ID. The Meta Graph API `subscribed_apps` edge belongs to `/{whats_app_business_account_id}/subscribed_apps`, not `/{phone_number_id}/subscribed_apps`. Using the phone number ID will cause Meta to reject the subscription or subscribe the wrong resource, so inbound webhook messages will never be delivered.</violation>
</file>

<file name="plugins/omi-telegram-app/simple_storage.py">

<violation number="1" location="plugins/omi-telegram-app/simple_storage.py:159">
P1: Pending setup tokens carrying sensitive credentials (bot_token, omi_dev_api_key) are persisted without any TTL or cleanup. `save_pending_setup` stamps `created_at`, but `pop_pending_setup` and its callers never validate token age, so leaked or abandoned setup tokens remain redeemable indefinitely and credentials can linger on disk forever.</violation>
</file>

<file name="plugins/omi-whatsapp-app/simple_storage.py">

<violation number="1" location="plugins/omi-whatsapp-app/simple_storage.py:44">
P1: Storage write failures are silently swallowed by _save(), causing endpoint operations to appear successful while data is lost on restart, and allowing one-shot setup tokens to resurrect from stale files.</violation>

<violation number="2" location="plugins/omi-whatsapp-app/simple_storage.py:52">
P1: Secret-bearing JSON storage files are created with default filesystem permissions instead of restricted owner-only (0o600) permissions.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread plugins/omi-telegram-app/main.py Outdated
Comment thread plugins/omi-telegram-app/main.py
)
]

async def _stream():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Stream contract mismatch: raw execute_chat_stream chunks are yielded directly into StreamingResponse without the SSE normalization that the existing chat.py call site performs, which can produce malformed or unparsable SSE output for clients.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/integration.py, line 788:

<comment>Stream contract mismatch: raw `execute_chat_stream` chunks are yielded directly into `StreamingResponse` without the SSE normalization that the existing `chat.py` call site performs, which can produce malformed or unparsable SSE output for clients.</comment>

<file context>
@@ -700,3 +703,92 @@ def get_tasks_via_integration(
+        )
+    ]
+
+    async def _stream():
+        async for chunk in execute_chat_stream(uid, messages, app=app):
+            if chunk is None:
</file context>

Comment thread plugins/omi-whatsapp-app/requirements.txt Outdated
Comment thread plugins/omi-whatsapp-app/whatsapp_client.py Outdated
return _client


async def aclose() -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Shared httpx.AsyncClient cleanup is not wired to FastAPI lifespan/shutdown events. The aclose() docstring claims it is called from FastAPI lifespan, but no lifespan handler or shutdown event in main.py calls whatsapp_client.aclose(), leaving the connection pool open across shutdowns and tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/omi-whatsapp-app/whatsapp_client.py, line 37:

<comment>Shared httpx.AsyncClient cleanup is not wired to FastAPI lifespan/shutdown events. The aclose() docstring claims it is called from FastAPI lifespan, but no lifespan handler or shutdown event in main.py calls whatsapp_client.aclose(), leaving the connection pool open across shutdowns and tests.</comment>

<file context>
@@ -0,0 +1,130 @@
+    return _client
+
+
+async def aclose() -> None:
+    """Close the shared client on shutdown (called from FastAPI lifespan)."""
+    global _client
</file context>

if "\n" not in data:
return data
# split("\n") preserves trailing empty strings; splitlines() would not.
return "\n".join(data.split("\n"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: _split_lines is a no-op identity function despite its docstring claiming it normalizes line endings. It splits on "\n" and rejoins with the same separator, returning the original string unchanged. This is misleading dead code that should be removed so _join_chunks directly concatenates chunks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/_shared/persona_client.py, line 155:

<comment>`_split_lines` is a no-op identity function despite its docstring claiming it normalizes line endings. It splits on `"\n"` and rejoins with the same separator, returning the original string unchanged. This is misleading dead code that should be removed so `_join_chunks` directly concatenates chunks.</comment>

<file context>
@@ -0,0 +1,155 @@
+    if "\n" not in data:
+        return data
+    # split("\n") preserves trailing empty strings; splitlines() would not.
+    return "\n".join(data.split("\n"))
</file context>

- plugins/omi-imessage-app/ (T-006)

Contract:
reply = await chat(app_id, api_key, omi_base, text, *, timeout_seconds=30.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The module-level Contract: docstring omits the required keyword-only uid parameter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/_shared/persona_client.py, line 9:

<comment>The module-level `Contract:` docstring omits the required keyword-only `uid` parameter.</comment>

<file context>
@@ -0,0 +1,155 @@
+- plugins/omi-imessage-app/  (T-006)
+
+Contract:
+    reply = await chat(app_id, api_key, omi_base, text, *, timeout_seconds=30.0)
+
+Returns the concatenated persona reply (single string) on success.
</file context>

uid,
)
return ""
except httpx.ConnectError as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Only httpx.TimeoutException, asyncio.TimeoutError, and httpx.ConnectError are caught, leaving other transport failures (e.g. ReadError, RemoteProtocolError, WriteError during SSE streaming) to propagate. The module contract says timeout or connection errors should return an empty string so callers do nothing, so these transport errors should be handled similarly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/_shared/persona_client.py, line 118:

<comment>Only httpx.TimeoutException, asyncio.TimeoutError, and httpx.ConnectError are caught, leaving other transport failures (e.g. ReadError, RemoteProtocolError, WriteError during SSE streaming) to propagate. The module contract says timeout or connection errors should return an empty string so callers do nothing, so these transport errors should be handled similarly.</comment>

<file context>
@@ -0,0 +1,155 @@
+            uid,
+        )
+        return ""
+    except httpx.ConnectError as e:
+        logger.error(
+            "persona chat connection failed (app_id=%s, uid=%s): %s",
</file context>

Comment thread plugins/_shared/README.md
@@ -0,0 +1,64 @@
# `plugins/_shared/`

Code shared by the AI Clone plugins (Telegram, WhatsApp, iMessage).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: README inaccurately lists iMessage as a current AI Clone plugin sharing this code, but no iMessage plugin exists in plugins/. The PR description lists iMessage as out-of-scope/future work (T-007).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/_shared/README.md, line 3:

<comment>README inaccurately lists iMessage as a current AI Clone plugin sharing this code, but no iMessage plugin exists in `plugins/`. The PR description lists iMessage as out-of-scope/future work (`T-007`).</comment>

<file context>
@@ -0,0 +1,64 @@
+# `plugins/_shared/`
+
+Code shared by the AI Clone plugins (Telegram, WhatsApp, iMessage).
+
+## Contents
</file context>
Suggested change
Code shared by the AI Clone plugins (Telegram, WhatsApp, iMessage).
Code shared by the AI Clone plugins (Telegram, WhatsApp). iMessage support is planned.

Cubic AI review of PR BasedHardware#8528 found 25 issues across the repo. Only the
ones in this PR's diff (desktop/macos) are in scope here — the rest
(plugin backends, backend router, shared client) live in their own
PRs.

## P1 — Deep link allowlist had a critical bug

The original allowlist used a `DeepLinkSafeHost` enum with cases
`me, com`. `URL(string: "https://t.me/...")?.host` returns the
literal "t.me" (not the registrable suffix "me"), so EVERY
legitimate Telegram/WhatsApp deep link was being rejected. The
"Open" button in the connect sheet would have been silently dead.
A sub-agent review caught this before merge.

Fix:
- Replaced the RawRepresentable enum with a `Set<String>` of literal
  hostnames ("t.me", "wa.me") and a `contains(host)` lookup.
- Extracted the safety check into a static `isSafeDeepLink(_:)`
  function so it's directly unit-testable without going through
  NSWorkspace.
- Added 9 tests in ConnectSheetDeepLinkSafetyTests covering both
  the legitimate cases (telegram, whatsapp, http dev) and 7 attack
  vectors (evil host, file://, ssh://, javascript:, malformed,
  empty).

## P2 — Other cubic findings in this PR's diff

1. **AIPlugin.verify_token marked as isSecure: true** (was false).
   Verify token is a shared secret used during webhook verification;
   should be treated with the same secrecy as access_token and bot_token.

2. **AIPlugin.toggleRequestBody hardcoded enabled: true** → now takes
   an `enabled: Bool` parameter so the disable path works. Added a
   test (testTelegramToggleBodySupportsDisable) covering the
   previously-broken disable code path.

3. **AIClonePage header**: removed "in any chat you choose to enable"
   (per-chat is not in v0.1; the toggle is shipped disabled until the
   plugins expose a global-toggle endpoint).

4. **AIClonePage hard-coded plugin cards** → now driven by
   `ForEach(AIPlugin.allCases)`. Adding a new plugin is a one-line
   enum addition.

5. **AIClonePage footer** overpromised privacy + HTTPS guarantees
   the code doesn't enforce. Tightened: "HTTPS recommended" instead
   of "over HTTPS", removed the "never leave your machine" claim
   (the user controls the plugin URL).

6. **ConnectSheet form body text** overclaimed "over HTTPS" → changed
   to "HTTPS recommended" with a note that the URL must be http or
   https (matching the actual endpointURL scheme validation).

## Build + test status

xcrun swift build -c debug --package-path Desktop  → clean.
xcrun swift test --package-path Desktop              → 38/38 suites pass.
  - AICloneClientTests: 16/16 (added testTelegramToggleBodySupportsDisable)
  - ConnectSheetDeepLinkSafetyTests: 9/9 (new suite)
python3 .github/scripts/check-desktop-changelog.py --base origin/main --head HEAD
  → 'Desktop changelog entry found.' ✅

## Out of scope (deferred to other PRs)

- plugins/omi-telegram-app/main.py (ephemeral webhook secret,
  /setup unauth) → feat/ai-clone
- plugins/omi-whatsapp-app/whatsapp_client.py (WABA vs phone_number_id)
  → feat/ai-clone-v0.2
- backend/routers/integration.py (SSE streaming) → backend
- plugins/_shared/persona_client.py (timeout coverage, _split_lines
  no-op) → feat/ai-clone-v0.2
- Plugin READMEs (claim desktop toggle works when it doesn't) →
  plugin PRs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="desktop/macos/Desktop/Sources/APIClient.swift">

<violation number="1" location="desktop/macos/Desktop/Sources/APIClient.swift:3416">
P2: Security comment overpromises protection: `getOrCreatePersona()` and `createAppKey()` claim to always target the prod backend, but they fall back to `baseURL` which resolves through `DesktopBackendEnvironment.pythonBaseURL()` and can be overridden by environment variables or dev config. Either explicitly hardcode the production URL for sensitive endpoints that send Firebase auth headers, or update the comment to match the actual behavior.</violation>
</file>

<file name="backend/routers/integration.py">

<violation number="1" location="backend/routers/integration.py:740">
P2: Persona-chat rate-limit key appends `:persona`, creating a separate Redis bucket from conversations despite the claim of sharing the same per-(app, user) ceiling</violation>

<violation number="2" location="backend/routers/integration.py:788">
P1: Stream contract mismatch: raw `execute_chat_stream` chunks are yielded directly into `StreamingResponse` without the SSE normalization that the existing `chat.py` call site performs, which can produce malformed or unparsable SSE output for clients.</violation>
</file>

<file name="plugins/_shared/README.md">

<violation number="1" location="plugins/_shared/README.md:3">
P3: README inaccurately lists iMessage as a current AI Clone plugin sharing this code, but no iMessage plugin exists in `plugins/`. The PR description lists iMessage as out-of-scope/future work (`T-007`).</violation>
</file>

<file name="plugins/_shared/persona_client.py">

<violation number="1" location="plugins/_shared/persona_client.py:9">
P2: The module-level `Contract:` docstring omits the required keyword-only `uid` parameter.</violation>

<violation number="2" location="plugins/_shared/persona_client.py:118">
P2: Only httpx.TimeoutException, asyncio.TimeoutError, and httpx.ConnectError are caught, leaving other transport failures (e.g. ReadError, RemoteProtocolError, WriteError during SSE streaming) to propagate. The module contract says timeout or connection errors should return an empty string so callers do nothing, so these transport errors should be handled similarly.</violation>

<violation number="3" location="plugins/_shared/persona_client.py:155">
P2: `_split_lines` is a no-op identity function despite its docstring claiming it normalizes line endings. It splits on `"\n"` and rejoins with the same separator, returning the original string unchanged. This is misleading dead code that should be removed so `_join_chunks` directly concatenates chunks.</violation>
</file>

<file name="plugins/omi-whatsapp-app/README.md">

<violation number="1" location="plugins/omi-whatsapp-app/README.md:25">
P2: README documents a non-functional desktop UI path: step 7 instructs users to "Toggle Auto-reply in the Omi desktop," but the desktop UI's auto-reply toggle is intentionally `.disabled(true)` with an inline explanation that it will only become functional once the `/global-toggle` backend endpoint lands. The documented happy path is currently not executable via the desktop UI, leading to setup expectation failures.</violation>
</file>

<file name="plugins/omi-telegram-app/README.md">

<violation number="1" location="plugins/omi-telegram-app/README.md:13">
P2: README instructs users to toggle Auto-reply from the Omi desktop UI, but the desktop auto-reply toggle is intentionally shipped disabled in this PR until a future `/global-toggle` backend contract exists. Users following step 5 will attempt a UI action that is currently unavailable, causing confusion and support churn.</violation>
</file>

<file name="plugins/omi-telegram-app/.gitignore">

<violation number="1" location="plugins/omi-telegram-app/.gitignore:3">
P2: Secret files `users_data.json` and `pending_setups.json` are only ignored within the plugin subdirectory. Because `simple_storage.py` can write them to arbitrary paths via the `STORAGE_DIR` environment variable, the root `.gitignore` should also exclude them to prevent accidental commits when the storage path is overridden.</violation>
</file>

<file name="plugins/omi-whatsapp-app/main.py">

<violation number="1" location="plugins/omi-whatsapp-app/main.py:383">
P2: `SetupRequest.public_base_url` is required but never consumed in `setup()` or anywhere in the file. This creates dead API surface where clients must provide a value that has no effect, causing contract confusion and potentially masking incomplete webhook registration behavior.</violation>

<violation number="2" location="plugins/omi-whatsapp-app/main.py:396">
P2: Bearer auth is added to /setup and /toggle but not /health, so the health-based connection test may falsely accept an invalid bearer token. The desktop client calls GET /health without sending the Authorization header and presents a successful response as "Plugin service reachable," even if the bearer token is wrong. Later calls to /setup or /toggle will then fail with 401, producing a confusing user experience. Either /health should also verify the bearer (if it's meant to validate credentials) or the client should call a separate authenticated endpoint to verify the token.</violation>
</file>

<file name="plugins/omi-whatsapp-app/whatsapp_client.py">

<violation number="1" location="plugins/omi-whatsapp-app/whatsapp_client.py:37">
P2: Shared httpx.AsyncClient cleanup is not wired to FastAPI lifespan/shutdown events. The aclose() docstring claims it is called from FastAPI lifespan, but no lifespan handler or shutdown event in main.py calls whatsapp_client.aclose(), leaving the connection pool open across shutdowns and tests.</violation>
</file>

<file name="desktop/macos/Desktop/Tests/QRCodeGeneratorTests.swift">

<violation number="1" location="desktop/macos/Desktop/Tests/QRCodeGeneratorTests.swift:37">
P2: testDeterministicOutput only checks image size, not actual pixel data, despite claiming to verify determinism</violation>
</file>

<file name="desktop/macos/Desktop/Sources/Utilities/TelegramTokenValidator.swift">

<violation number="1" location="desktop/macos/Desktop/Sources/Utilities/TelegramTokenValidator.swift:21">
P3: `\d` in NSRegularExpression matches Unicode decimal digits (category Nd), not only ASCII `0-9`. This can allow non-ASCII digits (e.g., Arabic-Indic, fullwidth) in the bot ID, which Telegram tokens do not use. The client-side validator would show a green checkmark for such tokens that would fail the real server-side check. Consider using `[0-9]+` instead to restrict to ASCII digits.</violation>
</file>

<file name="desktop/macos/Desktop/Sources/Utilities/QRCodeGenerator.swift">

<violation number="1" location="desktop/macos/Desktop/Sources/Utilities/QRCodeGenerator.swift:44">
P2: New CIContext allocated on every QR generation; during handshake polling the same QR is regenerated on every `handshakeSecondsRemaining` tick, wasting Core Image/GPU resources.</violation>
</file>

<file name="desktop/macos/Desktop/Sources/MainWindow/Components/AIClone/ConnectSheet.swift">

<violation number="1" location="desktop/macos/Desktop/Sources/MainWindow/Components/AIClone/ConnectSheet.swift:601">
P2: Local-backend setup sends empty personaId and omiDevApiKey to plugin /setup, relying on unverified plugin-side fallback behavior. If the upstream FastAPI plugin requires non-empty values, local setup will fail silently or with an error, and this path is completely untested.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread plugins/_shared/plugin_discovery.py
Comment thread plugins/omi-whatsapp-app/simple_storage.py Outdated
choguun added a commit to choguun/omi that referenced this pull request Jun 30, 2026
cubic-found P1 on PR BasedHardware#8528:

1. plugin_discovery.py: single fixed file path
   (ai-clone-plugin.json) breaks concurrent multi-plugin discovery.
   Telegram + WhatsApp running simultaneously would overwrite each
   other's file. Fixed: each plugin gets its own file
   (ai-clone-plugin-{plugin_type}.json). Backward-compatible default
   path maintained for single-plugin dev.

2. Both simple_storage.py: fixed .tmp filename races between
   concurrent writers. Now uses {path}.{pid}.tmp for uniqueness.

3. clear_discovery() now accepts plugin_type parameter to target
   the correct per-plugin file.

4. Tests updated: monkeypatch discovery_file() instead of
   DISCOVERY_FILE constant.

169/169 tests pass.
…#8531

Same cubic P1 fix: per-plugin discovery file paths + unique tmp
filenames for concurrent plugin support.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="plugins/_shared/plugin_discovery.py">

<violation number="1" location="plugins/_shared/plugin_discovery.py:135">
P2: The legacy DISCOVERY_FILE is documented as a backward-compatible fallback but is no longer maintained by write_discovery or clear_discovery, which can leave stale credentials or break discovery for older desktop builds.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic


# Per-plugin file (cubic P1: concurrent Telegram + WhatsApp
# plugins must not overwrite each other's discovery file).
target = discovery_file(plugin_type)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The legacy DISCOVERY_FILE is documented as a backward-compatible fallback but is no longer maintained by write_discovery or clear_discovery, which can leave stale credentials or break discovery for older desktop builds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/_shared/plugin_discovery.py, line 135:

<comment>The legacy DISCOVERY_FILE is documented as a backward-compatible fallback but is no longer maintained by write_discovery or clear_discovery, which can leave stale credentials or break discovery for older desktop builds.</comment>

<file context>
@@ -115,13 +130,18 @@ def write_discovery(
-    tmp = DISCOVERY_FILE.with_suffix(".tmp")
+    # Per-plugin file (cubic P1: concurrent Telegram + WhatsApp
+    # plugins must not overwrite each other's discovery file).
+    target = discovery_file(plugin_type)
+    # Unique tmp filename to avoid race between concurrent writers.
+    tmp = target.with_suffix(f".{os.getpid()}.tmp")
</file context>

@choguun choguun changed the title feat(desktop): AI Clone screen (v0.3) feat(desktop): AI Clone screen (AI Clone v0.3) Jun 30, 2026

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the continued hardening here. I reviewed the current head 54aac6dbe438a64ba4c344d6cb954b7039c73987. This is moving in a better direction and I do not see evidence of a malicious/supply-chain attempt, but I still need to request changes before this should be considered mergeable.

Blocking issues I confirmed:

  1. The plugin discovery producer and desktop consumer now disagree. plugins/_shared/plugin_discovery.py writes per-plugin files like ~/.config/omi/ai-clone-plugin-telegram.json / ai-clone-plugin-whatsapp.json, but the Swift desktop side still only reads ~/.config/omi/ai-clone-plugin.json via PluginDiscovery.filePath. That means the advertised zero-config flow will not discover fresh plugin launches, and any old legacy file can leave the desktop using stale URL/bearer material. Please either keep the legacy file in sync safely or teach the desktop to read the current per-plugin discovery files and handle multiple plugin types explicitly.

  2. Both plugin storage implementations still swallow persistence failures in _save(...). These files hold setup tokens, platform credentials, and omi_dev_... keys; if a write fails, /setup or /toggle can still proceed as if state was saved, leaving the user with a deep link that cannot complete after restart and potentially allowing stale on-disk state to resurrect. For credential-bearing setup/user state, persistence errors should fail closed and surface back to the endpoint rather than being printed and ignored.

  3. The desktop handshake success check is still not tied to the actual /start binding. ConnectSheet.startHandshakePolling() marks handshakeCompleted = true on /health, but /health is already expected to be 200 before /setup and does not validate that the user clicked the deep link, that a chat/phone was bound, or even that the bearer token works. Since the plugin now has authenticated status-style state, please gate the success UI on an authenticated connected-state check instead of liveness.

Even after these are fixed, this remains a large product/security feature spanning desktop setup UX, backend persona impersonation, public Telegram/WhatsApp webhook services, Docker/runtime files, credential storage, and user-facing docs. It should stay under human maintainer/product/security review rather than automation approval.

choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Cubic AI review of PR BasedHardware#8528 found 25 issues across the repo. Only the
ones in this PR's diff (desktop/macos) are in scope here — the rest
(plugin backends, backend router, shared client) live in their own
PRs.

## P1 — Deep link allowlist had a critical bug

The original allowlist used a `DeepLinkSafeHost` enum with cases
`me, com`. `URL(string: "https://t.me/...")?.host` returns the
literal "t.me" (not the registrable suffix "me"), so EVERY
legitimate Telegram/WhatsApp deep link was being rejected. The
"Open" button in the connect sheet would have been silently dead.
A sub-agent review caught this before merge.

Fix:
- Replaced the RawRepresentable enum with a `Set<String>` of literal
  hostnames ("t.me", "wa.me") and a `contains(host)` lookup.
- Extracted the safety check into a static `isSafeDeepLink(_:)`
  function so it's directly unit-testable without going through
  NSWorkspace.
- Added 9 tests in ConnectSheetDeepLinkSafetyTests covering both
  the legitimate cases (telegram, whatsapp, http dev) and 7 attack
  vectors (evil host, file://, ssh://, javascript:, malformed,
  empty).

## P2 — Other cubic findings in this PR's diff

1. **AIPlugin.verify_token marked as isSecure: true** (was false).
   Verify token is a shared secret used during webhook verification;
   should be treated with the same secrecy as access_token and bot_token.

2. **AIPlugin.toggleRequestBody hardcoded enabled: true** → now takes
   an `enabled: Bool` parameter so the disable path works. Added a
   test (testTelegramToggleBodySupportsDisable) covering the
   previously-broken disable code path.

3. **AIClonePage header**: removed "in any chat you choose to enable"
   (per-chat is not in v0.1; the toggle is shipped disabled until the
   plugins expose a global-toggle endpoint).

4. **AIClonePage hard-coded plugin cards** → now driven by
   `ForEach(AIPlugin.allCases)`. Adding a new plugin is a one-line
   enum addition.

5. **AIClonePage footer** overpromised privacy + HTTPS guarantees
   the code doesn't enforce. Tightened: "HTTPS recommended" instead
   of "over HTTPS", removed the "never leave your machine" claim
   (the user controls the plugin URL).

6. **ConnectSheet form body text** overclaimed "over HTTPS" → changed
   to "HTTPS recommended" with a note that the URL must be http or
   https (matching the actual endpointURL scheme validation).

## Build + test status

xcrun swift build -c debug --package-path Desktop  → clean.
xcrun swift test --package-path Desktop              → 38/38 suites pass.
  - AICloneClientTests: 16/16 (added testTelegramToggleBodySupportsDisable)
  - ConnectSheetDeepLinkSafetyTests: 9/9 (new suite)
python3 .github/scripts/check-desktop-changelog.py --base origin/main --head HEAD
  → 'Desktop changelog entry found.' ✅

## Out of scope (deferred to other PRs)

- plugins/omi-telegram-app/main.py (ephemeral webhook secret,
  /setup unauth) → feat/ai-clone
- plugins/omi-whatsapp-app/whatsapp_client.py (WABA vs phone_number_id)
  → feat/ai-clone-v0.2
- backend/routers/integration.py (SSE streaming) → backend
- plugins/_shared/persona_client.py (timeout coverage, _split_lines
  no-op) → feat/ai-clone-v0.2
- Plugin READMEs (claim desktop toggle works when it doesn't) →
  plugin PRs
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Cubic AI follow-up review on PR BasedHardware#8528 (review 4588508321):

> Deep-link validation checks scheme and host but doesn't bind the
> allowed host to the active plugin or validate the expected URL
> path/query format. The `isSafeDeepLink` static function accepts
> both `t.me` and `wa.me` regardless of whether the user is
> connecting Telegram or WhatsApp, and it allows arbitrary paths and
> query strings on those domains. Bind the host check to `plugin`
> (e.g., Telegram only → `t.me`, WhatsApp only → `wa.me`).

Fix: `isSafeDeepLink(_:plugin:)` now takes the active `AIPlugin`
and only accepts the host expected for that plugin. A compromised
plugin service can no longer phish by returning the other
platform's host — e.g. a `t.me` URL inside a WhatsApp connect
sheet is rejected, and vice versa.

Implementation:
- Replaced `Set<String>`-based allowlist with a per-plugin
  `DeepLinkSafeHost.expected(for:)` lookup.
- `openURL(_:)` passes the active plugin through.
- Added 2 tests covering both cross-plugin attack directions:
  - `testRejectsTelegramHostInWhatsAppContext`
  - `testRejectsWhatsAppHostInTelegramContext`

Test status: 11/11 ConnectSheetDeepLinkSafetyTests pass.
38/38 desktop test suites pass overall.
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Addresses the maintainer security review on PR BasedHardware#8528
(BasedHardware#8528 (review)):

  > Secrets are persisted in macOS UserDefaults (AICloneConfig.swift).
  > The plugin bearer token and omi_dev_... developer API key are long-
  > lived credentials that allow calls on the user's behalf, so they
  > should not be stored in plain defaults. Please move these to
  > Keychain (or an existing secure credential store) and keep
  > UserDefaults only for non-secret config like the plugin URL.

## Fix

New helper  — a ~30-line wrapper around the
Security framework (SecItemAdd / SecItemCopyMatching / SecItemUpdate /
SecItemDelete). The kSecAttrService is derived from Bundle.main.bundleIdentifier
so dev (com.omi.desktop-dev) and prod (com.omi.computer-macos)
installs don't share Keychain entries.

 now stores:
- pluginURL → UserDefaults (non-secret, the reviewer's explicit OK)
- bearerToken → AICloneKeychain.pluginBearerToken
- omiDevApiKey → AICloneKeychain.devApiKey

Setting a secret to empty string deletes it from Keychain.

## Migration

A previous build stored both secrets in UserDefaults. On first init
under this build,  copies any
existing legacy values into Keychain and clears the UserDefaults
entries. Migration is:
- idempotent (no-op when nothing to migrate)
- non-destructive when Keychain already has the same value
  (doesn't clobber an existing real Keychain entry with a stale
  UserDefaults value from a backup restore)

## Tests

New  — 9 tests pinning the behavior:
- secrets persist to Keychain, NOT UserDefaults
- secrets reload from Keychain on next init
- empty string deletes from Keychain
- pluginURL still goes to UserDefaults
- legacy UserDefaults values migrate to Keychain
- migration doesn't clobber an existing Keychain value
- migration is idempotent (second init is a no-op)
- isFullyConfigured reflects all three sources

Combined with the existing AICloneClientTests: 25/25 pass.

Security-review-flagged
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Addresses cubic review on PR BasedHardware#8528:

  > The comment incorrectly claims kSecAttrAccessibleWhenUnlocked
  > provides 'only this app' isolation. Because the app does not use
  > kSecUseDataProtectionKeychain, entitlements have no keychain
  > access groups, and app sandboxing is disabled
  > (com.apple.security.app-sandbox is <false/>), SecItem calls write
  > to the legacy file-based keychain. kSecAttrAccessibleWhenUnlocked
  > only controls WHEN the item is available (while the keychain is
  > unlocked), not WHICH process can access it — other user processes
  > can still read these items. The comment should be corrected to not
  > overstate the isolation guarantee.

## Fix

- AICloneKeychain.swift docstring now accurately describes the
  isolation guarantee. Keychain IS still meaningfully better than
  UserDefaults (other user processes must call SecItemCopyMatching
  with the right kSecAttrService + kSecAttrAccount instead of just
  reading a plaintext plist), but it is NOT full sandbox isolation.
- The in-code comment at the kSecAttrAccessible call site is corrected
  to say 'controls WHEN' not 'controls which app'.

## Residual risk (documented honestly in the docstring)

Without sandboxing + keychain access groups, other user processes
that know the bundle id and secret name can read these items.
Sandboxing the app is a project-wide architectural decision tracked
separately; this commit is the realistic improvement within current
entitlements.

## Tests

3 new tests in AICloneConfigTests pin the actual protection level:
- testStoredSecretIsNotPresentInUserDefaults — runtime assertion
  that the legacy plaintext plist path doesn't get re-introduced.
- testStoredSecretIsRetrievableViaKeychain — companion check that
  the round-trip IS through Keychain.
- testMigrationClearsLegacyUserDefaultsEntries — proves the
  migration removes the legacy key (not just copies it).

Combined with existing AICloneClientTests: 28/28 pass.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
… checks (cubic P1)

cubic-found P1 on PR BasedHardware#8528:

  > Test uses data(forKey:) instead of string(forKey:) to check legacy
  > UserDefaults keys, making the assertion a false pass for string values

UserDefaults.data(forKey:) only returns values that were stored as
Data. If a regression re-introduces UserDefaults storage of secrets
as String (the most natural regression shape), data(forKey:) returns
nil regardless of the value being there — the assertion silently
passes. Verified by directly poking a String value:

  data(forKey:) -> nil        (false pass)
  object(forKey:) -> NOT NIL  (correctly fails)

Switched to object(forKey:) which returns Any? and catches ANY type
under the key — strings, Data, ints, arrays, dicts, anything.

Same change applied to testMigrationClearsLegacyUserDefaultsEntries
for symmetry (its previous string(forKey:) would silently miss a
Data-typed value).

12/12 AICloneConfigTests pass; 28/28 desktop AI Clone combined.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Git-on-my-level on PR BasedHardware#8528: the Telegram plugin's Dockerfile does
COPY . . and simple_storage.py writes users_data.json +
pending_setups.json containing bot tokens and omi_dev_… keys. Without
a .dockerignore, an operator rebuilding from a working directory that
has runtime state would copy those secret JSON files into image
layers / registries.

Add a .dockerignore that mirrors plugins/omi-whatsapp-app/.dockerignore:
- Excludes test/, .venv/, __pycache__/, etc. (bloat)
- Excludes .env, .env.* (real bot tokens if developer committed them)
- Excludes users_data.json, pending_setups.json (runtime state with
  user tokens)
- Excludes .git/, .gitignore, .idea/, scripts/, E2E_RUNBOOK.md,
  requirements-dev.txt (dev-only artifacts)
- Keeps !.env.example so example files still ship

This matches the WhatsApp plugin's pattern exactly, so both AI Clone
plugins get the same Docker build hygiene.

maintainer-flagged
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
… review)

Git-on-my-level on PR BasedHardware#8528 flagged two issues in
plugins/omi-whatsapp-app/main.py:

1. Production in-function imports: the webhook-verify handler did
   "import simple_storage" locally, and the signature-verification
   branch did "import hmac" / "import hashlib". The repo Python
   guidance asks for imports at module scope. simple_storage is
   already imported at module scope; hmac + hashlib are stdlib.

   Fix:
   - Hoist hmac and hashlib to the module-top import block.
   - Remove the redundant simple_storage import from the
     webhook-verify handler.

2. The HMAC-mismatch warning logged the full presented and expected
   signatures. These are derived from WHATSAPP_APP_SECRET (HMAC key)
   — putting them in /tmp/omi-dev.log means anyone with file-read
   access can correlate them back to the secret, and log aggregators
   would persist them.

   Fix:
   - Drop the full sigs from the log.
   - Replace with a short non-sensitive prefix of the presented
     signature as a correlation id (first 8 hex chars) + the length.
   - The full sigs are still used internally for hmac.compare_digest()
     — just not emitted to logs.

maintainer-flagged
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Addresses the security blocker flagged by maintainer review on PR BasedHardware#8528
(BasedHardware#8528 (review)):

  > The desktop client is written as if plugin endpoints are protected by
  > the configured bearer token (`Authorization: Bearer ...`), but the
  > new Telegram and WhatsApp plugin /setup handlers do not actually
  > verify that bearer token. For a public self-hosted plugin URL, that
  > leaves the setup surface unauthenticated while it can call
  > Telegram/Meta APIs, set webhooks/subscriptions, and persist
  > user-supplied Omi/API/platform credentials.

## Fix

New module `plugins/_shared/auth.py` with a single FastAPI dependency
`require_bearer` that enforces the bearer-token contract documented on
the desktop side (search AICloneClient.swift for AI_CLONE_PLUGIN_TOKEN).

Applied to:
  - plugins/omi-telegram-app/main.py /setup and /toggle
  - plugins/omi-whatsapp-app/main.py /setup and /toggle

(Not applied to /webhook — Telegram/Meta authenticate /webhook via their
own per-platform HMAC / secret-token mechanisms, which were already
present and verified.)

## Policy

Behavior depends on AI_CLONE_PLUGIN_TOKEN + OMI_DEV_MODE:
  | token   | dev mode | outcome                              |
  |---------|----------|--------------------------------------|
  | set     | (any)    | bearer must match (secrets.compare)  |
  | unset   | 1        | allow all (explicit dev opt-in)      |
  | unset   | unset    | 503 Service Unavailable (misconfig)  |

Returns 503 for the misconfig case (rather than silently allowing all)
so a deploy that forgot to set the token fails closed rather than open.

## Response shape

Same 401 + same body for missing header / wrong scheme / wrong token, so
an attacker probing the endpoint cannot distinguish them.

## Tests

21 new tests (150/150 pass overall):

  - plugins/_shared/test/test_auth.py (11) — policy matrix, bearer
    match, indistinguishability, secrets.compare_digest path, env
    sentinel
  - plugins/omi-telegram-app/test/test_setup_auth.py (5) — actual
    /setup integration: 503 on misconfig, 401 on missing/wrong, 200 on
    correct bearer
  - plugins/omi-whatsapp-app/test/test_whatsapp_setup_auth.py (5) —
    mirror coverage for WhatsApp

Verified end-to-end: reverting the require_bearer additions in main.py
makes test_setup_without_token_returns_503 fail with a clear message —
the regression is genuinely caught.

Security-review-flagged
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Cubic-found on PR BasedHardware#8528 (review 4592784393): the Meta Graph API
endpoint POST /{id}/subscribed_apps belongs to the WhatsApp
Business Account (WABA), not the phone number. Posting to
/{phone_number_id}/subscribed_apps returns 400 / 'no edge found' from
Meta, so webhook messages would never be delivered to the plugin.

The user still provides only phone_number_id in the /setup request
(no API change); the plugin now does one extra lookup to resolve
the WABA id, then subscribes to the correct endpoint.

Resolution path:
  GET  /{phone_number_id}?fields=whatsapp_business_account{id}
  -> { 'whatsapp_business_account': { 'id': '<waba>' } }
  POST /{waba}/subscribed_apps
  -> { 'success': true }

Failure modes surface with a clear 400 from Meta (e.g. the access
token doesn't have whatsapp_business_management / whatsapp_business_assets
scopes, or the phone isn't on any WABA the token can manage). The
plugin maps non-2xx responses to a 502 with a generic 'WhatsApp
subscribe_app failed' message; the log carries the actual Meta error
for diagnostics.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
…afety gate

Cubic-found on PR BasedHardware#8528 (review 4592784393), 2 P1 issues in
ConnectSheet:

1. False-positive 'Connected' on timeout. The success view checked
   '\(!pollingForHandshake && setupResult != nil)' to render
   'Connected' — but 'pollingForHandshake' is also set false when
   the polling loop exhausts its window. So a user who never sent
   /start saw 'Connected' with a green checkmark after 45s of
   waiting, and would close the sheet thinking setup succeeded.

   Fix: add two new bools — 'handshakeCompleted' and 'handshakeTimedOut'.
   The polling loop only sets 'handshakeCompleted = true' on a
   successful /health hit. If the loop exits without that set,
   'handshakeTimedOut' becomes true (we can't reach that code
   without either timeout or cancellation, and Task.isCancelled
   is checked separately).

   The success view now branches:
     - 'handshakeCompleted' -> green check + 'Connected'
     - 'handshakeTimedOut'  -> red warning + 'Connection timed out'
                              + a 'Retry' button that restarts polling
     - polling in flight  -> 'Waiting for /start... (Xs remaining)'

   Note: this is a NECESSARY-not-SUFFICIENT check. /health returns
   200 as long as the plugin process is up, regardless of whether
   anyone sent /start. When the plugin gains a /status endpoint
   (Tier 2 of the onboarding plan), upgrade this gate to check
   the actual handshake-complete bit.

2. QR rendered for unsafe plugin URLs. The success view's
   'deepLinkWithQR' helper rendered a QR for whatever string the
   plugin returned. The 'Open' button is gated by isSafeDeepLink
   (rejects http://evil.com/ etc), but a user might scan a QR
   even when they wouldn't click the button — separate attack
   surface. A compromised plugin could return a t.me-lookalike
   host and phish via the QR.

   Fix: gate QR rendering by the same isSafeDeepLink predicate.
   On rejection, render an explicit 'Refusing to render QR \u2014
   plugin returned an unsafe URL' warning instead of a scannable
   image.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Cubic-found on PR BasedHardware#8528 (review 4592784393): ClipboardWatcher's
checkClipboard() read the clipboard string on every polling tick
(once per second), even when changeCount hadn't changed. The string
read is the expensive part (NSPasteboard round-trip + data copy);
changeCount is O(1). Pre-fix behavior:

  every tick -> call source() -> read BOTH changeCount + string
  -> early-return on no change -> string read was wasted

Two fixes here:

1. Split the single 'Source' closure into two:
   - 'ChangeCountSource: () -> Int' (cheap, called every tick)
   - 'StringSource: () -> String?' (expensive, called only when
     changeCount has moved)

2. checkClipboard() now reads changeCount first; only if it
   differs from the cached value does it call the string source.

A steady-state watch (no clipboard changes) now burns zero
string-reads per second instead of one.

Default sources read NSPasteboard.general directly; tests inject
fakes via the new init parameters.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
…(P1)

Cubic-found on PR BasedHardware#8528 (review 4592784393): the plugin's
TELEGRAM_WEBHOOK_SECRET resolution was 'env var OR fresh random on
every startup'. If the operator didn't set the env var, each
restart rotated the secret, breaking the handshake with Telegram:

  - Plugin calls setWebhook(url, secret_token=NEW_RANDOM)
  - Telegram stores the new secret
  - Webhook deliveries now come in with the NEW secret
  - BUT: if the user has set up their own webhook / uses a reverse
    proxy that re-resolves DNS, deliveries with the OLD secret
    continue to come in and get a 401

Even more directly: any operator that ran /setup once and then
restarted the plugin (without setting TELEGRAM_WEBHOOK_SECRET)
saw every webhook get 401 until they re-ran /setup.

Resolution order:
  1. TELEGRAM_WEBHOOK_SECRET env var (production)
  2. $STORAGE_DIR/webhook_secret (auto-generated, persisted on first run)
  3. secrets.token_urlsafe(32) + write to file (first run only)

The persisted file is created with mode 0o600 (owner read/write
only) so other users on the box can't read the secret. The parent
STORAGE_DIR is also chmod 0o700 (best-effort).

Best-effort: if the file is unreadable (permission denied), the
resolver logs a warning and falls back to generating a new secret
rather than crashing startup. Operators can see the warning and
fix the perm issue.
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Cubic-found on PR BasedHardware#8528 (review 4592784393), 2 P1 issues in
plugins/omi-whatsapp-app/simple_storage.py:

1. Storage files created with default umask (typically 0o644 —
   world-readable). The files hold user-bound secrets
   (access_token, omi_dev_api_key). Any local user could read
   them off disk.

   Fix: open with explicit 0o600 via os.open(O_CREAT|O_EXCL) so the
   file never briefly exists with the default umask. On load,
   tighten any existing file's perms to 0o600 (best-effort). Also
   chmod 0o700 on the parent STORAGE_DIR.

2. Storage write failures silently swallowed via bare
   'except Exception: print(...)'. If the disk was full or the
   directory was read-only, /setup would 'succeed' (no exception
   propagated to the caller) but the data wouldn't be persisted.
   On restart, the plugin would resurrect from the stale file —
   one-shot setup tokens could be re-redeemed indefinitely, and
   /toggle would appear to work while persisting nothing.

   Fix: log the error AND raise OSError. The caller (/setup) maps
   OSError to a 5xx response so the user knows the setup failed.
   Also: log via the module logger (not print) so the error
   shows up in /tmp/omi-dev.log alongside other plugin logs.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
…missing

P2 (cubic follow-up on PR BasedHardware#8528): raising httpx.HTTPStatusError with
a 2xx response is misleading \u2014 that exception is specifically for
4xx/5xx HTTP failures, and downstream log lines / error handlers
key off the .response.status_code field. The WABA-missing case
happens AFTER raise_for_status() succeeded (Meta returns 200 with
whatsapp_business_account empty) so the response is 200 OK, not
an HTTP error.

Use the base httpx.HTTPError(message) instead. The caller's
'except httpx.HTTPError' branch picks it up cleanly and logs
'WhatsApp subscribe_app failed: HTTPError' \u2014 no fake status
code, no confusion about which Meta endpoint returned what.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Cubic-found on PR BasedHardware#8528 (review 4592980496), 3 P1 issues in
_resolve_webhook_secret() (commit d01895a from the previous
review pass):

1. Default STORAGE_DIR was /tmp/omi-tg-e2e, ephemeral on most
   systems. The whole point of the previous fix was 'survive
   restarts'; /tmp defeats that. Now defaults to a persistent path
   (the plugin's own dir or a data/ subdir) and only falls back to
   the legacy /tmp path for back-compat migration.

2. File creation followed symlinks. A local attacker could pre-create
   a symlink at <STORAGE_DIR>/webhook_secret pointing to, e.g.,
   /dev/stdout or a file the attacker can read. The next write would
   follow the symlink, exfiltrating the secret. Now uses
   O_NOFOLLOW on every open (read AND write) so symlinks are
   rejected at the syscall level. ELOOP is caught and treated as
   'secret not present' with a logged warning.

3. Concurrent first startup could race. Two workers calling
   _resolve_webhook_secret() simultaneously would both see 'no file',
   both generate a new random secret, and both try to write the
   same fixed '.tmp' name. Whichever lost the race got an EEXIST
   that pre-fix code just ignored. Now: a short-lived fcntl.flock
   on <path>.lock serializes the first run, AND the write uses
   O_CREAT|O_EXCL so the loser sees 'someone else already wrote
   it, use their secret' instead of overwriting.

Also added the missing warning for PermissionError / EIO on read
(not just ENOENT/ELOOP) \u2014 the previous version silently returned
None on any OSError. Operators need to know when the file exists
but is unreadable (perm issue, broken mount, etc.).

Plus: legacy /tmp/omi-tg-e2e/webhook_secret is read on first call
(only) and migrated to the persistent path so the next restart
doesn't need the fallback. If the legacy file doesn't exist,
we generate a new one.

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Cubic-found on PR BasedHardware#8528 (review 4592980496): simple_storage._save
used a FIXED '.tmp' suffix with O_EXCL, no retry, and no per-call
uniqueness. Failure modes:

- Stale .tmp from a crashed previous write (e.g. process killed
  between os.open and os.replace) makes every subsequent _save()
  fail with EEXIST. The 'except' handler cleans up the stale file
  but re-raises \u2014 so the user gets a 5xx every time, not a one-
  shot recovery.

- Two processes in a multi-worker deployment (gunicorn -w 2, etc)
  racing on the same fixed '.tmp' name \u2014 whichever loses the race
  gets EEXIST. The losing process's cleanup would unlink the
  winner's in-progress file, breaking both saves.

Fix: use tempfile.mkstemp(prefix=os.path.basename(path)+'.',
suffix='.tmp', dir=target_dir). This:
- Generates a per-call unique name (atomic + exclusive by default)
- Co-locates the temp in the target directory so os.replace
  is atomic (same filesystem)
- Eliminates the stale-file failure mode entirely
- Eliminates the cross-process collision (different processes get
  different temp names automatically)

The mkstemp fd already returns a permission-fixed 0o600 on Linux
(mkstemp uses O_CREAT|O_EXCL with mode 0600 by default). Belt-and-
braces: explicit os.chmod(tmp, 0o600) after the fd is opened, in
case the platform's mkstemp uses a different default (some BSDs
default to 0o644).

cubic-found
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
…URL override, do/catch persona lookup

Addresses cubic + maintainer review on PR BasedHardware#8528:

1. P1 (ConnectSheet): replaced substring localhost detection with
   proper URL host parsing via isLoopbackURL(). Checks exact host
   values (localhost, 127.0.0.1, ::1) instead of fragile
   contains() which falsely matched 'localhost.evil.com'.
   Identified by cubic P2 + maintainer BasedHardware#4.

2. P1 (APIClient): removed backendURL override parameter from
   createAppKey() and getOrCreatePersona(). These authenticated
   calls send Firebase tokens + BYOK keys — the override could
   leak credentials to untrusted URLs. Identified by cubic P1 +
   maintainer BasedHardware#4.

3. P1 (ConnectSheet): replaced try? getPersona() with proper
   do/catch. try? collapsed network/auth/decoding errors into
   'no persona' and triggered unnecessary creation that masked
   the real failure. Now: errors propagate to the user.
   Identified by cubic P1 + maintainer BasedHardware#5.

4. P2 (APIClient): Persona.createdAt/updatedAt changed from
   Date to Date? (optional). Prevents crash when backend omits
   timestamps, and avoids masking contract regressions with
   non-deterministic Date() defaults. Identified by cubic P2.

Build clean. Build complete!
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
…esktop branch

The maintainer review on PR BasedHardware#8528 flagged 4 blockers that were
already fixed on feat/ai-clone-chat-tools (BasedHardware#8531) but missing
from feat/ai-clone-desktop (BasedHardware#8528). Both PRs share the same
base but diverged — the desktop branch didn't have the plugin
security fixes.

Synced files:

1. auth.py: whitespace-only AI_CLONE_PLUGIN_TOKEN now stripped
   (get_plugin_token returns .strip()). Whitespace bearer rejected.

2. auth.py: non-ASCII presented tokens caught with try/except
   before secrets.compare_digest → controlled 401, not TypeError 500.

3. simple_storage.py (Telegram): 0o600 file permissions on writes,
   os.makedirs for parent dir, try/except OSError for non-POSIX.
   Matches WhatsApp storage's permission-safe behavior.

4. simple_storage.py (both): TTL expiry on pending_setups (1 hour).
   pop_pending_setup() purges stale entries before returning.

5. persona_client.py: [DONE] breaks the SSE loop immediately
   (not just filtered). Regression tests included.

Tests: 30/30 shared tests pass.
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Addresses the security blocker flagged by maintainer review on PR BasedHardware#8528
(BasedHardware#8528 (review)):

  > The desktop client is written as if plugin endpoints are protected by
  > the configured bearer token (`Authorization: Bearer ...`), but the
  > new Telegram and WhatsApp plugin /setup handlers do not actually
  > verify that bearer token. For a public self-hosted plugin URL, that
  > leaves the setup surface unauthenticated while it can call
  > Telegram/Meta APIs, set webhooks/subscriptions, and persist
  > user-supplied Omi/API/platform credentials.

New module `plugins/_shared/auth.py` with a single FastAPI dependency
`require_bearer` that enforces the bearer-token contract documented on
the desktop side (search AICloneClient.swift for AI_CLONE_PLUGIN_TOKEN).

Applied to:
  - plugins/omi-telegram-app/main.py /setup and /toggle
  - plugins/omi-whatsapp-app/main.py /setup and /toggle

(Not applied to /webhook — Telegram/Meta authenticate /webhook via their
own per-platform HMAC / secret-token mechanisms, which were already
present and verified.)

Behavior depends on AI_CLONE_PLUGIN_TOKEN + OMI_DEV_MODE:
  | token   | dev mode | outcome                              |
  |---------|----------|--------------------------------------|
  | set     | (any)    | bearer must match (secrets.compare)  |
  | unset   | 1        | allow all (explicit dev opt-in)      |
  | unset   | unset    | 503 Service Unavailable (misconfig)  |

Returns 503 for the misconfig case (rather than silently allowing all)
so a deploy that forgot to set the token fails closed rather than open.

Same 401 + same body for missing header / wrong scheme / wrong token, so
an attacker probing the endpoint cannot distinguish them.

21 new tests (150/150 pass overall):

  - plugins/_shared/test/test_auth.py (11) — policy matrix, bearer
    match, indistinguishability, secrets.compare_digest path, env
    sentinel
  - plugins/omi-telegram-app/test/test_setup_auth.py (5) — actual
    /setup integration: 503 on misconfig, 401 on missing/wrong, 200 on
    correct bearer
  - plugins/omi-whatsapp-app/test/test_whatsapp_setup_auth.py (5) —
    mirror coverage for WhatsApp

Verified end-to-end: reverting the require_bearer additions in main.py
makes test_setup_without_token_returns_503 fail with a clear message —
the regression is genuinely caught.

Security-review-flagged
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Maintainer review on PR BasedHardware#8531: E2E_RUNBOOK.md is
agent-behavior-affecting documentation that still contains
stale guidance:

- Layer 2 / Layer 3 talk about /setup and /toggle not enforcing
  bearer auth ('the bearer token is currently not enforced'),
  but the auth-redesign (commit 5f1f710 on PR BasedHardware#8528) wired
  require_bearer on both /setup AND /toggle.

- The runbook was agent-facing: a coding agent following it would
  test the OLD secret-in-body flow on /toggle and conclude
  'auth gap exists' \u2014 false positive.

Remove the runbook. Real verification now happens via:
- 60+ unit tests in plugins/omi-telegram-app/test/ (FastAPI TestClient,
  exercise /setup + /webhook + /toggle + /.well-known/omi-tools.json)
- Manual smoke testing through the Omi Desktop UI (Settings \u2192 AI
  Clone \u2192 Telegram card, exercising the real bearer-auth flow)

Also drop the now-stale 'scripts/, E2E_RUNBOOK.md' reference from the
Dockerfile header comment (.dockerignore entries for those paths
remain harmless and document the build-context exclusions).

maintainer-flagged
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
Addresses cubic + maintainer review on PR BasedHardware#8528/BasedHardware#8531:

1. auth.py: get_plugin_token() now strips whitespace from the env
   value. A whitespace-only AI_CLONE_PLUGIN_TOKEN is treated as
   'not configured' (returns empty string), rejecting Bearer-of-spaces.
   Identified by maintainer review on PR BasedHardware#8528.

2. test_omi_qos_tiers.py: test_graph_py_key updated to expect
   'persona_chat' instead of 'chat_graph' in graph.py. We changed
   the feature name from chat_graph (wrong model, gpt-4.1-mini) to
   persona_chat (correct model, gpt-4.1-nano) as part of the
   streaming fix. The old test expected the wrong feature name.

Pre-existing failures NOT caused by our changes:
- test_lock_bypass_fixes.py (14 failures on main too)
- test_omi_qos_tiers.py Gemini thinking budget (env-dependent)
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
cubic-found P1 on PR BasedHardware#8528:

1. plugin_discovery.py: single fixed file path
   (ai-clone-plugin.json) breaks concurrent multi-plugin discovery.
   Telegram + WhatsApp running simultaneously would overwrite each
   other's file. Fixed: each plugin gets its own file
   (ai-clone-plugin-{plugin_type}.json). Backward-compatible default
   path maintained for single-plugin dev.

2. Both simple_storage.py: fixed .tmp filename races between
   concurrent writers. Now uses {path}.{pid}.tmp for uniqueness.

3. clear_discovery() now accepts plugin_type parameter to target
   the correct per-plugin file.

4. Tests updated: monkeypatch discovery_file() instead of
   DISCOVERY_FILE constant.

169/169 tests pass.
choguun added a commit to choguun/omi that referenced this pull request Jul 1, 2026
cubic-found P1 on PR BasedHardware#8528:

1. plugin_discovery.py: single fixed file path
   (ai-clone-plugin.json) breaks concurrent multi-plugin discovery.
   Telegram + WhatsApp running simultaneously would overwrite each
   other's file. Fixed: each plugin gets its own file
   (ai-clone-plugin-{plugin_type}.json). Backward-compatible default
   path maintained for single-plugin dev.

2. Both simple_storage.py: fixed .tmp filename races between
   concurrent writers. Now uses {path}.{pid}.tmp for uniqueness.

3. clear_discovery() now accepts plugin_type parameter to target
   the correct per-plugin file.

4. Tests updated: monkeypatch discovery_file() instead of
   DISCOVERY_FILE constant.

169/169 tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependency-review Touches dependencies or lockfiles; needs dependency review feature-fit-review Needs product/feature-fit assessment by maintainer needs-maintainer-review Needs human maintainer review before merge security-review Needs security/privacy review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants