Skip to content

feat(remote-access): mobile companion — Telegram first (foundation)#1339

Open
Astro-Han wants to merge 38 commits into
devfrom
claude/remote-access-companion
Open

feat(remote-access): mobile companion — Telegram first (foundation)#1339
Astro-Han wants to merge 38 commits into
devfrom
claude/remote-access-companion

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the mobile companion foundation plus its first platform (Telegram, manual token). From a chat app on your phone you can drive a PawWork desktop session: send prompts, read replies, and answer permission / question prompts. The desktop connects out to the Telegram Bot API and long-polls — nothing needs to be reachable from the internet.

  • remote-bridge Telegram adapter (packages/remote-bridge/src/platforms/telegram.ts) — a thin wrapper over the raw Bot API (getUpdates long-poll, sendMessage) implementing the existing Platform seam. No bot-framework dependency; the package stays zero-adapter-dep. Handles message chunking (4096-code-unit cap), 429 retry_after, and fatal vs. transient errors.
  • Security pairing (captureFirstSender) — closed by default. On connect, the bot waits for your first private message and only that sender is allowed to drive the agent. No wildcard / open audience.
  • In-process bridge runtime in Electron main (remote-bridge.ts) — serialized start/stop (no double poller), optimistic connecting → connected, degraded on run failure. Credentials stored main-process only via Electron safeStorage (encrypted at rest, 0o600); the renderer only ever sees masked status.
  • Settings → Remote access UI — paste token → message your bot → approve pairing → connected. Disconnect is a two-button confirm that wipes the saved token. en/zh i18n with full parity.

Why

Issue #1188 (maintainer P0): let people monitor and interact with running agents from their phone. The earlier Go bridge (#1275) was merged as a TS port (#1336) that left only the Platform seam, with no adapter. This PR fills that seam with a real platform, keeping to the desktop ethos: download-and-use, no public IP, no port forwarding, no ops.

Scope is deliberately small and built to extend: one platform, manual token. WeChat / Feishu / Slack / Discord / QR onboarding and the per-platform registry are explicitly deferred to follow-up PRs — the Platform interface is the extension point.

Related Issue

Part of #1188. Supersedes #1334 (same goal, rebuilt on the in-process bridge; #1334 is closed).

Human Review Status

Pending

Review Focus

  • Pairing concurrency (telegram.ts captureFirstSender + remote-bridge.ts): the capture poll and the real bridge must never getUpdates on the same token at once (Telegram returns 409). Offset is handed off via maxUpdateId + 1 and the backlog is drained first so a stale message can't pair the wrong account.
  • Credential isolation (remote-credentials.ts): token lives only in the main process via safeStorage; it never crosses the renderer-facing store-get/store-set IPC. Renderer gets masked status only.
  • Bridge lifecycle (remote-bridge.ts): single-entry serialized connect/disconnect, App.run() failure → degraded (never an unhandled rejection), idempotent stop with timeout, started after health.wait and torn down before killSidecar.
  • Closed-by-default audience: unauthorized senders are silently dropped; no open/wildcard default.

Risk Notes

  • Credentials & deletion: a Telegram bot token is stored encrypted under userData/remote-bridge-credentials.json (+ remote-bridge-state.json), mode 0o600. Disconnect deletes both. safeStorage is required — if encryption is unavailable the store throws rather than writing plaintext.
  • Platform (macOS/Windows): safeStorage backs onto Keychain (macOS) / DPAPI (Windows); userData paths and the main-process lifecycle were considered for both. No packaging/updater/signing surface changed.
  • No new adapter dependencies: only a workspace dependency on @opencode-ai/remote-bridge and allowImportingTsExtensions in the desktop tsconfig (consume the package's .ts source; valid under noEmit). bun.lock updated accordingly.
  • Screenshots skipped (checklist item): visible UI was added, but live in-app screenshots require a packaged Electron build. A faithful rendered design mockup (6 panels: not-connected → paste token → waiting → approve account → connected → disconnect confirm) was produced and shared during design review; live screenshots to follow before merge if requested.

How To Verify

typecheck (tsgo): remote-bridge OK, desktop-electron OK, app OK
remote-bridge tests:        112 pass / 0 fail (15 new Telegram tests)
desktop-electron runtime:     8 pass / 0 fail (remote-bridge.test.ts)
app i18n parity + branding:   4 pass / 0 fail

New Telegram tests cover: message chunking (incl. surrogate pairs), allowlist silent-drop, getUpdates offset handoff, bad-token rejection → degraded, capture drain/ignore-group/abort. Runtime tests cover: single live bridge under concurrent connect, fatal run → degraded (not unhandled), full pairing lifecycle (start → confirm saves → disconnect wipes).

Screenshots or Recordings

Live in-app screenshots pending a packaged build (see Risk Notes). The implemented UI follows the design mockup reviewed with the maintainer: Settings → Remote access with a Telegram row (status icon + Connect/Disconnect), a 3-step connect dialog (paste token → "message your bot" wait → "allow this account" approve), and a danger-confirm disconnect dialog.

Checklist

How to use this checklist:

  • Tick a box by replacing [ ] with [x]. Do not edit, add, or remove items.
  • The bot-applied label items can only be honestly ticked AFTER the PR is opened and the labeler / priority-triage bots have run — return to the PR description and tick them then.
  • Most items are required. The few that are conditional are explicitly marked (conditional); for those, leave unticked if they truly do not apply and explain why in Risk Notes. All other items must be ticked before requesting human review.
  • Type label — this PR carries exactly one of bug, enhancement, task, documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of app, ui, platform, harness, ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added secure “mobile companion” remote access with Telegram token pairing, approval, and disconnect.
    • Added a live Remote access Settings tab with real-time status (connected/connecting/degraded/disconnected), paired identity details, and connect/disconnect dialogs.
  • Documentation
    • Expanded documentation for the remote bridge platform adapter and Telegram integration expectations.
  • Tests
    • Added Telegram bridge/runtime tests, credential/encryption store tests, updated gateway/engine expectations, and a Remote access UI snapshot.
  • Chores
    • Improved remote API exposure and desktop integration with status subscriptions and coordinated readiness handling.

@github-actions github-actions Bot added ci Continuous integration / GitHub Actions app Application behavior and product flows ui Design system and user interface platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions P2 Medium priority labels Jun 16, 2026

@github-actions github-actions 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.

Suggested priority: P2 (includes user-path files (packages/app/src/app.tsx, packages/app/src/components/dialog-connect-remote.tsx, packages/app/src/desktop-api-contract.ts, packages/app/src/desktop-api.ts, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/pages/settings/remote.tsx, packages/desktop-electron/src/main/index.ts, packages/desktop-electron/src/main/ipc/remote.ts, packages/desktop-electron/src/main/remote-bridge.test.ts, packages/desktop-electron/src/main/remote-bridge.ts, packages/desktop-electron/src/main/remote-credentials.ts, packages/desktop-electron/src/preload/index.ts, packages/desktop-electron/src/preload/types.ts)).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 23a3ef4b-e059-4341-bff3-e0a9c89d0f89

📥 Commits

Reviewing files that changed from the base of the PR and between 5afa140 and 93b4211.

📒 Files selected for processing (4)
  • packages/app/src/components/dialog-connect-remote.tsx
  • packages/app/src/pages/settings/remote-connect-toast.test.ts
  • packages/app/src/pages/settings/remote-connect-toast.ts
  • packages/app/src/pages/settings/remote.tsx
✅ Files skipped from review due to trivial changes (1)
  • packages/app/src/pages/settings/remote-connect-toast.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/app/src/pages/settings/remote.tsx

📝 Walkthrough

Walkthrough

Adds a Telegram remote bridge feature enabling a mobile device to send messages to the desktop agent. The change introduces shared RemoteBridge contract types, a full Telegram Bot API platform adapter with polling and pairing logic, a serialized RemoteBridgeRuntime with encrypted Electron credential storage, IPC wiring between the main process and renderer, locale-aware i18n infrastructure integrated with the engine, and a live Settings page with connect/disconnect dialogs and updated English/Chinese i18n strings.

Changes

Telegram Remote Bridge

Layer / File(s) Summary
RemoteBridge shared contract and type exports
packages/app/src/desktop-api-contract.ts, packages/app/src/desktop-api.ts, packages/app/src/app.tsx, packages/desktop-electron/src/preload/types.ts, packages/desktop-electron/package.json, packages/desktop-electron/tsconfig.json
Defines RemoteState, RemoteStatus, RemotePairingResult, and RemoteBridge interface; re-exports types via desktop-api.ts; extends window.api and ElectronAPI with optional remote property; adds @opencode-ai/remote-bridge workspace dependency; enables allowImportingTsExtensions for TypeScript source imports.
Telegram Bot API platform adapter
packages/remote-bridge/src/platforms/telegram.ts, packages/remote-bridge/src/platforms/telegram.test.ts, packages/remote-bridge/src/platforms/README.md, packages/remote-bridge/src/types.ts
Implements TelegramPoller (long-poll loop with offset tracking, retry/backoff, sendMessage), TelegramPlatform (token validation, inbound dispatch, reply/send routing), and captureFirstSender pairing primitive. Adds error classification, message normalization, UTF-16-safe chunking, and context reconstruction. Updates Platform.start signature with optional onReady callback. Comprehensive test suite covers helpers, polling/platform behavior, conflict handling, message routing, and pairing backlog-draining/capture/ack flows with a fake Bot API server. Adds platform adapter contract documentation.
RemoteBridgeRuntime and encrypted credential storage
packages/desktop-electron/src/main/remote-bridge.ts, packages/desktop-electron/src/main/remote-credentials.ts, packages/desktop-electron/src/main/remote-bridge.test.ts, packages/desktop-electron/src/main/remote-credentials.test.ts
Implements RemoteBridgeRuntime with serialized async queue, pairing/bridge AbortControllers, pending pairing tracking, and degraded-status capture on fatal failures. Implements safeStorageCredentialStore with versioned encrypted JSON envelopes, fault-tolerant load(), and secure save()/clear() with 0o600 permissions. Full lifecycle, concurrency, and credential-persistence test coverage.
Electron main process setup and IPC wiring
packages/desktop-electron/src/main/index.ts, packages/desktop-electron/src/main/ipc/remote.ts
Initializes remoteBridge with encrypted credential store backed by Electron safeStorage; calls startIfConfigured() asynchronously after app readiness; stops bridge during before-quit. Implements registerRemoteIpc to broadcast remote:status to all windows and register ipcMain.handle endpoints for status/pairing/disconnect, mapping PairingCancelledError to null.
Preload API exposure
packages/desktop-electron/src/preload/index.ts
Exposes api.remote with IPC-backed methods (getStatus, startPairing, cancelPairing, confirmPairing, disconnect) and an onStatus subscription that registers an ipcRenderer listener and returns an unsubscribe function.
Locale-aware i18n infrastructure and Engine integration
packages/remote-bridge/src/i18n.ts, packages/remote-bridge/src/i18n.test.ts, packages/remote-bridge/src/engine.ts, packages/remote-bridge/src/engine.test.ts, packages/remote-bridge/src/gateway.ts, packages/remote-bridge/src/gateway.test.ts
Introduces new i18n.ts module with Locale type, localized English/Chinese message tables, t() translation helper with {param} substitution, and normalizeLocale() coercion. Updates Engine constructor to accept locale parameter and replaces hardcoded user-facing strings with locale-parameterized t() calls. Updates gateway.ts to accept Config.locale, normalize and pass it to Engine, and introduces App.run(onReady) callback that gates onReady until all platforms signal readiness via optional Platform.start(handler, onReady) callback. Comprehensive test coverage for i18n functions and Engine locale-aware prompts including Chinese support.
Settings RemotePage, dialogs, and toast coordination
packages/app/src/pages/settings/remote.tsx, packages/app/src/components/dialog-connect-remote.tsx, packages/app/src/components/dialog-disconnect-remote.tsx, packages/app/src/pages/settings/remote-connect-toast.ts, packages/app/src/pages/settings/remote-connect-toast.test.ts, packages/app/src/pages/settings/settings-shell.tsx
Transforms RemotePage into a live reactive status view with RemoteStatus subscription, localized labels/details, TelegramMark and Capability components, and dynamically imported dialogs. Implements DialogConnectRemote as a three-step state machine (token → waiting → confirm) with alive cleanup guard. Implements DialogDisconnectRemote with busy-state button disabling. Adds connectToastAction helper to manage deferred toast firing based on pairing state transitions. Adds "remoteAccess" tab to settings navigation.
English and Chinese i18n strings
packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/i18n/remote-placeholders.test.ts
Adds complete Telegram-specific translation keys: description, section header, connection states, paired identity label, actions, bot token prompt/help, authorization flow (waiting/confirmation), success toast, disconnect confirmation. Includes test verifying {{name}} template interpolation.
Electron bundling configuration
packages/desktop-electron/electron.vite.config.ts, packages/desktop-electron/electron-vite.config.test.ts
Excludes @opencode-ai/remote-bridge from externalizeDeps so it is bundled into the main process (while keeping node-pty externalized). Updates test assertions to validate bundling behavior.
End-to-end and smoke tests for remote access
packages/app/e2e/snap/settings-remote.snap.ts, packages/app/e2e/settings/settings-shell.spec.ts
Adds Playwright snapshot test that injects a stubbed window.api.remote and captures UI states through the full connect/disconnect flow (disconnected, token entry, waiting, confirmation, connected, degraded, disconnect prompt). Updates the settings shell smoke test to validate the new "Remote access" tab presence and button/row rendering.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant RemotePage
  participant DialogConnect
  participant PreloadAPI
  participant MainProcess
  participant RemoteBridge
  participant TelegramAPI
  rect rgba(70, 130, 180, 0.5)
    note over User,TelegramAPI: Connect Flow
    User->>RemotePage: click Connect button
    RemotePage->>DialogConnect: open via dynamic import
    User->>DialogConnect: enter bot token
    DialogConnect->>PreloadAPI: api.remote.startPairing(token)
    PreloadAPI->>MainProcess: ipcRenderer.invoke("remote:start-pairing")
    MainProcess->>RemoteBridge: startPairing(token)
    RemoteBridge->>TelegramAPI: getMe(token) validation
    RemoteBridge->>TelegramAPI: getUpdates (drain backlog)
    TelegramAPI-->>RemoteBridge: first new private message
    RemoteBridge-->>DialogConnect: RemotePairingResult (sender info)
    DialogConnect->>User: show "Allow" confirmation
    User->>DialogConnect: click Allow
    DialogConnect->>PreloadAPI: api.remote.confirmPairing()
    PreloadAPI->>MainProcess: ipcRenderer.invoke("remote:confirm-pairing")
    MainProcess->>RemoteBridge: confirmPairing()
    RemoteBridge->>RemoteBridge: persist credentials via safeStorage
    RemoteBridge->>TelegramAPI: start polling bridge
    RemoteBridge->>MainProcess: onStatusChange fired
    MainProcess->>RemotePage: send("remote:status", connected)
    RemotePage->>User: show connected status icon
  end
  rect rgba(180, 70, 70, 0.5)
    note over User,TelegramAPI: Disconnect Flow
    User->>RemotePage: click Disconnect
    RemotePage->>DialogConnect: open disconnect dialog
    User->>DialogConnect: confirm
    DialogConnect->>PreloadAPI: api.remote.disconnect()
    PreloadAPI->>MainProcess: ipcRenderer.invoke("remote:disconnect")
    MainProcess->>RemoteBridge: disconnect()
    RemoteBridge->>RemoteBridge: stop bridge, clear credentials
    RemoteBridge->>MainProcess: onStatusChange fired
    MainProcess->>RemotePage: send("remote:status", disconnected)
    RemotePage->>User: show disconnected state
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~80 minutes

Possibly related PRs

  • Astro-Han/pawwork#951: Introduces the new two-layer SettingsShell/SettingsTab structure upon which this PR's "remoteAccess" tab wiring and RemotePage rendering are built.

Suggested labels

desktop

🐇 A Telegram bridge takes flight,
Pairing tokens shining bright,
Encrypted keys in safe storage rest,
Mobile messages pass the test!
Locales switch with Chinese grace,
Connected code runs at full pace! 📱✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/remote-access-companion

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements a mobile-companion remote bridge that allows users to securely connect and message their desktop agent via Telegram. It introduces the frontend pairing and connection UI, localized strings, IPC communication, secure credential storage using Electron's safeStorage, and a background Telegram polling runtime. The review feedback highlights three key issues: first, the userName is lost upon reloading saved credentials, causing the display name to revert to a raw ID; second, the pairing backlog is only drained once, which can fail if there are more than 100 pending messages; and third, a non-numeric update_id can propagate NaN to the polling offset, permanently breaking subsequent requests.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/desktop-electron/src/main/remote-credentials.ts Outdated
Comment thread packages/remote-bridge/src/platforms/telegram.ts Outdated
Comment thread packages/remote-bridge/src/platforms/telegram.ts Outdated
@Astro-Han Astro-Han added enhancement New feature or request and removed ci Continuous integration / GitHub Actions labels Jun 16, 2026
@github-actions github-actions Bot added the ci Continuous integration / GitHub Actions label Jun 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/app/src/components/dialog-connect-remote.tsx`:
- Around line 46-50: The startPairing() call can resolve after the user cancels
the dialog, causing the store to update to the "confirm" step despite the
cancellation. Implement a pairing-attempt identifier (such as an incremented ID
or unique token) that is created when startPairing() begins. When the user
cancels, increment or invalidate this identifier. After the startPairing()
promise resolves, verify that the pairing-attempt identifier still matches the
current one before updating the store with setStore(). This ensures stale
completions from earlier pairing attempts are ignored and won't overwrite the
user's cancellation action. Apply this same pattern to the other location in the
file where a similar async result is handled.

In `@packages/app/src/i18n/en.ts`:
- Around line 968-983: The interpolation tokens in the translation strings are
using single braces instead of double braces, which will cause runtime values to
not be substituted correctly. In the en.ts file, update the following string
tokens: change {name} to {{name}} in the "settings.remote.pairedWith" string and
the "settings.remote.connect.confirm.body" string, and change {error} to
{{error}} in the "settings.remote.connect.error" string. This ensures that the
i18n interpolation system will properly substitute the actual values at runtime
instead of displaying raw placeholder text.

In `@packages/app/src/pages/settings/remote.tsx`:
- Around line 35-45: The statusLabel function in remote.tsx uses a default case
instead of explicitly handling all status states, causing an exhaustive-switch
lint error. Replace the default case with an explicit "disconnected" case that
returns the same language.t("settings.remote.status.disconnected") translation.
This ensures the switch statement covers all possible status states explicitly,
allowing the linter to properly type-check any future additions to the status
state type.

In `@packages/desktop-electron/src/main/remote-bridge.ts`:
- Around line 120-129: In the try-catch block where poller.getMe is called, the
catch handler currently always throws a generic "could not reach Telegram with
that token" error. You need to differentiate between an aborted signal (which
occurs when cancelPairing() is called) and other errors. Check if the caught
error is an AbortError or if the signal ac.signal has been aborted, and if so,
throw PairingCancelledError instead of the token error message. For non-abort
errors, keep the existing "could not reach Telegram with that token" error
message.

In `@packages/desktop-electron/src/main/remote-credentials.ts`:
- Around line 47-50: The load() method in the RemoteCredentials class is
dropping the userName field that is persisted by save(), causing identity
information to be lost on restart. Modify the return statement in the load()
method to include the userName field along with token and allowFrom. Update the
validation check on line 49 to verify that userName exists alongside token and
allowFrom, then include userName in the returned object on line 50 to preserve
the complete credential data across application relaunches.

In `@packages/remote-bridge/src/platforms/telegram.ts`:
- Around line 354-378: The captureFirstSender function has two paths without
retry logic for transient errors: the initial backlog poll and the final ack
call. The main polling loop (lines 361-370) already implements retry logic using
isFatalTelegramError to distinguish transient from fatal errors, but the backlog
fetch and ack skip this protection. Extract the retry logic from the main loop
into a helper function (e.g., getUpdatesWithRetry) that accepts the offset,
signal, and optional timeout parameter, handles the try-catch with
isFatalTelegramError checks, sleeps on transient errors using the same backoff
calculation, and returns the updates or retries. Then replace the standalone
backlog poll call and the ack call with invocations of this helper to ensure
both paths benefit from consistent transient error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 90ab82b8-60ca-43ff-a527-badfe77cf0e2

📥 Commits

Reviewing files that changed from the base of the PR and between 7ec06f5 and 0828c15.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • packages/app/src/app.tsx
  • packages/app/src/components/dialog-connect-remote.tsx
  • packages/app/src/desktop-api-contract.ts
  • packages/app/src/desktop-api.ts
  • packages/app/src/i18n/en.ts
  • packages/app/src/i18n/zh.ts
  • packages/app/src/pages/settings/remote.tsx
  • packages/desktop-electron/package.json
  • packages/desktop-electron/src/main/index.ts
  • packages/desktop-electron/src/main/ipc/remote.ts
  • packages/desktop-electron/src/main/remote-bridge.test.ts
  • packages/desktop-electron/src/main/remote-bridge.ts
  • packages/desktop-electron/src/main/remote-credentials.ts
  • packages/desktop-electron/src/preload/index.ts
  • packages/desktop-electron/src/preload/types.ts
  • packages/desktop-electron/tsconfig.json
  • packages/remote-bridge/src/platforms/README.md
  • packages/remote-bridge/src/platforms/telegram.test.ts
  • packages/remote-bridge/src/platforms/telegram.ts

Comment thread packages/app/src/components/dialog-connect-remote.tsx
Comment thread packages/app/src/i18n/en.ts Outdated
Comment thread packages/app/src/pages/settings/remote.tsx
Comment thread packages/desktop-electron/src/main/remote-bridge.ts Outdated
Comment thread packages/desktop-electron/src/main/remote-credentials.ts
Comment thread packages/remote-bridge/src/platforms/telegram.ts Outdated
Astro-Han added 11 commits June 16, 2026 23:53
…pturing a sender

A single immediate getUpdates returns at most one batch (~100 updates); a larger
pre-pairing backlog left the rest, and the first long-poll would then hand back a
stale message as the newly-approved sender. Loop the immediate drain until empty.
…rnalizing its TS source

remote-bridge exports only .ts source; externalized, the built out/main kept a bare
import that resolves to .ts at runtime (Electron can't run it) and trips the
runtime-import guard. Exclude it from externalizeDeps so Vite inlines it.
…entials

load() dropped userName, so after a restart the settings page showed the raw user
id instead of the approved display name.
…nding it

startPairing now stashes the token main-side; confirmPairing() takes no args and
approves the captured identity, so the renderer never resends the secret and can't
supply or swap a token at confirm time.
List the "disconnected" case explicitly instead of relying on default;
@typescript-eslint/switch-exhaustiveness-check does not treat default as
covering a union member.
The P1a fix added exclude: ["@opencode-ai/remote-bridge"] to externalizeDeps;
assert node-pty stays externalized and remote-bridge stays bundled.
…rministic test

The store imported electron directly, so its unit test relied on a global
electron module mock — order-dependent across test files (and it linked/evaluated
real electron, which fails without an installed binary). Make the store
Electron-free with an injected env; wire the safeStorage-backed env in index.ts.
The test now uses a fake env, no electron mock.
The resolver (@solid-primitives/i18n) substitutes {{name}}, not {name}, so the
remote strings rendered the raw braces. Fix pairedWith and confirm.body in both
locales and drop the unused connect.error key. Add a resolver test over the real
strings.
The final ack used .catch(() => []), so a transient failure left the captured
pairing message un-acked; the steady-state bridge (polling from offset 0) would
then replay it as the user's first prompt. Extract getUpdatesWithRetry and use it
for the drain, the wait, and the ack.
…cting it

startPairing set pending unconditionally after capture; a sender arriving after
cancelPairing (or a superseding startPairing) would revive an abandoned pairing.
Throw PairingCancelledError if the attempt was aborted or superseded before
storing pending.
@github-actions github-actions Bot added the harness Model harness, prompts, tool descriptions, and session mechanics label Jun 16, 2026
splitForTelegram broke a long reply on a line boundary by slicing the chunk
before the newline and then stripping a leading newline from the remainder, so
the delimiter was dropped from both sides — reassembling the chunks did not
equal the original text. Keep the newline as the chunk's last char and stop
stripping the remainder, so every character lands in exactly one chunk.

Test: a reply with a newline in the split window; reassembled bodies equal the
original. remote-bridge: typecheck clean, 115 tests pass.
writeFileSync's mode argument only applies when the file is created; rewriting
an existing credentials file kept its old permissions, so a token file ever
left world-readable (older build, copy) would not be tightened on the next
save. chmodSync the file to 0o600 after writing. No-op on Windows, which has no
POSIX mode bits.

Test (POSIX-only): pre-create the file 0o644, save(), assert final mode 0o600.
desktop-electron: typecheck clean, 541 tests pass.
Astro-Han added 16 commits June 17, 2026 11:47
RemotePage rendered an active Connect button even when window.api.remote was
absent (web preview, or a preload regression); clicking it opened a dialog that
silently no-ops. Render a disabled control and a 'requires the desktop app'
label when the preload API is missing, so the connect flow cannot be started
into a dead end.

The page is not yet wired into the settings surface, so there is no runtime
route to it; verified by typecheck and inspection. Lint clean, app typecheck
clean.
The connect dialog guarded late startPairing() results only by dialog lifetime
(alive), not by pairing attempt. If the main process captured a sender and the
IPC success was in flight at the moment the user cancelled (backToToken: step ->
token), the resolved continuation still ran and set step -> confirm, reversing
the UI after cancel. The main-side cancel guard can't prevent this because the
main startPairing may already have resolved before the cancel IPC arrives.

Add a monotonic attempt id, bumped on cancel/restart, and drop any resolution
whose id is no longer current. The dialog is not yet wired into settings (no
runtime route), so this is verified by typecheck/inspection; the render test
belongs with the wiring-in change. Lint clean, app typecheck clean.
The remote access page and connect dialog existed but were unreachable:
RemotePage was referenced nowhere and settings-shell deliberately omitted the
tab. That omission was a leftover note from the settings rewrite (#975), which
predates this work and is unrelated to it — not a decision to defer remote
access. Per the one-PR-per-provider plan, landing the Telegram page means wiring
it in: register the remoteAccess tab (type, TAB_VALUES, NAV_ITEMS with the
remote-control icon, content Match) after Integrations, and drop the stale note.

Add a settings-remote snap target that injects a window.api.remote stub (snap is
web Chromium with no preload) to capture the disconnected page, the token step,
and the confirm step. Lint + app typecheck clean.
Direction A (chosen): the Telegram row gains a vendor logo and a 2px status
left-rule (green connected / red degraded / neutral idle, per the cards rule in
docs/DESIGN.md), replacing the bare status line. Add a 'What you can do'
capability list (send prompts, approve permissions, switch/stop sessions) using
existing in-set glyphs (prompt / lock / new-session), and a footnote covering
@Botfather pairing and encrypted-on-device token storage. Connect stays
secondary for consistency with the other settings pages.

Verified with the settings-remote snap (web stub) plus lint and app typecheck.
The @Botfather pairing note sat as a page-level footnote, but it is
Telegram-specific and read as guidance for the whole Remote access page (and
would be wrong once other providers land). Move it inside the Telegram block,
under the same 2px status left-rule as the status row, so it clearly belongs to
Telegram. 'What you can do' stays page-level. Lint + app typecheck clean;
verified via the settings-remote snap.
The @Botfather pairing note was guidance for the connect flow, not a fact the
settings page needs to show at rest. Remove it from the page (the Telegram block
is back to a single status row + the page-level 'What you can do'). @Botfather
and the first-sender rule already live in the dialog (token.help / waiting.body);
fold the one missing point — the token is stored encrypted on this computer —
into token.help, where the user pastes it. Drops the settings.remote.note key.
Lint + app typecheck clean; verified via the settings-remote snap.
TelegramPlatform.start hardcoded runLoop(0), so after a restart Telegram would
redeliver any still-unacked updates and the bridge would re-dispatch them —
replaying a prompt re-drives the agent. Drain the backlog on (re)start (advance
the offset past what is queued without dispatching) and poll forward from the
live tip, so an offline-period prompt and a crash-interrupted batch are both
dropped instead of replayed.

This also subsumes message-id dedup: Telegram's offset ack already prevents
in-process duplicates, and the drain closes the cross-restart window. Extracted
the shared drain helper that pairing capture already used.
startBridge set status to 'connecting' then awaited serverInfo()/buildApp(). If
either threw, the exception propagated with the status stuck on 'connecting'.
startIfConfigured has its own catch, but confirmPairing awaits startBridge
directly, so a failed confirm could hang the UI on connecting forever. Set
degraded on any pre-run build failure (run() failures already become degraded),
so every failure path ends on a terminal status.
The comments claimed the bot token 'never crosses the renderer IPC boundary',
but the user pastes it once over remote:start-pairing. Correct them: the token
crosses IPC exactly once inbound; from there it is main-only and never sent back
(confirm carries no token, status is masked, the stored secret never returns).
No behavior change.
The shell spec still asserted the Remote access tab was absent (hidden until
ready), but this PR wired it in as a real entry — failing e2e-artifacts. Assert
the new fact: 7 tabs including Remote access, and clicking it renders the Telegram
connection row (Connect button + Telegram). Verified locally — 4 passed.
runLoop, drainBacklog, and captureFirstSender each hand-wrote the offset advance,
and Number(update_id) on a malformed id poisoned the offset with NaN — every later
getUpdates would then request offset=NaN (serialized to null) and replay the
backlog. Extract nextOffset(offset, update): advance only on a finite id, else
hold the offset. Added a malformed-update_id test.
before-quit runs `void stop(); killSidecar()` without awaiting, so the bridge's
abort previously landed inside the queued stopBridge — possibly after the sidecar
was already killed, leaving the poll loop running against a dead server. stop()
now aborts the live bridge on its synchronous prefix, before the first await, so
the abort precedes killSidecar. Added a test asserting the abort fires before
stop()'s promise settles.
The snap stubbed only the disconnected page plus the token/confirm dialogs. Make
the stubbed status mutable (driven via window.__remote) and park startPairing, so
one run snapshots every state the user sees: the disconnected/connected/degraded
pages and the token/waiting/confirm/disconnect dialogs.
Question/permission answers are typed text today. Capture the inline-keyboard /
card optimization a later agent would pick up — the Platform/engine/telegram changes,
the 64-byte callback_data trap, and the multi-select/multi-question staging — so the
plan isn't lost. Deferred on purpose in favour of broadening platform support first.
Question/permission/command copy was hardcoded English. Add Config.locale driving
an engine lookup table (en/zh) — plain tables + a tiny t(), no i18n framework for
~25 strings. Chinese uses the app's localized name 爪印 and drops the product name
in command replies where chat context already makes the sender obvious.

Clarity, in both languages: number multi-question prompts (Question N / 问题 N) so
'one answer per line' maps onto them; hints branch by single/multi-select and
multi-question; the line-count error states the count. permissionReplyForText also
accepts Chinese keywords (是/总是/否…) so a zh user never switches to English.
Chinese copy polished by DeepSeek v4-pro. Locale is wired from the desktop next.

Tests: i18n unit (t/normalizeLocale) + engine zh integration (numbering, hints,
line-count error, Chinese reply keyword).
The bridge renders chat copy in a locale now; wire it from the desktop's current
UI language (desktop-context-store) so a Chinese user gets Chinese prompts with no
extra setting. gateway re-exports normalizeLocale; the runtime reads deps.locale()
at bridge start and normalizes it into Config.locale (a snapshot — a language
change takes effect on the next reconnect).
confirmPairing() saved the token, and the safeStorage availability check
lived inside save() — so on a system without OS encryption the user walked
the entire token → message bot → confirm flow only to fail at the final save.

Expose isAvailable() on CredentialStore and check it at the top of
startPairing(), before any Telegram contact. When encryption is unavailable
pairing fails immediately and never starts a poller.

Reported by code review (P2).
…irst message

startPairing() ran getMe() first, then capture() — whose first step drains the
backlog. The UI invites the user to message the bot the moment startPairing is
pending, so a message sent during a slow getMe sat in the queue and was then
swept up by that drain. Pairing waited forever for a second message.

Fold verify + drain + capture into one pairing primitive: captureFirstSender now
drains (pinning the offset baseline and proving the token) BEFORE fetching the
bot identity, then long-polls for the first sender and returns botUsername too.
A message arriving during getMe now lands past the baseline and is captured.

Reported by code review (P2).
@Astro-Han

Copy link
Copy Markdown
Owner Author

Review round — pairing robustness

P2 — pairing dropped the user's first message (fixed fd5d6d5):
startPairing ran getMe first, then capture (whose first step drains the backlog). The UI invites the user to message the bot while startPairing is pending, so a message sent during a slow getMe sat in the queue and was swept up by that drain — pairing waited forever. Folded verify + drain + capture into one primitive: captureFirstSender now drains (pinning the offset baseline and proving the token) before fetching the bot identity, then long-polls. New test asserts drain precedes getMe and the post-baseline message is captured.

P2 — secure storage failed only at the final save (fixed 4eebe63):
confirmPairingsave checked isEncryptionAvailable last, so a box without OS encryption walked the user through the whole token → message → confirm flow before failing. Added isAvailable() to CredentialStore, preflighted at the top of startPairing before any Telegram contact. New test asserts it fails fast with no poller/capture.

Gemini bot — 3 open threads were stale (reviewed an earlier revision), resolved with current-HEAD evidence: load() already returns userName; drainBacklog already loops multi-batch; nextOffset already guards NaN. Each is covered by an existing test.

remote-bridge 21 pass · desktop remote 16 pass · typecheck clean.

…serving

startBridge reported "connected" the instant app.run() returned a Promise, but
run() still has to ready the event stream, hydrate, and let TelegramPlatform.start
drain the Telegram backlog before installing its live poll loop. A first prompt
sent right after the success status arrived during that window and was discarded
as backlog instead of delivered.

Thread an onReady signal from the platform up through the gateway: TelegramPlatform
fires it once the backlog drain is done and the poll loop is installed, App.run
fires its onReady once every platform is serving, and startBridge flips to
"connected" from that callback — staying "connecting" until then. A build/run
failure still lands on degraded, so the status can't hang.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Review fix — startup race (c3c04f1 )

[P2] Connected status could precede the live poll loop, dropping the first prompt (Codex fresh-eye)
startBridge set connected right after app.run() returned a Promise, but run() still has to ready the event stream, hydrate, and let TelegramPlatform.start drain the Telegram backlog before installing the poll loop (telegram.ts runLoop). A prompt sent in that window was swept away as backlog. Verified: run() resolves its readiness deferred on stream-ready, then hydrates and starts platforms; start() runs the poll loop inline only after drainBacklog.

Fix threads an onReady signal up the stack with no behavioral change to delivery:

  • Platform.start(handler, onReady?)TelegramPlatform fires it once the drain is done and the live loop is installed (not before).
  • App.run(signal, onReady?) — fires its onReady only after every platform is serving (and after stream + hydrate).
  • startBridge flips to connected from that callback and stays connecting until then; a build/run failure still lands on degraded, so the status can't hang.

Tests: start() signals onReady only after the backlog drain (≥2 drain polls) — telegram; run fires onReady only after stream/hydrate/platform — gateway; status stays connecting until onReady, then connected — desktop. remote-bridge 124 pass; desktop remote-bridge 14 pass; tsgo clean both packages.

… 409s

A getUpdates 409 means another client owns the bot token, so the poll loop
receives nothing. start() previously fired onReady right after installing the
loop and runLoop retried 409 forever, so the desktop showed connected over a
token that silently delivered nothing.

- runLoop fires onReady only after the first getUpdates that actually returns,
  and rejects with TelegramConflictError after MAX_CONFLICT_RETRIES 409s
  (counter resets on any success). getUpdatesWithRetry (drain/pairing) bounds
  409s the same way.
- start() hands onReady to runLoop instead of calling it on loop install.

A persistent 409 now keeps the bridge out of "ready" and rejects start(),
which the runtime surfaces as "degraded" ("another client is polling this bot
token") rather than a false "connected".
openDisconnect imported dialog-connect-remote just to reach
DialogDisconnectRemote, dragging the whole pairing state machine (token input,
capture poll, spinner, toast) into the disconnect path's chunk. Move the
disconnect confirm into dialog-disconnect-remote.tsx so it lazy-loads only the
small confirm dialog; the connect flow is unchanged.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/app/src/components/dialog-connect-remote.tsx (1)

97-104: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mask the bot token input instead of rendering it in plain text.

The pairing token is a secret, but the field is currently type="text". This exposes it on screen and during entry.

Suggested patch
-              <TextField
+              <TextField
                 autofocus
-                type="text"
+                type="password"
                 label={language.t("settings.remote.connect.token.label")}
                 placeholder={language.t("settings.remote.connect.token.placeholder")}
                 name="token"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/components/dialog-connect-remote.tsx` around lines 97 - 104,
The token input field in the remote connection dialog is exposing the secret
pairing token as plain text on screen, creating a security vulnerability. Change
the `type` attribute of the TextField component that contains `name="token"`
from "text" to "password" to mask the token input during entry, preventing it
from being visible on screen or captured in screenshots.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/app/src/components/dialog-connect-remote.tsx`:
- Around line 97-104: The token input field in the remote connection dialog is
exposing the secret pairing token as plain text on screen, creating a security
vulnerability. Change the `type` attribute of the TextField component that
contains `name="token"` from "text" to "password" to mask the token input during
entry, preventing it from being visible on screen or captured in screenshots.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c36c9290-7ce4-4900-ad6b-dc16ee4d30bb

📥 Commits

Reviewing files that changed from the base of the PR and between 7ed5be5 and 5afa140.

📒 Files selected for processing (17)
  • packages/app/src/components/dialog-connect-remote.tsx
  • packages/app/src/components/dialog-disconnect-remote.tsx
  • packages/app/src/pages/settings/remote.tsx
  • packages/desktop-electron/src/main/index.ts
  • packages/desktop-electron/src/main/remote-bridge.test.ts
  • packages/desktop-electron/src/main/remote-bridge.ts
  • packages/desktop-electron/src/main/remote-credentials.test.ts
  • packages/desktop-electron/src/main/remote-credentials.ts
  • packages/remote-bridge/src/engine.test.ts
  • packages/remote-bridge/src/engine.ts
  • packages/remote-bridge/src/gateway.test.ts
  • packages/remote-bridge/src/gateway.ts
  • packages/remote-bridge/src/i18n.test.ts
  • packages/remote-bridge/src/i18n.ts
  • packages/remote-bridge/src/platforms/telegram.test.ts
  • packages/remote-bridge/src/platforms/telegram.ts
  • packages/remote-bridge/src/types.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/desktop-electron/src/main/remote-credentials.test.ts
  • packages/desktop-electron/src/main/index.ts
  • packages/desktop-electron/src/main/remote-credentials.ts
  • packages/remote-bridge/src/platforms/telegram.test.ts
  • packages/app/src/pages/settings/remote.tsx

The connect dialog showed a "Telegram connected — you can now message your
agent" success toast the moment confirmPairing() returned, but the runtime only
reaches "connected" later, on the first live poll (onReady). With the 409
bounding, an Allow can resolve and then end up "degraded", so the toast claimed
a working bridge that silently received nothing.

Allow now just signals the page (onApproved); the page fires the success toast
only when status actually transitions to "connected", and never when it ends in
degraded/disconnected (the red status row carries the cause). The decision is a
pure connectToastAction(), unit-tested for the 409 and auto-reconnect cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows ci Continuous integration / GitHub Actions enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant