Skip to content

feat(mobile): share the phone's server with other devices through T3 Connect - #19

Open
tebiggs wants to merge 17 commits into
mainfrom
feature/phone-share-connect
Open

tebiggs wants to merge 17 commits into
mainfrom
feature/phone-share-connect

Conversation

@tebiggs

@tebiggs tebiggs commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

The phone's T3 Code server listens only on loopback and was never linked to T3 Connect, so its threads showed only in the app on that phone. This adds an opt-in Share with your other devices control with three choices (Off, View only, Full control), in phone setup (once the phone is connected) and on the phone's environment page.

  • View only or Full control: a dialog says what another device gets. Then the app installs cloudflared in Ubuntu through the server's relay client installer, stores the mode on the server, links the server to the T3 Connect account it's signed in to, and checks for up to a minute that the tunnel answers. Sharing can be turned off during that check.
  • View only is enforced by the server. Each T3 Connect credential's scopes are chosen when it's minted, from a mode the server stores. A View only device gets orchestration:read and nothing else: it can read threads, diffs of a thread's turns, projects, pull requests and open previews. It can't send messages, answer approvals, stop agents, or use terminals or files. Hosts that never stored a mode keep the full scopes.
  • orchestration:read is now actually read-only, on every host. Some read methods had side effects, so this tightens them for any session without orchestration:operate: mcpApps.readResource, device.detail and ACP registry session and provider listing now need operate. device.list gives the passive view, server.refreshProviders returns what's already known, and VCS status stays local, with no remote polling or auto-pull.
  • Changing the mode signs out every other client and revokes pairing links, in one step on the server (POST /api/connect/sharing-mode, which needs relay:write and access:write). The mode is stored first. A flag stays set until the sign-out finishes, so a failed sign-out shows on the phone instead of reading as done, and tapping the mode again retries. Mints, credential exchanges, pairing links, MCP approvals, preview sockets and device sockets are all covered, so nobody keeps the old access.
  • Off: stops the tunnel, signs out every other client, revokes pairing links and deregisters the phone. Anything another device changed or installed while it was shared stays, and the dialog says so.
  • Signing out or switching T3 Connect accounts ends sharing, or marks it to end when the server is next reachable. The old account keeps listing the phone until it signs in here again, because deregistering needs that sign-in.
  • Remove and Reinstall deregister using a saved record of the account, so they work with the server down, and they tell the user when they can't.
  • The server keeps listening on 127.0.0.1. cloudflared reaches it there and connects out.
  • Ongoing activity skips the phone's server. It linked every paired server with a managed tunnel, which would share the phone without the switch.

Ask each time is now decided on the phone. The Android bridge shows a prompt with the command and its whole request for each run, and no answer within a minute counts as no. It used to be only a thread prompt, which a remote client can answer, or skip by calling the bridge directly from a terminal. This changes local use too: Claude Code's ask rules are gone, so the phone is the one place that asks, for every provider.

One controller owns every change to sharing, working from a record saved before the work starts. An unfinished stop keeps the switch on and retries when the server or the app comes back. Removing the phone's connection stops sharing first, or asks.

Changes from the plan in #13

  • The app links the server instead of running t3 connect in proot. The app is already signed in to T3 Connect, and the server's link API is the flow web and mobile use for other hosts. This avoids a second device-code sign-in inside Ubuntu and the relay client's install prompt, which needs a terminal the runtime doesn't have. The server also doesn't need a restart.
  • The tunnel survives restarts. A t3 connect link releases its tunnel each time the server stops, and a new tunnel takes one to two minutes to resolve. App links keep their tunnel and recover it by credential.
  • feat(mobile): run the phone's server and Claude Code inside the app, replacing Termux #11 removed Termux, so only the in-app runtime needs covering.

Open questions from #13

  • Does it work inside proot? The sign-in question no longer applies. Still untested: whether cloudflared (Go, static linux-arm64) runs under proot on Android, and whether it stops when sharing is turned off.
  • How do other clients show the phone while its server is down? They show it offline, like any linked host. T3 Connect's idle cleanup drops the tunnel after a few minutes offline, and the server gets a replacement at the same address when it starts again (docs/user/remote-access.md).

Known limits

  • View only doesn't hide threads from the account. Anyone signed in to it can read every thread, including any secrets in them, and the pull requests the phone's GitHub sign-in can see. Left out of View only: filesystem:read (so the Review panel's local diffs and file browsing don't work), terminal:read, diagnostics:read, relay:read and access:read. A viewer also sees no remote ahead or behind counts unless another client is polling, and MCP apps in a thread can't read their server's resources.
  • The tunnel address stays public in both modes. These stay reachable: the server descriptor and web files with no sign-in, the mint endpoint with a relay-signed proof, guest links, and webhooks with the token in their URL. Protected routes reject requests without credentials, and the relay's own account check is trusted, not verified, because its code isn't in this repo.
  • If a mode change and the read after it both fail, the phone keeps showing the old mode next to the failure warning. A viewer's device panel shows an authorization error, but the phone has no device hub by default.
  • Switching to View only doesn't undo anything. What a device changed or set up under Full control stays, including guest links and scheduled tasks. Signed asset and upload URLs it already had stay valid until they expire (about ten minutes for uploads), and a preview action already running when its socket closes can finish.
  • A View only change needs a server that has it. The phone installs its server from the fork's releases, so an older server is refused View only with a prompt to update, and Full control still works on it.
  • Full control is full remote control of the phone's server. Agent access protects Android data and actions, not Ubuntu or /sdcard, which a remote terminal reaches directly.
  • proot isn't a security boundary, so Agent access may not hold against someone with a remote terminal. Guest processes run under the app's Android user with /dev and /proc. Direct Binder calls could use Android permissions the app already has, and /proc or untranslated syscalls could reach the app's private files. Neither has been tried on a device. The share dialog says this, and it needs a device test.
  • The record holds one listing. Reinstalling while signed out of the old account and then sharing under another leaves the old listing on the old account, offline and with a dead credential, until it's deregistered under Settings > T3 Connect. Keeping a list of them isn't worth it for one phone.
  • A link that times out could still finish on the server just after the rollback's unlink, with its listing already removed. Nothing retries that automatically. The switch would read as on the next time the screen opens, and turning it off clears it.
  • getResult on the shared preference atom waits for every write in progress, so another preference's failed write can fail a sharing step, which then retries, and a sharing record may stay visible after its write failed. The switch reads the server and treats a stopping record as on, so this can't make a shared phone look off.
  • Loading preferences has no deadline: deciding without the record is the unsafe part. If storage never settled, sharing changes would wait behind it, along with the rest of the app's preferences.
  • If preferences fail to load at launch while the connection catalog is still loading, a sign-out's stop finds no phone and reports success, so the tunnel stays up. This needs a storage failure, and turning the switch off still works.
  • Browser session cookies have no Secure flag (existing server behaviour, not changed here).

Testing

Thirteen rounds of Codex GPT-6.1 Sol security review: six on the first version, approving at 6c9337a, then seven on View only, ending with no blocking or minor findings at 41fffa7. Those seven found preview and device sockets outliving a mode change, MCP approvals redeemed after one, requests in flight during one, MCP apps reading any resource on their server, and read methods that could start the device hub, install or start providers, or auto-pull. All are fixed. Severity: blocking means a realistic way to expose the phone to someone unintended or to lose control of the switch, or a View only session exceeding read access. Rare races and defences against someone who already has the account are minor. A device that just held Full control already had a root terminal.

vp run check:changed passes. Tests cover the scopes minted per mode, including an unreadable or unknown stored mode, the mode-change ordering and its races with mints and exchanges, failed sign-outs, refused routes for a read-only session, MCP approvals from before a change, and preview and device sockets closing with their session.

I ran a dev server with a read-only session. RPC refused orchestration.dispatchCommand, terminal.open, terminal.write and projects.readFile; HTTP refused sharing mode, unlink, pairing, client revocation and trace export. In a browser the thread rendered with Send disabled, and Attach, Fork, Settle and project scripts were hidden. The approval cards were checked in code and through refused RPC, not by answering one in a browser.

Nothing has run on a real phone, including the new mode control, which needs the Android app with the phone runtime and a T3 Connect sign-in. Screenshots weren't taken for the same reason. Whether cloudflared runs under proot and stops on Off is still untested. The Kotlin approval prompt has no tests.

Also in this PR: agentContextBudget.json moves to the measured 46039 bytes. A clean main checkout measures the same, so that mismatch came from main.

🤖 Generated with Claude Code

tebiggs and others added 17 commits October 30, 2026 04:00
…Connect

The phone's server listens only on loopback and was never linked to T3 Connect,
so its threads showed only in the app on that phone.

Phone setup and the phone's environment page get a "Share with your other
devices" switch. Turning it on links the server to the T3 Connect account the
app is signed in to, through the server's own link API (the flow web and
mobile already use for other hosts), after installing cloudflared into the
phone's Ubuntu with the server's relay client installer. The server keeps
listening on loopback; cloudflared reaches it there and connects out. The
switch then waits up to a minute for the tunnel to answer, as `t3 connect`
does, and shows the result. Turning it off unlinks the server and deregisters
it from the account. Remove and Reinstall do the same first when the phone is
shared.

Turning on Ongoing activity no longer links the phone's server, which would
have shared it without the switch.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ded on the phone

A security review of phone sharing found ways it could turn on without the
switch, look off while still set up, and leave access in place after Off. It
also found that Ask each time was only a thread prompt, which a remote client
can answer or skip by calling the bridge directly.

- The Android bridge now asks on the phone for each Ask each time command,
  showing its arguments. No answer within a minute counts as no. Claude Code's
  ask rules are gone, as the bridge covers every provider and spelling.
- Off also signs out other clients and revokes pairing links. Each step runs
  even if an earlier one fails.
- Signing out or switching T3 Connect accounts ends sharing, or marks it to end
  when the server is next reachable. The account's listing remains until that
  account signs in here again, since deregistering needs it.
- Ongoing activity skips the phone's server by environment id and active route,
  not only the preferred route's address.
- A revoked link, or one under another account, shows as such and can be turned
  off. Off only deregisters under the account that owns the link.
- Remove and Reinstall deregister without needing the server, using a saved
  record of the account, and say so when they can't. A failed share is rolled
  back, and a failed deregistration is retried.
- The reachability check stops after a minute and no longer blocks turning
  sharing off, and stale reads can't overwrite newer results.
- The share dialog names everything another device gets, and the footer no
  longer claims network isolation that a tunnel doesn't provide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e whole request

A second security review found that share, stop and sign-out ran independently
and kept no memory of unfinished work: a sign-out could race an in-flight share,
a partial Off could read as off with sessions still valid, and removing the
connection dropped everything needed to clean up.

A single serialized controller now makes every change from a record saved before
the work starts, stored apart from the phone's connection so it outlives it.
Sign-out during a share stops what the share set up, an unfinished stop keeps the
switch on and retries when the server or the app comes back, and it waits for
saved preferences before deciding. A failed share rolls back without signing
anyone out. Removing the connection stops sharing first, or asks.

Approval prompts show the complete request with invisible characters escaped and
refuse one too long to read. What they show now stays in the app's process, so a
caller starting the activity can't change it, and at most four can wait at once.

The share dialog no longer promises Agent access is a hard barrier: guest
processes share the app's Android user, so it may not hold against someone with
a remote terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…restart

A third security review found that one stalled call could hold up every later
stop, including sign-out, and that restarting mid account switch could leave the
old account's share running. Rollback also assumed facts it hadn't established.

- Every step has a deadline, and a stop saves its intent before it probes the
  server. An unreadable server with no record now counts as a stop still owed.
- Reconciling compares the share's owner with the signed-in account, on
  startup, account changes and foreground.
- A new share is refused while a stop is unfinished, already on, or unknown.
- Every stop signs out other clients, since a failed share may have reached the
  server with only the response lost. Listings stay recorded until removal
  succeeds or the account confirms it has none.
- Records save durably before work continues, with no in-memory copy to drift.
- Removing a phone that also has a relay route now stops sharing first.
- Approval waiters are limited atomically, more invisible characters are
  escaped, and Agent access copy says it covers termux-* commands only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…load

A fourth review found that a ten-second limit on loading preferences could let a
sign-out's stop read no record and report success, leaving a saved share running.
Loading now has no deadline, since deciding without the record is what's unsafe,
and a load failure keeps the stop owed until the next reconcile. A failed load
also no longer wedges the queue.

Saving a record gets a deadline, the removal dialog says sharing couldn't be
confirmed off rather than that it's on, and an approval's slot is released on
every path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preferences that fail to load read as empty, so a sign-out's stop found no phone
and reported success. The phone is now also identified by its saved connection,
and its server's own state decides. An owed stop is settled before a new share,
so it can't end that share later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main now checks React Compiler coverage, which can't lower dynamic imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Other devices on a shared phone can now be limited to reading threads. The
server picks a T3 Connect credential's scopes from a stored mode, and changing
the mode signs out everyone else, so nobody keeps the old access. Web hides
the controls such a session can't use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They decide what a client can do once, at connect, so a device signed out by a
mode change could keep controlling a preview or device until it disconnected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code approved under Full control could still be exchanged for a 30-day session
after the switch to View only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…access

Approving an MCP client and creating a pairing link now run under the same lock
as the revocation, and a mode change from a caller signed out while it waited is
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The method takes any URI on the app's server, so a View only session could read
resources the thread never showed. The phone also keeps a mode change on screen
when the read after it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading a device or the full device list could install tools and start the hub.
Device detail now needs operate scope, and a session without it gets the passive
device listing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refreshing providers, listing ACP registry agents and polling a remote could
install or start software, or pull into the workspace, from a session that may
only read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant