diff --git a/.agents/skills/dsh-dev/SKILL.md b/.agents/skills/dsh-dev/SKILL.md index bfdce7e212..9aa6913eea 100644 --- a/.agents/skills/dsh-dev/SKILL.md +++ b/.agents/skills/dsh-dev/SKILL.md @@ -416,4 +416,15 @@ it look newly qualified. The launcher and `verify-app` share a temporary cookie jar named from `basename(DSH_HOME)`. Two isolated homes named `home` in different parent folders overwrite that jar, so the second launch can make the first Host's doctor return 401 while its own browser still works. Give concurrent homes distinct basenames (`host-a`, `host-b`), preserve the first refusal and verify each Host separately. Do not treat this verification-cookie collision as a product authentication failure. -The qualified workspace retains a hidden sidebar copy beside the visible Channels panel. The two Add remote buttons can have the same accessible name. A first-match selector can click or fill the hidden copy; choose visible elements as the two-Host journey requires, and wait for the submit button to become enabled after input. The real Windows Web baseline paired successfully after the driver selected visible controls and used keyboard input; no product change was required. +The qualified workspace retains a hidden sidebar copy beside the visible Channels panel. The two Add remote buttons can have the same accessible name. A first-match selector can click or fill the hidden copy; choose visible elements as the two-Host journey requires, and wait for the submit button to become enabled after input. The real Windows Web baseline paired successfully after the driver selected visible controls and used keyboard input; no product change was required. + +### Connect route provenance in pinned DSH 0.2.1-rc.2 (#2541) + +Arbitrary HTTP route headers do not enter Typert invocation context, and cookie-authenticated +browser calls share a Peer. The `connection/request` waterfall does not admit `remote.mux` +opens. A header-only or unary-only guard therefore cannot restrict all Connect calls. +BotHarness stamps its cloudflared-only origin with a process-local capability and owns both +unary admission and a bounded mux adapter at the Device Gateway, before native dispatch. +The shared client refuses before serializing secret arguments: a Host refusal cannot remove +plaintext already sent through Cloudflare. Verify HTTP, mux and ordinary streams against the +pinned Host; this adapter is not encryption or an upstream DSH API extension. diff --git a/.agents/skills/verify-deepseekbot/features/phone-pairing.md b/.agents/skills/verify-deepseekbot/features/phone-pairing.md index 34f7a5e2d0..0941592a37 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-pairing.md +++ b/.agents/skills/verify-deepseekbot/features/phone-pairing.md @@ -4,6 +4,8 @@ A Human pairs the phone app with their Host by a one-time QR code, and the phone ## Sub-features +- `full-client-removed`: no settings/chat/Inbox/routine/create action opens the full Host WebView; old `/full-client` deep links cannot load it. Private input directs the Human to the Host or Tailscale browser. + - `offer` creates a one-time pairing offer (5 minutes) with a QR code and a `deepseekbot://pair?p=…` link. - `pair` redeems the offer once; a second redeem answers `401 offer-invalid`. - `link-errors` words a refusal by how the phone got the offer: a pasted or opened link that is broken, expired or already used says the link is wrong, and a pasted one empties the field; a scan keeps the code wording. diff --git a/.agents/skills/verify-deepseekbot/features/phone-routines.md b/.agents/skills/verify-deepseekbot/features/phone-routines.md index 2e598b9d28..2562aeb7aa 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-routines.md +++ b/.agents/skills/verify-deepseekbot/features/phone-routines.md @@ -8,7 +8,7 @@ A paired phone runs, pauses, resumes, deletes, creates and edits a PersonaBot's - `run-now` calls `scheduleRunNow`; the manual firing shows under Recent runs. - `pause` calls `scheduleUpdate` with `enabled`; the button flips between Pause and Resume. - `delete` asks in place, then calls `scheduleDelete`. -- `author` uses a nested Edit or New routine page inside that same drawer, and creates (`scheduleCreate`) or edits (`scheduleUpdate`) a daily, weekly or every-N-hours routine, previewing the next runs with `schedulePreview`. Minute, once and cron triggers say the phone cannot edit them and open the full client. +- `author` uses a nested Edit or New routine page inside that same drawer, and creates (`scheduleCreate`) or edits (`scheduleUpdate`) a daily, weekly or every-N-hours routine, previewing the next runs with `schedulePreview`. Minute, once and cron triggers say the phone cannot edit them; no full-client action is available. ## How to get to it (user POV) @@ -18,7 +18,7 @@ A paired phone runs, pauses, resumes, deletes, creates and edits a PersonaBot's Preconditions: no model credential is needed. -- **Scripted journey.** `node scripts/e2e-mobile-routines-web.mjs [--skip-export]` exports the app for web, pairs it through a phone-origin proxy, creates a daily and a cron routine through the API, then checks Pause and Resume on the Host, Run now in the Host history, Delete after confirming, the cron routine's full-client note, and a new weekly routine whose preview matches the Host's `schedulePreview`, in light and dark. Evidence lands in `.humanlayer/verify/mobile-routines-web/evidence/`. +- **Scripted journey.** `node scripts/e2e-mobile-routines-web.mjs [--skip-export]` exports the app for web, pairs it through a phone-origin proxy, creates a daily and a cron routine through the API, then checks Pause and Resume on the Host, Run now in the Host history, Delete after confirming, the cron routine's read-only note, and a new weekly routine whose preview matches the Host's `schedulePreview`, in light and dark. Evidence lands in `.humanlayer/verify/mobile-routines-web/evidence/`. ## Gotchas diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index e86f9f171a..189ab6dc63 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -13,6 +13,7 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu - `message-body`: remote images, files, rich text and request cards use the existing message body, with authenticated resources and decisions on the source Host. - `upgrade`: existing browser pairings and the selected tab survive the storage-key rename. - `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs. +- `secret-route`: Connect private input shows a bilingual trusted-route notice without secret fields. Cancellation remains available; the shared Host client refuses before serialization. Loopback/Tailscale retain submission. - `viewers`: a remote Bot DM opens Browser or Computer when that Bot has access. The shared Host viewer modules read frames and send input to that Host only; closing, switching source or losing the connection tears down the viewer and releases requested control. - `viewer-control`: queued input belongs to one takeover. Resume or frame loss invalidates it; frame recovery remains read-only even when the Host Feed never disconnected. Remove closes viewers and waits up to five seconds for takeover/release cleanup before revoking the pairing. - `settings`: right-click a Remote Host icon → Remote settings opens that Host's models, usage and storage. Default model and preset changes use its shared Host client. Usage includes Memory activity, without a link to a This Host Bot page. Storage retains preview, acknowledgement and destructive confirmation. @@ -20,6 +21,7 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu Discovery uses the Host's notification query, not the Activity display window. Keep more than 30 old mentions and produce a new ordinary DM reply and a non-mention group reply; both must alert once when enabled. Traverse `deepseekbot inbox --notifications` cursors and compare the Host-decided items. Verify stable same-timestamp pagination, a live invalidation during paging, a partial-page failure followed by recovery, and no historical replay. A refused Notification constructor records a bounded redacted `remote-notification-show-*` diagnostic through the existing Client diagnostics surface. Settings apply immediately and close through the normal close control. + ## How to get to it (user POV) Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it. A remote Bot DM with Browser or Computer access shows the corresponding header button. Open its viewer, choose Pause Bot and take control, interact with the screen or send text, then Resume Bot or Close viewer. Groups have no viewer entry. @@ -55,6 +57,7 @@ For message bodies, upload an image and a text file to Host B through its attach - Control release uses the same device credential. An unreachable Host is retried when this viewer reconnects or reopens; a revoked credential cannot resume a Browser. In that case the Host operator resumes it. The Computer also has its existing Host-side pause lease. - Dismiss Continue and Skip onboarding overlays before selecting Messages. Retain any failed drive evidence before retrying. +For #2541, pair a Connect Host and open a pending private input card. Confirm the notice in both themes, absence of password fields and working Cancel. Verify marked unary and mux calls return `secret-route-refused`, and ordinary Host Feed traffic still works. The removed `/botharness-device/web/` route must return 404. Use synthetic secrets only; a model quota failure blocks live card creation and must remain an explicit QA gap. Activation race regression: while replies are disabled, write 35 old mentions and keep a notification page pending, then enable replies. The switch/CLI write commits only after a complete fresh baseline; old mentions stay silent. After success, send a new group reply and ordinary DM (including tied timestamps in automated fixtures): each alerts once. Fail a page and retry, disable/re-enable, change Host, close settings, revoke permission and dispose while activation waits; failed/cancelled operations cannot commit later. Preserve the original 37-alert failing trace separately from any subsequent pass. CLI deadline regression: let the real appearance relay refuse at eight seconds, then complete the notification baseline at nine seconds. Browser preferences and persisted storage must remain unchanged, and no late acknowledgement is sent. Repeat after control unmount; a pre-deadline successful write remains operable. Change the app locale while the DOM language is unchanged and verify private-input notification copy follows the bound translator without exposing the input. diff --git a/.github/pr-screenshots/2541/README.md b/.github/pr-screenshots/2541/README.md new file mode 100644 index 0000000000..d60a37d481 --- /dev/null +++ b/.github/pr-screenshots/2541/README.md @@ -0,0 +1,21 @@ +# Connect secret safety evidence + +Captured 2026-10-22 for #2541; all names and credential values are synthetic. + +- Before: `5210bf2a9` (includes the exact #2533 dependency `0f4b87633105`). The baseline copies of the three pictured components were checked byte-for-byte against that revision. +- After: Track A plus main `30f62e48c7d9`, integrated at `c2c21f80c`, with the routine Edit accessible label restored. +- Settings and routine pairs: Expo Web, 390 × 844 CSS pixels, scale 3, English, light/dark; real isolated DSH 0.2.1-rc.2 and Device Gateway. These are not native-device screenshots. +- Private-input pairs: cropped real Web Client remote card, 1280 × 800 browser viewport, English, light/dark. A synthetic Connect hostname maps to the actual local Connect origin; no real Cloudflare deployment or Tailscale connection is exercised. The pending card has one password field before, zero after; submit is disabled after. +- Routine capture waits for the empty history to settle. Daily edit, pause/resume, manual run/history, confirmed deletion, weekly creation and preview match were driven against the owning Host; no page errors were recorded. + +## Verification + +17 focused files / 173 tests passed after main integration: gateway, route provenance, refusal policy, HostClient preflight, remote cards/read state, Browser viewer registration and changed phone screens. Root/mobile type checks, lint, format and build passed. + +A rebuilt native DSH Host refused all eight marked endpoints over unary and mux at the Connect origin, allowed roster reads/streams and loopback credential set/unset, and returned 404 for the removed full-client path. CLI `ask-secret-submit --values-stdin` accepted a synthetic value and the canonical card status became configured. The model-created card used `deepseek-official/deepseek-flash` (catalog: DeepSeek-V4.1-Flash); this is not a claim of a completed DM reply while the Bot awaited input. + +The earlier broad local suite did not finish and is not green: source-policy timeout, docs public-surface build failure and a daylight assertion were observed. The daylight assertion also fails on the baseline. Focused tests do not replace the full CI plan. + +## Pending acceptance + +Native iOS/Android, old full-client deep links, chat/create/Activity entrances and native Browser/Computer regression captures remain pending. The shared Mac device queue is paused by the Human for combined acceptance. The runnable review path is Settings, chat overflow/private input, Activity private-input request, create menu, daily and advanced routines, followed by an old `/full-client` link. Use both themes. Actual Cloudflare/Tailscale, Human QA and the complete proposed ADR-0244 approval remain separate gates. No encryption, merge or release is claimed. diff --git a/.github/pr-screenshots/2541/private-after-dark.png b/.github/pr-screenshots/2541/private-after-dark.png new file mode 100644 index 0000000000..f6b06b2717 Binary files /dev/null and b/.github/pr-screenshots/2541/private-after-dark.png differ diff --git a/.github/pr-screenshots/2541/private-after-light.png b/.github/pr-screenshots/2541/private-after-light.png new file mode 100644 index 0000000000..4c8952f754 Binary files /dev/null and b/.github/pr-screenshots/2541/private-after-light.png differ diff --git a/.github/pr-screenshots/2541/private-before-dark.png b/.github/pr-screenshots/2541/private-before-dark.png new file mode 100644 index 0000000000..738ba2dd03 Binary files /dev/null and b/.github/pr-screenshots/2541/private-before-dark.png differ diff --git a/.github/pr-screenshots/2541/private-before-light.png b/.github/pr-screenshots/2541/private-before-light.png new file mode 100644 index 0000000000..1a221a637d Binary files /dev/null and b/.github/pr-screenshots/2541/private-before-light.png differ diff --git a/.github/pr-screenshots/2541/routine-after-dark.png b/.github/pr-screenshots/2541/routine-after-dark.png new file mode 100644 index 0000000000..9d7e870ff5 Binary files /dev/null and b/.github/pr-screenshots/2541/routine-after-dark.png differ diff --git a/.github/pr-screenshots/2541/routine-after-light.png b/.github/pr-screenshots/2541/routine-after-light.png new file mode 100644 index 0000000000..ff2ebaf421 Binary files /dev/null and b/.github/pr-screenshots/2541/routine-after-light.png differ diff --git a/.github/pr-screenshots/2541/routine-before-dark.png b/.github/pr-screenshots/2541/routine-before-dark.png new file mode 100644 index 0000000000..36ee237d41 Binary files /dev/null and b/.github/pr-screenshots/2541/routine-before-dark.png differ diff --git a/.github/pr-screenshots/2541/routine-before-light.png b/.github/pr-screenshots/2541/routine-before-light.png new file mode 100644 index 0000000000..65fa092539 Binary files /dev/null and b/.github/pr-screenshots/2541/routine-before-light.png differ diff --git a/.github/pr-screenshots/2541/settings-after-dark.png b/.github/pr-screenshots/2541/settings-after-dark.png new file mode 100644 index 0000000000..7df8961c65 Binary files /dev/null and b/.github/pr-screenshots/2541/settings-after-dark.png differ diff --git a/.github/pr-screenshots/2541/settings-after-light.png b/.github/pr-screenshots/2541/settings-after-light.png new file mode 100644 index 0000000000..b625af9871 Binary files /dev/null and b/.github/pr-screenshots/2541/settings-after-light.png differ diff --git a/.github/pr-screenshots/2541/settings-before-dark.png b/.github/pr-screenshots/2541/settings-before-dark.png new file mode 100644 index 0000000000..12133ed2a5 Binary files /dev/null and b/.github/pr-screenshots/2541/settings-before-dark.png differ diff --git a/.github/pr-screenshots/2541/settings-before-light.png b/.github/pr-screenshots/2541/settings-before-light.png new file mode 100644 index 0000000000..29160b0ca5 Binary files /dev/null and b/.github/pr-screenshots/2541/settings-before-light.png differ diff --git a/.github/pr-screenshots/remote-message-rendering/approved-dark.png b/.github/pr-screenshots/remote-message-rendering/approved-dark.png new file mode 100644 index 0000000000..1c43f10d09 Binary files /dev/null and b/.github/pr-screenshots/remote-message-rendering/approved-dark.png differ diff --git a/.github/pr-screenshots/remote-message-rendering/approved-light.png b/.github/pr-screenshots/remote-message-rendering/approved-light.png new file mode 100644 index 0000000000..013a489f4b Binary files /dev/null and b/.github/pr-screenshots/remote-message-rendering/approved-light.png differ diff --git a/.github/pr-screenshots/remote-message-rendering/image-dark.png b/.github/pr-screenshots/remote-message-rendering/image-dark.png new file mode 100644 index 0000000000..5e5db1b5a2 Binary files /dev/null and b/.github/pr-screenshots/remote-message-rendering/image-dark.png differ diff --git a/.github/pr-screenshots/remote-message-rendering/image-light.png b/.github/pr-screenshots/remote-message-rendering/image-light.png new file mode 100644 index 0000000000..900dbda7bc Binary files /dev/null and b/.github/pr-screenshots/remote-message-rendering/image-light.png differ diff --git a/.github/pr-screenshots/remote-message-rendering/pending-dark.png b/.github/pr-screenshots/remote-message-rendering/pending-dark.png new file mode 100644 index 0000000000..81558576ee Binary files /dev/null and b/.github/pr-screenshots/remote-message-rendering/pending-dark.png differ diff --git a/.github/pr-screenshots/remote-message-rendering/pending-light.png b/.github/pr-screenshots/remote-message-rendering/pending-light.png new file mode 100644 index 0000000000..efbb598877 Binary files /dev/null and b/.github/pr-screenshots/remote-message-rendering/pending-light.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index bb6d044646..39dd5917d4 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -94,7 +94,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - **+** opens a modal action panel in the island position, with close in the circular position. Back, accessibility escape and backdrop only dismiss it. The panel is always a scrollable vertical menu: each full-width row has a leading icon and left-aligned wrapping label. Target choices use the same row treatment. On iOS 26 the panel uses a native rounded-rectangle glass backdrop, including at larger text sizes; Reduce Transparency and older iOS keep an opaque raised surface. This panel opt-in does not change the existing multiline drawer-title fallback. The panel still uses the existing modal fade, not a native glass morph transition. - On Android, creation is a Material 3 **FAB** on Chats only: 56dp, 16dp corners, `canvas.chosen` (primary container), level-3 elevation, 16dp from the trailing edge and 16dp above the navigation bar. It opens a **FAB menu** (m3.material.io FAB menu tokens, `fab`): the FAB becomes a 56dp circular close button in the accent colour, and the actions stack above it, trailing-aligned, as full-pill items 56dp tall, 4dp apart, hugging their icon and label with 24dp side padding. Target choices and Back use the same items under a notice; errors show in that notice. The items scroll behind the close button when space runs out. Back, accessibility escape and the scrim dismiss it. Expo UI 58 has no Compose FAB menu, so it is drawn to the token values. - Computer: new Channel/new Bot. Owner Organization: new Channel/invite. All: new Channel/new Bot/join with link or QR, followed by explicit destination choice where needed. Member/guest workspaces do not gain owner controls. Pairing and Organization administration stay in the drawer. - - Creation binds the chosen Host ID or cloud account scope plus Organization ID. Cloud rechecks current identity and ownership before opening/submitting; stale scope refuses. Existing Host full-client and CloudClient idempotent mutations remain the business owners. Dismissal never starts a background operation. + - Creation binds the chosen Host ID or cloud account scope plus Organization ID. Cloud rechecks current identity and ownership before opening/submitting; stale scope refuses. Computer creation is unavailable on the phone after full WebView removal; CloudClient idempotent mutations remain the cloud business owner. Dismissal never starts a background operation. - **Chats** shows the current Host: - Conversation rows keep the title and timestamp on one horizontal line at every system text size. The title takes the remaining width and truncates with tail ellipsis; the timestamp keeps its own single-line slot. Preview/status and count stay on a second line. A conversation without a timestamp does not reserve an empty metadata line. Preserve native font scaling and the complete accessible title/preview. @@ -116,7 +116,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Three equal-width tabs each center the icon and label as one group. Only the selected tab shows its decorative leading icon. Its underline fills that entire third of the strip without outer gutters and follows the page's swipe position. Use `react-native-tab-view` with Expo-compatible native `react-native-pager-view`. Rows own horizontal swipes in the feed, so the pager's own swipe is off and a horizontal swipe on the tab strip moves to the next or previous tab; keep each page's virtualized list mounted after its first visit so its vertical position survives switching. Previous Activity collapse preferences do not hide content. Labels retain native system scaling and wrap within their third at large text. Switching pages preserves search, sort, Bot and conversation filters. All/handled filters temporarily show that category in the current page without a selected top tab; selecting or swiping to another tab resumes its category. Tabs remain selectable during loading or error. Respect reduced motion for programmatic transitions. - Bound list summary previews and their accessibility labels before passing them to native Text; a two-line preview must not measure an entire long reply. Keep the original item for opening its conversation. Loaded action-card ports keep their identity across page re-renders so returning to a page retains its loaded content and draft instead of flashing a loading card. - Keep all three decorative tab icons mounted. Derive their opacity, subtle scale and icon/label group translation from the same continuous native pager position as the underline, so the incoming icon appears during a drag and reverses when the drag is cancelled. At rest, the selected icon/label pair is centered; inactive labels are centered alone. Avoid selection-triggered mount animations and per-frame React updates. Reduced motion uses settled icon states without the morph transform. - - Approvals, questions and folder requests can be decided in place, on the same cards as chat. Auto-rejected calls (Allow once), group join requests (Accept / Decline) and Assignment asks (reply that carries the Assignment target) open a native detail with the same card. Private input retains its full-client route: its chat card and Inbox detail open the full client at that request. + - Approvals, questions and folder requests can be decided in place, on the same cards as chat. Auto-rejected calls (Allow once), group join requests (Accept / Decline) and Assignment asks (reply that carries the Assignment target) open a native detail with the same card. Private input cards and Inbox details explain that entry requires the Host client or a browser over Tailscale. There is no full Host WebView or in-app fallback. - Posts and rows are separated by space and layer fills, not divider lines. - Activity is cleared one item at a time, like an email inbox. Swiping a row or post left reveals its actions, as in Mail; swiping through the row runs the first one, with a haptic tick as it arms. A tap elsewhere on an open row closes it, and opening another row closes the first. Long-press opens the same actions in a native menu, and screen readers get them as accessibility actions. Rightward drags stay with the screen. - **Needs you**: a pending request is never cleared, since the Bot would keep waiting. Its actions are **Tomorrow** (snooze until 9:00 tomorrow, the swipe-through action) and **Later…** (1 hour, this evening, tomorrow or next week). The item returns on its own at that time. The one exception is an **interrupted message** (the Bot stopped partway through a Human's message after it had started acting, so the Host will not replay it). Nothing resolves it on its own, so its actions are **Clear** (the swipe-through action, with Undo) and **Later…**; its detail page explains what happened and offers Open conversation and Clear. diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 35811d263b..7e42180e10 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -35,7 +35,7 @@ pnpm --filter @botharness/mobile export # Metro bundles for iOS and And ## Full client in the app -Every native screen can offer "Open full client" (`src/ui/open-full-client.tsx`), which opens `app/full-client.tsx` as a full-screen modal with no header bar; a draggable floating exit button (`src/ui/floating-exit.tsx`) that snaps to either side edge leaves it, and holding it shows an "Exit web mode" tooltip. It loads `/botharness-device/web/` in a WebView on the route the native screens are using and sends the device credential as a header only on that first load; the Host answers with a session cookie scoped to that directory ([ADR-0199](../../docs/adr/0199-paired-devices-reach-the-host-through-a-same-listener-device-gateway.md#full-web-client-in-the-app-1654)). The target (`home`, a PersonaBot DM, or a private input request) becomes a hash the Web Client reads to land there. Links that leave the Host's web directory open in the system browser. When the Host refuses the device, the modal shows "Pair again" instead of the page. `react-native-webview` is native code, so a development build made before this change must be rebuilt. +The full Host WebView and all in-app entry points are removed (#2541). Use native screens; operations they do not support require the Host client or a separately opened browser over Tailscale. The old `/botharness-device/web/` HTTP, cookie and socket proxy is unavailable. Native Browser/Computer viewers are separate features. ## Drive it on the iOS Simulator (Mac) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 297ff7f611..eb17ad27cd 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -135,15 +135,6 @@ function RootNavigator() { ...sheetScreenOptions(design, true), }} /> - diff --git a/apps/mobile/app/full-client.tsx b/apps/mobile/app/full-client.tsx deleted file mode 100644 index 1a6253e652..0000000000 --- a/apps/mobile/app/full-client.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Text } from '../src/ui/primitives/system-text'; -import { useLocalSearchParams, useNavigation, usePreventRemove, useRouter } from 'expo-router'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useRef, useState } from 'react'; -import { Platform, Pressable, View } from 'react-native'; - -import { fullClientSource, fullClientTarget, type FullClientParams } from '../src/host/full-client'; -import { useHostSession, useHostSnapshot } from '../src/host/host-session'; -import { FloatingExit } from '../src/ui/floating-exit'; -import { FullClientView, type FullClientWeb } from '../src/ui/full-client-view'; -import { t } from '../src/ui/strings'; -import { useTheme } from '../src/ui/theme'; -import { LoadingIndicator } from '../src/ui/primitives/loading-indicator'; - -export default function FullClientScreen() { - const theme = useTheme(); - const insets = useSafeAreaInsets(); - const router = useRouter(); - const params = useLocalSearchParams(); - const { saved, client } = useHostSession(); - const snapshot = useHostSnapshot(client); - const [attempt, setAttempt] = useState(0); - const [state, setState] = useState<'loading' | 'loaded' | 'refused' | 'unreachable'>('loading'); - - const sourceChanged = !!params.hostId && saved?.hostId !== params.hostId; - const route = - snapshot.connection.route !== '' ? snapshot.connection.route : (saved?.routes[0] ?? ''); - const revoked = saved === null || state === 'refused' || snapshot.connection.status === 'refused'; - - const navigation = useNavigation(); - const web = useRef(null); - const exiting = useRef(false); - const [webBack, setWebBack] = useState(false); - usePreventRemove(Platform.OS === 'android' && webBack, ({ data }) => { - if (exiting.current) navigation.dispatch(data.action); - else web.current?.goBack(); - }); - - const close = () => { - exiting.current = true; - if (router.canGoBack()) router.back(); - else router.replace('/'); - }; - const retry = () => { - setState('loading'); - setAttempt((value) => value + 1); - }; - - return ( - - {sourceChanged ? ( - - {t.creationSourceChanged} - - ) : revoked || state === 'unreachable' ? ( - - - {revoked ? t.fullClientRevoked : t.fullClientUnreachable} - - {state === 'unreachable' && !revoked ? ( - - {t.tried}: {route} - - ) : null} - router.replace('/pair') : retry} - accessibilityRole="button" - style={{ - alignSelf: 'center', - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 10, - backgroundColor: theme.accent, - }} - > - - {revoked ? t.pairAgain : t.retry} - - - - ) : saved === undefined || route === '' ? ( - - - - ) : ( - - setState((current) => (current === 'loading' ? 'loaded' : current))} - onFailed={setState} - onCanGoBack={setWebBack} - /> - {state === 'loading' ? ( - - - - ) : null} - - )} - - - ); -} diff --git a/apps/mobile/app/inbox/[id].tsx b/apps/mobile/app/inbox/[id].tsx index 9164223ad2..74d7b56518 100644 --- a/apps/mobile/app/inbox/[id].tsx +++ b/apps/mobile/app/inbox/[id].tsx @@ -1,9 +1,8 @@ import { Text } from '../../src/ui/primitives/system-text'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useMemo } from 'react'; -import { ScrollView, View } from 'react-native'; +import { ScrollView } from 'react-native'; -import { fullClientParams, type FullClientTarget } from '../../src/host/full-client'; import { useHostSession, useHostSnapshot, useInbox } from '../../src/host/host-session'; import { messageHref } from '../../src/host/push'; import { @@ -19,7 +18,7 @@ import { ApprovalView } from '../../src/ui/approval-view'; import { ATTENTION_KINDS, AttentionItemView } from '../../src/ui/attention-item'; import { GrantRequestCard } from '../../src/ui/chat/grant-request-card'; import { QuestionView } from '../../src/ui/question-view'; -import { ActionButton, Notice } from '../../src/ui/request-parts'; +import { Notice } from '../../src/ui/request-parts'; import { t } from '../../src/ui/strings'; import { useTheme } from '../../src/ui/theme'; @@ -95,14 +94,6 @@ export default function InboxItemScreen() { () => ({ id: params.id, botSlug: params.botSlug }), [params.id, params.botSlug], ); - const target: FullClientTarget = - params.kind === 'private-input' && ref !== undefined - ? { - kind: 'private-input', - channelId: ref.channelId, - messageId: ref.messageId, - } - : { kind: 'home' }; const title = t.kind[params.kind] ?? t.otherKind; const fallback = ( @@ -115,19 +106,6 @@ export default function InboxItemScreen() { {params.kind === 'private-input' ? t.privateInputHint : t.readOnlyHint} - {saved ? ( - - - router.push({ - pathname: '/full-client', - params: fullClientParams(target), - }) - } - /> - - ) : null} ); let body; diff --git a/apps/mobile/app/settings/index.tsx b/apps/mobile/app/settings/index.tsx index a8e9d2ba46..17f302ef3c 100644 --- a/apps/mobile/app/settings/index.tsx +++ b/apps/mobile/app/settings/index.tsx @@ -1,7 +1,6 @@ import { useRouter } from 'expo-router'; import { useMemo } from 'react'; -import { fullClientParams } from '../../src/host/full-client'; import { hostSession, useHostSession, useHostSnapshot } from '../../src/host/host-session'; import { loadUsage } from '../../src/host/usage'; import { appBuildLabel } from '../../src/ui/app-build'; @@ -47,11 +46,6 @@ export default function SettingsScreen() { version={appBuildLabel(currentAppBuild())} actions={{ onUsage: () => router.push('/settings/usage'), - onOpenFullClient: () => - router.push({ - pathname: '/full-client', - params: fullClientParams({ kind: 'home' }), - }), onComputers: () => { router.back(); openHostDrawer(); diff --git a/apps/mobile/src/host/client.ts b/apps/mobile/src/host/client.ts index 628c47dbf5..317600bae3 100644 --- a/apps/mobile/src/host/client.ts +++ b/apps/mobile/src/host/client.ts @@ -3,7 +3,8 @@ import { ActivitySmoother } from '../../../../packages/client/src/client/activit import type { BotPrimaryColor } from '../../../../packages/core/src/bots/primary-color'; import type { HostCarrier, HostSocket } from './carrier'; import { DEVICE_PREFIX } from './pairing'; -import { socketOrigin } from './route'; +import { isConnectRoute, socketOrigin } from './route'; +import { secretRouteRefusal } from '../../../../packages/core/src/bridge/secret-route'; import { REMOTE_STREAM_MUX_PATH, type ActivityLiveFrame, @@ -397,6 +398,8 @@ export function createHostClient(options: HostClientOptions): HostClient { ): Promise => withDeadline(async (signal) => { const name = method.includes('/') ? method : `botharness/${method}`; + const refusal = isConnectRoute(via) ? secretRouteRefusal(name) : undefined; + if (refusal !== undefined) throw new MethodRefused(refusal.code, refusal.message); const request: ClientRequest = { type: 'client-request', rpcId: id(), diff --git a/apps/mobile/src/host/conversation.ts b/apps/mobile/src/host/conversation.ts index be87bbef9f..944767c62e 100644 --- a/apps/mobile/src/host/conversation.ts +++ b/apps/mobile/src/host/conversation.ts @@ -220,6 +220,7 @@ export interface Conversation { send(body: string, files?: OutgoingFile[], tags?: MessageTags): string; retry(messageId: string): void; react(messageId: string, emoji: string, active: boolean): Promise; + wireMessage?(messageId: string): ChannelWireMessage | undefined; } export interface ConversationOptions { @@ -812,6 +813,7 @@ export function createConversation(options: ConversationOptions): Conversation { }, getSnapshot: () => snapshot, getTimelineSnapshot: () => timelineSnapshot, + wireMessage: (messageId) => window.find((entry) => entry.message.id === messageId)?.message, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); diff --git a/apps/mobile/src/host/full-client.ts b/apps/mobile/src/host/full-client.ts deleted file mode 100644 index 7c61e2b2c9..0000000000 --- a/apps/mobile/src/host/full-client.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - fullClientHash, - type FullClientTarget, -} from '../../../../packages/core/src/credentials/web-locator'; - -export type { FullClientTarget }; - -export const FULL_CLIENT_PATH = '/botharness-device/web/'; - -export interface FullClientSource { - uri: string; - headers: { Authorization: string }; -} - -export function fullClientSource( - route: string, - credential: string, - target: FullClientTarget, -): FullClientSource { - return { - uri: `${route.replace(/\/+$/u, '')}${FULL_CLIENT_PATH}${fullClientHash(target)}`, - headers: { Authorization: `Device ${credential}` }, - }; -} - -export function staysInFullClient(route: string, url: string): boolean { - const base = `${route.replace(/\/+$/u, '')}${FULL_CLIENT_PATH}`; - return url === base.slice(0, -1) || url.startsWith(base) || url === 'about:blank'; -} - -export type FullClientParams = { - hostId?: string; - target?: string; - slug?: string; - channelId?: string; - messageId?: string; -}; - -export function fullClientParams(target: FullClientTarget): FullClientParams { - if (target.kind === 'dm') return { target: 'dm', slug: target.slug }; - if (target.kind === 'channel') return { target: 'channel', channelId: target.channelId }; - if (target.kind === 'new-bot' || target.kind === 'new-channel') return { target: target.kind }; - if (target.kind === 'private-input') - return { target: 'private-input', channelId: target.channelId, messageId: target.messageId }; - return { target: 'home' }; -} - -export function fullClientTarget(params: FullClientParams): FullClientTarget { - if (params.target === 'dm' && params.slug !== undefined) return { kind: 'dm', slug: params.slug }; - if (params.target === 'channel' && params.channelId !== undefined) - return { kind: 'channel', channelId: params.channelId }; - if (params.target === 'new-bot' || params.target === 'new-channel') - return { kind: params.target }; - if ( - params.target === 'private-input' && - params.channelId !== undefined && - params.messageId !== undefined - ) - return { kind: 'private-input', channelId: params.channelId, messageId: params.messageId }; - return { kind: 'home' }; -} diff --git a/apps/mobile/src/host/route.ts b/apps/mobile/src/host/route.ts index 9220a04d7f..94b26b807f 100644 --- a/apps/mobile/src/host/route.ts +++ b/apps/mobile/src/host/route.ts @@ -37,3 +37,13 @@ export function checkRoute(input: string): RouteCheck { export function socketOrigin(origin: string): string { return origin.replace(/^http/u, 'ws'); } + +export function isConnectRoute(route: string): boolean { + try { + return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.deepseekbot\.dev$/u.test( + new URL(route).hostname, + ); + } catch { + return false; + } +} diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx index 9098d88a4b..811539ad9b 100644 --- a/apps/mobile/src/ui/chat/conversation-screen.tsx +++ b/apps/mobile/src/ui/chat/conversation-screen.tsx @@ -118,8 +118,6 @@ import { WorkingStatus } from './working-status'; import { TimeSeparator } from './time-separator'; import { AccentProvider, useDesignTheme } from '../design/theme'; import { edge, edgeFade, pullToCompose, radius, space } from '../design/tokens'; -import { OpenFullClientButton } from '../open-full-client'; -import { fullClientParams, type FullClientTarget } from '../../host/full-client'; import { leaveScreen } from '../shell/leave-screen'; import { Text } from '../primitives/text'; import { MarkdownViewportContext } from '../markdown-viewport'; @@ -235,7 +233,6 @@ const RequestCard = memo(function RequestCard({ () => grantRequestPort(client, ref, () => void inbox?.refresh()), [client, inbox, ref], ); - const router = useRouter(); const subject = card.subject === undefined ? {} : { subject: card.subject }; if (card.kind === 'private-input') return ( @@ -245,16 +242,6 @@ const RequestCard = memo(function RequestCard({ resolved={resolution?.kind === 'private-input'} focused={focused} group={group} - onFill={() => - router.push({ - pathname: '/full-client', - params: fullClientParams({ - kind: 'private-input', - channelId: card.channelId, - messageId: message.id, - }), - }) - } /> ); if (card.kind === 'grant') @@ -827,16 +814,6 @@ function ChatConversation({ } /> ); - const fullClient: FullClientTarget | undefined = - slug !== undefined - ? { kind: 'dm', slug } - : channelId !== undefined - ? { kind: 'channel', channelId } - : undefined; - const trailing = - fullClient === undefined ? null : ( - - ); const ios = Platform.OS === 'ios'; const back = ; const titleItem = ( @@ -910,28 +887,6 @@ function ChatConversation({ { type: 'spacing' as const, spacing: 0 }, { type: 'custom' as const, element: titleItem }, ], - unstable_headerRightItems: () => - fullClient === undefined - ? [] - : headerIslands - ? [ - { - type: 'custom' as const, - element: , - }, - ] - : [ - { - type: 'button' as const, - label: t.openFullClient, - icon: { type: 'sfSymbol' as const, name: 'macwindow' as const }, - onPress: () => - router.push({ - pathname: '/full-client', - params: fullClientParams(fullClient), - }), - }, - ], } : { headerTitleAlign: 'left' as const, @@ -946,7 +901,6 @@ function ChatConversation({ {title} ), - headerRight: () => trailing, ...(canGoBack && !floatingHeader ? {} : { headerLeft: () => back }), }), }} diff --git a/apps/mobile/src/ui/chat/private-input-card.tsx b/apps/mobile/src/ui/chat/private-input-card.tsx index 04d078673a..d50cbe5fa0 100644 --- a/apps/mobile/src/ui/chat/private-input-card.tsx +++ b/apps/mobile/src/ui/chat/private-input-card.tsx @@ -1,6 +1,5 @@ -import { CardButtons, CardLine, CardNote, ActionCard } from './action-card'; +import { CardLine, CardNote, ActionCard } from './action-card'; import type { BubbleGroup } from './bubble-shape'; -import { Button } from '../primitives/button'; import { Text } from '../primitives/text'; import { t } from '../strings'; @@ -11,7 +10,6 @@ export function PrivateInputCard({ focused = false, group, wide = false, - onFill, }: { botName: string; purpose?: string | undefined; @@ -19,7 +17,6 @@ export function PrivateInputCard({ focused?: boolean; group?: BubbleGroup; wide?: boolean; - onFill(): void; }) { const category = t.kind['private-input']!; if (resolved) @@ -49,14 +46,6 @@ export function PrivateInputCard({ })} > {t.privateInputNote} - - @@ -220,7 +237,7 @@ export function PrivateInputCard({ ) : null} - {error ? ( + {error && !routeRefused ? (
{t('privateInput.error')}{' '}