Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
13e9693
feat(mobile): share the phone's server with other devices through T3 …
tebiggs Oct 30, 2026
27c5762
docs(fork): list phone sharing in FORK.md
tebiggs Oct 30, 2026
919db0a
fix(mobile): phone sharing ends access when it's off, and Ask is deci…
tebiggs Oct 30, 2026
6d41723
fix(mobile): one controller owns phone sharing, and approvals show th…
tebiggs Oct 30, 2026
c100a65
fix(mobile): phone sharing can't stall behind one call, or outlive a …
tebiggs Oct 30, 2026
9763e82
style(mobile): shorten the phone sharing controller's comment
tebiggs Oct 30, 2026
371da41
fix(mobile): a sign-out's stop survives preferences that are slow to …
tebiggs Oct 30, 2026
6c9337a
fix(mobile): sign-out stops sharing even when preferences fail to load
tebiggs Oct 30, 2026
c31c3aa
Merge origin/main into feature/phone-share-connect
tebiggs Nov 1, 2026
aaf1108
fix(mobile): phone removal stays outside the compiled hook
tebiggs Nov 1, 2026
304de73
feat(mobile): View only sharing, enforced by the phone's server
tebiggs Nov 1, 2026
7d9f713
fix(server): preview and device sockets end with their session
tebiggs Nov 1, 2026
7d66dc5
fix(server): MCP approvals from before a mode change can't be redeemed
tebiggs Nov 1, 2026
6a6dd47
fix(server): a request in flight during a mode change can't keep old …
tebiggs Nov 1, 2026
b4834f0
fix(server): reading an MCP app's resources needs operate scope
tebiggs Nov 1, 2026
8ed72d5
fix(server): read-only sessions can't ready device hosts
tebiggs Nov 1, 2026
41fffa7
fix(server): read-only sessions can't start providers or auto-pull
tebiggs Nov 1, 2026
5bd650c
Merge origin/main into feature/phone-share-connect
tebiggs Nov 5, 2026
c20a2c4
Merge origin/main into feature/phone-share-connect
tebiggs Nov 5, 2026
759d56d
fix(mobile): Off signs other devices out before stopping the tunnel
tebiggs Nov 5, 2026
318279d
fix(server): a relay client that won't exit no longer hangs unlink
tebiggs Nov 6, 2026
d68bec3
Merge origin/main into feature/phone-share-connect
tebiggs Nov 6, 2026
2ff1a17
test(server): run the fake relay client with this Node, not one on PATH
tebiggs Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ adds the feature, and remove it in the PR that drops the feature.
`apps/mobile/src/features/device-runtime/`,
`apps/mobile/src/features/phone/`,
`apps/mobile/src/persistence/mobile-preferences.ts` (`phoneServer`).
- #19 The phone's server can be shared with the user's other devices through
T3 Connect: the app installs cloudflared in the phone's Ubuntu and links the
server with its own T3 Connect sign-in. View only or Full control sets what the
server's T3 Connect credentials grant, and changing it signs out other clients.
Off and signing out end the tunnel and other clients' sessions, Ongoing activity
skips the phone's server, and the Android bridge asks on the phone for each Ask
each time command: `apps/server/src/cloud/CloudLink.ts` (`setSharingMode`),
`apps/server/src/auth/EnvironmentAuth.ts` (`revokeOtherAccess`),
`packages/contracts/src/auth.ts` (`AuthViewOnlyClientScopes`),
`apps/mobile/src/features/phone/phone-sharing.ts`,
`apps/mobile/src/features/phone/phone-sharing-controller.ts`,
`apps/mobile/src/features/phone/phone-sharing-commands.ts`,
`apps/mobile/src/features/phone/PhoneSharingSection.tsx`,
`apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts`,
`apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt`.
- #21 Open in VS Code and other SSH editor links use the host the client reached directly when
sshd is listening, instead of the server's first advertised host: `apps/web/src/remoteOpen.ts`,
`apps/server/src/environment/RemoteOpenTargets.ts` (`sshd`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
</queries>

<application>
<activity
android:name="expo.modules.t3deviceruntime.AccessApprovalActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<activity
android:name="expo.modules.t3deviceruntime.AccessPromptActivity"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ There is no Termux, Termux:API app or `pkg`: install packages with `apt-get` (ru
always allow or never): `termux-location`, `termux-telephony-cellinfo`,
`termux-camera-photo`, `termux-microphone-record`, `termux-contact-list`,
`termux-sms-list`, `termux-sms-send`, `termux-call-log`, `termux-telephony-call`,
`termux-telephony-deviceinfo`, `termux-notification-list`. Run them by name so the user
is asked. The first use also shows Android's own permission prompt on the phone. If one
fails because it's set to Never or Android wasn't allowed, pass on the place the error
names instead of working around it.
`termux-telephony-deviceinfo`, `termux-notification-list`. With Ask each time, each run
waits up to a minute for the user to answer on the phone, and fails if nobody does. The
first use also shows Android's own permission prompt. If one fails because it's set to
Never, wasn't approved or Android wasn't allowed, pass on the place the error names
instead of working around it.
- While T3 Code isn't on screen, the clipboard can't be read, the camera, microphone and
location fail, and links, files, shares and calls arrive as a notification for the user
to tap.
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package expo.modules.t3deviceruntime

import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

private const val EXTRA_REQUEST = "request"
private const val APPROVAL_TIMEOUT_SECONDS = 60L
private const val DETAILS_LIMIT = 4000
private const val MAX_WAITING = 4

// Format characters (bidi controls, zero widths, soft hyphens), C1 controls, line separators,
// and the fillers and variation selectors that show as nothing.
private val INVISIBLE =
Regex("[\\p{Cf}\\p{Zl}\\p{Zp}\\u0080-\\u009F\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u3164\\uFE00-\\uFE0F\\uFFA0]")

/**
* A command's whole request, as the user approves it. JSON escapes newlines and control
* characters; the rest that could hide or reorder text gets escaped here. A dialog scrolls, so
* nothing is cut: a request too long to read is refused, since nobody could approve it.
*/
internal fun approvalDetails(body: JSONObject): String {
if (body.length() == 0) return ""
val details = body.toString(2).replace(INVISIBLE) { match ->
match.value.toCharArray().joinToString("") { "\\u%04X".format(it.code) }
}
if (details.length > DETAILS_LIMIT) throw BridgeError("This request is too long to show on the phone for approval.", 413)
return details
}

private class WaitingApproval(val title: String, val message: String, val answer: CompletableFuture<Boolean>)

/**
* One prompt per request, so an answer can't also cover a command the user never saw. What the
* prompt shows stays in this process, keyed by a random id, so a same-UID caller that starts the
* activity can't change what the user reads, and only a tap on its dialog can answer.
*/
internal object AccessApproval {
private val pending = ConcurrentHashMap<String, WaitingApproval>()
private val slots = Semaphore(MAX_WAITING)

/** Whether the user allowed it. Nobody answering in time counts as no. */
fun request(
context: Context,
category: AccessCategory,
command: String,
details: String,
show: (Intent) -> Unit,
dismiss: () -> Unit,
): Boolean {
// Requests wait on bridge connections, which unrelated commands need too.
if (!slots.tryAcquire()) throw BridgeError("Too many requests are waiting for an answer on the phone.", 429)
try {
return waitForAnswer(context, category, command, details, show, dismiss)
} finally {
slots.release()
}
}

private fun waitForAnswer(
context: Context,
category: AccessCategory,
command: String,
details: String,
show: (Intent) -> Unit,
dismiss: () -> Unit,
): Boolean {
val id = UUID.randomUUID().toString()
val waiting = waitingFor(category, command, details)
pending[id] = waiting
try {
show(
Intent(context, AccessApprovalActivity::class.java)
// A distinct intent per request: Android would otherwise reuse a waiting prompt's.
.setData(Uri.parse("t3-access://$id"))
.putExtra(EXTRA_REQUEST, id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
return waiting.answer.get(APPROVAL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (error: TimeoutException) {
throw BridgeError("Nobody answered T3 Code's ${category.label} prompt on the phone.", 403)
} finally {
pending.remove(id)
// Closes a prompt that's still open, so a late tap can't look like it counted.
waiting.answer.complete(false)
dismiss()
}
}

private fun waitingFor(category: AccessCategory, command: String, details: String): WaitingApproval {
val message = buildString {
append("termux-$command")
if (details.isNotEmpty()) append("\n\n$details")
append("\n\nAn agent or another device using this phone's server asked for this.")
}
return WaitingApproval("Allow ${category.label}?", message, CompletableFuture())
}

fun prompt(id: String): Pair<String, String>? = pending[id]?.let { it.title to it.message }

fun answer(id: String, allowed: Boolean) {
pending[id]?.answer?.complete(allowed)
}

/** Runs once the request ends, or at once when it already has, as for an old notification. */
fun whenEnded(id: String, action: () -> Unit) {
val waiting = pending[id]
if (waiting == null) action() else waiting.answer.whenComplete { _, _ -> action() }
}
}

/** A see-through activity holding one approval dialog, over whatever is on screen. */
class AccessApprovalActivity : Activity() {
private var request: String? = null
private var dialog: AlertDialog? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val id = intent.getStringExtra(EXTRA_REQUEST) ?: return finish()
val (title, message) = AccessApproval.prompt(id) ?: return finish()
request = id
val shown = AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert)
.setTitle(title)
.setMessage(message)
.setPositiveButton("Allow") { _, _ -> answer(true) }
.setNegativeButton("Don't allow") { _, _ -> answer(false) }
.setOnCancelListener { answer(false) }
.create()
shown.setCanceledOnTouchOutside(false)
shown.show()
// Another app's overlay shouldn't be able to trick the user into tapping Allow.
shown.getButton(DialogInterface.BUTTON_POSITIVE).filterTouchesWhenObscured = true
dialog = shown
AccessApproval.whenEnded(id) { runOnUiThread { if (!isFinishing) finish() } }
}

private fun answer(allowed: Boolean) {
request?.let { AccessApproval.answer(it, allowed) }
finish()
}

override fun onDestroy() {
dialog?.dismiss()
// Closed without an answer. A fold or rotation only recreates it, and it asks again.
if (isFinishing) request?.let { AccessApproval.answer(it, false) }
super.onDestroy()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ enum class AccessPolicy(val id: String) {
class AccessCategory(val id: String, val label: String, val commands: List<String>, val permissions: List<String>)

/**
* The user's policy per category. Claude Code enforces it through managed settings in the
* guest: ask rules prompt even in bypassPermissions mode, and T3 shows the prompt in the
* thread. A rule only matches how a command is written, so the bridge refuses denied ones too.
* The user's policy per category, enforced by the bridge, outside the guest. Never refuses and
* Ask prompts on the phone, for any provider and however a termux-* command is spelled. It covers
* this bridge only: guest processes run as this app's Android user, so it isn't a hard barrier
* against a terminal in the guest. Claude Code's managed settings add allow and deny rules so it
* doesn't ask again or try a refused command.
*/
object AgentAccess {
val categories = listOf(
Expand Down Expand Up @@ -133,7 +135,10 @@ object AgentAccess {
for (list in AccessPolicy.entries) {
val existing = permissions.optJSONArray(list.id) ?: JSONArray()
val kept = (0 until existing.length()).map { existing.get(it) }.filterNot { it is String && it in ours }
val added = categories.filter { policy(context, it) == list }.flatMap(::rules)
// An ask rule would only add a thread prompt, which a remote client can answer.
val added =
if (list == AccessPolicy.Ask) emptyList()
else categories.filter { policy(context, it) == list }.flatMap(::rules)
if (kept.isEmpty() && added.isEmpty()) permissions.remove(list.id)
else permissions.put(list.id, JSONArray(kept + added))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,11 @@ class AndroidBridge private constructor(context: Context, private val rootfs: Fi
val bytes = readBody(input, request.headers, deadline)
val body = if (bytes.isEmpty()) JSONObject() else JSONObject(String(bytes, Charsets.UTF_8))
Log.i(TAG, "Android bridge: $command")
AgentAccess.check(context, command)?.let {
requireGranted(it)
// The user can choose Never while Android's prompt waits for an answer.
AgentAccess.check(context, command)?.let { category ->
requireGranted(category)
// The user can choose Never while a prompt waits for an answer.
AgentAccess.check(context, command)
requireApproval(category, command, body)
AgentAccess.check(context, command)
}
if (command in WAITING_COMMANDS) return perform(command, body)
Expand Down Expand Up @@ -404,6 +406,34 @@ class AndroidBridge private constructor(context: Context, private val rootfs: Fi
}

/** The user let the agent run this, or always allows it; Android still has to let T3 Code. */
/**
* Ask each time is decided here, on the phone. Anything that can reach the guest, a remote
* client's terminal included, can skip a command's spelling and call this bridge directly.
*/
private fun requireApproval(category: AccessCategory, command: String, body: JSONObject) {
if (AgentAccess.policy(context, category) != AccessPolicy.Ask) return
val label = category.label
val notificationId = requestIds.incrementAndGet()
val approved = AccessApproval.request(
context,
category,
command,
approvalDetails(body),
show = { intent ->
if (onScreen()) {
context.startActivity(intent)
} else {
val tap = PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_IMMUTABLE)
notify(REQUEST_CHANNEL, REQUEST_TAG, notificationId, "Allow $label?", "An agent wants to run termux-$command. Tap to answer.", tap)
}
},
dismiss = { notificationManager().cancel(REQUEST_TAG, notificationId) },
)
if (!approved) {
throw BridgeError("The user didn't allow termux-$command on the phone. ${AgentAccess.where(category)} controls this.", 403)
}
}

private fun requireGranted(category: AccessCategory) {
if (AgentAccess.granted(context, category)) return
val label = category.label
Expand Down Expand Up @@ -566,7 +596,7 @@ class AndroidBridge private constructor(context: Context, private val rootfs: Fi
/** Starts the bridge and installs its commands in the guest; without it the server runs anyway. */
fun start(context: Context): AndroidBridge? = try {
val rootfs = DeviceRuntime(context) {}.rootfs
// Without the access rules agents would reach phone data unasked, so no rules, no bridge.
// Claude Code's rules have to match the user's policy, so no rules, no bridge.
AgentAccess.writeManagedSettings(context, rootfs)
installCommands(context, rootfs)
installGuide(context, rootfs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("liveActivityPreferences", () => {
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => true,
phoneServerEnvironmentId: null,
}),
);

Expand Down Expand Up @@ -116,14 +117,39 @@ describe("liveActivityPreferences", () => {
...connection,
environmentId: "read-only" as EnvironmentId,
};
const phoneConnection: SavedRemoteConnection = {
...connection,
environmentId: "phone" as EnvironmentId,
displayUrl: "http://127.0.0.1:3774/",
httpBaseUrl: "http://127.0.0.1:3774/",
wsBaseUrl: "ws://127.0.0.1:3774/ws",
};
// A relay route preferred over the loopback one that's in use.
const phoneOnFallback: SavedRemoteConnection = {
...phoneConnection,
environmentId: "phone-fallback" as EnvironmentId,
displayUrl: "https://phone.example.test/",
};
const configuredPhone: SavedRemoteConnection = {
...connection,
environmentId: "configured-phone" as EnvironmentId,
};

return Effect.gen(function* () {
yield* setLiveActivityUpdatesEnabled({
enabled: true,
previousEnabled: false,
clerkToken: "clerk-token",
connections: [readOnlyConnection, connection],
canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId,
connections: [
readOnlyConnection,
phoneConnection,
phoneOnFallback,
configuredPhone,
connection,
],
canConfigureEnvironment: (environmentId) =>
environmentId !== readOnlyConnection.environmentId,
phoneServerEnvironmentId: configuredPhone.environmentId,
});

expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
Expand Down Expand Up @@ -152,6 +178,7 @@ describe("liveActivityPreferences", () => {
clerkToken: "clerk-token",
connections: [connection],
canConfigureEnvironment: () => canConfigureEnvironment,
phoneServerEnvironmentId: null,
});

expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts";

import type { SavedRemoteConnection } from "../../lib/connection";
import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";
import { phoneServerPort } from "../phone/phone-setup";
import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration";

export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")(
Expand All @@ -12,9 +13,16 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
readonly clerkToken: string | null;
readonly connections: ReadonlyArray<SavedRemoteConnection>;
readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean;
readonly phoneServerEnvironmentId: EnvironmentId | null;
}) {
// Linking gives a server a tunnel, so the phone's own server is linked only by its share
// switch. displayUrl follows the preferred route, which may not be the loopback one.
const linkedConnections = input.connections.filter(
(connection) => connection.bearerToken !== null,
(connection) =>
connection.bearerToken !== null &&
connection.environmentId !== input.phoneServerEnvironmentId &&
phoneServerPort(connection.displayUrl) === null &&
phoneServerPort(connection.httpBaseUrl) === null,
);

const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* (
Expand Down
Loading
Loading