From 13e96935088c3f795d797465f12c04212ffd591f Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:00:02 +0000 Subject: [PATCH 01/19] feat(mobile): share the phone's server with other devices through T3 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 --- .../liveActivityPreferences.test.ts | 12 +- .../liveActivityPreferences.ts | 5 +- .../DeviceRuntimeSetupScreen.tsx | 10 + .../phone/PhoneServerSettingsSection.tsx | 162 ++++++++-------- .../features/phone/PhoneSharingSection.tsx | 181 ++++++++++++++++++ .../src/features/phone/phone-sharing.ts | 174 +++++++++++++++++ 6 files changed, 463 insertions(+), 81 deletions(-) create mode 100644 apps/mobile/src/features/phone/PhoneSharingSection.tsx create mode 100644 apps/mobile/src/features/phone/phone-sharing.ts diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 3f1fe66908e93..97234b7368d85 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -116,14 +116,22 @@ 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", + }; return Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, previousEnabled: false, clerkToken: "clerk-token", - connections: [readOnlyConnection, connection], - canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId, + connections: [readOnlyConnection, phoneConnection, connection], + canConfigureEnvironment: (environmentId) => + environmentId !== readOnlyConnection.environmentId, }); expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 826edaf839b91..2b9845db28cc8 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -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")( @@ -13,8 +14,10 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn readonly connections: ReadonlyArray; readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean; }) { + // Linking gives a server a tunnel, so the phone's own server is linked only by its share switch. const linkedConnections = input.connections.filter( - (connection) => connection.bearerToken !== null, + (connection) => + connection.bearerToken !== null && phoneServerPort(connection.displayUrl) === null, ); const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( diff --git a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx index c3104c0f33476..401a9626beef1 100644 --- a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx +++ b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx @@ -9,6 +9,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { ListRow } from "../../components/ui/ListRow"; import { ScreenScrollView } from "../../components/ui/ScreenScrollView"; import { setPhoneServerSettings, usePhoneServerSettings } from "../../state/phone-server-settings"; +import { useSavedRemoteConnection } from "../../state/use-remote-environment-registry"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { confirmPhoneServerReplacement } from "../phone/confirmPhoneServerReplacement"; import { @@ -16,6 +17,8 @@ import { forgetPhoneEnvironments, stopPhoneServerWhenSettled, } from "../phone/phone-server"; +import { releasePhoneSharing } from "../phone/phone-sharing"; +import { PhoneSharingSection } from "../phone/PhoneSharingSection"; import { StepRow, StepText } from "../phone/SetupStep"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { SettingsSection } from "../settings/components/SettingsSection"; @@ -81,6 +84,7 @@ export function DeviceRuntimeSetupScreen() { (candidate) => candidate.environmentId === settings?.environmentId, ) ?? null; const connected = environment?.connectionState === "connected"; + const connection = useSavedRemoteConnection(settings?.environmentId ?? null); const inspect = useCallback(() => { if (!native.getStatus().installed) { @@ -160,6 +164,7 @@ export function DeviceRuntimeSetupScreen() { style: "destructive", onPress: () => void run("install", async () => { + await releasePhoneSharing(connection); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); @@ -181,6 +186,7 @@ export function DeviceRuntimeSetupScreen() { onPress: () => void run("remove", async () => { if (settings) setPhoneServerSettings({ ...settings, autoStart: false }); + await releasePhoneSharing(connection); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); @@ -295,6 +301,10 @@ export function DeviceRuntimeSetupScreen() { ) : null} + {connected && settings ? ( + + ) : null} + {status.installed ? ( - navigation.navigate("PhoneSetup")} - /> - {server.claude ? ( + <> + navigation.navigate("PhoneSetup")} /> - ) : null} - navigation.navigate("AgentAccess")} - /> - setPhoneServerSettings({ ...settings, autoStart })} - /> - {connected ? ( + {server.claude ? ( + navigation.navigate("PhoneSetup")} + /> + ) : null} navigation.navigate("AgentAccess")} + /> + setPhoneServerSettings({ ...settings, autoStart })} + /> + {connected ? ( + { + setBusy("stopping"); + setActionError(null); + setPhoneServerSettings({ ...settings, autoStart: false }); + stopPhoneServerWhenSettled() + .catch((error: unknown) => + setActionError( + error instanceof Error ? error.message : "Couldn't stop the server.", + ), + ) + .finally(() => setBusy(null)); + }} + /> + ) : ( + void startAndReconnectPhoneServer(settings)} + /> + )} + { - setBusy("stopping"); + setBusy("updating"); setActionError(null); - setPhoneServerSettings({ ...settings, autoStart: false }); - stopPhoneServerWhenSettled() + updatePhoneServer(settings) .catch((error: unknown) => setActionError( - error instanceof Error ? error.message : "Couldn't stop the server.", + error instanceof Error ? error.message : "The update didn't finish.", ), ) - .finally(() => setBusy(null)); + .finally(() => { + setBusy(null); + server.check(); + }); }} /> - ) : ( - void startAndReconnectPhoneServer(settings)} - /> - )} - { - setBusy("updating"); - setActionError(null); - updatePhoneServer(settings) - .catch((error: unknown) => - setActionError(error instanceof Error ? error.message : "The update didn't finish."), - ) - .finally(() => { - setBusy(null); - server.check(); - }); - }} - /> - + + + ); } diff --git a/apps/mobile/src/features/phone/PhoneSharingSection.tsx b/apps/mobile/src/features/phone/PhoneSharingSection.tsx new file mode 100644 index 0000000000000..a8f421b193ea2 --- /dev/null +++ b/apps/mobile/src/features/phone/PhoneSharingSection.tsx @@ -0,0 +1,181 @@ +import { useAuth } from "@clerk/expo"; +import { useAtomValue } from "@effect/atom-react"; +import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { formatConnectReachable, type EnvironmentId } from "@t3tools/contracts"; +import type * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { useCallback, useState } from "react"; + +import * as Dialog from "../../components/ui/Dialog"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { mobilePreferencesAtom } from "../../state/preferences"; +import { useSavedRemoteConnection } from "../../state/use-remote-environment-registry"; +import { useWorkspaceEnvironments } from "../../state/workspace"; +import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; +import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; +import { + readPhoneSharing, + sharePhoneServer, + stopSharingPhoneServer, + type PhoneSharing, +} from "./phone-sharing"; + +function failureText(result: { readonly cause: Cause.Cause }, fallback: string): string { + const error = squashAtomCommandFailure(result); + return error instanceof Error && error.message.length > 0 ? error.message : fallback; +} + +function sharingText(sharing: PhoneSharing | null): string { + if (sharing === null) return "Checking…"; + if (!sharing.shared) return "Off. Only the app on this phone can open its threads."; + const reachability = sharing.reachability; + if (reachability?.status === "unreachable") { + return `On, but other devices can't connect yet: ${reachability.reason}`; + } + return reachability?.status === "reachable" + ? `On. ${formatConnectReachable(reachability)}.` + : "On. Devices signed in to your T3 Connect account can open this phone's threads."; +} + +/** Phone setup and the phone's environment page: share the phone's server through T3 Connect. */ +export function PhoneSharingSection(props: { readonly environmentId: EnvironmentId }) { + // useAuth needs the ClerkProvider, which builds without cloud config don't render. + return hasCloudPublicConfig() ? : null; +} + +function ConfiguredPhoneSharingSection(props: { readonly environmentId: EnvironmentId }) { + const navigation = useNavigation(); + const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const accountId = useAtomValue(managedRelaySessionAtom)?.accountId ?? null; + const preferences = useAtomValue(mobilePreferencesAtom); + const connection = useSavedRemoteConnection(props.environmentId); + const connected = + useWorkspaceEnvironments().find((candidate) => candidate.environmentId === props.environmentId) + ?.connectionState === "connected"; + const [sharing, setSharing] = useState(null); + const [busy, setBusy] = useState<"share" | "stop" | null>(null); + const [error, setError] = useState(null); + + const check = useCallback(() => { + if (connection === null || !connected) return; + void runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { + reportFailure: false, + }).then((result) => { + if (AsyncResult.isSuccess(result)) setSharing(result.value); + else setError(failureText(result, "Couldn't check whether this phone is shared.")); + }); + }, [connection, connected]); + useFocusEffect(check); + + if (connection === null) return null; + + const share = async () => { + const token = await getToken(resolveRelayClerkTokenOptions()).catch(() => null); + if (!token) { + setError("Couldn't get a T3 Connect sign-in. Sign in again under Settings."); + return; + } + const result = await runAtomCommand( + appAtomRegistry, + sharePhoneServer, + { + connection, + clerkToken: token, + liveActivitiesEnabled: + !AsyncResult.isSuccess(preferences) || preferences.value.liveActivitiesEnabled !== false, + }, + { reportFailure: false }, + ); + if (AsyncResult.isSuccess(result)) { + setSharing(result.value); + refreshManagedRelayEnvironments(); + } else { + setError(failureText(result, "Couldn't share this phone.")); + check(); + } + }; + + const stopSharing = async () => { + const result = await runAtomCommand( + appAtomRegistry, + stopSharingPhoneServer, + { connection, accountId }, + { reportFailure: false }, + ); + if (AsyncResult.isFailure(result)) setError(failureText(result, "Couldn't stop sharing.")); + refreshManagedRelayEnvironments(); + check(); + }; + + const run = (kind: "share" | "stop", action: () => Promise) => { + setBusy(kind); + setError(null); + void action().finally(() => setBusy(null)); + }; + + const onValueChange = (on: boolean) => { + if (!on) { + Dialog.alert( + "Stop sharing this phone?", + "Your other devices lose access to this phone's threads. The threads stay on this phone.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Stop sharing", style: "destructive", onPress: () => run("stop", stopSharing) }, + ], + ); + return; + } + if (!isSignedIn) { + Dialog.alert( + "Sign in to T3 Connect", + "Sharing uses your T3 Connect account. Sign in, then turn sharing on.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Continue", onPress: () => navigation.navigate("SettingsAuth") }, + ], + ); + return; + } + Dialog.alert( + "Share this phone?", + "Anyone signed in to your T3 Connect account can open this phone's threads and run agents on it while its server runs. The phone counts as one of your account's T3 Connect machines. The first time, T3 Code downloads Cloudflare's tunnel client (about 40 MB).", + [ + { text: "Cancel", style: "cancel" }, + { text: "Share", onPress: () => run("share", share) }, + ], + ); + }; + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/phone/phone-sharing.ts b/apps/mobile/src/features/phone/phone-sharing.ts new file mode 100644 index 0000000000000..404149827c0bc --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing.ts @@ -0,0 +1,174 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { + deregisterManagedRelayEnvironment, + managedRelaySessionAtom, +} from "@t3tools/client-runtime/relay"; +import { makeEnvironmentHttpApiClient, request, runStream } from "@t3tools/client-runtime/rpc"; +import { createRuntimeCommand, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + type EnvironmentCloudLinkStateResult, + type EnvironmentConnectReachability, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { AsyncResult, type AtomRegistry } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../../connection/runtime"; +import type { SavedRemoteConnection } from "../../lib/connection"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; + +export class PhoneSharingError extends Data.TaggedError("PhoneSharingError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** Whether other devices on the user's T3 Connect account can reach the phone's server. */ +export type PhoneSharing = + | { readonly shared: false } + | { readonly shared: true; readonly reachability: EnvironmentConnectReachability | null }; + +export function phoneSharingFromLinkState(state: EnvironmentCloudLinkStateResult): PhoneSharing { + return state.linked && !state.linkRevoked && state.managedTunnelActive + ? { shared: true, reachability: state.reachability ?? null } + : { shared: false }; +} + +// As `t3 connect` waits: a new tunnel's hostname takes a while to resolve. +const REACHABILITY_CHECK_TIMEOUT = Duration.seconds(30); +const REACHABILITY_RETRY = Duration.seconds(5); +const REACHABILITY_RETRIES = 11; + +const failWith = (message: string) => (cause: unknown) => new PhoneSharingError({ message, cause }); + +const phoneServerApi = Effect.fn("PhoneSharing.phoneServerApi")(function* ( + connection: SavedRemoteConnection, +) { + if (connection.bearerToken === null) { + return yield* new PhoneSharingError({ message: "Connect this phone again from phone setup." }); + } + const api = yield* makeEnvironmentHttpApiClient(connection.httpBaseUrl); + return { api, headers: { authorization: `Bearer ${connection.bearerToken}` } }; +}); + +const readSharing = Effect.fn("PhoneSharing.read")(function* (connection: SavedRemoteConnection) { + const { api, headers } = yield* phoneServerApi(connection); + const state = yield* api.connect + .linkState({ headers }) + .pipe(Effect.mapError(failWith("Couldn't check whether this phone is shared."))); + return phoneSharingFromLinkState(state); +}); + +/** cloudflared runs the tunnel; the phone's Ubuntu starts without it. */ +const ensureRelayClient = Effect.fn("PhoneSharing.ensureRelayClient")(function* ( + connection: SavedRemoteConnection, +) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const status = yield* registry + .run(connection.environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) + .pipe(Effect.mapError(failWith("Couldn't check for the T3 Connect relay client."))); + if (status.status === "available") return; + if (status.status === "unsupported") { + return yield* new PhoneSharingError({ + message: `T3 Connect has no relay client for ${status.platform}-${status.arch}.`, + }); + } + const installed = yield* registry + .runStream(connection.environmentId, runStream(WS_METHODS.cloudInstallRelayClient, {})) + .pipe( + Stream.runLast, + Effect.mapError(failWith("Couldn't install the T3 Connect relay client.")), + ); + if ( + Option.isNone(installed) || + installed.value.type !== "complete" || + installed.value.status.status !== "available" + ) { + return yield* new PhoneSharingError({ + message: "The T3 Connect relay client didn't install.", + }); + } +}); + +export const readPhoneSharing = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:read-sharing", + execute: readSharing, +}); + +/** + * Links the phone's server to the signed-in T3 Connect account with a managed tunnel. The + * server keeps listening on loopback; cloudflared reaches it there and connects out. + */ +export const sharePhoneServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:share", + execute: Effect.fn("PhoneSharing.share")(function* (input: { + readonly connection: SavedRemoteConnection; + readonly clerkToken: string; + readonly liveActivitiesEnabled: boolean; + }) { + yield* ensureRelayClient(input.connection); + yield* linkEnvironmentToCloudWithPreference(input).pipe( + Effect.mapError((cause) => new PhoneSharingError({ message: cause.message, cause })), + ); + const { api, headers } = yield* phoneServerApi(input.connection); + // The link stands without a passing check; the last result says why devices can't connect. + const reachability = yield* api.connect.checkReachability({ headers }).pipe( + Effect.timeout(REACHABILITY_CHECK_TIMEOUT), + Effect.orElseSucceed(() => null), + Effect.repeat({ + schedule: Schedule.spaced(REACHABILITY_RETRY), + until: (result) => result?.status === "reachable", + times: REACHABILITY_RETRIES, + }), + ); + return { shared: true, reachability } satisfies PhoneSharing; + }), +}); + +/** Stops the tunnel on the phone, then removes the phone from the T3 Connect account. */ +export const stopSharingPhoneServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:stop-sharing", + execute: Effect.fn("PhoneSharing.stop")(function* ( + input: { readonly connection: SavedRemoteConnection; readonly accountId: string | null }, + registry: AtomRegistry.AtomRegistry, + ) { + const { api, headers } = yield* phoneServerApi(input.connection); + yield* api.connect + .unlink({ headers }) + .pipe(Effect.mapError(failWith("Couldn't stop sharing this phone."))); + if (input.accountId === null) return; + yield* deregisterManagedRelayEnvironment(registry, { + accountId: input.accountId, + environmentId: input.connection.environmentId, + }).pipe( + Effect.mapError( + failWith( + "Stopped sharing, but T3 Connect still lists this phone. Remove it in Settings under T3 Connect.", + ), + ), + ); + }), +}); + +/** + * Remove and Reinstall delete the server's link along with Ubuntu, so a shared phone comes off + * the T3 Connect account first. Best effort: a server that isn't running can't be checked. + */ +export async function releasePhoneSharing(connection: SavedRemoteConnection | null): Promise { + if (connection === null) return; + const sharing = await runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { + reportFailure: false, + }); + if (!AsyncResult.isSuccess(sharing) || !sharing.value.shared) return; + await runAtomCommand( + appAtomRegistry, + stopSharingPhoneServer, + { connection, accountId: appAtomRegistry.get(managedRelaySessionAtom)?.accountId ?? null }, + { reportFailure: false }, + ); +} From 27c576208a1fab567b5ff8b98d8d1dd957e3f784 Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:00:47 +0000 Subject: [PATCH 02/19] docs(fork): list phone sharing in FORK.md Co-Authored-By: Claude Opus 5.5 --- FORK.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/FORK.md b/FORK.md index 0d75e769cd44b..f68032e7b5d70 100644 --- a/FORK.md +++ b/FORK.md @@ -49,3 +49,9 @@ 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, and Ongoing activity skips the + phone's server: `apps/mobile/src/features/phone/phone-sharing.ts`, + `apps/mobile/src/features/phone/PhoneSharingSection.tsx`, + `apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts`. From 919db0a3838fe2ae1ccb36ba30dd65e9046e036c Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:49:34 +0000 Subject: [PATCH 03/19] fix(mobile): phone sharing ends access when it's off, and Ask is decided 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 --- FORK.md | 10 +- .../android/src/main/AndroidManifest.xml | 5 + .../src/main/assets/phone-agent-guide.md | 9 +- .../modules/t3deviceruntime/AccessApproval.kt | 129 ++++++++ .../modules/t3deviceruntime/AgentAccess.kt | 11 +- .../modules/t3deviceruntime/AndroidBridge.kt | 38 ++- .../liveActivityPreferences.test.ts | 21 +- .../liveActivityPreferences.ts | 9 +- .../features/cloud/CloudAuthProvider.test.ts | 17 ++ .../src/features/cloud/cloudAccountService.ts | 4 + .../device-runtime/AgentAccessRouteScreen.tsx | 5 +- .../DeviceRuntimeSetupScreen.tsx | 8 +- .../features/phone/PhoneSharingSection.tsx | 124 ++++++-- .../mobile/src/features/phone/phone-server.ts | 10 + .../features/phone/phone-sharing-commands.ts | 275 ++++++++++++++++++ .../src/features/phone/phone-sharing.test.ts | 129 ++++++++ .../src/features/phone/phone-sharing.ts | 216 ++++++-------- .../SettingsNotificationsRouteScreen.tsx | 4 + .../src/persistence/mobile-preferences.ts | 8 + .../state/use-remote-environment-registry.ts | 2 + docs/user/remote-access.md | 2 +- 21 files changed, 857 insertions(+), 179 deletions(-) create mode 100644 apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt create mode 100644 apps/mobile/src/features/phone/phone-sharing-commands.ts create mode 100644 apps/mobile/src/features/phone/phone-sharing.test.ts diff --git a/FORK.md b/FORK.md index f68032e7b5d70..1cc5f4e86902f 100644 --- a/FORK.md +++ b/FORK.md @@ -51,7 +51,11 @@ adds the feature, and remove it in the PR that drops the feature. `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, and Ongoing activity skips the - phone's server: `apps/mobile/src/features/phone/phone-sharing.ts`, + server with its own T3 Connect sign-in. 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/mobile/src/features/phone/phone-sharing.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/src/features/agent-awareness/liveActivityPreferences.ts`, + `apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt`. diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml index 433baefd412a2..adb61bfb51f79 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml @@ -36,6 +36,11 @@ + "$key: ${body.opt(key)}" } + if (details.length <= DETAILS_LIMIT) return details + return "${details.take(DETAILS_LIMIT)}… (${details.length - DETAILS_LIMIT} more characters)" +} + +/** + * One prompt per request, so an answer can't also cover a command the user never saw. Answers + * only ever come from AccessApprovalActivity, which nothing outside this app can start. + */ +internal object AccessApproval { + private val pending = ConcurrentHashMap>() + + /** 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 { + val id = UUID.randomUUID().toString() + val answer = CompletableFuture() + pending[id] = answer + try { + show(intent(context, id, category, command, details)) + return 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. + answer.complete(false) + dismiss() + } + } + + fun answer(id: String, allowed: Boolean) { + pending[id]?.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 answer = pending[id] + if (answer == null) action() else answer.whenComplete { _, _ -> action() } + } + + private fun intent( + context: Context, + id: String, + category: AccessCategory, + command: String, + details: String, + ): Intent { + 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 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) + .putExtra(EXTRA_TITLE, "Allow ${category.label}?") + .putExtra(EXTRA_MESSAGE, message) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } +} + +/** 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() + request = id + val shown = AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setTitle(intent.getStringExtra(EXTRA_TITLE)) + .setMessage(intent.getStringExtra(EXTRA_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() + } +} diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt index de15e3de6d3e4..b7a4e714225fb 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt @@ -32,9 +32,9 @@ enum class AccessPolicy(val id: String) { class AccessCategory(val id: String, val label: String, val commands: List, val permissions: List) /** - * 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 the command is written. 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( @@ -133,7 +133,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)) } diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt index df8d23ff507e5..f5de6e420beef 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt @@ -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) @@ -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 @@ -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) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 97234b7368d85..0c8305b0eff89 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -88,6 +88,7 @@ describe("liveActivityPreferences", () => { clerkToken: "clerk-token", connections: [connection], canConfigureEnvironment: () => true, + phoneServerEnvironmentId: null, }), ); @@ -123,15 +124,32 @@ describe("liveActivityPreferences", () => { 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, phoneConnection, connection], + connections: [ + readOnlyConnection, + phoneConnection, + phoneOnFallback, + configuredPhone, + connection, + ], canConfigureEnvironment: (environmentId) => environmentId !== readOnlyConnection.environmentId, + phoneServerEnvironmentId: configuredPhone.environmentId, }); expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ @@ -160,6 +178,7 @@ describe("liveActivityPreferences", () => { clerkToken: "clerk-token", connections: [connection], canConfigureEnvironment: () => canConfigureEnvironment, + phoneServerEnvironmentId: null, }); expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 2b9845db28cc8..d7b6eab62fc49 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -13,11 +13,16 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn readonly clerkToken: string | null; readonly connections: ReadonlyArray; 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. + // 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 && phoneServerPort(connection.displayUrl) === null, + connection.bearerToken !== null && + connection.environmentId !== input.phoneServerEnvironmentId && + phoneServerPort(connection.displayUrl) === null && + phoneServerPort(connection.httpBaseUrl) === null, ); const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts index de4726f06d276..3d35762c943a3 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts @@ -8,6 +8,7 @@ import { deactivateCloudRelayAccount, } from "./cloudAccountService"; import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration"; +import { stopPhoneSharingAfterSignOut } from "../phone/phone-sharing-commands"; import { requestConnectOnboarding } from "./connectOnboarding"; import { restoreCloudComposerDrafts } from "../../state/use-composer-drafts"; @@ -33,6 +34,9 @@ vi.mock("../../connection/catalog", () => ({ })); vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); +vi.mock("../phone/phone-sharing-commands", () => ({ + stopPhoneSharingAfterSignOut: vi.fn(async () => undefined), +})); vi.mock("./connectOnboarding", () => ({ clearConnectOnboardingRequest: vi.fn(), requestConnectOnboarding: vi.fn(), @@ -109,6 +113,19 @@ describe("cloudAccountService", () => { expect(vi.mocked(requestConnectOnboarding)).not.toHaveBeenCalled(); }); + it("stops sharing the phone when the account signs out", async () => { + const { clerk, emit } = fakeClerk(); + using _detach = { [Symbol.dispose]: cloudAccountService.attach(clerk) }; + emit("account-1"); + await vi.waitFor(() => + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"), + ); + expect(vi.mocked(stopPhoneSharingAfterSignOut)).not.toHaveBeenCalled(); + + emit(null); + await vi.waitFor(() => expect(vi.mocked(stopPhoneSharingAfterSignOut)).toHaveBeenCalledOnce()); + }); + it("retries an activation whose draft restore failed on the next emission", async () => { const { clerk, emit } = fakeClerk(); using _detach = { [Symbol.dispose]: cloudAccountService.attach(clerk) }; diff --git a/apps/mobile/src/features/cloud/cloudAccountService.ts b/apps/mobile/src/features/cloud/cloudAccountService.ts index aa7bff16b1548..7e0c27f5c077e 100644 --- a/apps/mobile/src/features/cloud/cloudAccountService.ts +++ b/apps/mobile/src/features/cloud/cloudAccountService.ts @@ -23,6 +23,7 @@ import { setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; +import { stopPhoneSharingAfterSignOut } from "../phone/phone-sharing-commands"; import { removeCloudEnvironments } from "./cloud-drafts"; import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; @@ -55,6 +56,9 @@ const cleanUpCloudAccount = async ( accountId: string | null, wasActive: boolean, ) => { + await stopPhoneSharingAfterSignOut().catch((error: unknown) => + console.warn("Couldn't stop sharing this phone after sign-out", error), + ); const removal = await runAtomCommand(appAtomRegistry, removeCloudEnvironments, accountId, { reportFailure: false, reportDefect: false, diff --git a/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx b/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx index 13bab1072d6bf..8de4a9af28ad4 100644 --- a/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx +++ b/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx @@ -33,7 +33,7 @@ const POLICIES: ReadonlyArray<{ { policy: "ask", label: "Ask each time", - description: "Claude asks in the thread before it runs one.", + description: "This phone asks you before each one runs.", }, { policy: "allow", label: "Always allow", description: "Agents use it without asking." }, { policy: "deny", label: "Never", description: "Agents can't use it." }, @@ -87,7 +87,8 @@ export function AgentAccessRouteScreen() { > Agents on this phone reach Android through termux-* commands. These need your say: with - Ask each time, Claude asks in the thread before it runs one. + Ask each time, this phone asks you before each one runs, even when another device started + it. Nobody answering within a minute counts as no. {categories.map((category) => { const meta = CATEGORIES[category.id]; diff --git a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx index 401a9626beef1..48642b761e743 100644 --- a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx +++ b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx @@ -17,7 +17,7 @@ import { forgetPhoneEnvironments, stopPhoneServerWhenSettled, } from "../phone/phone-server"; -import { releasePhoneSharing } from "../phone/phone-sharing"; +import { releasePhoneSharing } from "../phone/phone-sharing-commands"; import { PhoneSharingSection } from "../phone/PhoneSharingSection"; import { StepRow, StepText } from "../phone/SetupStep"; import { SettingsScreen } from "../settings/components/SettingsScreen"; @@ -164,12 +164,13 @@ export function DeviceRuntimeSetupScreen() { style: "destructive", onPress: () => void run("install", async () => { - await releasePhoneSharing(connection); + const stillListed = await releasePhoneSharing(connection); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); setInspection(null); await installRuntime(); + if (stillListed) setError(stillListed); }), }, ], @@ -186,10 +187,11 @@ export function DeviceRuntimeSetupScreen() { onPress: () => void run("remove", async () => { if (settings) setPhoneServerSettings({ ...settings, autoStart: false }); - await releasePhoneSharing(connection); + const stillListed = await releasePhoneSharing(connection); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); + if (stillListed) Dialog.alert("Removed from this phone", stillListed); navigation.goBack(); }), }, diff --git a/apps/mobile/src/features/phone/PhoneSharingSection.tsx b/apps/mobile/src/features/phone/PhoneSharingSection.tsx index a8f421b193ea2..f5472b4174b2e 100644 --- a/apps/mobile/src/features/phone/PhoneSharingSection.tsx +++ b/apps/mobile/src/features/phone/PhoneSharingSection.tsx @@ -1,15 +1,15 @@ import { useAuth } from "@clerk/expo"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; -import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { formatConnectReachable, type EnvironmentId } from "@t3tools/contracts"; import type * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import * as Dialog from "../../components/ui/Dialog"; import { appAtomRegistry } from "../../state/atom-registry"; +import { usePhoneServerSettings } from "../../state/phone-server-settings"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnection } from "../../state/use-remote-environment-registry"; import { useWorkspaceEnvironments } from "../../state/workspace"; @@ -17,66 +17,110 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { SettingsSection } from "../settings/components/SettingsSection"; import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; +import type { PhoneSharing } from "./phone-sharing"; import { + checkPhoneReachability, readPhoneSharing, + retryPhoneDeregistration, sharePhoneServer, stopSharingPhoneServer, - type PhoneSharing, -} from "./phone-sharing"; +} from "./phone-sharing-commands"; function failureText(result: { readonly cause: Cause.Cause }, fallback: string): string { const error = squashAtomCommandFailure(result); return error instanceof Error && error.message.length > 0 ? error.message : fallback; } -function sharingText(sharing: PhoneSharing | null): string { - if (sharing === null) return "Checking…"; +function sharingText(sharing: PhoneSharing, accountId: string | null | undefined): string { if (!sharing.shared) return "Off. Only the app on this phone can open its threads."; + if (sharing.revoked) { + return "T3 Connect no longer accepts this link, but its tunnel may still be set up here. Turn this off to clear it."; + } + if (sharing.cloudUserId !== null && sharing.cloudUserId !== accountId) { + return "Shared with a different T3 Connect account. Turn this off to stop it."; + } const reachability = sharing.reachability; if (reachability?.status === "unreachable") { return `On, but other devices can't connect yet: ${reachability.reason}`; } return reachability?.status === "reachable" ? `On. ${formatConnectReachable(reachability)}.` - : "On. Devices signed in to your T3 Connect account can open this phone's threads."; + : "On. Devices signed in to your T3 Connect account can use this phone's server."; } /** Phone setup and the phone's environment page: share the phone's server through T3 Connect. */ export function PhoneSharingSection(props: { readonly environmentId: EnvironmentId }) { // useAuth needs the ClerkProvider, which builds without cloud config don't render. - return hasCloudPublicConfig() ? : null; + return hasCloudPublicConfig() ? ( + + ) : null; } function ConfiguredPhoneSharingSection(props: { readonly environmentId: EnvironmentId }) { const navigation = useNavigation(); - const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const accountId = useAtomValue(managedRelaySessionAtom)?.accountId ?? null; + const { getToken, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const preferences = useAtomValue(mobilePreferencesAtom); + const settings = usePhoneServerSettings(); const connection = useSavedRemoteConnection(props.environmentId); const connected = useWorkspaceEnvironments().find((candidate) => candidate.environmentId === props.environmentId) ?.connectionState === "connected"; const [sharing, setSharing] = useState(null); const [busy, setBusy] = useState<"share" | "stop" | null>(null); - const [error, setError] = useState(null); + const [verifying, setVerifying] = useState(false); + const [readError, setReadError] = useState(null); + const [actionError, setActionError] = useState(null); + // Only the newest read or action may show its result, and reads wait out a share or stop. + const latest = useRef(0); + const acting = useRef(false); const check = useCallback(() => { - if (connection === null || !connected) return; + if (connection === null || !connected || acting.current) return; + latest.current += 1; + const id = latest.current; + setVerifying(false); void runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { reportFailure: false, }).then((result) => { - if (AsyncResult.isSuccess(result)) setSharing(result.value); - else setError(failureText(result, "Couldn't check whether this phone is shared.")); + if (id !== latest.current) return; + if (AsyncResult.isSuccess(result)) { + setReadError(null); + setSharing(result.value); + if (!result.value.shared) void retryPhoneDeregistration(); + } else { + setReadError(failureText(result, "Couldn't check whether this phone is shared.")); + } }); }, [connection, connected]); useFocusEffect(check); if (connection === null) return null; + // The switch can turn sharing off during this wait, which discards the result. + const verify = () => { + latest.current += 1; + const id = latest.current; + setVerifying(true); + void runAtomCommand(appAtomRegistry, checkPhoneReachability, connection, { + reportFailure: false, + }).then((result) => { + if (id !== latest.current) return; + setVerifying(false); + const reachability = AsyncResult.isSuccess(result) ? result.value : null; + setSharing((current) => (current?.shared ? { ...current, reachability } : current)); + }); + }; + + const finish = () => { + acting.current = false; + setBusy(null); + }; + const share = async () => { - const token = await getToken(resolveRelayClerkTokenOptions()).catch(() => null); - if (!token) { - setError("Couldn't get a T3 Connect sign-in. Sign in again under Settings."); + const token = userId ? await getToken(resolveRelayClerkTokenOptions()).catch(() => null) : null; + if (!token || !userId) { + setActionError("Couldn't get a T3 Connect sign-in. Sign in again under Settings."); + finish(); return; } const result = await runAtomCommand( @@ -84,44 +128,55 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm sharePhoneServer, { connection, + accountId: userId, clerkToken: token, liveActivitiesEnabled: !AsyncResult.isSuccess(preferences) || preferences.value.liveActivitiesEnabled !== false, }, { reportFailure: false }, ); + finish(); + refreshManagedRelayEnvironments(); if (AsyncResult.isSuccess(result)) { - setSharing(result.value); - refreshManagedRelayEnvironments(); + setSharing({ shared: true, revoked: false, cloudUserId: userId, reachability: null }); + verify(); } else { - setError(failureText(result, "Couldn't share this phone.")); + setActionError(failureText(result, "Couldn't share this phone.")); check(); } }; const stopSharing = async () => { + const owner = + (sharing?.shared ? sharing.cloudUserId : null) ?? settings?.sharing?.accountId ?? null; const result = await runAtomCommand( appAtomRegistry, stopSharingPhoneServer, - { connection, accountId }, + { connection, owner }, { reportFailure: false }, ); - if (AsyncResult.isFailure(result)) setError(failureText(result, "Couldn't stop sharing.")); + if (AsyncResult.isFailure(result)) { + setActionError(failureText(result, "Couldn't stop sharing.")); + } + finish(); refreshManagedRelayEnvironments(); check(); }; const run = (kind: "share" | "stop", action: () => Promise) => { + acting.current = true; + latest.current += 1; setBusy(kind); - setError(null); - void action().finally(() => setBusy(null)); + setVerifying(false); + setActionError(null); + void action(); }; const onValueChange = (on: boolean) => { if (!on) { Dialog.alert( "Stop sharing this phone?", - "Your other devices lose access to this phone's threads. The threads stay on this phone.", + "Stops the tunnel and signs out every other device, so they lose access to this phone's server. Your threads stay here, and so does anything another device changed or installed while it was shared.", [ { text: "Cancel", style: "cancel" }, { text: "Stop sharing", style: "destructive", onPress: () => run("stop", stopSharing) }, @@ -142,7 +197,7 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm } Dialog.alert( "Share this phone?", - "Anyone signed in to your T3 Connect account can open this phone's threads and run agents on it while its server runs. The phone counts as one of your account's T3 Connect machines. The first time, T3 Code downloads Cloudflare's tunnel client (about 40 MB).", + "Anyone signed in to your T3 Connect account gets full control of this phone's server: threads, agents, a terminal running as root in its Ubuntu, and your phone's files if you've allowed phone storage. They can also use commands such as the clipboard and notifications. Agent access still asks on this phone, and Never still applies.\n\nThe phone counts as one of your account's T3 Connect machines. The first time, T3 Code downloads Cloudflare's tunnel client (about 40 MB).", [ { text: "Cancel", style: "cancel" }, { text: "Share", onPress: () => run("share", share) }, @@ -154,8 +209,9 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm { port, autoStart: previous?.autoStart ?? true, host: "device", + ...(previous?.environmentId === environmentId && previous.sharing + ? { sharing: previous.sharing } + : {}), }); await retireOtherPhoneEnvironments(environmentId); return environmentId; @@ -273,6 +277,12 @@ export function usePhoneServerWorker() { useEffect(() => { if (deviceRuntime !== null && down) void autoStartPhoneServer(); }, [down]); + const connected = phone?.connectionState === "connected"; + const pendingSharingStop = settings?.sharing?.pendingStop === true; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- ends sharing that a sign-out couldn't, as soon as the server answers + useEffect(() => { + if (connected && pendingSharingStop) void finishPendingSharingStop(); + }, [connected, pendingSharingStop]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the app returning to the foreground useEffect(() => { if (deviceRuntime === null) return; diff --git a/apps/mobile/src/features/phone/phone-sharing-commands.ts b/apps/mobile/src/features/phone/phone-sharing-commands.ts new file mode 100644 index 0000000000000..26590148760ae --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing-commands.ts @@ -0,0 +1,275 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { + deregisterManagedRelayEnvironment, + type ManagedRelay, + managedRelaySessionAtom, +} from "@t3tools/client-runtime/relay"; +import { request, runStream, subscribe } from "@t3tools/client-runtime/rpc"; +import { createRuntimeCommand, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { type EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { AsyncResult, type AtomRegistry } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../../connection/runtime"; +import type { SavedRemoteConnection } from "../../lib/connection"; +import type { PhoneServerSettings } from "../../persistence/mobile-preferences"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { phoneServerSettingsAtom, setPhoneServerSettings } from "../../state/phone-server-settings"; +import { savedRemoteConnectionAtom } from "../../state/use-remote-environment-registry"; +import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; +import { + failWith, + PHONE_STILL_LISTED, + PhoneSharingError, + readSharing, + stopServerSharing, + unlinkServer, + waitForReachability, +} from "./phone-sharing"; + +const PAIRING_LINKS_TIMEOUT = Duration.seconds(10); + +/** Remembers the account a shared phone is listed under, for the times its server is down. */ +function setSharingRecord(sharing: PhoneServerSettings["sharing"]) { + const settings = appAtomRegistry.get(phoneServerSettingsAtom); + if (settings !== null) setPhoneServerSettings({ ...settings, sharing }); +} + +const deregister = ( + registry: AtomRegistry.AtomRegistry, + accountId: string, + environmentId: EnvironmentId, +) => + deregisterManagedRelayEnvironment(registry, { accountId, environmentId }).pipe( + Effect.tap(() => Effect.sync(() => setSharingRecord(undefined))), + ); + +const pairingLinkIds = Effect.fn("PhoneSharing.pairingLinkIds")(function* ( + environmentId: EnvironmentId, +) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const first = yield* registry + .runStream(environmentId, subscribe(WS_METHODS.subscribeAuthAccess, {})) + .pipe( + Stream.filter((event) => event.type === "snapshot"), + Stream.runHead, + Effect.timeout(PAIRING_LINKS_TIMEOUT), + ); + return Option.match(first, { + onNone: () => [], + onSome: (event) => + event.type === "snapshot" ? event.payload.pairingLinks.map((link) => link.id) : [], + }); +}); + +/** cloudflared runs the tunnel; the phone's Ubuntu starts without it. */ +const ensureRelayClient = Effect.fn("PhoneSharing.ensureRelayClient")(function* ( + connection: SavedRemoteConnection, +) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const status = yield* registry + .run(connection.environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) + .pipe(Effect.mapError(failWith("Couldn't check for the T3 Connect relay client."))); + if (status.status === "available") return; + if (status.status === "unsupported") { + return yield* new PhoneSharingError({ + message: `T3 Connect has no relay client for ${status.platform}-${status.arch}.`, + }); + } + const installed = yield* registry + .runStream(connection.environmentId, runStream(WS_METHODS.cloudInstallRelayClient, {})) + .pipe( + Stream.runLast, + Effect.mapError(failWith("Couldn't install the T3 Connect relay client.")), + ); + if ( + Option.isNone(installed) || + installed.value.type !== "complete" || + installed.value.status.status !== "available" + ) { + return yield* new PhoneSharingError({ + message: "The T3 Connect relay client didn't install.", + }); + } +}); + +export const readPhoneSharing = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:read-sharing", + execute: readSharing, +}); + +export const checkPhoneReachability = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:check-reachability", + execute: waitForReachability, +}); + +/** + * Links the phone's server to the signed-in T3 Connect account with a managed tunnel. The + * server keeps listening on loopback; cloudflared reaches it there and connects out. + */ +export const sharePhoneServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:share", + execute: Effect.fn("PhoneSharing.share")(function* ( + input: { + readonly connection: SavedRemoteConnection; + readonly accountId: string; + readonly clerkToken: string; + readonly liveActivitiesEnabled: boolean; + }, + registry: AtomRegistry.AtomRegistry, + ) { + yield* ensureRelayClient(input.connection); + yield* linkEnvironmentToCloudWithPreference(input).pipe( + Effect.mapError((cause) => new PhoneSharingError({ message: cause.message, cause })), + // The relay may have linked before the server failed to take the config. + Effect.tapError(() => + unlinkServer(input.connection).pipe( + Effect.andThen( + deregisterManagedRelayEnvironment(registry, { + accountId: input.accountId, + environmentId: input.connection.environmentId, + }), + ), + Effect.ignore, + ), + ), + ); + yield* Effect.sync(() => setSharingRecord({ accountId: input.accountId })); + }), +}); + +/** Off: stops the tunnel, signs out other clients, and takes the phone off the account. */ +export const stopSharingPhoneServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:stop-sharing", + execute: Effect.fn("PhoneSharing.stop")(function* ( + input: { readonly connection: SavedRemoteConnection; readonly owner: string | null }, + registry: AtomRegistry.AtomRegistry, + ) { + const signedIn = registry.get(managedRelaySessionAtom)?.accountId ?? null; + const { connection, owner } = input; + yield* stopServerSharing(connection, { + pairingLinkIds: pairingLinkIds(connection.environmentId), + deregister: Effect.suspend( + (): Effect.Effect => { + if (owner === null) return Effect.sync(() => setSharingRecord(undefined)); + return owner === signedIn + ? deregister(registry, owner, connection.environmentId) + : Effect.fail(new PhoneSharingError({ message: PHONE_STILL_LISTED })); + }, + ), + }); + }), +}); + +/** Ends a share on the server alone, for when no account is available to deregister with. */ +const stopServerWithoutAccount = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:stop-without-account", + execute: Effect.fn("PhoneSharing.stopWithoutAccount")(function* ( + connection: SavedRemoteConnection, + ) { + const sharing = yield* readSharing(connection); + if (!sharing.shared) return null; + yield* stopServerSharing(connection, { + pairingLinkIds: pairingLinkIds(connection.environmentId), + deregister: Effect.void, + }); + return sharing.cloudUserId; + }), +}); + +const deregisterPhone = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:deregister", + execute: ( + input: { readonly accountId: string; readonly environmentId: EnvironmentId }, + registry, + ) => deregister(registry, input.accountId, input.environmentId), +}); + +/** + * Signing out or switching accounts ends sharing, as the phone would otherwise stay open to an + * account this app no longer uses. A server that's down finishes it when it next connects. + * The account's listing stays until that account signs in here again: deregistering needs it. + */ +export async function stopPhoneSharingAfterSignOut(): Promise { + const settings = appAtomRegistry.get(phoneServerSettingsAtom); + if (settings === null) return; + const connection = appAtomRegistry.get(savedRemoteConnectionAtom(settings.environmentId)); + const result = + connection === null + ? null + : await runAtomCommand(appAtomRegistry, stopServerWithoutAccount, connection, { + reportFailure: false, + }); + if (result !== null && AsyncResult.isSuccess(result)) { + const owner = result.value ?? settings.sharing?.accountId; + setSharingRecord(owner === undefined ? undefined : { accountId: owner }); + } else if (settings.sharing) { + setSharingRecord({ ...settings.sharing, pendingStop: true }); + } +} + +/** Finishes a sign-out's stop once the phone's server is reachable again. */ +export async function finishPendingSharingStop(): Promise { + const settings = appAtomRegistry.get(phoneServerSettingsAtom); + if (!settings?.sharing?.pendingStop) return; + const connection = appAtomRegistry.get(savedRemoteConnectionAtom(settings.environmentId)); + if (connection === null) return; + const result = await runAtomCommand(appAtomRegistry, stopServerWithoutAccount, connection, { + reportFailure: false, + }); + if (AsyncResult.isSuccess(result)) setSharingRecord({ accountId: settings.sharing.accountId }); +} + +/** Retries a deregistration that failed earlier, once the server is off and the account is back. */ +export async function retryPhoneDeregistration(): Promise { + const settings = appAtomRegistry.get(phoneServerSettingsAtom); + const record = settings?.sharing; + if (!settings || !record || record.pendingStop) return; + if (appAtomRegistry.get(managedRelaySessionAtom)?.accountId !== record.accountId) return; + await runAtomCommand( + appAtomRegistry, + deregisterPhone, + { accountId: record.accountId, environmentId: settings.environmentId }, + { reportFailure: false }, + ); +} + +/** + * Remove and Reinstall delete the server's link along with Ubuntu, so a shared phone comes off + * the account first. Returns what the user has to do by hand when that isn't possible, such as + * when they're signed out. A server that's down doesn't matter: Ubuntu's about to go anyway. + */ +export async function releasePhoneSharing( + connection: SavedRemoteConnection | null, +): Promise { + const settings = appAtomRegistry.get(phoneServerSettingsAtom); + const read = + connection === null + ? null + : await runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { + reportFailure: false, + }); + const sharing = read !== null && AsyncResult.isSuccess(read) ? read.value : null; + const record = settings?.sharing; + if (!sharing?.shared && !record) return null; + if (sharing?.shared && connection !== null) { + await runAtomCommand(appAtomRegistry, stopServerWithoutAccount, connection, { + reportFailure: false, + }); + } + const owner = (sharing?.shared ? sharing.cloudUserId : null) ?? record?.accountId ?? null; + if (owner === null) return null; + const environmentId = connection?.environmentId ?? settings?.environmentId; + if (environmentId === undefined) return PHONE_STILL_LISTED; + if (appAtomRegistry.get(managedRelaySessionAtom)?.accountId !== owner) return PHONE_STILL_LISTED; + const removed = await runAtomCommand( + appAtomRegistry, + deregisterPhone, + { accountId: owner, environmentId }, + { reportFailure: false }, + ); + return AsyncResult.isSuccess(removed) ? null : PHONE_STILL_LISTED; +} diff --git a/apps/mobile/src/features/phone/phone-sharing.test.ts b/apps/mobile/src/features/phone/phone-sharing.test.ts new file mode 100644 index 0000000000000..dcd2a9f4d5ed5 --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, vi } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { phoneSharingFromLinkState, PHONE_STILL_LISTED, stopServerSharing } from "./phone-sharing"; + +const connection = { + environmentId: EnvironmentId.make("phone"), + environmentLabel: "Phone", + pairingUrl: "http://127.0.0.1:3774/", + displayUrl: "http://127.0.0.1:3774/", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + bearerToken: "local-bearer", +}; + +const linkState = { + linked: false, + linkRevoked: false, + cloudUserId: null, + relayUrl: null, + relayIssuer: null, + managedTunnelActive: false, + publishAgentActivity: false, + holdWebhooksWhileOffline: false, +}; + +function layerServer(failing: ReadonlySet = new Set()) { + const requests: Array = []; + const respond = (path: string) => { + if (failing.has(path)) return Response.json({ error: "no" }, { status: 500 }); + if (path === "/api/connect/unlink") { + return Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }); + } + if (path === "/api/auth/clients/revoke-others") return Response.json({ revokedCount: 2 }); + return Response.json({ revoked: true }); + }; + const layer = layerRemoteHttpClient((input) => { + const path = new URL(String(input)).pathname; + requests.push(path); + return Promise.resolve(respond(path)); + }); + return { requests, layer }; +} + +describe("phoneSharingFromLinkState", () => { + it("counts anything the server still holds as shared, so Off can clear it", () => { + expect(phoneSharingFromLinkState(linkState).shared).toBe(false); + expect( + phoneSharingFromLinkState({ + ...linkState, + linked: true, + linkRevoked: true, + cloudUserId: "user_a", + managedTunnelActive: true, + }), + ).toEqual({ shared: true, revoked: true, cloudUserId: "user_a", reachability: null }); + expect(phoneSharingFromLinkState({ ...linkState, managedTunnelActive: true }).shared).toBe( + true, + ); + }); +}); + +describe("stopServerSharing", () => { + const deregister = vi.fn(() => Effect.void); + beforeEach(() => { + deregister.mockClear(); + }); + + it.effect("stops the tunnel, then signs out other clients and their pairing links", () => { + const server = layerServer(); + return Effect.gen(function* () { + yield* stopServerSharing(connection, { + pairingLinkIds: Effect.succeed(["link-1"]), + deregister: Effect.suspend(deregister), + }); + expect(server.requests).toEqual([ + "/api/connect/unlink", + "/api/auth/clients/revoke-others", + "/api/auth/pairing-links/revoke", + ]); + expect(deregister).toHaveBeenCalledOnce(); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("leaves the account alone when the tunnel didn't stop", () => { + const server = layerServer(new Set(["/api/connect/unlink"])); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + stopServerSharing(connection, { + pairingLinkIds: Effect.succeed([]), + deregister: Effect.suspend(deregister), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(deregister).not.toHaveBeenCalled(); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("still deregisters when signing out clients fails, and reports the failure", () => { + const server = layerServer(new Set(["/api/auth/clients/revoke-others"])); + return Effect.gen(function* () { + const error = yield* Effect.flip( + stopServerSharing(connection, { + pairingLinkIds: Effect.succeed([]), + deregister: Effect.suspend(deregister), + }), + ); + expect(error.message).toContain("couldn't be signed out"); + expect(deregister).toHaveBeenCalledOnce(); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("says so when the account still lists the phone", () => { + const server = layerServer(); + return Effect.gen(function* () { + const error = yield* Effect.flip( + stopServerSharing(connection, { + pairingLinkIds: Effect.succeed([]), + deregister: Effect.fail("relay down"), + }), + ); + expect(error.message).toBe(PHONE_STILL_LISTED); + }).pipe(Effect.provide(server.layer)); + }); +}); diff --git a/apps/mobile/src/features/phone/phone-sharing.ts b/apps/mobile/src/features/phone/phone-sharing.ts index 404149827c0bc..bb63fed46c41f 100644 --- a/apps/mobile/src/features/phone/phone-sharing.ts +++ b/apps/mobile/src/features/phone/phone-sharing.ts @@ -1,50 +1,53 @@ -import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; -import { - deregisterManagedRelayEnvironment, - managedRelaySessionAtom, -} from "@t3tools/client-runtime/relay"; -import { makeEnvironmentHttpApiClient, request, runStream } from "@t3tools/client-runtime/rpc"; -import { createRuntimeCommand, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; -import { - type EnvironmentCloudLinkStateResult, - type EnvironmentConnectReachability, - WS_METHODS, +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import type { + EnvironmentCloudLinkStateResult, + EnvironmentConnectReachability, } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; +import * as Exit from "effect/Exit"; +import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; -import * as Stream from "effect/Stream"; -import { AsyncResult, type AtomRegistry } from "effect/reactivity"; -import { connectionAtomRuntime } from "../../connection/runtime"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; export class PhoneSharingError extends Data.TaggedError("PhoneSharingError")<{ readonly message: string; readonly cause?: unknown; }> {} -/** Whether other devices on the user's T3 Connect account can reach the phone's server. */ +export const PHONE_STILL_LISTED = + "T3 Connect still lists this phone. Remove it under Settings > T3 Connect, signed in to the account it was shared with."; + +/** What the phone's server holds. Any link or tunnel config counts: only Off clears them. */ export type PhoneSharing = | { readonly shared: false } - | { readonly shared: true; readonly reachability: EnvironmentConnectReachability | null }; + | { + readonly shared: true; + /** T3 Connect refused the link. The server keeps its tunnel config until it's turned off. */ + readonly revoked: boolean; + readonly cloudUserId: string | null; + readonly reachability: EnvironmentConnectReachability | null; + }; export function phoneSharingFromLinkState(state: EnvironmentCloudLinkStateResult): PhoneSharing { - return state.linked && !state.linkRevoked && state.managedTunnelActive - ? { shared: true, reachability: state.reachability ?? null } - : { shared: false }; + if (!state.linked && !state.managedTunnelActive) return { shared: false }; + return { + shared: true, + revoked: state.linkRevoked === true, + cloudUserId: state.cloudUserId, + reachability: state.reachability ?? null, + }; } -// As `t3 connect` waits: a new tunnel's hostname takes a while to resolve. +// `t3 connect` waits this long too: a new tunnel's hostname takes a while to resolve. +const REACHABILITY_WAIT = Duration.seconds(60); const REACHABILITY_CHECK_TIMEOUT = Duration.seconds(30); const REACHABILITY_RETRY = Duration.seconds(5); -const REACHABILITY_RETRIES = 11; -const failWith = (message: string) => (cause: unknown) => new PhoneSharingError({ message, cause }); +export const failWith = (message: string) => (cause: unknown) => + new PhoneSharingError({ message, cause }); const phoneServerApi = Effect.fn("PhoneSharing.phoneServerApi")(function* ( connection: SavedRemoteConnection, @@ -56,7 +59,9 @@ const phoneServerApi = Effect.fn("PhoneSharing.phoneServerApi")(function* ( return { api, headers: { authorization: `Bearer ${connection.bearerToken}` } }; }); -const readSharing = Effect.fn("PhoneSharing.read")(function* (connection: SavedRemoteConnection) { +export const readSharing = Effect.fn("PhoneSharing.read")(function* ( + connection: SavedRemoteConnection, +) { const { api, headers } = yield* phoneServerApi(connection); const state = yield* api.connect .linkState({ headers }) @@ -64,111 +69,76 @@ const readSharing = Effect.fn("PhoneSharing.read")(function* (connection: SavedR return phoneSharingFromLinkState(state); }); -/** cloudflared runs the tunnel; the phone's Ubuntu starts without it. */ -const ensureRelayClient = Effect.fn("PhoneSharing.ensureRelayClient")(function* ( +/** The latest result when the time is up; the link stands whatever this finds. */ +export const waitForReachability = Effect.fn("PhoneSharing.waitForReachability")(function* ( connection: SavedRemoteConnection, ) { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const status = yield* registry - .run(connection.environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) - .pipe(Effect.mapError(failWith("Couldn't check for the T3 Connect relay client."))); - if (status.status === "available") return; - if (status.status === "unsupported") { - return yield* new PhoneSharingError({ - message: `T3 Connect has no relay client for ${status.platform}-${status.arch}.`, - }); - } - const installed = yield* registry - .runStream(connection.environmentId, runStream(WS_METHODS.cloudInstallRelayClient, {})) - .pipe( - Stream.runLast, - Effect.mapError(failWith("Couldn't install the T3 Connect relay client.")), - ); - if ( - Option.isNone(installed) || - installed.value.type !== "complete" || - installed.value.status.status !== "available" - ) { - return yield* new PhoneSharingError({ - message: "The T3 Connect relay client didn't install.", - }); - } + const { api, headers } = yield* phoneServerApi(connection); + const latest = yield* Ref.make(null); + yield* api.connect.checkReachability({ headers }).pipe( + Effect.tap((result) => Ref.set(latest, result)), + Effect.timeout(REACHABILITY_CHECK_TIMEOUT), + Effect.orElseSucceed(() => null), + Effect.repeat({ + schedule: Schedule.spaced(REACHABILITY_RETRY), + until: (result) => result?.status === "reachable", + }), + Effect.timeoutOption(REACHABILITY_WAIT), + ); + return yield* Ref.get(latest); }); -export const readPhoneSharing = createRuntimeCommand(connectionAtomRuntime, { - label: "phone-server:read-sharing", - execute: readSharing, +/** Stops the tunnel. Credentials that clients already hold outlive it. */ +export const unlinkServer = Effect.fn("PhoneSharing.unlinkServer")(function* ( + connection: SavedRemoteConnection, +) { + const { api, headers } = yield* phoneServerApi(connection); + yield* api.connect + .unlink({ headers }) + .pipe(Effect.mapError(failWith("Couldn't stop sharing this phone."))); }); /** - * Links the phone's server to the signed-in T3 Connect account with a managed tunnel. The - * server keeps listening on loopback; cloudflared reaches it there and connects out. + * Off ends access, not just the route to it: without this, a client that connected while the + * phone was shared would get back in if sharing came on again, even under another account. */ -export const sharePhoneServer = createRuntimeCommand(connectionAtomRuntime, { - label: "phone-server:share", - execute: Effect.fn("PhoneSharing.share")(function* (input: { - readonly connection: SavedRemoteConnection; - readonly clerkToken: string; - readonly liveActivitiesEnabled: boolean; - }) { - yield* ensureRelayClient(input.connection); - yield* linkEnvironmentToCloudWithPreference(input).pipe( - Effect.mapError((cause) => new PhoneSharingError({ message: cause.message, cause })), - ); - const { api, headers } = yield* phoneServerApi(input.connection); - // The link stands without a passing check; the last result says why devices can't connect. - const reachability = yield* api.connect.checkReachability({ headers }).pipe( - Effect.timeout(REACHABILITY_CHECK_TIMEOUT), - Effect.orElseSucceed(() => null), - Effect.repeat({ - schedule: Schedule.spaced(REACHABILITY_RETRY), - until: (result) => result?.status === "reachable", - times: REACHABILITY_RETRIES, - }), - ); - return { shared: true, reachability } satisfies PhoneSharing; - }), +const revokeOtherAccess = Effect.fn("PhoneSharing.revokeOtherAccess")(function* ( + connection: SavedRemoteConnection, + pairingLinkIds: Effect.Effect, E, R>, +) { + const { api, headers } = yield* phoneServerApi(connection); + yield* api.auth.revokeOtherClients({ headers }); + const ids = yield* pairingLinkIds; + yield* Effect.forEach(ids, (id) => api.auth.revokePairingLink({ headers, payload: { id } }), { + discard: true, + }); }); -/** Stops the tunnel on the phone, then removes the phone from the T3 Connect account. */ -export const stopSharingPhoneServer = createRuntimeCommand(connectionAtomRuntime, { - label: "phone-server:stop-sharing", - execute: Effect.fn("PhoneSharing.stop")(function* ( - input: { readonly connection: SavedRemoteConnection; readonly accountId: string | null }, - registry: AtomRegistry.AtomRegistry, - ) { - const { api, headers } = yield* phoneServerApi(input.connection); - yield* api.connect - .unlink({ headers }) - .pipe(Effect.mapError(failWith("Couldn't stop sharing this phone."))); - if (input.accountId === null) return; - yield* deregisterManagedRelayEnvironment(registry, { - accountId: input.accountId, - environmentId: input.connection.environmentId, - }).pipe( - Effect.mapError( - failWith( - "Stopped sharing, but T3 Connect still lists this phone. Remove it in Settings under T3 Connect.", - ), - ), - ); - }), +/** Each step runs even when the one before it failed, so one failure can't leave access open. */ +export const stopServerSharing = Effect.fn("PhoneSharing.stopServerSharing")(function* < + E1, + R1, + E2, + R2, +>( + connection: SavedRemoteConnection, + steps: { + readonly pairingLinkIds: Effect.Effect, E1, R1>; + readonly deregister: Effect.Effect; + }, +) { + yield* unlinkServer(connection); + // Exits, not typed failures: a defect in one step must not skip the next either. + const revoked = yield* Effect.exit(revokeOtherAccess(connection, steps.pairingLinkIds)); + const deregistered = yield* Effect.exit(steps.deregister); + if (Exit.isFailure(revoked)) { + yield* Effect.logWarning("Couldn't sign out the phone's other clients", revoked.cause); + return yield* new PhoneSharingError({ + message: "The tunnel is off, but other devices couldn't be signed out. Try again.", + }); + } + if (Exit.isFailure(deregistered)) { + yield* Effect.logWarning("Couldn't deregister the phone", deregistered.cause); + return yield* new PhoneSharingError({ message: PHONE_STILL_LISTED }); + } }); - -/** - * Remove and Reinstall delete the server's link along with Ubuntu, so a shared phone comes off - * the T3 Connect account first. Best effort: a server that isn't running can't be checked. - */ -export async function releasePhoneSharing(connection: SavedRemoteConnection | null): Promise { - if (connection === null) return; - const sharing = await runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { - reportFailure: false, - }); - if (!AsyncResult.isSuccess(sharing) || !sharing.value.shared) return; - await runAtomCommand( - appAtomRegistry, - stopSharingPhoneServer, - { connection, accountId: appAtomRegistry.get(managedRelaySessionAtom)?.accountId ?? null }, - { reportFailure: false }, - ); -} diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index 4aa5cc4c70be8..69902b887b87b 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -36,6 +36,7 @@ import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom"; import { runtime } from "../../lib/runtime"; +import { usePhoneServerSettings } from "../../state/phone-server-settings"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; @@ -147,6 +148,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.liveActivitiesEnabled !== false; + const phoneServerEnvironmentId = usePhoneServerSettings()?.environmentId ?? null; const connections = Object.values(savedConnectionsById); const environmentCount = connections.length; @@ -274,6 +276,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { setLiveActivityUpdatesEnabled({ canConfigureEnvironment: (environmentId) => readEnvironmentScope(environmentId, AuthRelayWriteScope), + phoneServerEnvironmentId, enabled: true, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, @@ -353,6 +356,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { setLiveActivityUpdatesEnabled({ canConfigureEnvironment: (environmentId) => readEnvironmentScope(environmentId, AuthRelayWriteScope), + phoneServerEnvironmentId, enabled: false, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index e88ce9455221e..00e08d4bec848 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -72,6 +72,14 @@ export const PhoneServerSettings = Schema.Struct({ autoStart: Schema.Boolean, /** Marks settings from the in-app runtime; the retired Termux setup's had no host. */ host: Schema.optional(Schema.Literal("device")), + /** The T3 Connect account listing this phone, until it's deregistered. */ + sharing: Schema.optional( + Schema.Struct({ + accountId: Schema.String, + /** Signed out while the server was down: stop sharing when it's next reachable. */ + pendingStop: Schema.optional(Schema.Boolean), + }), + ), }); export type PhoneServerSettings = typeof PhoneServerSettings.Type; diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index b8fde3c15531d..4a1d244ef3a71 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -101,6 +101,8 @@ const savedConnectionsByIdAtom = Atom.make((get) => { ) as Record; }).pipe(Atom.withLabel("mobile:saved-connections-by-id")); +export const savedRemoteConnectionAtom = remoteEnvironmentProjections.savedConnectionAtom; + export function useSavedRemoteConnections() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index eca8907e87e14..ffcb3c282bc7f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -35,7 +35,7 @@ disconnect an otherwise healthy conversation. ### More machines with T3 Chat Each T3 Connect account can link up to 3 machines. Phones and browsers do not -count. A paid T3 Chat plan raises the limit to 10 machines with Pro, or 100 with +count, unless the phone shares a server of its own. A paid T3 Chat plan raises the limit to 10 machines with Pro, or 100 with Premier. To link T3 Chat, open your account menu on the web or desktop app, go to From 6d417230a6053e8758f1019535a1e584c412d291 Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:15:51 +0000 Subject: [PATCH 04/19] fix(mobile): one controller owns phone sharing, and approvals show the 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 --- FORK.md | 1 + .../modules/t3deviceruntime/AccessApproval.kt | 86 +++-- .../src/features/cloud/cloudAccountService.ts | 3 +- .../DeviceRuntimeSetupScreen.tsx | 6 +- .../features/phone/PhoneSharingSection.tsx | 72 ++-- .../mobile/src/features/phone/phone-server.ts | 23 +- .../features/phone/phone-sharing-commands.ts | 361 ++++++++---------- .../phone/phone-sharing-controller.test.ts | 212 ++++++++++ .../phone/phone-sharing-controller.ts | 170 +++++++++ .../src/features/phone/phone-sharing.test.ts | 51 +-- .../src/features/phone/phone-sharing.ts | 73 ++-- .../src/persistence/mobile-preferences.ts | 28 +- .../mobile/src/state/phone-server-settings.ts | 15 +- .../state/use-remote-environment-registry.ts | 28 +- 14 files changed, 728 insertions(+), 401 deletions(-) create mode 100644 apps/mobile/src/features/phone/phone-sharing-controller.test.ts create mode 100644 apps/mobile/src/features/phone/phone-sharing-controller.ts diff --git a/FORK.md b/FORK.md index 1cc5f4e86902f..f6bf86bb3f877 100644 --- a/FORK.md +++ b/FORK.md @@ -55,6 +55,7 @@ adds the feature, and remove it in the PR that drops the feature. 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/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`, diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt index 1f9e6cedcf877..3350660c2872b 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt @@ -15,24 +15,33 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException private const val EXTRA_REQUEST = "request" -private const val EXTRA_TITLE = "title" -private const val EXTRA_MESSAGE = "message" private const val APPROVAL_TIMEOUT_SECONDS = 60L -private const val DETAILS_LIMIT = 600 +private const val DETAILS_LIMIT = 4000 +private const val MAX_WAITING = 4 -/** A command's arguments, as the user approves them: the number to call, the text to send. */ +private val INVISIBLE = Regex("[\\u061C\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u206F\\uFEFF]") + +/** + * 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 { - val details = body.keys().asSequence().joinToString("\n") { key -> "$key: ${body.opt(key)}" } - if (details.length <= DETAILS_LIMIT) return details - return "${details.take(DETAILS_LIMIT)}… (${details.length - DETAILS_LIMIT} more characters)" + if (body.length() == 0) return "" + val details = body.toString(2).replace(INVISIBLE) { "\\u%04X".format(it.value[0].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) + /** - * One prompt per request, so an answer can't also cover a command the user never saw. Answers - * only ever come from AccessApprovalActivity, which nothing outside this app can start. + * 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>() + private val pending = ConcurrentHashMap() /** Whether the user allowed it. Nobody answering in time counts as no. */ fun request( @@ -43,51 +52,45 @@ internal object AccessApproval { show: (Intent) -> Unit, dismiss: () -> Unit, ): Boolean { + // Requests wait on bridge connections, which unrelated commands need too. + if (pending.size >= MAX_WAITING) throw BridgeError("Too many requests are waiting for an answer on the phone.", 429) val id = UUID.randomUUID().toString() - val answer = CompletableFuture() - pending[id] = answer + 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.") + } + val waiting = WaitingApproval("Allow ${category.label}?", message, CompletableFuture()) + pending[id] = waiting try { - show(intent(context, id, category, command, details)) - return answer.get(APPROVAL_TIMEOUT_SECONDS, TimeUnit.SECONDS) + 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. - answer.complete(false) + waiting.answer.complete(false) dismiss() } } + fun prompt(id: String): Pair? = pending[id]?.let { it.title to it.message } + fun answer(id: String, allowed: Boolean) { - pending[id]?.complete(allowed) + 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 answer = pending[id] - if (answer == null) action() else answer.whenComplete { _, _ -> action() } - } - - private fun intent( - context: Context, - id: String, - category: AccessCategory, - command: String, - details: String, - ): Intent { - 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 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) - .putExtra(EXTRA_TITLE, "Allow ${category.label}?") - .putExtra(EXTRA_MESSAGE, message) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val waiting = pending[id] + if (waiting == null) action() else waiting.answer.whenComplete { _, _ -> action() } } } @@ -99,10 +102,11 @@ class AccessApprovalActivity : Activity() { 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(intent.getStringExtra(EXTRA_TITLE)) - .setMessage(intent.getStringExtra(EXTRA_MESSAGE)) + .setTitle(title) + .setMessage(message) .setPositiveButton("Allow") { _, _ -> answer(true) } .setNegativeButton("Don't allow") { _, _ -> answer(false) } .setOnCancelListener { answer(false) } diff --git a/apps/mobile/src/features/cloud/cloudAccountService.ts b/apps/mobile/src/features/cloud/cloudAccountService.ts index 7e0c27f5c077e..dec1677f5987d 100644 --- a/apps/mobile/src/features/cloud/cloudAccountService.ts +++ b/apps/mobile/src/features/cloud/cloudAccountService.ts @@ -56,7 +56,8 @@ const cleanUpCloudAccount = async ( accountId: string | null, wasActive: boolean, ) => { - await stopPhoneSharingAfterSignOut().catch((error: unknown) => + // Not awaited: it waits behind any share in flight, and cleanup shouldn't wait with it. + void stopPhoneSharingAfterSignOut().catch((error: unknown) => console.warn("Couldn't stop sharing this phone after sign-out", error), ); const removal = await runAtomCommand(appAtomRegistry, removeCloudEnvironments, accountId, { diff --git a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx index 48642b761e743..0b83e9542fe4d 100644 --- a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx +++ b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx @@ -9,7 +9,6 @@ import * as Dialog from "../../components/ui/Dialog"; import { ListRow } from "../../components/ui/ListRow"; import { ScreenScrollView } from "../../components/ui/ScreenScrollView"; import { setPhoneServerSettings, usePhoneServerSettings } from "../../state/phone-server-settings"; -import { useSavedRemoteConnection } from "../../state/use-remote-environment-registry"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { confirmPhoneServerReplacement } from "../phone/confirmPhoneServerReplacement"; import { @@ -84,7 +83,6 @@ export function DeviceRuntimeSetupScreen() { (candidate) => candidate.environmentId === settings?.environmentId, ) ?? null; const connected = environment?.connectionState === "connected"; - const connection = useSavedRemoteConnection(settings?.environmentId ?? null); const inspect = useCallback(() => { if (!native.getStatus().installed) { @@ -164,7 +162,7 @@ export function DeviceRuntimeSetupScreen() { style: "destructive", onPress: () => void run("install", async () => { - const stillListed = await releasePhoneSharing(connection); + const stillListed = await releasePhoneSharing(); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); @@ -187,7 +185,7 @@ export function DeviceRuntimeSetupScreen() { onPress: () => void run("remove", async () => { if (settings) setPhoneServerSettings({ ...settings, autoStart: false }); - const stillListed = await releasePhoneSharing(connection); + const stillListed = await releasePhoneSharing(); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); diff --git a/apps/mobile/src/features/phone/PhoneSharingSection.tsx b/apps/mobile/src/features/phone/PhoneSharingSection.tsx index f5472b4174b2e..8be8c6fe250d7 100644 --- a/apps/mobile/src/features/phone/PhoneSharingSection.tsx +++ b/apps/mobile/src/features/phone/PhoneSharingSection.tsx @@ -9,7 +9,7 @@ import { useCallback, useRef, useState } from "react"; import * as Dialog from "../../components/ui/Dialog"; import { appAtomRegistry } from "../../state/atom-registry"; -import { usePhoneServerSettings } from "../../state/phone-server-settings"; +import { usePhoneSharingRecord } from "../../state/phone-server-settings"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnection } from "../../state/use-remote-environment-registry"; import { useWorkspaceEnvironments } from "../../state/workspace"; @@ -17,13 +17,13 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { SettingsSection } from "../settings/components/SettingsSection"; import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; -import type { PhoneSharing } from "./phone-sharing"; +import { PHONE_STILL_LISTED, type PhoneSharing } from "./phone-sharing"; import { checkPhoneReachability, readPhoneSharing, - retryPhoneDeregistration, - sharePhoneServer, - stopSharingPhoneServer, + reconcilePhoneSharing, + sharePhone, + stopPhoneSharing, } from "./phone-sharing-commands"; function failureText(result: { readonly cause: Cause.Cause }, fallback: string): string { @@ -60,7 +60,8 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm const navigation = useNavigation(); const { getToken, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const preferences = useAtomValue(mobilePreferencesAtom); - const settings = usePhoneServerSettings(); + const record = usePhoneSharingRecord(); + const stopUnfinished = record?.phase === "stopping"; const connection = useSavedRemoteConnection(props.environmentId); const connected = useWorkspaceEnvironments().find((candidate) => candidate.environmentId === props.environmentId) @@ -86,7 +87,7 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm if (AsyncResult.isSuccess(result)) { setReadError(null); setSharing(result.value); - if (!result.value.shared) void retryPhoneDeregistration(); + if (!result.value.shared) void reconcilePhoneSharing(); } else { setReadError(failureText(result, "Couldn't check whether this phone is shared.")); } @@ -123,41 +124,26 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm finish(); return; } - const result = await runAtomCommand( - appAtomRegistry, - sharePhoneServer, - { - connection, - accountId: userId, - clerkToken: token, - liveActivitiesEnabled: - !AsyncResult.isSuccess(preferences) || preferences.value.liveActivitiesEnabled !== false, - }, - { reportFailure: false }, - ); + const failure = await sharePhone({ + connection, + accountId: userId, + clerkToken: token, + liveActivitiesEnabled: + !AsyncResult.isSuccess(preferences) || preferences.value.liveActivitiesEnabled !== false, + }); finish(); refreshManagedRelayEnvironments(); - if (AsyncResult.isSuccess(result)) { + if (failure === null) { setSharing({ shared: true, revoked: false, cloudUserId: userId, reachability: null }); verify(); } else { - setActionError(failureText(result, "Couldn't share this phone.")); + setActionError(failure); check(); } }; const stopSharing = async () => { - const owner = - (sharing?.shared ? sharing.cloudUserId : null) ?? settings?.sharing?.accountId ?? null; - const result = await runAtomCommand( - appAtomRegistry, - stopSharingPhoneServer, - { connection, owner }, - { reportFailure: false }, - ); - if (AsyncResult.isFailure(result)) { - setActionError(failureText(result, "Couldn't stop sharing.")); - } + setActionError(await stopPhoneSharing()); finish(); refreshManagedRelayEnvironments(); check(); @@ -197,7 +183,7 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm } Dialog.alert( "Share this phone?", - "Anyone signed in to your T3 Connect account gets full control of this phone's server: threads, agents, a terminal running as root in its Ubuntu, and your phone's files if you've allowed phone storage. They can also use commands such as the clipboard and notifications. Agent access still asks on this phone, and Never still applies.\n\nThe phone counts as one of your account's T3 Connect machines. The first time, T3 Code downloads Cloudflare's tunnel client (about 40 MB).", + "Anyone signed in to your T3 Connect account gets full control of this phone's server: threads, agents, a terminal running as root in its Ubuntu, and your phone's files if you've allowed phone storage. They can also use commands such as the clipboard and notifications.\n\nAgent access covers what agents normally do on your phone, but it isn't a hard barrier against someone using that terminal. Share only with an account you fully trust.\n\nThe phone counts as one of your account's T3 Connect machines. The first time, T3 Code downloads Cloudflare's tunnel client (about 40 MB).", [ { text: "Cancel", style: "cancel" }, { text: "Share", onPress: () => run("share", share) }, @@ -224,15 +210,19 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm ? "Sharing…" : busy === "stop" ? "Stopping…" - : verifying - ? "On. Checking that other devices can connect, for up to a minute." - : sharing === null - ? readError === null - ? "Checking…" - : "Couldn't check." - : sharingText(sharing, userId) + : stopUnfinished + ? "Still stopping. Other devices may have access until this finishes. Turn this off to try again." + : verifying + ? "On. Checking that other devices can connect, for up to a minute." + : sharing === null + ? readError === null + ? "Checking…" + : "Couldn't check." + : !sharing.shared && record?.phase === "listed" + ? `Off. ${PHONE_STILL_LISTED}` + : sharingText(sharing, userId) } - value={sharing?.shared === true} + value={sharing?.shared === true || stopUnfinished} disabled={!connected || busy !== null || sharing === null} onValueChange={onValueChange} /> diff --git a/apps/mobile/src/features/phone/phone-server.ts b/apps/mobile/src/features/phone/phone-server.ts index 63d0dbe621cc9..75a20175f28f7 100644 --- a/apps/mobile/src/features/phone/phone-server.ts +++ b/apps/mobile/src/features/phone/phone-server.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -13,10 +14,11 @@ import { phoneServerSettingsAtom, setPhoneServerSettings, usePhoneServerSettings, + usePhoneSharingRecord, } from "../../state/phone-server-settings"; import { useWorkspaceEnvironments, workspaceConnections } from "../../state/workspace"; import { deviceRuntime, deviceServer } from "../device-runtime/device-runtime"; -import { finishPendingSharingStop } from "./phone-sharing-commands"; +import { reconcilePhoneSharing } from "./phone-sharing-commands"; import { phoneServerPort } from "./phone-setup"; // The first start migrates a fresh database inside proot, which is slow on a phone. @@ -178,9 +180,6 @@ export async function connectPhoneServer(port: number): Promise { port, autoStart: previous?.autoStart ?? true, host: "device", - ...(previous?.environmentId === environmentId && previous.sharing - ? { sharing: previous.sharing } - : {}), }); await retireOtherPhoneEnvironments(environmentId); return environmentId; @@ -278,16 +277,22 @@ export function usePhoneServerWorker() { if (deviceRuntime !== null && down) void autoStartPhoneServer(); }, [down]); const connected = phone?.connectionState === "connected"; - const pendingSharingStop = settings?.sharing?.pendingStop === true; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- ends sharing that a sign-out couldn't, as soon as the server answers + const sharingUnfinished = (usePhoneSharingRecord()?.phase ?? "on") !== "on"; + const relayAccount = useAtomValue(managedRelaySessionAtom)?.accountId ?? null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- finishes stopping or deregistering once the server or the account comes back useEffect(() => { - if (connected && pendingSharingStop) void finishPendingSharingStop(); - }, [connected, pendingSharingStop]); + if (deviceRuntime !== null && sharingUnfinished && (connected || relayAccount !== null)) { + void reconcilePhoneSharing(); + } + }, [connected, sharingUnfinished, relayAccount]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the app returning to the foreground useEffect(() => { if (deviceRuntime === null) return; const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") void autoStartPhoneServer(); + if (state === "active") { + void autoStartPhoneServer(); + void reconcilePhoneSharing(); + } }); return () => subscription.remove(); }, []); diff --git a/apps/mobile/src/features/phone/phone-sharing-commands.ts b/apps/mobile/src/features/phone/phone-sharing-commands.ts index 26590148760ae..92370c0a6cc10 100644 --- a/apps/mobile/src/features/phone/phone-sharing-commands.ts +++ b/apps/mobile/src/features/phone/phone-sharing-commands.ts @@ -1,52 +1,44 @@ import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { deregisterManagedRelayEnvironment, - type ManagedRelay, managedRelaySessionAtom, } from "@t3tools/client-runtime/relay"; import { request, runStream, subscribe } from "@t3tools/client-runtime/rpc"; -import { createRuntimeCommand, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + createRuntimeCommand, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { type EnvironmentId, WS_METHODS } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; -import { AsyncResult, type AtomRegistry } from "effect/reactivity"; +import { AsyncResult, AtomRegistry } from "effect/reactivity"; import { connectionAtomRuntime } from "../../connection/runtime"; import type { SavedRemoteConnection } from "../../lib/connection"; -import type { PhoneServerSettings } from "../../persistence/mobile-preferences"; +import type { PhoneSharingRecord } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; -import { phoneServerSettingsAtom, setPhoneServerSettings } from "../../state/phone-server-settings"; +import { + phoneServerSettingsAtom, + phoneSharingRecordAtom, + setPhoneSharingRecord, +} from "../../state/phone-server-settings"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { savedRemoteConnectionAtom } from "../../state/use-remote-environment-registry"; import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; import { failWith, - PHONE_STILL_LISTED, PhoneSharingError, readSharing, stopServerSharing, - unlinkServer, waitForReachability, } from "./phone-sharing"; +import { createPhoneSharingController } from "./phone-sharing-controller"; const PAIRING_LINKS_TIMEOUT = Duration.seconds(10); -/** Remembers the account a shared phone is listed under, for the times its server is down. */ -function setSharingRecord(sharing: PhoneServerSettings["sharing"]) { - const settings = appAtomRegistry.get(phoneServerSettingsAtom); - if (settings !== null) setPhoneServerSettings({ ...settings, sharing }); -} - -const deregister = ( - registry: AtomRegistry.AtomRegistry, - accountId: string, - environmentId: EnvironmentId, -) => - deregisterManagedRelayEnvironment(registry, { accountId, environmentId }).pipe( - Effect.tap(() => Effect.sync(() => setSharingRecord(undefined))), - ); - const pairingLinkIds = Effect.fn("PhoneSharing.pairingLinkIds")(function* ( environmentId: EnvironmentId, ) { @@ -66,34 +58,66 @@ const pairingLinkIds = Effect.fn("PhoneSharing.pairingLinkIds")(function* ( }); /** cloudflared runs the tunnel; the phone's Ubuntu starts without it. */ -const ensureRelayClient = Effect.fn("PhoneSharing.ensureRelayClient")(function* ( - connection: SavedRemoteConnection, -) { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const status = yield* registry - .run(connection.environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) - .pipe(Effect.mapError(failWith("Couldn't check for the T3 Connect relay client."))); - if (status.status === "available") return; - if (status.status === "unsupported") { - return yield* new PhoneSharingError({ - message: `T3 Connect has no relay client for ${status.platform}-${status.arch}.`, - }); - } - const installed = yield* registry - .runStream(connection.environmentId, runStream(WS_METHODS.cloudInstallRelayClient, {})) - .pipe( - Stream.runLast, - Effect.mapError(failWith("Couldn't install the T3 Connect relay client.")), - ); - if ( - Option.isNone(installed) || - installed.value.type !== "complete" || - installed.value.status.status !== "available" +const ensureRelayClient = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:ensure-relay-client", + execute: Effect.fn("PhoneSharing.ensureRelayClient")(function* ( + connection: SavedRemoteConnection, ) { - return yield* new PhoneSharingError({ - message: "The T3 Connect relay client didn't install.", - }); - } + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const status = yield* registry + .run(connection.environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) + .pipe(Effect.mapError(failWith("Couldn't check for the T3 Connect relay client."))); + if (status.status === "available") return; + if (status.status === "unsupported") { + return yield* new PhoneSharingError({ + message: `T3 Connect has no relay client for ${status.platform}-${status.arch}.`, + }); + } + const installed = yield* registry + .runStream(connection.environmentId, runStream(WS_METHODS.cloudInstallRelayClient, {})) + .pipe( + Stream.runLast, + Effect.mapError(failWith("Couldn't install the T3 Connect relay client.")), + ); + if ( + Option.isNone(installed) || + installed.value.type !== "complete" || + installed.value.status.status !== "available" + ) { + return yield* new PhoneSharingError({ + message: "The T3 Connect relay client didn't install.", + }); + } + }), +}); + +const linkServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:link", + execute: (input: { + readonly connection: SavedRemoteConnection; + readonly clerkToken: string; + readonly liveActivitiesEnabled: boolean; + }) => linkEnvironmentToCloudWithPreference(input), +}); + +const stopOnServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:stop-sharing", + execute: (input: { + readonly connection: SavedRemoteConnection; + readonly revokeAccess: boolean; + }) => + stopServerSharing(input.connection, { + revokeAccess: input.revokeAccess, + pairingLinkIds: pairingLinkIds(input.connection.environmentId), + }), +}); + +const deregisterPhone = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:deregister", + execute: ( + input: { readonly accountId: string; readonly environmentId: EnvironmentId }, + registry, + ) => deregisterManagedRelayEnvironment(registry, input), }); export const readPhoneSharing = createRuntimeCommand(connectionAtomRuntime, { @@ -106,170 +130,95 @@ export const checkPhoneReachability = createRuntimeCommand(connectionAtomRuntime execute: waitForReachability, }); -/** - * Links the phone's server to the signed-in T3 Connect account with a managed tunnel. The - * server keeps listening on loopback; cloudflared reaches it there and connects out. - */ -export const sharePhoneServer = createRuntimeCommand(connectionAtomRuntime, { - label: "phone-server:share", - execute: Effect.fn("PhoneSharing.share")(function* ( - input: { - readonly connection: SavedRemoteConnection; - readonly accountId: string; - readonly clerkToken: string; - readonly liveActivitiesEnabled: boolean; - }, - registry: AtomRegistry.AtomRegistry, - ) { - yield* ensureRelayClient(input.connection); - yield* linkEnvironmentToCloudWithPreference(input).pipe( - Effect.mapError((cause) => new PhoneSharingError({ message: cause.message, cause })), - // The relay may have linked before the server failed to take the config. - Effect.tapError(() => - unlinkServer(input.connection).pipe( - Effect.andThen( - deregisterManagedRelayEnvironment(registry, { - accountId: input.accountId, - environmentId: input.connection.environmentId, - }), - ), - Effect.ignore, - ), +const failureText = ( + result: AsyncResult.AsyncResult, + fallback: string, +): string | null => { + if (AsyncResult.isSuccess(result)) return null; + if (!AsyncResult.isFailure(result)) return fallback; + const error = squashAtomCommandFailure(result); + return error instanceof Error && error.message.length > 0 ? error.message : fallback; +}; + +// Saving is asynchronous, and the controller reads back what it just saved. +let lastSaved: PhoneSharingRecord | null | undefined; + +const controller = createPhoneSharingController({ + hydrated: () => + Effect.runPromise( + AtomRegistry.getResult(appAtomRegistry, mobilePreferencesAtom, { + suspendOnWaiting: true, + }).pipe(Effect.ignore), + ), + loadRecord: () => + lastSaved === undefined ? appAtomRegistry.get(phoneSharingRecordAtom) : lastSaved, + saveRecord: (record) => { + lastSaved = record; + setPhoneSharingRecord(record); + }, + phoneEnvironmentId: () => appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId ?? null, + connectionFor: (environmentId) => appAtomRegistry.get(savedRemoteConnectionAtom(environmentId)), + signedInAccount: () => appAtomRegistry.get(managedRelaySessionAtom)?.accountId ?? null, + readSharing: async (connection) => { + const result = await runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { + reportFailure: false, + }); + return AsyncResult.isSuccess(result) ? result.value : null; + }, + ensureRelayClient: async (connection) => + failureText( + await runAtomCommand(appAtomRegistry, ensureRelayClient, connection, { + reportFailure: false, + }), + "Couldn't set up the T3 Connect relay client.", + ), + stopOnServer: async (connection, { revokeAccess }) => + failureText( + await runAtomCommand( + appAtomRegistry, + stopOnServer, + { connection, revokeAccess }, + { reportFailure: false }, ), - ); - yield* Effect.sync(() => setSharingRecord({ accountId: input.accountId })); - }), -}); - -/** Off: stops the tunnel, signs out other clients, and takes the phone off the account. */ -export const stopSharingPhoneServer = createRuntimeCommand(connectionAtomRuntime, { - label: "phone-server:stop-sharing", - execute: Effect.fn("PhoneSharing.stop")(function* ( - input: { readonly connection: SavedRemoteConnection; readonly owner: string | null }, - registry: AtomRegistry.AtomRegistry, - ) { - const signedIn = registry.get(managedRelaySessionAtom)?.accountId ?? null; - const { connection, owner } = input; - yield* stopServerSharing(connection, { - pairingLinkIds: pairingLinkIds(connection.environmentId), - deregister: Effect.suspend( - (): Effect.Effect => { - if (owner === null) return Effect.sync(() => setSharingRecord(undefined)); - return owner === signedIn - ? deregister(registry, owner, connection.environmentId) - : Effect.fail(new PhoneSharingError({ message: PHONE_STILL_LISTED })); - }, + "Couldn't stop sharing this phone.", + ), + deregister: async (accountId, environmentId) => + failureText( + await runAtomCommand( + appAtomRegistry, + deregisterPhone, + { accountId, environmentId }, + { reportFailure: false }, ), - }); - }), -}); - -/** Ends a share on the server alone, for when no account is available to deregister with. */ -const stopServerWithoutAccount = createRuntimeCommand(connectionAtomRuntime, { - label: "phone-server:stop-without-account", - execute: Effect.fn("PhoneSharing.stopWithoutAccount")(function* ( - connection: SavedRemoteConnection, - ) { - const sharing = yield* readSharing(connection); - if (!sharing.shared) return null; - yield* stopServerSharing(connection, { - pairingLinkIds: pairingLinkIds(connection.environmentId), - deregister: Effect.void, - }); - return sharing.cloudUserId; - }), -}); - -const deregisterPhone = createRuntimeCommand(connectionAtomRuntime, { - label: "phone-server:deregister", - execute: ( - input: { readonly accountId: string; readonly environmentId: EnvironmentId }, - registry, - ) => deregister(registry, input.accountId, input.environmentId), + "Couldn't remove this phone from T3 Connect.", + ), }); /** - * Signing out or switching accounts ends sharing, as the phone would otherwise stay open to an - * account this app no longer uses. A server that's down finishes it when it next connects. - * The account's listing stays until that account signs in here again: deregistering needs it. + * Links the phone's server to the signed-in T3 Connect account with a managed tunnel. The + * server keeps listening on loopback; cloudflared reaches it there and connects out. */ -export async function stopPhoneSharingAfterSignOut(): Promise { - const settings = appAtomRegistry.get(phoneServerSettingsAtom); - if (settings === null) return; - const connection = appAtomRegistry.get(savedRemoteConnectionAtom(settings.environmentId)); - const result = - connection === null - ? null - : await runAtomCommand(appAtomRegistry, stopServerWithoutAccount, connection, { - reportFailure: false, - }); - if (result !== null && AsyncResult.isSuccess(result)) { - const owner = result.value ?? settings.sharing?.accountId; - setSharingRecord(owner === undefined ? undefined : { accountId: owner }); - } else if (settings.sharing) { - setSharingRecord({ ...settings.sharing, pendingStop: true }); - } -} - -/** Finishes a sign-out's stop once the phone's server is reachable again. */ -export async function finishPendingSharingStop(): Promise { - const settings = appAtomRegistry.get(phoneServerSettingsAtom); - if (!settings?.sharing?.pendingStop) return; - const connection = appAtomRegistry.get(savedRemoteConnectionAtom(settings.environmentId)); - if (connection === null) return; - const result = await runAtomCommand(appAtomRegistry, stopServerWithoutAccount, connection, { - reportFailure: false, - }); - if (AsyncResult.isSuccess(result)) setSharingRecord({ accountId: settings.sharing.accountId }); -} - -/** Retries a deregistration that failed earlier, once the server is off and the account is back. */ -export async function retryPhoneDeregistration(): Promise { - const settings = appAtomRegistry.get(phoneServerSettingsAtom); - const record = settings?.sharing; - if (!settings || !record || record.pendingStop) return; - if (appAtomRegistry.get(managedRelaySessionAtom)?.accountId !== record.accountId) return; - await runAtomCommand( - appAtomRegistry, - deregisterPhone, - { accountId: record.accountId, environmentId: settings.environmentId }, - { reportFailure: false }, +export const sharePhone = (input: { + readonly connection: SavedRemoteConnection; + readonly accountId: string; + readonly clerkToken: string; + readonly liveActivitiesEnabled: boolean; +}) => + controller.share(input.connection, input.accountId, async () => + failureText( + await runAtomCommand(appAtomRegistry, linkServer, input, { reportFailure: false }), + "Couldn't share this phone.", + ), ); -} + +export const stopPhoneSharing = () => controller.stop(); +export const reconcilePhoneSharing = () => controller.reconcile(); +export const releasePhoneSharing = () => controller.release(); +export const stopPhoneSharingBeforeForgetting = () => controller.stopBeforeForgetting(); /** - * Remove and Reinstall delete the server's link along with Ubuntu, so a shared phone comes off - * the account first. Returns what the user has to do by hand when that isn't possible, such as - * when they're signed out. A server that's down doesn't matter: Ubuntu's about to go anyway. + * Signing out or switching accounts ends sharing, as the phone would otherwise stay open to an + * account this app no longer uses. The listing stays until that account signs in here again: + * deregistering needs its sign-in, and Clerk's profile view has already ended it by now. */ -export async function releasePhoneSharing( - connection: SavedRemoteConnection | null, -): Promise { - const settings = appAtomRegistry.get(phoneServerSettingsAtom); - const read = - connection === null - ? null - : await runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { - reportFailure: false, - }); - const sharing = read !== null && AsyncResult.isSuccess(read) ? read.value : null; - const record = settings?.sharing; - if (!sharing?.shared && !record) return null; - if (sharing?.shared && connection !== null) { - await runAtomCommand(appAtomRegistry, stopServerWithoutAccount, connection, { - reportFailure: false, - }); - } - const owner = (sharing?.shared ? sharing.cloudUserId : null) ?? record?.accountId ?? null; - if (owner === null) return null; - const environmentId = connection?.environmentId ?? settings?.environmentId; - if (environmentId === undefined) return PHONE_STILL_LISTED; - if (appAtomRegistry.get(managedRelaySessionAtom)?.accountId !== owner) return PHONE_STILL_LISTED; - const removed = await runAtomCommand( - appAtomRegistry, - deregisterPhone, - { accountId: owner, environmentId }, - { reportFailure: false }, - ); - return AsyncResult.isSuccess(removed) ? null : PHONE_STILL_LISTED; -} +export const stopPhoneSharingAfterSignOut = () => controller.stop(); diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts new file mode 100644 index 0000000000000..af42f21e81fcc --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; + +import type { PhoneSharingRecord } from "../../persistence/mobile-preferences"; +import { PHONE_STILL_LISTED, type PhoneSharing } from "./phone-sharing"; +import { createPhoneSharingController } from "./phone-sharing-controller"; + +const environmentId = EnvironmentId.make("phone"); +const connection = { + environmentId, + environmentLabel: "Phone", + pairingUrl: "http://127.0.0.1:3774/", + displayUrl: "http://127.0.0.1:3774/", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + bearerToken: "local-bearer", +}; +const shared = (cloudUserId: string | null): PhoneSharing => ({ + shared: true, + revoked: false, + cloudUserId, + reachability: null, +}); + +function setup(initial: PhoneSharingRecord | null = null) { + const state = { + record: initial, + account: "user_a" as string | null, + connected: true, + sharing: { shared: false } as PhoneSharing | null, + stopFailure: null as string | null, + deregisterFailure: null as string | null, + }; + const stopOnServer = vi.fn( + async (_connection: unknown, _options: { readonly revokeAccess: boolean }) => { + if (state.stopFailure === null) state.sharing = { shared: false }; + return state.stopFailure; + }, + ); + const deregister = vi.fn(async () => state.deregisterFailure); + const controller = createPhoneSharingController({ + hydrated: async () => undefined, + loadRecord: () => state.record, + saveRecord: (record) => { + state.record = record; + }, + phoneEnvironmentId: () => environmentId, + connectionFor: () => (state.connected ? connection : null), + signedInAccount: () => state.account, + readSharing: async () => (state.connected ? state.sharing : null), + ensureRelayClient: async () => null, + stopOnServer, + deregister, + }); + return { state, controller, stopOnServer, deregister }; +} + +const on: PhoneSharingRecord = { environmentId, accountId: "user_a", phase: "on" }; + +describe("createPhoneSharingController", () => { + it("undoes a share when the account signs out while it's linking", async () => { + const { state, controller, stopOnServer } = setup(); + let finishLink = (_failure: string | null) => {}; + const sharing = controller.share( + connection, + "user_a", + () => + new Promise((resolve) => { + finishLink = resolve; + }), + ); + const stopping = (async () => { + await vi.waitFor(() => expect(state.record?.phase).toBe("on")); + state.account = null; + const stopped = controller.stop(); + state.sharing = shared("user_a"); + finishLink(null); + return stopped; + })(); + expect(await sharing).toContain("signed out"); + await stopping; + expect(stopOnServer).toHaveBeenCalled(); + expect(state.sharing).toEqual({ shared: false }); + }); + + it("keeps Off open until signing out other clients succeeds", async () => { + const { state, controller, stopOnServer, deregister } = setup(on); + state.sharing = shared("user_a"); + state.stopFailure = "other devices couldn't be signed out"; + + expect(await controller.stop()).toBe("other devices couldn't be signed out"); + expect(state.record?.phase).toBe("stopping"); + expect(deregister).not.toHaveBeenCalled(); + + // The server now reads as off, which must not end the retry. + state.sharing = { shared: false }; + state.stopFailure = null; + expect(await controller.reconcile()).toBeNull(); + expect(stopOnServer).toHaveBeenCalledTimes(2); + expect(stopOnServer).toHaveBeenLastCalledWith(connection, { revokeAccess: true }); + expect(deregister).toHaveBeenCalledOnce(); + expect(state.record).toBeNull(); + }); + + it("finishes a sign-out's stop once the server answers, leaving the listing for the account", async () => { + const { state, controller, stopOnServer, deregister } = setup(on); + state.account = null; + state.connected = false; + + await controller.stop(); + expect(state.record?.phase).toBe("stopping"); + expect(stopOnServer).not.toHaveBeenCalled(); + + state.connected = true; + expect(await controller.reconcile()).toBe(PHONE_STILL_LISTED); + expect(stopOnServer).toHaveBeenCalledOnce(); + expect(state.record?.phase).toBe("listed"); + expect(deregister).not.toHaveBeenCalled(); + + state.account = "user_a"; + expect(await controller.reconcile()).toBeNull(); + expect(deregister).toHaveBeenCalledOnce(); + expect(state.record).toBeNull(); + }); + + it("stops a share it has no record of, and never deregisters under another account", async () => { + const { state, controller, deregister, stopOnServer } = setup(); + state.sharing = shared("user_b"); + expect(await controller.stop()).toBe(PHONE_STILL_LISTED); + expect(stopOnServer).toHaveBeenCalledOnce(); + expect(deregister).not.toHaveBeenCalled(); + expect(state.record).toEqual({ environmentId, accountId: "user_b", phase: "listed" }); + }); + + it("rolls a failed share back without signing anyone out, and drops its record", async () => { + const { state, controller, stopOnServer, deregister } = setup(); + state.deregisterFailure = "not linked"; + const failure = await controller.share(connection, "user_a", async () => "over the limit"); + expect(failure).toBe("over the limit"); + expect(stopOnServer).toHaveBeenCalledWith(connection, { revokeAccess: false }); + expect(deregister).toHaveBeenCalledOnce(); + expect(state.record).toBeNull(); + }); + + it("treats a share a killed app never finished as failed", async () => { + const { state, controller, stopOnServer } = setup(on); + expect(await controller.reconcile()).toBeNull(); + expect(stopOnServer).toHaveBeenCalledWith(connection, { revokeAccess: false }); + expect(state.record).toBeNull(); + }); + + it("leaves a healthy share alone", async () => { + const { state, controller, stopOnServer } = setup(on); + state.sharing = shared("user_a"); + expect(await controller.reconcile()).toBeNull(); + expect(stopOnServer).not.toHaveBeenCalled(); + expect(state.record).toEqual(on); + }); + + it("removes the listing when Ubuntu goes, even with the server down", async () => { + const { state, controller, deregister } = setup(on); + state.connected = false; + expect(await controller.release()).toBeNull(); + expect(deregister).toHaveBeenCalledOnce(); + expect(state.record).toBeNull(); + }); + + it("keeps the listing's record through a release while signed out", async () => { + const { state, controller } = setup(on); + state.connected = false; + state.account = null; + expect(await controller.release()).toBe(PHONE_STILL_LISTED); + expect(state.record?.phase).toBe("listed"); + }); + + it("won't let a connection be forgotten while its tunnel can't be stopped", async () => { + const { state, controller } = setup(on); + state.stopFailure = "unreachable"; + expect(await controller.stopBeforeForgetting()).toBe("unreachable"); + state.stopFailure = null; + expect(await controller.stopBeforeForgetting()).toBeNull(); + }); + + it("waits for saved preferences before deciding anything", async () => { + const { state, stopOnServer } = setup(); + let hydrate = () => {}; + const hydrating = new Promise((resolve) => { + hydrate = resolve; + }); + const controller = createPhoneSharingController({ + hydrated: () => hydrating, + loadRecord: () => state.record, + saveRecord: (record) => { + state.record = record; + }, + phoneEnvironmentId: () => environmentId, + connectionFor: () => connection, + signedInAccount: () => null, + readSharing: async () => shared("user_a"), + ensureRelayClient: async () => null, + stopOnServer, + deregister: async () => null, + }); + const stopped = controller.stop(); + expect(stopOnServer).not.toHaveBeenCalled(); + state.record = on; + hydrate(); + await stopped; + expect(stopOnServer).toHaveBeenCalledOnce(); + expect(state.record?.accountId).toBe("user_a"); + }); +}); diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.ts b/apps/mobile/src/features/phone/phone-sharing-controller.ts new file mode 100644 index 0000000000000..16446be5e67b2 --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -0,0 +1,170 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { SavedRemoteConnection } from "../../lib/connection"; +import type { PhoneSharingRecord } from "../../persistence/mobile-preferences"; +import { PHONE_STILL_LISTED, type PhoneSharing } from "./phone-sharing"; + +const STOP_UNFINISHED = + "Sharing hasn't finished stopping: other devices may still have access. T3 Code tries again when this phone's server answers."; + +export interface PhoneSharingEffects { + /** Resolves once saved preferences are loaded, so a decision never reads an empty record. */ + readonly hydrated: () => Promise; + readonly loadRecord: () => PhoneSharingRecord | null; + readonly saveRecord: (record: PhoneSharingRecord | null) => void; + readonly phoneEnvironmentId: () => EnvironmentId | null; + readonly connectionFor: (environmentId: EnvironmentId) => SavedRemoteConnection | null; + readonly signedInAccount: () => string | null; + /** Null when the server can't answer. */ + readonly readSharing: (connection: SavedRemoteConnection) => Promise; + /** Each returns the failure's text, or null. */ + readonly ensureRelayClient: (connection: SavedRemoteConnection) => Promise; + /** Idempotent, so an unfinished stop can run again from the start. */ + readonly stopOnServer: ( + connection: SavedRemoteConnection, + options: { readonly revokeAccess: boolean }, + ) => Promise; + readonly deregister: (accountId: string, environmentId: EnvironmentId) => Promise; +} + +/** + * Every change to sharing runs here, one at a time, from a record saved before the work starts. + * A sign-out during a share therefore stops what the share set up, and anything left unfinished + * by a failure, a dead server or a killed app is picked up by the next `reconcile`. + */ +export function createPhoneSharingController(effects: PhoneSharingEffects) { + let queue: Promise = Promise.resolve(); + const enqueue = (task: () => Promise): Promise => { + const run = queue.then(async () => { + await effects.hydrated(); + return task(); + }); + queue = run.catch(() => undefined); + return run; + }; + + /** Works a stopping or listed record as far as it can go. Null means nothing is left to do. */ + const settle = async (): Promise => { + let record = effects.loadRecord(); + if (record === null || record.phase === "on") return null; + if (record.phase === "stopping") { + const connection = effects.connectionFor(record.environmentId); + if (connection === null) return STOP_UNFINISHED; + const failure = await effects.stopOnServer(connection, { + revokeAccess: record.failedShare !== true, + }); + if (failure !== null) return failure; + record = { ...record, phase: "listed" }; + effects.saveRecord(record); + } + // A failed share may never have reached the account, so its listing is only worth one try. + const failedShare = record.failedShare === true; + const owner = record.accountId; + if (owner === null) { + effects.saveRecord(null); + return null; + } + const removed = + effects.signedInAccount() === owner + ? (await effects.deregister(owner, record.environmentId)) === null + : false; + if (removed || failedShare) { + effects.saveRecord(null); + return null; + } + return PHONE_STILL_LISTED; + }; + + /** Sharing on the server counts even with no record, such as a link made some other way. */ + const markStopping = async () => { + const record = effects.loadRecord(); + const environmentId = record?.environmentId ?? effects.phoneEnvironmentId(); + if (environmentId === null) return; + const connection = effects.connectionFor(environmentId); + const sharing = connection === null ? null : await effects.readSharing(connection); + const shared = sharing?.shared === true ? sharing : null; + if (record === null) { + if (shared !== null) { + effects.saveRecord({ environmentId, accountId: shared.cloudUserId, phase: "stopping" }); + } + return; + } + if (record.phase === "on" || shared !== null) { + effects.saveRecord({ + ...record, + accountId: shared?.cloudUserId ?? record.accountId, + phase: "stopping", + }); + } + }; + + return { + /** Null when the share took; otherwise why it didn't, with its setup already undone. */ + share: ( + connection: SavedRemoteConnection, + accountId: string, + link: () => Promise, + ) => + enqueue(async () => { + await settle(); + const notReady = await effects.ensureRelayClient(connection); + if (notReady !== null) return notReady; + const environmentId = connection.environmentId; + effects.saveRecord({ environmentId, accountId, phase: "on" }); + const failure = await link(); + if (failure !== null) { + effects.saveRecord({ environmentId, accountId, phase: "stopping", failedShare: true }); + await settle(); + return failure; + } + if (effects.signedInAccount() !== accountId) { + effects.saveRecord({ environmentId, accountId, phase: "stopping" }); + await settle(); + return "You signed out of T3 Connect, so this phone wasn't shared."; + } + return null; + }), + + /** Off, and signing out or switching accounts. Null when nothing is left to do. */ + stop: () => + enqueue(async () => { + await markStopping(); + return settle(); + }), + + /** Retries unfinished work, and notices a share a killed app never finished. */ + reconcile: () => + enqueue(async () => { + const record = effects.loadRecord(); + if (record?.phase === "on") { + const connection = effects.connectionFor(record.environmentId); + const sharing = connection === null ? null : await effects.readSharing(connection); + if (sharing?.shared === false) { + effects.saveRecord({ ...record, phase: "stopping", failedShare: true }); + } + } + return settle(); + }), + + /** Before Ubuntu is deleted: only the account's listing can outlive it. */ + release: () => + enqueue(async () => { + await markStopping(); + await settle(); + const record = effects.loadRecord(); + if (record?.phase === "stopping") { + effects.saveRecord({ ...record, phase: "listed" }); + await settle(); + } + return effects.loadRecord() === null ? null : PHONE_STILL_LISTED; + }), + + /** Before forgetting the connection, which is the only way to reach the server. */ + stopBeforeForgetting: () => + enqueue(async () => { + await markStopping(); + const failure = await settle(); + return effects.loadRecord()?.phase === "stopping" ? failure : null; + }), + }; +} diff --git a/apps/mobile/src/features/phone/phone-sharing.test.ts b/apps/mobile/src/features/phone/phone-sharing.test.ts index dcd2a9f4d5ed5..11a5470849e09 100644 --- a/apps/mobile/src/features/phone/phone-sharing.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing.test.ts @@ -1,11 +1,9 @@ -import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import { phoneSharingFromLinkState, PHONE_STILL_LISTED, stopServerSharing } from "./phone-sharing"; +import { phoneSharingFromLinkState, stopServerSharing } from "./phone-sharing"; const connection = { environmentId: EnvironmentId.make("phone"), @@ -65,65 +63,42 @@ describe("phoneSharingFromLinkState", () => { }); describe("stopServerSharing", () => { - const deregister = vi.fn(() => Effect.void); - beforeEach(() => { - deregister.mockClear(); - }); - it.effect("stops the tunnel, then signs out other clients and their pairing links", () => { const server = layerServer(); return Effect.gen(function* () { yield* stopServerSharing(connection, { + revokeAccess: true, pairingLinkIds: Effect.succeed(["link-1"]), - deregister: Effect.suspend(deregister), }); expect(server.requests).toEqual([ "/api/connect/unlink", "/api/auth/clients/revoke-others", "/api/auth/pairing-links/revoke", ]); - expect(deregister).toHaveBeenCalledOnce(); - }).pipe(Effect.provide(server.layer)); - }); - - it.effect("leaves the account alone when the tunnel didn't stop", () => { - const server = layerServer(new Set(["/api/connect/unlink"])); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - stopServerSharing(connection, { - pairingLinkIds: Effect.succeed([]), - deregister: Effect.suspend(deregister), - }), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(deregister).not.toHaveBeenCalled(); }).pipe(Effect.provide(server.layer)); }); - it.effect("still deregisters when signing out clients fails, and reports the failure", () => { - const server = layerServer(new Set(["/api/auth/clients/revoke-others"])); + it.effect("signs nobody out after a failed share", () => { + const server = layerServer(); return Effect.gen(function* () { - const error = yield* Effect.flip( - stopServerSharing(connection, { - pairingLinkIds: Effect.succeed([]), - deregister: Effect.suspend(deregister), - }), - ); - expect(error.message).toContain("couldn't be signed out"); - expect(deregister).toHaveBeenCalledOnce(); + yield* stopServerSharing(connection, { + revokeAccess: false, + pairingLinkIds: Effect.die("unused"), + }); + expect(server.requests).toEqual(["/api/connect/unlink"]); }).pipe(Effect.provide(server.layer)); }); - it.effect("says so when the account still lists the phone", () => { + it.effect("fails when a pairing link can't be read, so the stop stays unfinished", () => { const server = layerServer(); return Effect.gen(function* () { const error = yield* Effect.flip( stopServerSharing(connection, { - pairingLinkIds: Effect.succeed([]), - deregister: Effect.fail("relay down"), + revokeAccess: true, + pairingLinkIds: Effect.fail("no socket"), }), ); - expect(error.message).toBe(PHONE_STILL_LISTED); + expect(error.message).toContain("couldn't be signed out"); }).pipe(Effect.provide(server.layer)); }); }); diff --git a/apps/mobile/src/features/phone/phone-sharing.ts b/apps/mobile/src/features/phone/phone-sharing.ts index bb63fed46c41f..3503d2534339f 100644 --- a/apps/mobile/src/features/phone/phone-sharing.ts +++ b/apps/mobile/src/features/phone/phone-sharing.ts @@ -6,7 +6,6 @@ import type { import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; @@ -45,6 +44,7 @@ export function phoneSharingFromLinkState(state: EnvironmentCloudLinkStateResult const REACHABILITY_WAIT = Duration.seconds(60); const REACHABILITY_CHECK_TIMEOUT = Duration.seconds(30); const REACHABILITY_RETRY = Duration.seconds(5); +const STOP_STEP_TIMEOUT = Duration.seconds(20); export const failWith = (message: string) => (cause: unknown) => new PhoneSharingError({ message, cause }); @@ -88,57 +88,34 @@ export const waitForReachability = Effect.fn("PhoneSharing.waitForReachability") return yield* Ref.get(latest); }); -/** Stops the tunnel. Credentials that clients already hold outlive it. */ -export const unlinkServer = Effect.fn("PhoneSharing.unlinkServer")(function* ( - connection: SavedRemoteConnection, -) { - const { api, headers } = yield* phoneServerApi(connection); - yield* api.connect - .unlink({ headers }) - .pipe(Effect.mapError(failWith("Couldn't stop sharing this phone."))); -}); - /** - * Off ends access, not just the route to it: without this, a client that connected while the - * phone was shared would get back in if sharing came on again, even under another account. + * Off ends access, not just the route to it: without the revocations, a client that connected + * while the phone was shared would get back in if sharing came on again, even under another + * account. A failed share has no clients to sign out, so it skips them. */ -const revokeOtherAccess = Effect.fn("PhoneSharing.revokeOtherAccess")(function* ( - connection: SavedRemoteConnection, - pairingLinkIds: Effect.Effect, E, R>, -) { - const { api, headers } = yield* phoneServerApi(connection); - yield* api.auth.revokeOtherClients({ headers }); - const ids = yield* pairingLinkIds; - yield* Effect.forEach(ids, (id) => api.auth.revokePairingLink({ headers, payload: { id } }), { - discard: true, - }); -}); - -/** Each step runs even when the one before it failed, so one failure can't leave access open. */ -export const stopServerSharing = Effect.fn("PhoneSharing.stopServerSharing")(function* < - E1, - R1, - E2, - R2, ->( +export const stopServerSharing = Effect.fn("PhoneSharing.stopServerSharing")(function* ( connection: SavedRemoteConnection, - steps: { - readonly pairingLinkIds: Effect.Effect, E1, R1>; - readonly deregister: Effect.Effect; + options: { + readonly revokeAccess: boolean; + readonly pairingLinkIds: Effect.Effect, E, R>; }, ) { - yield* unlinkServer(connection); - // Exits, not typed failures: a defect in one step must not skip the next either. - const revoked = yield* Effect.exit(revokeOtherAccess(connection, steps.pairingLinkIds)); - const deregistered = yield* Effect.exit(steps.deregister); - if (Exit.isFailure(revoked)) { - yield* Effect.logWarning("Couldn't sign out the phone's other clients", revoked.cause); - return yield* new PhoneSharingError({ - message: "The tunnel is off, but other devices couldn't be signed out. Try again.", + const { api, headers } = yield* phoneServerApi(connection); + yield* api.connect + .unlink({ headers }) + .pipe( + Effect.timeout(STOP_STEP_TIMEOUT), + Effect.mapError(failWith("Couldn't stop sharing this phone.")), + ); + if (!options.revokeAccess) return; + yield* Effect.gen(function* () { + yield* api.auth.revokeOtherClients({ headers }); + const ids = yield* options.pairingLinkIds; + yield* Effect.forEach(ids, (id) => api.auth.revokePairingLink({ headers, payload: { id } }), { + discard: true, }); - } - if (Exit.isFailure(deregistered)) { - yield* Effect.logWarning("Couldn't deregister the phone", deregistered.cause); - return yield* new PhoneSharingError({ message: PHONE_STILL_LISTED }); - } + }).pipe( + Effect.timeout(STOP_STEP_TIMEOUT), + Effect.mapError(failWith("The tunnel is off, but other devices couldn't be signed out.")), + ); }); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 00e08d4bec848..7f9836405daa2 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -72,17 +72,23 @@ export const PhoneServerSettings = Schema.Struct({ autoStart: Schema.Boolean, /** Marks settings from the in-app runtime; the retired Termux setup's had no host. */ host: Schema.optional(Schema.Literal("device")), - /** The T3 Connect account listing this phone, until it's deregistered. */ - sharing: Schema.optional( - Schema.Struct({ - accountId: Schema.String, - /** Signed out while the server was down: stop sharing when it's next reachable. */ - pendingStop: Schema.optional(Schema.Boolean), - }), - ), }); export type PhoneServerSettings = typeof PhoneServerSettings.Type; +/** + * Sharing the phone's server, saved before each change so unfinished work can be picked up. It + * stands apart from PhoneServerSettings because it has to outlive the phone's connection. + */ +export const PhoneSharingRecord = Schema.Struct({ + environmentId: EnvironmentId, + /** The T3 Connect account listing the phone. */ + accountId: Schema.NullOr(Schema.String), + /** on: shared. stopping: the server's tunnel and sessions must go. listed: only the listing. */ + phase: Schema.Literals(["on", "stopping", "listed"]), + failedShare: Schema.optional(Schema.Boolean), +}); +export type PhoneSharingRecord = typeof PhoneSharingRecord.Type; + /** * The page each workspace tab last showed, reopened when the tab returns with columns. `key` is * that page's route key: reopening under the same key reuses the screen a tab kept mounted. @@ -117,6 +123,7 @@ const isWorkspaceSelections = Schema.is(WorkspaceSelections); const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); const isEnvironmentPowerPreferences = Schema.is(EnvironmentPowerPreferences); const isPhoneServerSettings = Schema.is(PhoneServerSettings); +const isPhoneSharingRecord = Schema.is(PhoneSharingRecord); const ThreadProject = Schema.Struct({ environmentId: Schema.NonEmptyString, projectId: Schema.NonEmptyString, @@ -168,6 +175,7 @@ export interface Preferences { readonly environmentPower?: Readonly>; /** Null once the phone environment is removed. */ readonly phoneServer?: PhoneServerSettings | null; + readonly phoneSharing?: PhoneSharingRecord | null; readonly workspaceSelections?: WorkspaceSelections; } @@ -247,6 +255,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { pullRequestListFilters?: PullRequestListPreferences; environmentPower?: Readonly>; phoneServer?: PhoneServerSettings; + phoneSharing?: PhoneSharingRecord; workspaceSelections?: WorkspaceSelections; } = {}; @@ -406,6 +415,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (isPhoneServerSettings(parsed.phoneServer) && parsed.phoneServer.host === "device") { preferences.phoneServer = parsed.phoneServer; } + if (isPhoneSharingRecord(parsed.phoneSharing)) { + preferences.phoneSharing = parsed.phoneSharing; + } if (isWorkspaceSelections(parsed.workspaceSelections)) { preferences.workspaceSelections = parsed.workspaceSelections; } diff --git a/apps/mobile/src/state/phone-server-settings.ts b/apps/mobile/src/state/phone-server-settings.ts index 2f8ec1bfc5b8f..6974c9e073a43 100644 --- a/apps/mobile/src/state/phone-server-settings.ts +++ b/apps/mobile/src/state/phone-server-settings.ts @@ -1,7 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult, Atom } from "effect/reactivity"; -import type { PhoneServerSettings } from "../persistence/mobile-preferences"; +import type { PhoneServerSettings, PhoneSharingRecord } from "../persistence/mobile-preferences"; import { appAtomRegistry } from "./atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "./preferences"; @@ -17,3 +17,16 @@ export function setPhoneServerSettings(settings: PhoneServerSettings | null) { export function usePhoneServerSettings(): PhoneServerSettings | null { return useAtomValue(phoneServerSettingsAtom); } + +export const phoneSharingRecordAtom = Atom.make((get): PhoneSharingRecord | null => { + const result = get(mobilePreferencesAtom); + return AsyncResult.isSuccess(result) ? (result.value.phoneSharing ?? null) : null; +}).pipe(Atom.keepAlive, Atom.withLabel("mobile:phone-sharing:record")); + +export function setPhoneSharingRecord(record: PhoneSharingRecord | null) { + appAtomRegistry.set(updateMobilePreferencesAtom, { phoneSharing: record }); +} + +export function usePhoneSharingRecord(): PhoneSharingRecord | null { + return useAtomValue(phoneSharingRecordAtom); +} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 4a1d244ef3a71..ae5382d923844 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -205,10 +205,27 @@ export function useRemoteConnections() { if (appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId === environmentId) { // Removing only the connection would leave the server running on the phone with a wake // lock and nothing in the app pointing at it, so a failed stop keeps everything. - const forgetPhone = () => { + const forget = () => { setPhoneServerSettings(null); void controller.removeEnvironment(environmentId); }; + // Forgetting the connection would leave no way to reach a shared server's tunnel. + const forgetPhone = async () => { + const sharing = await import("../features/phone/phone-sharing-commands"); + const stillSharing = await sharing.stopPhoneSharingBeforeForgetting(); + if (stillSharing === null) { + forget(); + return; + } + Dialog.alert( + "This phone is still shared", + `${stillSharing}\n\nRemove it anyway to finish this later: sharing stops when this phone is connected again.`, + [ + { text: "Keep", style: "cancel" }, + { text: "Remove anyway", style: "destructive", onPress: forget }, + ], + ); + }; const stopThenForget = async () => { // Auto-start would otherwise relaunch the server as soon as its connection drops. const previous = appAtomRegistry.get(phoneServerSettingsAtom); @@ -216,8 +233,11 @@ export function useRemoteConnections() { try { // Loaded on demand: the phone server pulls in the expo runtime. const phoneServer = await import("../features/phone/phone-server"); + const sharing = await import("../features/phone/phone-sharing-commands"); + const stillSharing = await sharing.stopPhoneSharingBeforeForgetting(); + if (stillSharing !== null) throw new Error(stillSharing); await phoneServer.stopPhoneServerWhenSettled(); - forgetPhone(); + forget(); } catch (error) { Dialog.alert( "The server didn't stop", @@ -230,7 +250,7 @@ export function useRemoteConnections() { if (previous) setPhoneServerSettings(previous); }, }, - { text: "Remove anyway", style: "destructive", onPress: forgetPhone }, + { text: "Remove anyway", style: "destructive", onPress: forget }, ], ); } @@ -240,7 +260,7 @@ export function useRemoteConnections() { `Forget ${environment.environmentLabel} and its cached threads in the app. Ubuntu, Claude Code and your projects stay on the phone; phone setup can connect them again.`, [ { text: "Cancel", style: "cancel" }, - { text: "Remove only", onPress: forgetPhone }, + { text: "Remove only", onPress: () => void forgetPhone() }, { text: "Remove and stop server", style: "destructive", From c100a65e351a36d88dd056cbcce71061697c5341 Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:39:30 +0000 Subject: [PATCH 05/19] fix(mobile): phone sharing can't stall behind one call, or outlive a 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 --- .../modules/t3deviceruntime/AccessApproval.kt | 30 +++- .../modules/t3deviceruntime/AgentAccess.kt | 6 +- .../device-runtime/AgentAccessRouteScreen.tsx | 7 +- .../mobile/src/features/phone/phone-server.ts | 5 +- .../features/phone/phone-sharing-commands.ts | 57 +++--- .../phone/phone-sharing-controller.test.ts | 164 ++++++++++++------ .../phone/phone-sharing-controller.ts | 113 ++++++------ .../src/features/phone/phone-sharing.test.ts | 23 +-- .../src/features/phone/phone-sharing.ts | 16 +- .../src/persistence/mobile-preferences.ts | 1 - .../state/use-remote-environment-registry.ts | 41 ++--- 11 files changed, 263 insertions(+), 200 deletions(-) diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt index 3350660c2872b..99f2239bfb09b 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt @@ -11,6 +11,7 @@ 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 @@ -19,7 +20,10 @@ private const val APPROVAL_TIMEOUT_SECONDS = 60L private const val DETAILS_LIMIT = 4000 private const val MAX_WAITING = 4 -private val INVISIBLE = Regex("[\\u061C\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u206F\\uFEFF]") +// 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 @@ -28,7 +32,9 @@ private val INVISIBLE = Regex("[\\u061C\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u */ internal fun approvalDetails(body: JSONObject): String { if (body.length() == 0) return "" - val details = body.toString(2).replace(INVISIBLE) { "\\u%04X".format(it.value[0].code) } + 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 } @@ -42,6 +48,7 @@ private class WaitingApproval(val title: String, val message: String, val answer */ internal object AccessApproval { private val pending = ConcurrentHashMap() + private val slots = Semaphore(MAX_WAITING) /** Whether the user allowed it. Nobody answering in time counts as no. */ fun request( @@ -53,14 +60,9 @@ internal object AccessApproval { dismiss: () -> Unit, ): Boolean { // Requests wait on bridge connections, which unrelated commands need too. - if (pending.size >= MAX_WAITING) throw BridgeError("Too many requests are waiting for an answer on the phone.", 429) + if (!slots.tryAcquire()) throw BridgeError("Too many requests are waiting for an answer on the phone.", 429) val id = UUID.randomUUID().toString() - 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.") - } - val waiting = WaitingApproval("Allow ${category.label}?", message, CompletableFuture()) + val waiting = waitingFor(category, command, details) pending[id] = waiting try { show( @@ -77,10 +79,20 @@ internal object AccessApproval { pending.remove(id) // Closes a prompt that's still open, so a late tap can't look like it counted. waiting.answer.complete(false) + slots.release() 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? = pending[id]?.let { it.title to it.message } fun answer(id: String, allowed: Boolean) { diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt index b7a4e714225fb..25bbcf23d955e 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt @@ -33,8 +33,10 @@ class AccessCategory(val id: String, val label: String, val commands: List Agents on this phone reach Android through termux-* commands. These need your say: with Ask each time, this phone asks you before each one runs, even when another device started - it. Nobody answering within a minute counts as no. + it. Nobody answering within a minute counts as no. This covers those commands. It isn't a + hard barrier against someone using a terminal on this phone's server. {categories.map((category) => { const meta = CATEGORIES[category.id]; diff --git a/apps/mobile/src/features/phone/phone-server.ts b/apps/mobile/src/features/phone/phone-server.ts index 75a20175f28f7..9bbed5702d4b3 100644 --- a/apps/mobile/src/features/phone/phone-server.ts +++ b/apps/mobile/src/features/phone/phone-server.ts @@ -277,8 +277,11 @@ export function usePhoneServerWorker() { if (deviceRuntime !== null && down) void autoStartPhoneServer(); }, [down]); const connected = phone?.connectionState === "connected"; - const sharingUnfinished = (usePhoneSharingRecord()?.phase ?? "on") !== "on"; + const sharing = usePhoneSharingRecord(); const relayAccount = useAtomValue(managedRelaySessionAtom)?.accountId ?? null; + const sharingUnfinished = + sharing !== null && + (sharing.phase !== "on" || (relayAccount !== null && relayAccount !== sharing.accountId)); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- finishes stopping or deregistering once the server or the account comes back useEffect(() => { if (deviceRuntime !== null && sharingUnfinished && (connected || relayAccount !== null)) { diff --git a/apps/mobile/src/features/phone/phone-sharing-commands.ts b/apps/mobile/src/features/phone/phone-sharing-commands.ts index 92370c0a6cc10..c682de4b5be66 100644 --- a/apps/mobile/src/features/phone/phone-sharing-commands.ts +++ b/apps/mobile/src/features/phone/phone-sharing-commands.ts @@ -18,14 +18,13 @@ import { AsyncResult, AtomRegistry } from "effect/reactivity"; import { connectionAtomRuntime } from "../../connection/runtime"; import type { SavedRemoteConnection } from "../../lib/connection"; -import type { PhoneSharingRecord } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { phoneServerSettingsAtom, phoneSharingRecordAtom, setPhoneSharingRecord, } from "../../state/phone-server-settings"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { savedRemoteConnectionAtom } from "../../state/use-remote-environment-registry"; import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; import { @@ -38,6 +37,11 @@ import { import { createPhoneSharingController } from "./phone-sharing-controller"; const PAIRING_LINKS_TIMEOUT = Duration.seconds(10); +const HYDRATION_TIMEOUT = Duration.seconds(10); +// A deadline per step keeps one stalled call from holding up every later stop, sign-out included. +const RELAY_CLIENT_TIMEOUT = Duration.minutes(5); +const LINK_TIMEOUT = Duration.seconds(60); +const DEREGISTER_TIMEOUT = Duration.seconds(30); const pairingLinkIds = Effect.fn("PhoneSharing.pairingLinkIds")(function* ( environmentId: EnvironmentId, @@ -88,7 +92,7 @@ const ensureRelayClient = createRuntimeCommand(connectionAtomRuntime, { message: "The T3 Connect relay client didn't install.", }); } - }), + }, Effect.timeout(RELAY_CLIENT_TIMEOUT)), }); const linkServer = createRuntimeCommand(connectionAtomRuntime, { @@ -97,19 +101,13 @@ const linkServer = createRuntimeCommand(connectionAtomRuntime, { readonly connection: SavedRemoteConnection; readonly clerkToken: string; readonly liveActivitiesEnabled: boolean; - }) => linkEnvironmentToCloudWithPreference(input), + }) => linkEnvironmentToCloudWithPreference(input).pipe(Effect.timeout(LINK_TIMEOUT)), }); const stopOnServer = createRuntimeCommand(connectionAtomRuntime, { label: "phone-server:stop-sharing", - execute: (input: { - readonly connection: SavedRemoteConnection; - readonly revokeAccess: boolean; - }) => - stopServerSharing(input.connection, { - revokeAccess: input.revokeAccess, - pairingLinkIds: pairingLinkIds(input.connection.environmentId), - }), + execute: (connection: SavedRemoteConnection) => + stopServerSharing(connection, pairingLinkIds(connection.environmentId)), }); const deregisterPhone = createRuntimeCommand(connectionAtomRuntime, { @@ -117,7 +115,15 @@ const deregisterPhone = createRuntimeCommand(connectionAtomRuntime, { execute: ( input: { readonly accountId: string; readonly environmentId: EnvironmentId }, registry, - ) => deregisterManagedRelayEnvironment(registry, input), + ) => + deregisterManagedRelayEnvironment(registry, input).pipe( + // Deregistering is the goal, and an account with no such listing has reached it. + Effect.catchIf( + (error) => error._tag === "ManagedRelayRequestFailedError" && error.status === 404, + () => Effect.void, + ), + Effect.timeout(DEREGISTER_TIMEOUT), + ), }); export const readPhoneSharing = createRuntimeCommand(connectionAtomRuntime, { @@ -140,21 +146,21 @@ const failureText = ( return error instanceof Error && error.message.length > 0 ? error.message : fallback; }; -// Saving is asynchronous, and the controller reads back what it just saved. -let lastSaved: PhoneSharingRecord | null | undefined; - const controller = createPhoneSharingController({ hydrated: () => Effect.runPromise( AtomRegistry.getResult(appAtomRegistry, mobilePreferencesAtom, { suspendOnWaiting: true, - }).pipe(Effect.ignore), + }).pipe(Effect.timeoutOption(HYDRATION_TIMEOUT), Effect.ignore), ), - loadRecord: () => - lastSaved === undefined ? appAtomRegistry.get(phoneSharingRecordAtom) : lastSaved, - saveRecord: (record) => { - lastSaved = record; + loadRecord: () => appAtomRegistry.get(phoneSharingRecordAtom), + saveRecord: async (record) => { setPhoneSharingRecord(record); + await Effect.runPromise( + AtomRegistry.getResult(appAtomRegistry, updateMobilePreferencesAtom, { + suspendOnWaiting: true, + }), + ); }, phoneEnvironmentId: () => appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId ?? null, connectionFor: (environmentId) => appAtomRegistry.get(savedRemoteConnectionAtom(environmentId)), @@ -172,14 +178,9 @@ const controller = createPhoneSharingController({ }), "Couldn't set up the T3 Connect relay client.", ), - stopOnServer: async (connection, { revokeAccess }) => + stopOnServer: async (connection) => failureText( - await runAtomCommand( - appAtomRegistry, - stopOnServer, - { connection, revokeAccess }, - { reportFailure: false }, - ), + await runAtomCommand(appAtomRegistry, stopOnServer, connection, { reportFailure: false }), "Couldn't stop sharing this phone.", ), deregister: async (accountId, environmentId) => diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts index af42f21e81fcc..b2d25737a0270 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts @@ -21,8 +21,9 @@ const shared = (cloudUserId: string | null): PhoneSharing => ({ cloudUserId, reachability: null, }); +const on: PhoneSharingRecord = { environmentId, accountId: "user_a", phase: "on" }; -function setup(initial: PhoneSharingRecord | null = null) { +function setup(initial: PhoneSharingRecord | null = null, hydrated = async () => {}) { const state = { record: initial, account: "user_a" as string | null, @@ -30,18 +31,19 @@ function setup(initial: PhoneSharingRecord | null = null) { sharing: { shared: false } as PhoneSharing | null, stopFailure: null as string | null, deregisterFailure: null as string | null, + saveFailure: null as string | null, }; - const stopOnServer = vi.fn( - async (_connection: unknown, _options: { readonly revokeAccess: boolean }) => { - if (state.stopFailure === null) state.sharing = { shared: false }; - return state.stopFailure; - }, - ); + const stopOnServer = vi.fn(async () => { + if (state.stopFailure === null) state.sharing = { shared: false }; + return state.stopFailure; + }); const deregister = vi.fn(async () => state.deregisterFailure); + const loadRecord = vi.fn(() => state.record); const controller = createPhoneSharingController({ - hydrated: async () => undefined, - loadRecord: () => state.record, - saveRecord: (record) => { + hydrated, + loadRecord, + saveRecord: async (record) => { + if (state.saveFailure !== null) throw new Error(state.saveFailure); state.record = record; }, phoneEnvironmentId: () => environmentId, @@ -52,11 +54,9 @@ function setup(initial: PhoneSharingRecord | null = null) { stopOnServer, deregister, }); - return { state, controller, stopOnServer, deregister }; + return { state, controller, stopOnServer, deregister, loadRecord }; } -const on: PhoneSharingRecord = { environmentId, accountId: "user_a", phase: "on" }; - describe("createPhoneSharingController", () => { it("undoes a share when the account signs out while it's linking", async () => { const { state, controller, stopOnServer } = setup(); @@ -69,16 +69,14 @@ describe("createPhoneSharingController", () => { finishLink = resolve; }), ); - const stopping = (async () => { - await vi.waitFor(() => expect(state.record?.phase).toBe("on")); - state.account = null; - const stopped = controller.stop(); - state.sharing = shared("user_a"); - finishLink(null); - return stopped; - })(); + await vi.waitFor(() => expect(state.record?.phase).toBe("on")); + state.account = null; + const stopped = controller.stop(); + state.sharing = shared("user_a"); + finishLink(null); + expect(await sharing).toContain("signed out"); - await stopping; + await stopped; expect(stopOnServer).toHaveBeenCalled(); expect(state.sharing).toEqual({ shared: false }); }); @@ -97,11 +95,33 @@ describe("createPhoneSharingController", () => { state.stopFailure = null; expect(await controller.reconcile()).toBeNull(); expect(stopOnServer).toHaveBeenCalledTimes(2); - expect(stopOnServer).toHaveBeenLastCalledWith(connection, { revokeAccess: true }); expect(deregister).toHaveBeenCalledOnce(); expect(state.record).toBeNull(); }); + it("refuses a new share while a stop is unfinished, keeping its record", async () => { + const stopping: PhoneSharingRecord = { ...on, phase: "stopping" }; + const { state, controller } = setup(stopping); + state.stopFailure = "other devices couldn't be signed out"; + const link = vi.fn(async () => null); + + expect(await controller.share(connection, "user_a", link)).toBe(state.stopFailure); + expect(link).not.toHaveBeenCalled(); + expect(state.record).toEqual(stopping); + }); + + it("won't link over a share that's already on, or when it can't tell", async () => { + const { state, controller } = setup(); + const link = vi.fn(async () => null); + + state.sharing = shared("user_b"); + expect(await controller.share(connection, "user_a", link)).toContain("already shared"); + state.sharing = null; + expect(await controller.share(connection, "user_a", link)).toContain("Couldn't check"); + expect(link).not.toHaveBeenCalled(); + expect(state.record).toBeNull(); + }); + it("finishes a sign-out's stop once the server answers, leaving the listing for the account", async () => { const { state, controller, stopOnServer, deregister } = setup(on); state.account = null; @@ -132,27 +152,64 @@ describe("createPhoneSharingController", () => { expect(state.record).toEqual({ environmentId, accountId: "user_b", phase: "listed" }); }); - it("rolls a failed share back without signing anyone out, and drops its record", async () => { + it("treats an unreadable server with no record as a stop still owed", async () => { + const { state, controller, stopOnServer } = setup(); + state.connected = false; + expect(await controller.stop()).not.toBeNull(); + expect(state.record).toEqual({ environmentId, accountId: null, phase: "stopping" }); + + state.connected = true; + expect(await controller.reconcile()).toBeNull(); + expect(stopOnServer).toHaveBeenCalledOnce(); + expect(state.record).toBeNull(); + }); + + it("leaves a confirmed-off phone with no record alone", async () => { + const { state, controller, stopOnServer } = setup(); + expect(await controller.stop()).toBeNull(); + expect(stopOnServer).not.toHaveBeenCalled(); + expect(state.record).toBeNull(); + }); + + it("rolls a failed share back in full, and keeps the listing until it's really gone", async () => { const { state, controller, stopOnServer, deregister } = setup(); - state.deregisterFailure = "not linked"; - const failure = await controller.share(connection, "user_a", async () => "over the limit"); - expect(failure).toBe("over the limit"); - expect(stopOnServer).toHaveBeenCalledWith(connection, { revokeAccess: false }); - expect(deregister).toHaveBeenCalledOnce(); + state.deregisterFailure = "relay down"; + expect(await controller.share(connection, "user_a", async () => "over the limit")).toBe( + "over the limit", + ); + expect(stopOnServer).toHaveBeenCalledOnce(); + expect(state.record?.phase).toBe("listed"); + + state.deregisterFailure = null; + expect(await controller.reconcile()).toBeNull(); + expect(deregister).toHaveBeenCalledTimes(2); expect(state.record).toBeNull(); }); - it("treats a share a killed app never finished as failed", async () => { + it("ends a share a killed app left half set up", async () => { const { state, controller, stopOnServer } = setup(on); expect(await controller.reconcile()).toBeNull(); - expect(stopOnServer).toHaveBeenCalledWith(connection, { revokeAccess: false }); + expect(stopOnServer).toHaveBeenCalledOnce(); expect(state.record).toBeNull(); }); - it("leaves a healthy share alone", async () => { + it("ends a share still open to an account this app has left", async () => { + const { state, controller, stopOnServer, deregister } = setup(on); + state.sharing = shared("user_a"); + state.account = "user_b"; + expect(await controller.reconcile()).toBe(PHONE_STILL_LISTED); + expect(stopOnServer).toHaveBeenCalledOnce(); + expect(deregister).not.toHaveBeenCalled(); + expect(state.record?.phase).toBe("listed"); + }); + + it("leaves a healthy share alone, including before the account is known", async () => { const { state, controller, stopOnServer } = setup(on); state.sharing = shared("user_a"); expect(await controller.reconcile()).toBeNull(); + state.account = null; + state.sharing = null; + expect(await controller.reconcile()).toBeNull(); expect(stopOnServer).not.toHaveBeenCalled(); expect(state.record).toEqual(on); }); @@ -181,32 +238,37 @@ describe("createPhoneSharingController", () => { expect(await controller.stopBeforeForgetting()).toBeNull(); }); - it("waits for saved preferences before deciding anything", async () => { - const { state, stopOnServer } = setup(); + it("reports a record it couldn't save, and later tasks still run", async () => { + const { state, controller } = setup(on); + state.saveFailure = "storage full"; + expect(await controller.stop()).toBe("storage full"); + state.saveFailure = null; + expect(await controller.stop()).toBeNull(); + expect(state.record).toBeNull(); + }); + + it("reads and does nothing until saved preferences are loaded", async () => { let hydrate = () => {}; - const hydrating = new Promise((resolve) => { - hydrate = resolve; - }); - const controller = createPhoneSharingController({ - hydrated: () => hydrating, - loadRecord: () => state.record, - saveRecord: (record) => { - state.record = record; - }, - phoneEnvironmentId: () => environmentId, - connectionFor: () => connection, - signedInAccount: () => null, - readSharing: async () => shared("user_a"), - ensureRelayClient: async () => null, - stopOnServer, - deregister: async () => null, + let entered = () => {}; + const entering = new Promise((resolve) => { + entered = resolve; }); + const hydrated = () => + new Promise((resolve) => { + hydrate = resolve; + entered(); + }); + const { state, controller, stopOnServer, loadRecord } = setup(null, hydrated); + state.sharing = shared("user_a"); + const stopped = controller.stop(); + await entering; + expect(loadRecord).not.toHaveBeenCalled(); expect(stopOnServer).not.toHaveBeenCalled(); + state.record = on; hydrate(); await stopped; expect(stopOnServer).toHaveBeenCalledOnce(); - expect(state.record?.accountId).toBe("user_a"); }); }); diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.ts b/apps/mobile/src/features/phone/phone-sharing-controller.ts index 16446be5e67b2..dd13d75eac638 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -11,19 +11,18 @@ export interface PhoneSharingEffects { /** Resolves once saved preferences are loaded, so a decision never reads an empty record. */ readonly hydrated: () => Promise; readonly loadRecord: () => PhoneSharingRecord | null; - readonly saveRecord: (record: PhoneSharingRecord | null) => void; + /** Rejects when the record couldn't be saved. */ + readonly saveRecord: (record: PhoneSharingRecord | null) => Promise; readonly phoneEnvironmentId: () => EnvironmentId | null; readonly connectionFor: (environmentId: EnvironmentId) => SavedRemoteConnection | null; readonly signedInAccount: () => string | null; - /** Null when the server can't answer. */ + /** Null when the server can't answer. Every effect here has its own deadline. */ readonly readSharing: (connection: SavedRemoteConnection) => Promise; /** Each returns the failure's text, or null. */ readonly ensureRelayClient: (connection: SavedRemoteConnection) => Promise; /** Idempotent, so an unfinished stop can run again from the start. */ - readonly stopOnServer: ( - connection: SavedRemoteConnection, - options: { readonly revokeAccess: boolean }, - ) => Promise; + readonly stopOnServer: (connection: SavedRemoteConnection) => Promise; + /** Null too when the account no longer lists the phone. */ readonly deregister: (accountId: string, environmentId: EnvironmentId) => Promise; } @@ -31,15 +30,20 @@ export interface PhoneSharingEffects { * Every change to sharing runs here, one at a time, from a record saved before the work starts. * A sign-out during a share therefore stops what the share set up, and anything left unfinished * by a failure, a dead server or a killed app is picked up by the next `reconcile`. + * + * A stop always signs out other clients: a share that looks failed may have reached the server + * with only its response lost, so nothing can be assumed about who connected. */ export function createPhoneSharingController(effects: PhoneSharingEffects) { let queue: Promise = Promise.resolve(); - const enqueue = (task: () => Promise): Promise => { + const enqueue = (task: () => Promise): Promise => { const run = queue.then(async () => { await effects.hydrated(); - return task(); + return task().catch((error: unknown) => + error instanceof Error ? error.message : "Couldn't update phone sharing.", + ); }); - queue = run.catch(() => undefined); + queue = run; return run; }; @@ -50,49 +54,45 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { if (record.phase === "stopping") { const connection = effects.connectionFor(record.environmentId); if (connection === null) return STOP_UNFINISHED; - const failure = await effects.stopOnServer(connection, { - revokeAccess: record.failedShare !== true, - }); + const failure = await effects.stopOnServer(connection); if (failure !== null) return failure; record = { ...record, phase: "listed" }; - effects.saveRecord(record); + await effects.saveRecord(record); } - // A failed share may never have reached the account, so its listing is only worth one try. - const failedShare = record.failedShare === true; const owner = record.accountId; - if (owner === null) { - effects.saveRecord(null); - return null; - } - const removed = - effects.signedInAccount() === owner - ? (await effects.deregister(owner, record.environmentId)) === null - : false; - if (removed || failedShare) { - effects.saveRecord(null); - return null; + if (owner !== null) { + if (effects.signedInAccount() !== owner) return PHONE_STILL_LISTED; + if ((await effects.deregister(owner, record.environmentId)) !== null) { + return PHONE_STILL_LISTED; + } } - return PHONE_STILL_LISTED; + await effects.saveRecord(null); + return null; }; - /** Sharing on the server counts even with no record, such as a link made some other way. */ + /** An unreadable server counts as shared until proven otherwise: off is the claim to prove. */ const markStopping = async () => { const record = effects.loadRecord(); const environmentId = record?.environmentId ?? effects.phoneEnvironmentId(); if (environmentId === null) return; + if (record?.phase === "on") await effects.saveRecord({ ...record, phase: "stopping" }); const connection = effects.connectionFor(environmentId); const sharing = connection === null ? null : await effects.readSharing(connection); - const shared = sharing?.shared === true ? sharing : null; - if (record === null) { - if (shared !== null) { - effects.saveRecord({ environmentId, accountId: shared.cloudUserId, phase: "stopping" }); + const current = effects.loadRecord(); + if (current === null) { + if (sharing === null) { + await effects.saveRecord({ environmentId, accountId: null, phase: "stopping" }); + } else if (sharing.shared) { + await effects.saveRecord({ + environmentId, + accountId: sharing.cloudUserId, + phase: "stopping", + }); } - return; - } - if (record.phase === "on" || shared !== null) { - effects.saveRecord({ - ...record, - accountId: shared?.cloudUserId ?? record.accountId, + } else if (sharing?.shared === true) { + await effects.saveRecord({ + ...current, + accountId: sharing.cloudUserId ?? current.accountId, phase: "stopping", }); } @@ -106,23 +106,20 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { link: () => Promise, ) => enqueue(async () => { - await settle(); + const unfinished = await settle(); + if (effects.loadRecord()?.phase === "stopping") return unfinished ?? STOP_UNFINISHED; + const sharing = await effects.readSharing(connection); + if (sharing === null) return "Couldn't check whether this phone is already shared."; + if (sharing.shared) return "This phone is already shared."; const notReady = await effects.ensureRelayClient(connection); if (notReady !== null) return notReady; const environmentId = connection.environmentId; - effects.saveRecord({ environmentId, accountId, phase: "on" }); + await effects.saveRecord({ environmentId, accountId, phase: "on" }); const failure = await link(); - if (failure !== null) { - effects.saveRecord({ environmentId, accountId, phase: "stopping", failedShare: true }); - await settle(); - return failure; - } - if (effects.signedInAccount() !== accountId) { - effects.saveRecord({ environmentId, accountId, phase: "stopping" }); - await settle(); - return "You signed out of T3 Connect, so this phone wasn't shared."; - } - return null; + if (failure === null && effects.signedInAccount() === accountId) return null; + await effects.saveRecord({ environmentId, accountId, phase: "stopping" }); + await settle(); + return failure ?? "You signed out of T3 Connect, so this phone wasn't shared."; }), /** Off, and signing out or switching accounts. Null when nothing is left to do. */ @@ -132,15 +129,19 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { return settle(); }), - /** Retries unfinished work, and notices a share a killed app never finished. */ + /** + * Retries unfinished work. Also ends a share that a killed app left behind, either half set + * up or still open to an account this app has since left. + */ reconcile: () => enqueue(async () => { const record = effects.loadRecord(); if (record?.phase === "on") { + const account = effects.signedInAccount(); const connection = effects.connectionFor(record.environmentId); const sharing = connection === null ? null : await effects.readSharing(connection); - if (sharing?.shared === false) { - effects.saveRecord({ ...record, phase: "stopping", failedShare: true }); + if ((account !== null && account !== record.accountId) || sharing?.shared === false) { + await effects.saveRecord({ ...record, phase: "stopping" }); } } return settle(); @@ -153,7 +154,7 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { await settle(); const record = effects.loadRecord(); if (record?.phase === "stopping") { - effects.saveRecord({ ...record, phase: "listed" }); + await effects.saveRecord({ ...record, phase: "listed" }); await settle(); } return effects.loadRecord() === null ? null : PHONE_STILL_LISTED; @@ -164,7 +165,7 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { enqueue(async () => { await markStopping(); const failure = await settle(); - return effects.loadRecord()?.phase === "stopping" ? failure : null; + return effects.loadRecord()?.phase === "stopping" ? (failure ?? STOP_UNFINISHED) : null; }), }; } diff --git a/apps/mobile/src/features/phone/phone-sharing.test.ts b/apps/mobile/src/features/phone/phone-sharing.test.ts index 11a5470849e09..0e196852f6de7 100644 --- a/apps/mobile/src/features/phone/phone-sharing.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing.test.ts @@ -66,10 +66,7 @@ describe("stopServerSharing", () => { it.effect("stops the tunnel, then signs out other clients and their pairing links", () => { const server = layerServer(); return Effect.gen(function* () { - yield* stopServerSharing(connection, { - revokeAccess: true, - pairingLinkIds: Effect.succeed(["link-1"]), - }); + yield* stopServerSharing(connection, Effect.succeed(["link-1"])); expect(server.requests).toEqual([ "/api/connect/unlink", "/api/auth/clients/revoke-others", @@ -78,26 +75,10 @@ describe("stopServerSharing", () => { }).pipe(Effect.provide(server.layer)); }); - it.effect("signs nobody out after a failed share", () => { - const server = layerServer(); - return Effect.gen(function* () { - yield* stopServerSharing(connection, { - revokeAccess: false, - pairingLinkIds: Effect.die("unused"), - }); - expect(server.requests).toEqual(["/api/connect/unlink"]); - }).pipe(Effect.provide(server.layer)); - }); - it.effect("fails when a pairing link can't be read, so the stop stays unfinished", () => { const server = layerServer(); return Effect.gen(function* () { - const error = yield* Effect.flip( - stopServerSharing(connection, { - revokeAccess: true, - pairingLinkIds: Effect.fail("no socket"), - }), - ); + const error = yield* Effect.flip(stopServerSharing(connection, Effect.fail("no socket"))); expect(error.message).toContain("couldn't be signed out"); }).pipe(Effect.provide(server.layer)); }); diff --git a/apps/mobile/src/features/phone/phone-sharing.ts b/apps/mobile/src/features/phone/phone-sharing.ts index 3503d2534339f..f6636dd93bbb1 100644 --- a/apps/mobile/src/features/phone/phone-sharing.ts +++ b/apps/mobile/src/features/phone/phone-sharing.ts @@ -45,6 +45,7 @@ const REACHABILITY_WAIT = Duration.seconds(60); const REACHABILITY_CHECK_TIMEOUT = Duration.seconds(30); const REACHABILITY_RETRY = Duration.seconds(5); const STOP_STEP_TIMEOUT = Duration.seconds(20); +const READ_TIMEOUT = Duration.seconds(10); export const failWith = (message: string) => (cause: unknown) => new PhoneSharingError({ message, cause }); @@ -65,7 +66,10 @@ export const readSharing = Effect.fn("PhoneSharing.read")(function* ( const { api, headers } = yield* phoneServerApi(connection); const state = yield* api.connect .linkState({ headers }) - .pipe(Effect.mapError(failWith("Couldn't check whether this phone is shared."))); + .pipe( + Effect.timeout(READ_TIMEOUT), + Effect.mapError(failWith("Couldn't check whether this phone is shared.")), + ); return phoneSharingFromLinkState(state); }); @@ -91,14 +95,11 @@ export const waitForReachability = Effect.fn("PhoneSharing.waitForReachability") /** * Off ends access, not just the route to it: without the revocations, a client that connected * while the phone was shared would get back in if sharing came on again, even under another - * account. A failed share has no clients to sign out, so it skips them. + * account. */ export const stopServerSharing = Effect.fn("PhoneSharing.stopServerSharing")(function* ( connection: SavedRemoteConnection, - options: { - readonly revokeAccess: boolean; - readonly pairingLinkIds: Effect.Effect, E, R>; - }, + pairingLinkIds: Effect.Effect, E, R>, ) { const { api, headers } = yield* phoneServerApi(connection); yield* api.connect @@ -107,10 +108,9 @@ export const stopServerSharing = Effect.fn("PhoneSharing.stopServerSharing")(fun Effect.timeout(STOP_STEP_TIMEOUT), Effect.mapError(failWith("Couldn't stop sharing this phone.")), ); - if (!options.revokeAccess) return; yield* Effect.gen(function* () { yield* api.auth.revokeOtherClients({ headers }); - const ids = yield* options.pairingLinkIds; + const ids = yield* pairingLinkIds; yield* Effect.forEach(ids, (id) => api.auth.revokePairingLink({ headers, payload: { id } }), { discard: true, }); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 7f9836405daa2..e3b1a365b1206 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -85,7 +85,6 @@ export const PhoneSharingRecord = Schema.Struct({ accountId: Schema.NullOr(Schema.String), /** on: shared. stopping: the server's tunnel and sessions must go. listed: only the listing. */ phase: Schema.Literals(["on", "stopping", "listed"]), - failedShare: Schema.optional(Schema.Boolean), }); export type PhoneSharingRecord = typeof PhoneSharingRecord.Type; diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index ae5382d923844..f5b92e80eaabe 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -182,26 +182,7 @@ export function useRemoteConnections() { void controller.removeEnvironment(environmentId); }, } as const; - if (environment.isRelayManaged) { - Dialog.alert( - "Remove from this device?", - `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, - [ - { text: "Cancel", style: "cancel" }, - // SettingsAuth renders nothing in builds without cloud config. - ...(hasCloudPublicConfig() - ? [ - { - text: "Open T3 Account", - onPress: () => navigation.navigate("SettingsAuth"), - }, - ] - : []), - remove, - ], - ); - return; - } + // First: a shared phone also has a relay route, and that mustn't skip stopping its share. if (appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId === environmentId) { // Removing only the connection would leave the server running on the phone with a wake // lock and nothing in the app pointing at it, so a failed stop keeps everything. @@ -270,6 +251,26 @@ export function useRemoteConnections() { ); return; } + if (environment.isRelayManaged) { + Dialog.alert( + "Remove from this device?", + `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, + [ + { text: "Cancel", style: "cancel" }, + // SettingsAuth renders nothing in builds without cloud config. + ...(hasCloudPublicConfig() + ? [ + { + text: "Open T3 Account", + onPress: () => navigation.navigate("SettingsAuth"), + }, + ] + : []), + remove, + ], + ); + return; + } Dialog.alert( "Remove from this device?", `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`, From 9763e8228e89dccd99143ad2e9a8856952101ccc Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:42:46 +0000 Subject: [PATCH 06/19] style(mobile): shorten the phone sharing controller's comment Co-Authored-By: Claude Opus 5.5 --- .../src/features/phone/phone-sharing-controller.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.ts b/apps/mobile/src/features/phone/phone-sharing-controller.ts index dd13d75eac638..779ddb18c336d 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -27,12 +27,9 @@ export interface PhoneSharingEffects { } /** - * Every change to sharing runs here, one at a time, from a record saved before the work starts. - * A sign-out during a share therefore stops what the share set up, and anything left unfinished - * by a failure, a dead server or a killed app is picked up by the next `reconcile`. - * - * A stop always signs out other clients: a share that looks failed may have reached the server - * with only its response lost, so nothing can be assumed about who connected. + * Every change to sharing runs here, one at a time, from a record saved before the work starts, + * so a sign-out during a share stops what it set up and `reconcile` picks up anything unfinished. + * A stop always signs out other clients: a "failed" share may have reached the server. */ export function createPhoneSharingController(effects: PhoneSharingEffects) { let queue: Promise = Promise.resolve(); From 371da41c7acd76422d56c614b15b07aa5005a12d Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:10:16 +0000 Subject: [PATCH 07/19] fix(mobile): a sign-out's stop survives preferences that are slow to 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 --- .../modules/t3deviceruntime/AccessApproval.kt | 16 ++++++++++- .../features/phone/phone-sharing-commands.ts | 7 ++--- .../phone/phone-sharing-controller.test.ts | 19 +++++++++++++ .../phone/phone-sharing-controller.ts | 27 +++++++++++++------ .../state/use-remote-environment-registry.ts | 4 +-- 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt index 99f2239bfb09b..3dc3ca7e3b21c 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt @@ -61,6 +61,21 @@ internal object AccessApproval { ): 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 @@ -79,7 +94,6 @@ internal object AccessApproval { pending.remove(id) // Closes a prompt that's still open, so a late tap can't look like it counted. waiting.answer.complete(false) - slots.release() dismiss() } } diff --git a/apps/mobile/src/features/phone/phone-sharing-commands.ts b/apps/mobile/src/features/phone/phone-sharing-commands.ts index c682de4b5be66..6c7d595d90468 100644 --- a/apps/mobile/src/features/phone/phone-sharing-commands.ts +++ b/apps/mobile/src/features/phone/phone-sharing-commands.ts @@ -37,7 +37,7 @@ import { import { createPhoneSharingController } from "./phone-sharing-controller"; const PAIRING_LINKS_TIMEOUT = Duration.seconds(10); -const HYDRATION_TIMEOUT = Duration.seconds(10); +const SAVE_TIMEOUT = Duration.seconds(10); // A deadline per step keeps one stalled call from holding up every later stop, sign-out included. const RELAY_CLIENT_TIMEOUT = Duration.minutes(5); const LINK_TIMEOUT = Duration.seconds(60); @@ -147,11 +147,12 @@ const failureText = ( }; const controller = createPhoneSharingController({ + // No deadline: deciding before the record loads would read a saved share as no share at all. hydrated: () => Effect.runPromise( AtomRegistry.getResult(appAtomRegistry, mobilePreferencesAtom, { suspendOnWaiting: true, - }).pipe(Effect.timeoutOption(HYDRATION_TIMEOUT), Effect.ignore), + }).pipe(Effect.asVoid), ), loadRecord: () => appAtomRegistry.get(phoneSharingRecordAtom), saveRecord: async (record) => { @@ -159,7 +160,7 @@ const controller = createPhoneSharingController({ await Effect.runPromise( AtomRegistry.getResult(appAtomRegistry, updateMobilePreferencesAtom, { suspendOnWaiting: true, - }), + }).pipe(Effect.timeout(SAVE_TIMEOUT)), ); }, phoneEnvironmentId: () => appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId ?? null, diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts index b2d25737a0270..de007f8c2271d 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts @@ -247,6 +247,25 @@ describe("createPhoneSharingController", () => { expect(state.record).toBeNull(); }); + it("keeps a sign-out's stop when saved preferences can't load, and honours it later", async () => { + let loaded = false; + const { state, controller, stopOnServer } = setup(null, async () => { + if (!loaded) throw new Error("storage busy"); + }); + state.account = null; + + // The share only shows up once the record can be read. + expect(await controller.stop()).toBe("storage busy"); + expect(stopOnServer).not.toHaveBeenCalled(); + + loaded = true; + state.record = on; + state.sharing = shared("user_a"); + expect(await controller.reconcile()).toBe(PHONE_STILL_LISTED); + expect(stopOnServer).toHaveBeenCalledOnce(); + expect(state.record?.phase).toBe("listed"); + }); + it("reads and does nothing until saved preferences are loaded", async () => { let hydrate = () => {}; let entered = () => {}; diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.ts b/apps/mobile/src/features/phone/phone-sharing-controller.ts index 779ddb18c336d..7b02648664ad2 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -8,7 +8,7 @@ const STOP_UNFINISHED = "Sharing hasn't finished stopping: other devices may still have access. T3 Code tries again when this phone's server answers."; export interface PhoneSharingEffects { - /** Resolves once saved preferences are loaded, so a decision never reads an empty record. */ + /** Resolves once saved preferences are loaded, and rejects if they can't be. */ readonly hydrated: () => Promise; readonly loadRecord: () => PhoneSharingRecord | null; /** Rejects when the record couldn't be saved. */ @@ -33,12 +33,16 @@ export interface PhoneSharingEffects { */ export function createPhoneSharingController(effects: PhoneSharingEffects) { let queue: Promise = Promise.resolve(); + // A stop asked for before the record could be read, which a later task has to honour. + let stopOwed = false; const enqueue = (task: () => Promise): Promise => { const run = queue.then(async () => { - await effects.hydrated(); - return task().catch((error: unknown) => - error instanceof Error ? error.message : "Couldn't update phone sharing.", - ); + try { + await effects.hydrated(); + return await task(); + } catch (error) { + return error instanceof Error ? error.message : "Couldn't update phone sharing."; + } }); queue = run; return run; @@ -120,11 +124,14 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { }), /** Off, and signing out or switching accounts. Null when nothing is left to do. */ - stop: () => - enqueue(async () => { + stop: () => { + stopOwed = true; + return enqueue(async () => { await markStopping(); + stopOwed = false; return settle(); - }), + }); + }, /** * Retries unfinished work. Also ends a share that a killed app left behind, either half set @@ -132,6 +139,10 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { */ reconcile: () => enqueue(async () => { + if (stopOwed) { + await markStopping(); + stopOwed = false; + } const record = effects.loadRecord(); if (record?.phase === "on") { const account = effects.signedInAccount(); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index f5b92e80eaabe..c6b182928fc9f 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -199,8 +199,8 @@ export function useRemoteConnections() { return; } Dialog.alert( - "This phone is still shared", - `${stillSharing}\n\nRemove it anyway to finish this later: sharing stops when this phone is connected again.`, + "Couldn't confirm sharing is off", + `${stillSharing}\n\nRemove it anyway to finish this later: if this phone is connected again, T3 Code stops any sharing and signs out other devices then.`, [ { text: "Keep", style: "cancel" }, { text: "Remove anyway", style: "destructive", onPress: forget }, From 6c9337ad0111ab22a491a372a95ea935f15c004b Mon Sep 17 00:00:00 2001 From: Tim Biggs <192789+tebiggs@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:19:50 +0000 Subject: [PATCH 08/19] fix(mobile): sign-out stops sharing even when preferences fail to load 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 --- .../features/phone/phone-sharing-commands.ts | 10 ++++++++- .../phone/phone-sharing-controller.test.ts | 21 +++++++++++++++++++ .../phone/phone-sharing-controller.ts | 11 ++++++---- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/phone/phone-sharing-commands.ts b/apps/mobile/src/features/phone/phone-sharing-commands.ts index 6c7d595d90468..7bba6a7feac67 100644 --- a/apps/mobile/src/features/phone/phone-sharing-commands.ts +++ b/apps/mobile/src/features/phone/phone-sharing-commands.ts @@ -26,6 +26,7 @@ import { } from "../../state/phone-server-settings"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { savedRemoteConnectionAtom } from "../../state/use-remote-environment-registry"; +import { workspaceConnections } from "../../state/workspace"; import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; import { failWith, @@ -35,6 +36,7 @@ import { waitForReachability, } from "./phone-sharing"; import { createPhoneSharingController } from "./phone-sharing-controller"; +import { phoneServerPort } from "./phone-setup"; const PAIRING_LINKS_TIMEOUT = Duration.seconds(10); const SAVE_TIMEOUT = Duration.seconds(10); @@ -163,7 +165,13 @@ const controller = createPhoneSharingController({ }).pipe(Effect.timeout(SAVE_TIMEOUT)), ); }, - phoneEnvironmentId: () => appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId ?? null, + // Preferences that fail to load read as empty, so a connection can identify the phone too. + phoneEnvironmentId: () => + appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId ?? + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .find((environment) => phoneServerPort(environment.displayUrl) !== null)?.environmentId ?? + null, connectionFor: (environmentId) => appAtomRegistry.get(savedRemoteConnectionAtom(environmentId)), signedInAccount: () => appAtomRegistry.get(managedRelaySessionAtom)?.accountId ?? null, readSharing: async (connection) => { diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts index de007f8c2271d..13255191095d3 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts @@ -266,6 +266,27 @@ describe("createPhoneSharingController", () => { expect(state.record?.phase).toBe("listed"); }); + it("settles an owed stop before a new share, so it can't end that share later", async () => { + const { state, controller, stopOnServer } = setup(); + state.connected = false; + state.saveFailure = "storage busy"; + expect(await controller.stop()).toBe("storage busy"); + + state.connected = true; + state.saveFailure = null; + const link = vi.fn(async () => { + state.sharing = shared("user_a"); + return null; + }); + expect(await controller.share(connection, "user_a", link)).toBeNull(); + expect(link).toHaveBeenCalledOnce(); + expect(stopOnServer).not.toHaveBeenCalled(); + + expect(await controller.reconcile()).toBeNull(); + expect(stopOnServer).not.toHaveBeenCalled(); + expect(state.record?.phase).toBe("on"); + }); + it("reads and does nothing until saved preferences are loaded", async () => { let hydrate = () => {}; let entered = () => {}; diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.ts b/apps/mobile/src/features/phone/phone-sharing-controller.ts index 7b02648664ad2..5ab9b6982e2c6 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -35,6 +35,11 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { let queue: Promise = Promise.resolve(); // A stop asked for before the record could be read, which a later task has to honour. let stopOwed = false; + const honourOwedStop = async () => { + if (!stopOwed) return; + await markStopping(); + stopOwed = false; + }; const enqueue = (task: () => Promise): Promise => { const run = queue.then(async () => { try { @@ -107,6 +112,7 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { link: () => Promise, ) => enqueue(async () => { + await honourOwedStop(); const unfinished = await settle(); if (effects.loadRecord()?.phase === "stopping") return unfinished ?? STOP_UNFINISHED; const sharing = await effects.readSharing(connection); @@ -139,10 +145,7 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { */ reconcile: () => enqueue(async () => { - if (stopOwed) { - await markStopping(); - stopOwed = false; - } + await honourOwedStop(); const record = effects.loadRecord(); if (record?.phase === "on") { const account = effects.signedInAccount(); From aaf11082e4320c4ec8d0a0ed9335947b1ee8c829 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 06:26:38 +0000 Subject: [PATCH 09/19] fix(mobile): phone removal stays outside the compiled hook Main now checks React Compiler coverage, which can't lower dynamic imports. Co-Authored-By: Claude Opus 5.5 --- .../state/use-remote-environment-registry.ts | 130 +++++++++--------- 1 file changed, 67 insertions(+), 63 deletions(-) diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index e867488693bee..9805770ac6267 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -156,6 +156,71 @@ export function useRemoteConnectionStatus() { }; } +// Outside the hook: React Compiler can't lower dynamic imports or a throw inside try/catch. +function confirmRemovePhone(environmentLabel: string, forget: () => void) { + // Removing only the connection would leave the server running on the phone with a wake + // lock and nothing in the app pointing at it, so a failed stop keeps everything. + // Forgetting the connection would leave no way to reach a shared server's tunnel. + const forgetPhone = async () => { + const sharing = await import("../features/phone/phone-sharing-commands"); + const stillSharing = await sharing.stopPhoneSharingBeforeForgetting(); + if (stillSharing === null) { + forget(); + return; + } + Dialog.alert( + "Couldn't confirm sharing is off", + `${stillSharing}\n\nRemove it anyway to finish this later: if this phone is connected again, T3 Code stops any sharing and signs out other devices then.`, + [ + { text: "Keep", style: "cancel" }, + { text: "Remove anyway", style: "destructive", onPress: forget }, + ], + ); + }; + const stopThenForget = async () => { + // Auto-start would otherwise relaunch the server as soon as its connection drops. + const previous = appAtomRegistry.get(phoneServerSettingsAtom); + if (previous) setPhoneServerSettings({ ...previous, autoStart: false }); + try { + // Loaded on demand: the phone server pulls in the expo runtime. + const phoneServer = await import("../features/phone/phone-server"); + const sharing = await import("../features/phone/phone-sharing-commands"); + const stillSharing = await sharing.stopPhoneSharingBeforeForgetting(); + if (stillSharing !== null) throw new Error(stillSharing); + await phoneServer.stopPhoneServerWhenSettled(); + forget(); + } catch (error) { + Dialog.alert( + "The server didn't stop", + `${error instanceof Error ? error.message : "It didn't stop."}\n\n${environmentLabel} is still saved. Remove it anyway, or stop the server from its notification first.`, + [ + { + text: "Keep", + style: "cancel", + onPress: () => { + if (previous) setPhoneServerSettings(previous); + }, + }, + { text: "Remove anyway", style: "destructive", onPress: forget }, + ], + ); + } + }; + Dialog.alert( + "Remove this phone?", + `Forget ${environmentLabel} and its cached threads in the app. Ubuntu, Claude Code and your projects stay on the phone; phone setup can connect them again.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Remove only", onPress: () => void forgetPhone() }, + { + text: "Remove and stop server", + style: "destructive", + onPress: () => void stopThenForget(), + }, + ], + ); +} + export function useRemoteConnections() { const controller = useConnectionController(); const navigation = useNavigation(); @@ -194,71 +259,10 @@ export function useRemoteConnections() { } as const; // First: a shared phone also has a relay route, and that mustn't skip stopping its share. if (appAtomRegistry.get(phoneServerSettingsAtom)?.environmentId === environmentId) { - // Removing only the connection would leave the server running on the phone with a wake - // lock and nothing in the app pointing at it, so a failed stop keeps everything. - const forget = () => { + confirmRemovePhone(environment.environmentLabel, () => { setPhoneServerSettings(null); void controller.removeEnvironment(environmentId); - }; - // Forgetting the connection would leave no way to reach a shared server's tunnel. - const forgetPhone = async () => { - const sharing = await import("../features/phone/phone-sharing-commands"); - const stillSharing = await sharing.stopPhoneSharingBeforeForgetting(); - if (stillSharing === null) { - forget(); - return; - } - Dialog.alert( - "Couldn't confirm sharing is off", - `${stillSharing}\n\nRemove it anyway to finish this later: if this phone is connected again, T3 Code stops any sharing and signs out other devices then.`, - [ - { text: "Keep", style: "cancel" }, - { text: "Remove anyway", style: "destructive", onPress: forget }, - ], - ); - }; - const stopThenForget = async () => { - // Auto-start would otherwise relaunch the server as soon as its connection drops. - const previous = appAtomRegistry.get(phoneServerSettingsAtom); - if (previous) setPhoneServerSettings({ ...previous, autoStart: false }); - try { - // Loaded on demand: the phone server pulls in the expo runtime. - const phoneServer = await import("../features/phone/phone-server"); - const sharing = await import("../features/phone/phone-sharing-commands"); - const stillSharing = await sharing.stopPhoneSharingBeforeForgetting(); - if (stillSharing !== null) throw new Error(stillSharing); - await phoneServer.stopPhoneServerWhenSettled(); - forget(); - } catch (error) { - Dialog.alert( - "The server didn't stop", - `${error instanceof Error ? error.message : "It didn't stop."}\n\n${environment.environmentLabel} is still saved. Remove it anyway, or stop the server from its notification first.`, - [ - { - text: "Keep", - style: "cancel", - onPress: () => { - if (previous) setPhoneServerSettings(previous); - }, - }, - { text: "Remove anyway", style: "destructive", onPress: forget }, - ], - ); - } - }; - Dialog.alert( - "Remove this phone?", - `Forget ${environment.environmentLabel} and its cached threads in the app. Ubuntu, Claude Code and your projects stay on the phone; phone setup can connect them again.`, - [ - { text: "Cancel", style: "cancel" }, - { text: "Remove only", onPress: () => void forgetPhone() }, - { - text: "Remove and stop server", - style: "destructive", - onPress: () => void stopThenForget(), - }, - ], - ); + }); return; } if (environment.isRelayManaged) { From 304de73f5d9500b1f6a299dca492662d7fbcb26f Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 07:35:00 +0000 Subject: [PATCH 10/19] feat(mobile): View only sharing, enforced by the phone's server 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 --- FORK.md | 10 +- .../features/phone/PhoneSharingSection.tsx | 177 +++++++--- .../features/phone/phone-sharing-commands.ts | 31 +- .../phone/phone-sharing-controller.test.ts | 100 +++++- .../phone/phone-sharing-controller.ts | 29 +- .../src/features/phone/phone-sharing.test.ts | 48 ++- .../src/features/phone/phone-sharing.ts | 37 +- apps/server/src/auth/EnvironmentAuth.ts | 179 ++++++---- .../src/auth/EnvironmentAuthAdmin.test.ts | 90 +++++ apps/server/src/auth/McpOAuth.test.ts | 4 + apps/server/src/auth/RpcAuthorization.test.ts | 38 +++ apps/server/src/cloud/CloudLink.test.ts | 318 +++++++++++++++++- apps/server/src/cloud/CloudLink.ts | 66 +++- apps/server/src/cloud/config.ts | 2 + apps/server/src/cloud/http.test.ts | 86 ++++- apps/server/src/cloud/http.ts | 9 + .../src/provider/agentContextBudget.json | 2 +- apps/web/src/components/ChatView.tsx | 5 +- apps/web/src/components/chat/ChatComposer.tsx | 13 +- .../components/chat/ThreadDetailsPanel.tsx | 22 +- .../components/sidebar/SidebarThreadRow.tsx | 7 +- docs/user/remote-access.md | 24 ++ packages/contracts/src/auth.test.ts | 13 + packages/contracts/src/auth.ts | 5 + packages/contracts/src/environmentHttp.ts | 20 ++ 25 files changed, 1170 insertions(+), 165 deletions(-) diff --git a/FORK.md b/FORK.md index 9f491c267a9ef..80e78b7bbdaa8 100644 --- a/FORK.md +++ b/FORK.md @@ -51,9 +51,13 @@ adds the feature, and remove it in the PR that drops the feature. `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. 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: + 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`, diff --git a/apps/mobile/src/features/phone/PhoneSharingSection.tsx b/apps/mobile/src/features/phone/PhoneSharingSection.tsx index 8be8c6fe250d7..e20ea50f0640a 100644 --- a/apps/mobile/src/features/phone/PhoneSharingSection.tsx +++ b/apps/mobile/src/features/phone/PhoneSharingSection.tsx @@ -2,12 +2,19 @@ import { useAuth } from "@clerk/expo"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { formatConnectReachable, type EnvironmentId } from "@t3tools/contracts"; +import { + formatConnectReachable, + type EnvironmentId, + type EnvironmentSharingMode, +} from "@t3tools/contracts"; import type * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { useCallback, useRef, useState } from "react"; +import { View } from "react-native"; +import { AppText as Text } from "../../components/ui/AppText"; import * as Dialog from "../../components/ui/Dialog"; +import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { appAtomRegistry } from "../../state/atom-registry"; import { usePhoneSharingRecord } from "../../state/phone-server-settings"; import { mobilePreferencesAtom } from "../../state/preferences"; @@ -16,9 +23,9 @@ import { useWorkspaceEnvironments } from "../../state/workspace"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { SettingsSection } from "../settings/components/SettingsSection"; -import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; import { PHONE_STILL_LISTED, type PhoneSharing } from "./phone-sharing"; import { + changePhoneSharingMode, checkPhoneReachability, readPhoneSharing, reconcilePhoneSharing, @@ -26,6 +33,25 @@ import { stopPhoneSharing, } from "./phone-sharing-commands"; +type SharingChoice = "off" | EnvironmentSharingMode; + +const SHARING_CHOICES = [ + { value: "off", label: "Off" }, + { value: "view-only", label: "View only" }, + { value: "full-control", label: "Full control" }, +] as const; + +const MODE_LABEL = { "view-only": "View only", "full-control": "Full control" } as const; + +const VIEW_ONLY_WARNING = + "Anyone signed in to your T3 Connect account can read this phone's threads, including any secrets in them, such as a token an agent printed. They can also see its projects, its pull requests and any open preview.\n\nThey can't send messages, answer approvals, or use the terminal or files. Only this phone can do that."; + +const FULL_CONTROL_WARNING = + "Anyone signed in to your T3 Connect account gets full control of this phone's server: threads, agents, a terminal running as root in its Ubuntu, and your phone's files if you've allowed phone storage. They can also use commands such as the clipboard and notifications.\n\nAgent access covers what agents normally do on your phone, but it isn't a hard barrier against someone using that terminal. Share only with an account you fully trust."; + +const FIRST_SHARE_NOTE = + "The phone counts as one of your account's T3 Connect machines. The first time, T3 Code downloads Cloudflare's tunnel client (about 40 MB)."; + function failureText(result: { readonly cause: Cause.Cause }, fallback: string): string { const error = squashAtomCommandFailure(result); return error instanceof Error && error.message.length > 0 ? error.message : fallback; @@ -36,16 +62,23 @@ function sharingText(sharing: PhoneSharing, accountId: string | null | undefined if (sharing.revoked) { return "T3 Connect no longer accepts this link, but its tunnel may still be set up here. Turn this off to clear it."; } + const mode = MODE_LABEL[sharing.mode]; + if (sharing.modeChangePending) { + return `${mode} is set, but devices signed in before the change may keep their old access. Tap ${mode} to try again.`; + } if (sharing.cloudUserId !== null && sharing.cloudUserId !== accountId) { return "Shared with a different T3 Connect account. Turn this off to stop it."; } const reachability = sharing.reachability; if (reachability?.status === "unreachable") { - return `On, but other devices can't connect yet: ${reachability.reason}`; + return `${mode}, but other devices can't connect yet: ${reachability.reason}`; + } + if (reachability?.status === "reachable") { + return `${mode}. ${formatConnectReachable(reachability)}.`; } - return reachability?.status === "reachable" - ? `On. ${formatConnectReachable(reachability)}.` - : "On. Devices signed in to your T3 Connect account can use this phone's server."; + return sharing.mode === "view-only" + ? "View only. Devices signed in to your T3 Connect account can read this phone's threads." + : "Full control. Devices signed in to your T3 Connect account can use this phone's server."; } /** Phone setup and the phone's environment page: share the phone's server through T3 Connect. */ @@ -67,7 +100,7 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm useWorkspaceEnvironments().find((candidate) => candidate.environmentId === props.environmentId) ?.connectionState === "connected"; const [sharing, setSharing] = useState(null); - const [busy, setBusy] = useState<"share" | "stop" | null>(null); + const [busy, setBusy] = useState<"share" | "stop" | "mode" | null>(null); const [verifying, setVerifying] = useState(false); const [readError, setReadError] = useState(null); const [actionError, setActionError] = useState(null); @@ -117,7 +150,7 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm setBusy(null); }; - const share = async () => { + const share = async (mode: EnvironmentSharingMode) => { const token = userId ? await getToken(resolveRelayClerkTokenOptions()).catch(() => null) : null; if (!token || !userId) { setActionError("Couldn't get a T3 Connect sign-in. Sign in again under Settings."); @@ -130,11 +163,19 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm clerkToken: token, liveActivitiesEnabled: !AsyncResult.isSuccess(preferences) || preferences.value.liveActivitiesEnabled !== false, + mode, }); finish(); refreshManagedRelayEnvironments(); if (failure === null) { - setSharing({ shared: true, revoked: false, cloudUserId: userId, reachability: null }); + setSharing({ + shared: true, + revoked: false, + cloudUserId: userId, + reachability: null, + mode, + modeChangePending: false, + }); verify(); } else { setActionError(failure); @@ -149,7 +190,13 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm check(); }; - const run = (kind: "share" | "stop", action: () => Promise) => { + const changeMode = async (mode: EnvironmentSharingMode) => { + setActionError(await changePhoneSharingMode(connection, mode)); + finish(); + check(); + }; + + const run = (kind: "share" | "stop" | "mode", action: () => Promise) => { acting.current = true; latest.current += 1; setBusy(kind); @@ -158,8 +205,18 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm void action(); }; - const onValueChange = (on: boolean) => { - if (!on) { + const selected: SharingChoice = sharing?.shared + ? sharing.mode + : // Mid-stop the server may still grant anything, so it doesn't read as off. + stopUnfinished + ? "full-control" + : "off"; + const locked = !connected || busy !== null || sharing === null; + + const onSelect = (choice: SharingChoice) => { + if (locked) return; + if (choice === "off") { + if (selected === "off") return; Dialog.alert( "Stop sharing this phone?", "Stops the tunnel and signs out every other device, so they lose access to this phone's server. Your threads stay here, and so does anything another device changed or installed while it was shared.", @@ -170,27 +227,69 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm ); return; } - if (!isSignedIn) { + if (selected === "off") { + if (!isSignedIn) { + Dialog.alert( + "Sign in to T3 Connect", + "Sharing uses your T3 Connect account. Sign in, then turn sharing on.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Continue", onPress: () => navigation.navigate("SettingsAuth") }, + ], + ); + return; + } Dialog.alert( - "Sign in to T3 Connect", - "Sharing uses your T3 Connect account. Sign in, then turn sharing on.", + choice === "view-only" ? "Share this phone View only?" : "Give full control of this phone?", + `${choice === "view-only" ? VIEW_ONLY_WARNING : FULL_CONTROL_WARNING}\n\n${FIRST_SHARE_NOTE}`, [ { text: "Cancel", style: "cancel" }, - { text: "Continue", onPress: () => navigation.navigate("SettingsAuth") }, + { text: "Share", onPress: () => run("share", () => share(choice)) }, ], ); return; } + if (!sharing?.shared) return; + if (choice === selected) { + if (sharing.modeChangePending) run("mode", () => changeMode(choice)); + return; + } Dialog.alert( - "Share this phone?", - "Anyone signed in to your T3 Connect account gets full control of this phone's server: threads, agents, a terminal running as root in its Ubuntu, and your phone's files if you've allowed phone storage. They can also use commands such as the clipboard and notifications.\n\nAgent access covers what agents normally do on your phone, but it isn't a hard barrier against someone using that terminal. Share only with an account you fully trust.\n\nThe phone counts as one of your account's T3 Connect machines. The first time, T3 Code downloads Cloudflare's tunnel client (about 40 MB).", + choice === "view-only" ? "Switch to View only?" : "Give full control of this phone?", + choice === "view-only" + ? `Signs out every other device. When they reconnect they can only read.\n\n${VIEW_ONLY_WARNING}\n\nAnything another device changed or installed while it had full control stays.` + : `Signs out every other device. When they reconnect they have full control.\n\n${FULL_CONTROL_WARNING}`, [ { text: "Cancel", style: "cancel" }, - { text: "Share", onPress: () => run("share", share) }, + { + text: choice === "view-only" ? "Switch to View only" : "Give full control", + ...(choice === "view-only" ? {} : { style: "destructive" as const }), + onPress: () => run("mode", () => changeMode(choice)), + }, ], ); }; + const status = !connected + ? "Start the server on this phone first." + : busy === "share" + ? "Sharing…" + : busy === "stop" + ? "Stopping…" + : busy === "mode" + ? "Signing out other devices…" + : stopUnfinished + ? "Still stopping. Other devices may have access until this finishes. Tap Off to try again." + : verifying + ? "On. Checking that other devices can connect, for up to a minute." + : sharing === null + ? readError === null + ? "Checking…" + : "Couldn't check." + : !sharing.shared && record?.phase === "listed" + ? `Off. ${PHONE_STILL_LISTED}` + : sharingText(sharing, userId); + return ( - + + Share with your other devices + + + + + {status} + + ); } diff --git a/apps/mobile/src/features/phone/phone-sharing-commands.ts b/apps/mobile/src/features/phone/phone-sharing-commands.ts index 7bba6a7feac67..6627210bcd8bb 100644 --- a/apps/mobile/src/features/phone/phone-sharing-commands.ts +++ b/apps/mobile/src/features/phone/phone-sharing-commands.ts @@ -9,7 +9,7 @@ import { runAtomCommand, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { type EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { type EnvironmentId, type EnvironmentSharingMode, WS_METHODS } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -30,8 +30,10 @@ import { workspaceConnections } from "../../state/workspace"; import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; import { failWith, + MODE_CHANGE_UNFINISHED, PhoneSharingError, readSharing, + setServerSharingMode, stopServerSharing, waitForReachability, } from "./phone-sharing"; @@ -112,6 +114,14 @@ const stopOnServer = createRuntimeCommand(connectionAtomRuntime, { stopServerSharing(connection, pairingLinkIds(connection.environmentId)), }); +const setModeOnServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:set-sharing-mode", + execute: (input: { + readonly connection: SavedRemoteConnection; + readonly mode: EnvironmentSharingMode; + }) => setServerSharingMode(input.connection, input.mode), +}); + const deregisterPhone = createRuntimeCommand(connectionAtomRuntime, { label: "phone-server:deregister", execute: ( @@ -187,6 +197,18 @@ const controller = createPhoneSharingController({ }), "Couldn't set up the T3 Connect relay client.", ), + // Fixed text, whatever went wrong: what matters is that old sign-ins may have survived. + setSharingMode: async (connection, mode) => + AsyncResult.isSuccess( + await runAtomCommand( + appAtomRegistry, + setModeOnServer, + { connection, mode }, + { reportFailure: false }, + ), + ) + ? null + : MODE_CHANGE_UNFINISHED, stopOnServer: async (connection) => failureText( await runAtomCommand(appAtomRegistry, stopOnServer, connection, { reportFailure: false }), @@ -213,14 +235,19 @@ export const sharePhone = (input: { readonly accountId: string; readonly clerkToken: string; readonly liveActivitiesEnabled: boolean; + readonly mode: EnvironmentSharingMode; }) => - controller.share(input.connection, input.accountId, async () => + controller.share(input.connection, input.accountId, input.mode, async () => failureText( await runAtomCommand(appAtomRegistry, linkServer, input, { reportFailure: false }), "Couldn't share this phone.", ), ); +export const changePhoneSharingMode = ( + connection: SavedRemoteConnection, + mode: EnvironmentSharingMode, +) => controller.changeMode(connection, mode); export const stopPhoneSharing = () => controller.stop(); export const reconcilePhoneSharing = () => controller.reconcile(); export const releasePhoneSharing = () => controller.release(); diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts index 13255191095d3..dbf6cc403a345 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts @@ -20,6 +20,8 @@ const shared = (cloudUserId: string | null): PhoneSharing => ({ revoked: false, cloudUserId, reachability: null, + mode: "view-only", + modeChangePending: false, }); const on: PhoneSharingRecord = { environmentId, accountId: "user_a", phase: "on" }; @@ -32,7 +34,9 @@ function setup(initial: PhoneSharingRecord | null = null, hydrated = async () => stopFailure: null as string | null, deregisterFailure: null as string | null, saveFailure: null as string | null, + modeFailure: null as string | null, }; + const setSharingMode = vi.fn(async () => state.modeFailure); const stopOnServer = vi.fn(async () => { if (state.stopFailure === null) state.sharing = { shared: false }; return state.stopFailure; @@ -51,10 +55,11 @@ function setup(initial: PhoneSharingRecord | null = null, hydrated = async () => signedInAccount: () => state.account, readSharing: async () => (state.connected ? state.sharing : null), ensureRelayClient: async () => null, + setSharingMode, stopOnServer, deregister, }); - return { state, controller, stopOnServer, deregister, loadRecord }; + return { state, controller, setSharingMode, stopOnServer, deregister, loadRecord }; } describe("createPhoneSharingController", () => { @@ -64,6 +69,7 @@ describe("createPhoneSharingController", () => { const sharing = controller.share( connection, "user_a", + "view-only", () => new Promise((resolve) => { finishLink = resolve; @@ -81,6 +87,80 @@ describe("createPhoneSharingController", () => { expect(state.sharing).toEqual({ shared: false }); }); + it("sets the mode before linking, and doesn't link when it can't", async () => { + const { state, controller, setSharingMode } = setup(); + const link = vi.fn(async () => null); + state.modeFailure = "couldn't set the mode"; + expect(await controller.share(connection, "user_a", "view-only", link)).toBe( + "couldn't set the mode", + ); + expect(link).not.toHaveBeenCalled(); + expect(state.record).toBeNull(); + + state.modeFailure = null; + expect(await controller.share(connection, "user_a", "full-control", link)).toBeNull(); + expect(setSharingMode).toHaveBeenLastCalledWith(connection, "full-control"); + expect(setSharingMode.mock.invocationCallOrder.at(-1)).toBeLessThan( + link.mock.invocationCallOrder[0]!, + ); + }); + + it("won't share View only from a server too old to enforce it, but Full control still works", async () => { + const { state, controller, setSharingMode } = setup(); + const link = vi.fn(async () => null); + state.sharing = { shared: false, modesUnsupported: true }; + + expect(await controller.share(connection, "user_a", "view-only", link)).toContain("too old"); + expect(link).not.toHaveBeenCalled(); + expect(state.record).toBeNull(); + + expect(await controller.share(connection, "user_a", "full-control", link)).toBeNull(); + expect(link).toHaveBeenCalledOnce(); + expect(setSharingMode).not.toHaveBeenCalled(); + }); + + it("won't switch a server too old to enforce it to View only", async () => { + const { state, controller, setSharingMode } = setup(on); + state.sharing = { ...shared("user_a"), modesUnsupported: true }; + + expect(await controller.changeMode(connection, "view-only")).toContain("too old"); + expect(setSharingMode).not.toHaveBeenCalled(); + }); + + it("changes the mode only on a share that's on", async () => { + const { state, controller, setSharingMode } = setup(on); + state.sharing = shared("user_a"); + expect(await controller.changeMode(connection, "full-control")).toBeNull(); + expect(setSharingMode).toHaveBeenCalledExactlyOnceWith(connection, "full-control"); + + state.modeFailure = "not everyone was signed out"; + expect(await controller.changeMode(connection, "view-only")).toBe( + "not everyone was signed out", + ); + + setSharingMode.mockClear(); + state.modeFailure = null; + state.sharing = { shared: false }; + expect(await controller.changeMode(connection, "view-only")).toContain("isn't shared"); + state.sharing = null; + expect(await controller.changeMode(connection, "view-only")).toContain("Couldn't check"); + state.sharing = shared("user_a"); + state.record = { ...on, phase: "stopping" }; + expect(await controller.changeMode(connection, "view-only")).toContain("hasn't finished"); + expect(setSharingMode).not.toHaveBeenCalled(); + }); + + it("lets a sign-out's stop win over a mode change queued after it", async () => { + const { state, controller, setSharingMode, stopOnServer } = setup(on); + state.sharing = shared("user_a"); + const stopped = controller.stop(); + const changed = controller.changeMode(connection, "full-control"); + await stopped; + expect(await changed).toContain("isn't shared"); + expect(stopOnServer).toHaveBeenCalled(); + expect(setSharingMode).not.toHaveBeenCalled(); + }); + it("keeps Off open until signing out other clients succeeds", async () => { const { state, controller, stopOnServer, deregister } = setup(on); state.sharing = shared("user_a"); @@ -105,7 +185,7 @@ describe("createPhoneSharingController", () => { state.stopFailure = "other devices couldn't be signed out"; const link = vi.fn(async () => null); - expect(await controller.share(connection, "user_a", link)).toBe(state.stopFailure); + expect(await controller.share(connection, "user_a", "view-only", link)).toBe(state.stopFailure); expect(link).not.toHaveBeenCalled(); expect(state.record).toEqual(stopping); }); @@ -115,9 +195,13 @@ describe("createPhoneSharingController", () => { const link = vi.fn(async () => null); state.sharing = shared("user_b"); - expect(await controller.share(connection, "user_a", link)).toContain("already shared"); + expect(await controller.share(connection, "user_a", "view-only", link)).toContain( + "already shared", + ); state.sharing = null; - expect(await controller.share(connection, "user_a", link)).toContain("Couldn't check"); + expect(await controller.share(connection, "user_a", "view-only", link)).toContain( + "Couldn't check", + ); expect(link).not.toHaveBeenCalled(); expect(state.record).toBeNull(); }); @@ -174,9 +258,9 @@ describe("createPhoneSharingController", () => { it("rolls a failed share back in full, and keeps the listing until it's really gone", async () => { const { state, controller, stopOnServer, deregister } = setup(); state.deregisterFailure = "relay down"; - expect(await controller.share(connection, "user_a", async () => "over the limit")).toBe( - "over the limit", - ); + expect( + await controller.share(connection, "user_a", "view-only", async () => "over the limit"), + ).toBe("over the limit"); expect(stopOnServer).toHaveBeenCalledOnce(); expect(state.record?.phase).toBe("listed"); @@ -278,7 +362,7 @@ describe("createPhoneSharingController", () => { state.sharing = shared("user_a"); return null; }); - expect(await controller.share(connection, "user_a", link)).toBeNull(); + expect(await controller.share(connection, "user_a", "view-only", link)).toBeNull(); expect(link).toHaveBeenCalledOnce(); expect(stopOnServer).not.toHaveBeenCalled(); diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.ts b/apps/mobile/src/features/phone/phone-sharing-controller.ts index 5ab9b6982e2c6..456c0830e4f7f 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -1,8 +1,8 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, EnvironmentSharingMode } from "@t3tools/contracts"; import type { SavedRemoteConnection } from "../../lib/connection"; import type { PhoneSharingRecord } from "../../persistence/mobile-preferences"; -import { PHONE_STILL_LISTED, type PhoneSharing } from "./phone-sharing"; +import { PHONE_STILL_LISTED, type PhoneSharing, VIEW_ONLY_NEEDS_UPDATE } from "./phone-sharing"; const STOP_UNFINISHED = "Sharing hasn't finished stopping: other devices may still have access. T3 Code tries again when this phone's server answers."; @@ -20,6 +20,11 @@ export interface PhoneSharingEffects { readonly readSharing: (connection: SavedRemoteConnection) => Promise; /** Each returns the failure's text, or null. */ readonly ensureRelayClient: (connection: SavedRemoteConnection) => Promise; + /** Idempotent, so a change that couldn't sign everyone out can run again. */ + readonly setSharingMode: ( + connection: SavedRemoteConnection, + mode: EnvironmentSharingMode, + ) => Promise; /** Idempotent, so an unfinished stop can run again from the start. */ readonly stopOnServer: (connection: SavedRemoteConnection) => Promise; /** Null too when the account no longer lists the phone. */ @@ -109,6 +114,7 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { share: ( connection: SavedRemoteConnection, accountId: string, + mode: EnvironmentSharingMode, link: () => Promise, ) => enqueue(async () => { @@ -118,8 +124,15 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { const sharing = await effects.readSharing(connection); if (sharing === null) return "Couldn't check whether this phone is already shared."; if (sharing.shared) return "This phone is already shared."; + if (sharing.modesUnsupported && mode === "view-only") return VIEW_ONLY_NEEDS_UPDATE; const notReady = await effects.ensureRelayClient(connection); if (notReady !== null) return notReady; + // Before the link, so the first device to connect already gets this mode. A server too + // old to store one already grants Full control, the only mode it can be asked for here. + if (!sharing.modesUnsupported) { + const modeFailure = await effects.setSharingMode(connection, mode); + if (modeFailure !== null) return modeFailure; + } const environmentId = connection.environmentId; await effects.saveRecord({ environmentId, accountId, phase: "on" }); const failure = await link(); @@ -129,6 +142,18 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { return failure ?? "You signed out of T3 Connect, so this phone wasn't shared."; }), + /** Between View only and Full control on a share that's already on. */ + changeMode: (connection: SavedRemoteConnection, mode: EnvironmentSharingMode) => + enqueue(async () => { + await honourOwedStop(); + if (effects.loadRecord()?.phase === "stopping") return STOP_UNFINISHED; + const sharing = await effects.readSharing(connection); + if (sharing === null) return "Couldn't check whether this phone is shared."; + if (!sharing.shared) return "This phone isn't shared anymore."; + if (sharing.modesUnsupported) return VIEW_ONLY_NEEDS_UPDATE; + return effects.setSharingMode(connection, mode); + }), + /** Off, and signing out or switching accounts. Null when nothing is left to do. */ stop: () => { stopOwed = true; diff --git a/apps/mobile/src/features/phone/phone-sharing.test.ts b/apps/mobile/src/features/phone/phone-sharing.test.ts index 0e196852f6de7..f2104ea559062 100644 --- a/apps/mobile/src/features/phone/phone-sharing.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing.test.ts @@ -3,7 +3,11 @@ import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { phoneSharingFromLinkState, stopServerSharing } from "./phone-sharing"; +import { + phoneSharingFromLinkState, + setServerSharingMode, + stopServerSharing, +} from "./phone-sharing"; const connection = { environmentId: EnvironmentId.make("phone"), @@ -16,6 +20,7 @@ const connection = { }; const linkState = { + sharingModeChangePending: false, linked: false, linkRevoked: false, cloudUserId: null, @@ -34,6 +39,7 @@ function layerServer(failing: ReadonlySet = new Set()) { return Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }); } if (path === "/api/auth/clients/revoke-others") return Response.json({ revokedCount: 2 }); + if (path === "/api/connect/sharing-mode") return Response.json(linkState); return Response.json({ revoked: true }); }; const layer = layerRemoteHttpClient((input) => { @@ -45,6 +51,27 @@ function layerServer(failing: ReadonlySet = new Set()) { } describe("phoneSharingFromLinkState", () => { + it("reads a shared server with no stored mode as Full control, which is what it grants", () => { + const shared = { ...linkState, linked: true, cloudUserId: "user_a" }; + expect(phoneSharingFromLinkState(shared)).toMatchObject({ + mode: "full-control", + modeChangePending: false, + }); + expect( + phoneSharingFromLinkState({ + ...shared, + sharingMode: "view-only", + sharingModeChangePending: true, + }), + ).toMatchObject({ mode: "view-only", modeChangePending: true }); + }); + + it("spots a server from before sharing modes by what its link state leaves out", () => { + const { sharingModeChangePending: _absent, ...older } = linkState; + expect(phoneSharingFromLinkState(older)).toEqual({ shared: false, modesUnsupported: true }); + expect(phoneSharingFromLinkState(linkState)).toEqual({ shared: false }); + }); + it("counts anything the server still holds as shared, so Off can clear it", () => { expect(phoneSharingFromLinkState(linkState).shared).toBe(false); expect( @@ -55,7 +82,14 @@ describe("phoneSharingFromLinkState", () => { cloudUserId: "user_a", managedTunnelActive: true, }), - ).toEqual({ shared: true, revoked: true, cloudUserId: "user_a", reachability: null }); + ).toEqual({ + shared: true, + revoked: true, + cloudUserId: "user_a", + reachability: null, + mode: "full-control", + modeChangePending: false, + }); expect(phoneSharingFromLinkState({ ...linkState, managedTunnelActive: true }).shared).toBe( true, ); @@ -83,3 +117,13 @@ describe("stopServerSharing", () => { }).pipe(Effect.provide(server.layer)); }); }); + +describe("setServerSharingMode", () => { + it.effect("asks the server to change the mode", () => { + const server = layerServer(); + return Effect.gen(function* () { + yield* setServerSharingMode(connection, "view-only"); + expect(server.requests).toEqual(["/api/connect/sharing-mode"]); + }).pipe(Effect.provide(server.layer)); + }); +}); diff --git a/apps/mobile/src/features/phone/phone-sharing.ts b/apps/mobile/src/features/phone/phone-sharing.ts index f6636dd93bbb1..d696050124ab3 100644 --- a/apps/mobile/src/features/phone/phone-sharing.ts +++ b/apps/mobile/src/features/phone/phone-sharing.ts @@ -2,6 +2,7 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import type { EnvironmentCloudLinkStateResult, EnvironmentConnectReachability, + EnvironmentSharingMode, } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; @@ -19,8 +20,11 @@ export class PhoneSharingError extends Data.TaggedError("PhoneSharingError")<{ export const PHONE_STILL_LISTED = "T3 Connect still lists this phone. Remove it under Settings > T3 Connect, signed in to the account it was shared with."; +export const VIEW_ONLY_NEEDS_UPDATE = + "This phone's T3 Code server is too old for View only. Update T3 Code on this phone, then try again."; + /** What the phone's server holds. Any link or tunnel config counts: only Off clears them. */ -export type PhoneSharing = +export type PhoneSharing = ( | { readonly shared: false } | { readonly shared: true; @@ -28,15 +32,28 @@ export type PhoneSharing = readonly revoked: boolean; readonly cloudUserId: string | null; readonly reachability: EnvironmentConnectReachability | null; - }; + readonly mode: EnvironmentSharingMode; + /** The last mode change didn't finish, so earlier sign-ins may keep their old access. */ + readonly modeChangePending: boolean; + } +) & { + /** The phone installs its server from releases, so it can be older than this app. */ + readonly modesUnsupported?: true; +}; export function phoneSharingFromLinkState(state: EnvironmentCloudLinkStateResult): PhoneSharing { - if (!state.linked && !state.managedTunnelActive) return { shared: false }; + const age = + state.sharingModeChangePending === undefined ? { modesUnsupported: true as const } : {}; + if (!state.linked && !state.managedTunnelActive) return { shared: false, ...age }; return { + ...age, shared: true, revoked: state.linkRevoked === true, cloudUserId: state.cloudUserId, reachability: state.reachability ?? null, + // A server shared before modes existed never stored one, and it still grants everything. + mode: state.sharingMode ?? "full-control", + modeChangePending: state.sharingModeChangePending === true, }; } @@ -73,6 +90,20 @@ export const readSharing = Effect.fn("PhoneSharing.read")(function* ( return phoneSharingFromLinkState(state); }); +export const MODE_CHANGE_UNFINISHED = + "Couldn't finish changing what other devices can do. Devices signed in earlier may keep their old access. Try again."; + +/** Stored before other devices are signed out, so one reconnecting now gets the new mode. */ +export const setServerSharingMode = Effect.fn("PhoneSharing.setServerSharingMode")(function* ( + connection: SavedRemoteConnection, + mode: EnvironmentSharingMode, +) { + const { api, headers } = yield* phoneServerApi(connection); + yield* api.connect + .sharingMode({ headers, payload: { mode } }) + .pipe(Effect.timeout(STOP_STEP_TIMEOUT), Effect.mapError(failWith(MODE_CHANGE_UNFINISHED))); +}); + /** The latest result when the time is up; the link stands whatever this finds. */ export const waitForReachability = Effect.fn("PhoneSharing.waitForReachability")(function* ( connection: SavedRemoteConnection, diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 6f77975734245..1f3f4bc561923 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -33,6 +33,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -519,6 +520,10 @@ export class EnvironmentAuth extends Context.Service< readonly revokeOtherClientSessions: ( currentSessionId: AuthSessionId, ) => Effect.Effect; + /** Ends every other session and pairing link, including credentials being exchanged now. */ + readonly revokeOtherAccess: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; @@ -703,6 +708,8 @@ export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; + // Spending a credential and issuing its session are separate steps; revocation must not fit between. + const credentialExchangeLock = yield* Semaphore.make(1); const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); @@ -867,23 +874,29 @@ export const make = Effect.gen(function* () { Effect.catchIf(SessionStore.isSessionCredentialInvalidError, () => Effect.void), Effect.mapError((cause) => new ServerAuthSessionCredentialValidationError({ cause })), ); - const grant = yield* bootstrapCredentials - .consume(credential) - .pipe(Effect.mapError(toBootstrapExchangeError)); - const session = yield* sessions - .issue({ - method: "browser-session-cookie", - subject: grant.subject, - scopes: grant.scopes, - ...(previousSession?.method === "browser-session-cookie" - ? { replaceSessionId: previousSession.sessionId } - : {}), - client: { - ...requestMetadata, - ...(grant.label ? { label: grant.label } : {}), - }, - }) - .pipe(Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause }))); + const session = yield* credentialExchangeLock.withPermits(1)( + bootstrapCredentials.consume(credential).pipe( + Effect.mapError(toBootstrapExchangeError), + Effect.flatMap((grant) => + sessions + .issue({ + method: "browser-session-cookie", + subject: grant.subject, + scopes: grant.scopes, + ...(previousSession?.method === "browser-session-cookie" + ? { replaceSessionId: previousSession.sessionId } + : {}), + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), + ), + ), + ), + ); return { response: { authenticated: true, @@ -944,64 +957,69 @@ export const make = Effect.gen(function* () { const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => { - return resolveBootstrapGrant(credential, { - ...input, - ...(requestedScopes !== undefined ? { requestedScopes } : {}), - }).pipe( - Effect.flatMap((grant) => - Effect.gen(function* () { - const grantedScopes = - requestedScopes === undefined - ? grant.scopes - : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); - if (grantedScopes.length === 0) { - return yield* new ServerAuthScopeNotGrantedError({}); - } - return yield* sessions - .issue({ - method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", - subject: grant.subject, - scopes: grantedScopes, - ...(input?.proofKeyThumbprint - ? { - proofKeyThumbprint: input.proofKeyThumbprint, - ttl: Duration.hours(1), - } - : {}), - replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", - client: { - ...requestMetadata, - ...(grant.label ? { label: grant.label } : {}), - }, - }) - .pipe( - Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), - ), - ); - }), - ), - Effect.flatMap((session) => - DateTime.now.pipe( - Effect.map( - (now) => - ({ - access_token: session.token, - issued_token_type: AuthAccessTokenType, - token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", - expires_in: Math.max( - 0, - Math.floor( - (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000, + return credentialExchangeLock + .withPermits(1)( + resolveBootstrapGrant(credential, { + ...input, + ...(requestedScopes !== undefined ? { requestedScopes } : {}), + }).pipe( + Effect.flatMap((grant) => + Effect.gen(function* () { + const grantedScopes = + requestedScopes === undefined + ? grant.scopes + : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); + if (grantedScopes.length === 0) { + return yield* new ServerAuthScopeNotGrantedError({}); + } + return yield* sessions + .issue({ + method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", + subject: grant.subject, + scopes: grantedScopes, + ...(input?.proofKeyThumbprint + ? { + proofKeyThumbprint: input.proofKeyThumbprint, + ttl: Duration.hours(1), + } + : {}), + replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), ), - ), - scope: encodeOAuthScope(session.scopes), - }) satisfies AuthAccessTokenResult, + ); + }), ), ), - ), - Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), - ); + ) + .pipe( + Effect.flatMap((session) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + access_token: session.token, + issued_token_type: AuthAccessTokenType, + token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", + expires_in: Math.max( + 0, + Math.floor( + (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000, + ), + ), + scope: encodeOAuthScope(session.scopes), + }) satisfies AuthAccessTokenResult, + ), + ), + ), + Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), + ); }; const issuePairingCredentialForSubject = (input: { @@ -1188,6 +1206,18 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); + const revokeOtherAccess: EnvironmentAuth["Service"]["revokeOtherAccess"] = (currentSessionId) => + credentialExchangeLock.withPermits(1)( + listPairingLinks().pipe( + Effect.flatMap((pairingLinks) => + Effect.forEach(pairingLinks, (link) => revokePairingLink(link.id), { discard: true }), + ), + Effect.andThen(revokeOtherSessionsExcept(currentSessionId)), + Effect.asVoid, + Effect.withSpan("EnvironmentAuth.revokeOtherAccess"), + ), + ); + const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => issueStartupPairingCredential().pipe( Effect.map((issued) => { @@ -1384,6 +1414,7 @@ export const make = Effect.gen(function* () { listClientSessions, revokeClientSession, revokeOtherClientSessions, + revokeOtherAccess, authenticateHttpRequest, authenticateWebSocketUpgrade, issueWebSocketTicket, @@ -1395,7 +1426,9 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(EnvironmentAuth, make).pipe( +export const layerFromStores = Layer.effect(EnvironmentAuth, make); + +export const layer = layerFromStores.pipe( Layer.provideMerge(PairingGrantStore.layer), Layer.provideMerge(SessionStore.layer), Layer.provideMerge(EnvironmentAuthPolicy.layer), diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 9801e069c2c8b..f679b118c387c 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,13 +1,17 @@ import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; +import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; @@ -41,7 +45,93 @@ const layerEnvironmentAuth = ( Layer.provide(layerServerConfig(overrides)), ); +// Holds the session a credential exchange is about to issue, after the credential is spent. +const layerEnvironmentAuthHoldingIssue = (hold: { + readonly reached: Deferred.Deferred; + readonly released: Deferred.Deferred; +}) => + EnvironmentAuth.layerFromStores.pipe( + Layer.provideMerge(PairingGrantStore.layer), + Layer.provideMerge( + Layer.effect( + SessionStore.SessionStore, + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + return { + ...sessions, + issue: (input: Parameters[0]) => + input?.subject === "exchanging" + ? Deferred.succeed(hold.reached, undefined).pipe( + Effect.andThen(Deferred.await(hold.released)), + Effect.andThen(sessions.issue(input)), + ) + : sessions.issue(input), + }; + }), + ).pipe(Layer.provide(SessionStore.layer)), + ), + Layer.provideMerge(EnvironmentAuthPolicy.layer), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(SqlitePersistence.layerMemory), + Layer.provide(ServerEnvironment.layerIdentity), + Layer.provide(layerServerConfig()), + ); + it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) => { + it.effect("revoking other access ends sessions and unused links but keeps the caller", () => + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const caller = yield* environmentAuth.issueSession({ label: "phone" }); + const other = yield* environmentAuth.issueSession({ label: "laptop" }); + const unused = yield* environmentAuth.createPairingLink({ label: "unused" }); + + yield* environmentAuth.revokeOtherAccess(caller.sessionId); + + expect((yield* sessions.verify(caller.token)).sessionId).toBe(caller.sessionId); + expect(yield* Effect.isFailure(sessions.verify(other.token))).toBe(true); + expect(yield* environmentAuth.listPairingLinks()).toEqual([]); + expect( + yield* Effect.isFailure( + environmentAuth.exchangeBootstrapCredentialForAccessToken(unused.credential, undefined, { + deviceType: "unknown", + }), + ), + ).toBe(true); + }).pipe(Effect.provide(layerEnvironmentAuth())), + ); + + it.effect("a credential already being exchanged can't outlive the revocation", () => + Effect.gen(function* () { + const hold = { + reached: yield* Deferred.make(), + released: yield* Deferred.make(), + }; + yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const caller = yield* environmentAuth.issueSession({ label: "phone" }); + const link = yield* environmentAuth.createPairingLink({ subject: "exchanging" }); + + const exchange = yield* environmentAuth + .exchangeBootstrapCredentialForAccessToken(link.credential, undefined, { + deviceType: "unknown", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(hold.reached); + const revocation = yield* environmentAuth + .revokeOtherAccess(caller.sessionId) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(hold.released, undefined); + const exchanged = yield* Fiber.join(exchange); + yield* Fiber.join(revocation); + + expect(yield* Effect.isFailure(sessions.verify(exchanged.access_token))).toBe(true); + }).pipe(Effect.provide(layerEnvironmentAuthHoldingIssue(hold))); + }), + ); + it.effect("creates, lists, and revokes client pairing links", () => Effect.gen(function* () { const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 29588040217ae..7b1d1f4c7d415 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -555,6 +555,10 @@ it.live("one-click approves only access the browser session holds the scopes for expect(forged.status).toBe(400); expect(forged.message).toContain("Enter a pairing code instead"); + // A session shared View only, so it can't hand an MCP client more than it has. + const viewer = yield* browserCookie(["orchestration:read"]); + expect((yield* details(viewer)).csrfToken).toBeUndefined(); + const reader = yield* browserCookie(["access:write", "orchestration:read"]); const readerDetails = yield* details(reader); expect(readerDetails.csrfToken).toEqual(expect.any(String)); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 8044740a7e2ee..2f95282ca4f13 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -11,6 +11,8 @@ import { ThreadId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthViewOnlyClientScopes, + ORCHESTRATION_V2_WS_METHODS, AuthSourceControlWriteScope, AuthTerminalReadScope, AuthTerminalOperateScope, @@ -318,6 +320,42 @@ describe("thread context authorization", () => { ); }); +it("a View only session reads threads and reaches nothing that changes them", () => { + const viewerMethods = [ + ORCHESTRATION_V2_WS_METHODS.dispatchCommand, + ORCHESTRATION_V2_WS_METHODS.launchThread, + ORCHESTRATION_V2_WS_METHODS.forceStopThread, + ORCHESTRATION_V2_WS_METHODS.subscribeThread, + ORCHESTRATION_V2_WS_METHODS.getThreadProjection, + WS_METHODS.terminalOpen, + WS_METHODS.terminalAttach, + WS_METHODS.terminalWrite, + WS_METHODS.terminalObserve, + WS_METHODS.subscribeTerminalMetadata, + WS_METHODS.projectsReadFile, + WS_METHODS.projectsWriteFile, + WS_METHODS.filesystemBrowse, + WS_METHODS.attachmentsCreateUploadUrl, + WS_METHODS.assetsPersistChatAttachments, + WS_METHODS.secretsAnswerRequest, + WS_METHODS.serverUpdateSettings, + WS_METHODS.cloudInstallRelayClient, + WS_METHODS.subscribeAuthAccess, + WS_METHODS.guestShareCreate, + WS_METHODS.peerLinksAnswerRequest, + WS_METHODS.mcpAppsCallTool, + WS_METHODS.shellOpenInEditor, + WS_METHODS.scheduledTasksUpsert, + ]; + const allowed = viewerMethods.filter((method) => + AuthViewOnlyClientScopes.some((scope) => scope === requiredScopeForRpcMethod(method)), + ); + expect(allowed).toEqual([ + ORCHESTRATION_V2_WS_METHODS.subscribeThread, + ORCHESTRATION_V2_WS_METHODS.getThreadProjection, + ]); +}); + it.effect("separates host file URLs from readable attachment URLs", () => Effect.gen(function* () { const group = WsRpcGroup.omit( diff --git a/apps/server/src/cloud/CloudLink.test.ts b/apps/server/src/cloud/CloudLink.test.ts index 6851117d38ef9..32ca7245a080e 100644 --- a/apps/server/src/cloud/CloudLink.test.ts +++ b/apps/server/src/cloud/CloudLink.test.ts @@ -1,5 +1,15 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { + AuthSessionId, + AuthStandardClientScopes, + AuthViewOnlyClientScopes, + EnvironmentId, +} from "@t3tools/contracts"; +import { RELAY_MINT_REQUEST_TYP, signRelayJwt } from "@t3tools/shared/relayJwt"; import { assert, it } from "@effect/vitest"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync. +import * as NodeCrypto from "node:crypto"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -19,6 +29,10 @@ import * as CloudLink from "./CloudLink.ts"; import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { + CLOUD_LINKED_USER_ID, + CLOUD_MINT_PUBLIC_KEY, + CLOUD_SHARING_MODE, + CLOUD_SHARING_MODE_CHANGE_PENDING, HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, @@ -26,6 +40,36 @@ import { } from "./config.ts"; const encode = (value: string) => new TextEncoder().encode(value); +const decode = (value: Uint8Array | undefined) => new TextDecoder().decode(value); +const callerSession = AuthSessionId.make("session-phone"); + +const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, +}); +let mintProofCount = 0; +const mintRequest = Effect.gen(function* () { + mintProofCount += 1; + const now = Math.floor((yield* Clock.currentTimeMillis) / 1000); + const proof = yield* signRelayJwt({ + privateKey: mintKeys.privateKey, + typ: RELAY_MINT_REQUEST_TYP, + payload: { + iss: "https://relay.test", + aud: "t3-env:environment-1", + sub: "user-1", + jti: `jti-${mintProofCount}`, + iat: now, + exp: now + 60, + environmentId: "environment-1", + clientProofKeyThumbprint: "thumbprint", + cnf: { jkt: "thumbprint" }, + nonce: `nonce-${mintProofCount}`, + scope: ["environment:connect"], + }, + }); + return { proof }; +}).pipe(Effect.orDie); const withService = ( options: { @@ -38,9 +82,13 @@ const withService = ( readonly started: () => void; readonly released: Promise; }; + readonly auth?: Partial; + readonly initialSecrets?: ReadonlyArray; + readonly failSecretRead?: string; }, body: (input: { readonly preferences: { readonly update: CloudLink.CloudLink["Service"]["updatePreferences"] }; + readonly link: CloudLink.CloudLink["Service"]; readonly stored: Map; readonly relayCalls: Array; }) => Effect.Effect, @@ -51,6 +99,11 @@ const withService = ( [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential")], [HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, encode("false")], [PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")], + [CLOUD_MINT_PUBLIC_KEY, encode(mintKeys.publicKey)], + [CLOUD_LINKED_USER_ID, encode("user-1")], + ...(options.initialSecrets ?? []).map( + ([name, value]) => [name, encode(value)] as [string, Uint8Array], + ), ]); const relayCalls: Array = []; const fetch: typeof globalThis.fetch = Object.assign( @@ -71,7 +124,8 @@ const withService = ( Layer.mock(ServerSecretStore.ServerSecretStore)({ get: (name) => (options.failActivityRead && name === PUBLISH_AGENT_ACTIVITY_SECRET) || - (options.failHoldRead && name === HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET) + (options.failHoldRead && name === HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET) || + options.failSecretRead === name ? Effect.fail( new ServerSecretStore.SecretStoreReadError({ resource: name, @@ -89,6 +143,8 @@ const withService = ( }), ) : Effect.sync(() => void stored.set(name, value)), + create: (name, value) => Effect.sync(() => void stored.set(name, value)), + remove: (name) => Effect.sync(() => void stored.delete(name)), }), Layer.mock(ServerEnvironment.ServerEnvironment)({ getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-1")), @@ -98,7 +154,7 @@ const withService = ( Layer.mock(ConnectReachability.ConnectReachability)({ latest: Effect.succeedNone, }), - Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)(options.auth ?? {}), Layer.mock(CliTokenManager.CloudCliTokenManager)({}), Layer.mock(HttpServer.HttpServer)({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773), @@ -112,7 +168,12 @@ const withService = ( ); return yield* Effect.gen(function* () { const link = yield* CloudLink.CloudLink; - return yield* body({ preferences: { update: link.updatePreferences }, stored, relayCalls }); + return yield* body({ + preferences: { update: link.updatePreferences }, + link, + stored, + relayCalls, + }); }).pipe( Effect.provide(CloudLink.layer.pipe(Layer.provide(layerDependencies))), Effect.provideService(FetchHttpClient.Fetch, fetch), @@ -244,3 +305,252 @@ it.effect("an update the client walks away from still finishes", () => { }), ); }); + +const issuedLink = { + id: "link", + credential: "credential", + scopes: [], + subject: "cloud-connect", + expiresAt: DateTime.makeUnsafe(120_000), +} as unknown as EnvironmentAuth.IssuedPairingLink; + +it.effect.each([ + { name: "no stored mode", initial: [], scopes: AuthStandardClientScopes }, + { + name: "Full control", + initial: [[CLOUD_SHARING_MODE, "full-control"]] as const, + scopes: AuthStandardClientScopes, + }, + { + name: "View only", + initial: [[CLOUD_SHARING_MODE, "view-only"]] as const, + scopes: AuthViewOnlyClientScopes, + }, + { + name: "a mode this version doesn't know", + initial: [[CLOUD_SHARING_MODE, "guest"]] as const, + scopes: AuthViewOnlyClientScopes, + }, +])("a device minting under $name gets the matching scopes", ({ initial, scopes }) => { + const minted: Array> = []; + return withService( + { + initialSecrets: initial, + auth: { + createPairingLink: (input) => { + minted.push(input?.scopes ?? []); + return Effect.succeed(issuedLink); + }, + }, + }, + ({ link }) => + Effect.gen(function* () { + yield* link.mintCredential(yield* mintRequest); + assert.deepEqual(minted, [scopes]); + }), + ); +}); + +it.effect("an unreadable mode refuses the mint instead of granting full access", () => { + const minted: Array = []; + return withService( + { + failSecretRead: CLOUD_SHARING_MODE, + auth: { + createPairingLink: (input) => { + minted.push(input); + return Effect.succeed(issuedLink); + }, + }, + }, + ({ link }) => + Effect.gen(function* () { + const failure = yield* link.mintCredential(yield* mintRequest).pipe(Effect.flip); + assert.equal(failure._tag, "CloudLinkInternalError"); + assert.deepEqual(minted, []); + }), + ); +}); + +it.effect("a mode change stores the mode before signing anyone out", () => { + const seenAtRevoke: Array = []; + let stored: Map | undefined; + return withService( + { + auth: { + revokeOtherAccess: (sessionId) => + Effect.sync(() => { + seenAtRevoke.push(sessionId, decode(stored?.get(CLOUD_SHARING_MODE))); + }), + }, + }, + ({ link, stored: secrets }) => + Effect.gen(function* () { + stored = secrets; + const state = yield* link.setSharingMode("view-only", callerSession); + assert.deepEqual(seenAtRevoke, [callerSession, "view-only"]); + assert.equal(state.sharingMode, "view-only"); + assert.equal(state.sharingModeChangePending, false); + assert.isFalse(secrets.has(CLOUD_SHARING_MODE_CHANGE_PENDING)); + }), + ); +}); + +it.effect("a failed sign-out keeps the new mode and says the change isn't finished", () => { + let failing = true; + return withService( + { + auth: { + revokeOtherAccess: () => + failing + ? Effect.fail( + new EnvironmentAuth.ServerAuthOtherSessionsRevocationError({ + cause: new Error("locked"), + }), + ) + : Effect.void, + }, + }, + ({ link, stored }) => + Effect.gen(function* () { + const failure = yield* link.setSharingMode("view-only", callerSession).pipe(Effect.flip); + assert.equal(failure._tag, "CloudLinkInternalError"); + assert.equal(decode(stored.get(CLOUD_SHARING_MODE)), "view-only"); + const pending = yield* link.linkState(); + assert.equal(pending.sharingMode, "view-only"); + assert.equal(pending.sharingModeChangePending, true); + + failing = false; + const retried = yield* link.setSharingMode("view-only", callerSession); + assert.equal(retried.sharingModeChangePending, false); + }), + ); +}); + +it.effect("a change the client walks away from still signs the others out", () => { + let release!: () => void; + let started!: () => void; + let revoked = false; + const released = new Promise((resolve) => { + release = resolve; + }); + const atRevoke = new Promise((resolve) => { + started = resolve; + }); + return withService( + { + auth: { + revokeOtherAccess: () => + Effect.promise(() => { + started(); + return released; + }).pipe( + Effect.tap(() => + Effect.sync(() => { + revoked = true; + }), + ), + ), + }, + }, + ({ link, stored }) => + Effect.gen(function* () { + const change = yield* link + .setSharingMode("view-only", callerSession) + .pipe(Effect.forkChild); + yield* Effect.promise(() => atRevoke); + const interrupted = yield* Fiber.interrupt(change).pipe(Effect.forkChild); + yield* Effect.yieldNow; + release(); + yield* Fiber.join(interrupted); + assert.isTrue(revoked); + assert.isFalse(stored.has(CLOUD_SHARING_MODE_CHANGE_PENDING)); + }), + ); +}); + +it.effect("a mint in progress finishes before a mode change can sign anyone out", () => { + const events: Array = []; + let release!: () => void; + let started!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const atMint = new Promise((resolve) => { + started = resolve; + }); + return withService( + { + auth: { + createPairingLink: (input) => + Effect.promise(() => { + started(); + return released; + }).pipe( + Effect.map(() => { + events.push(`minted:${String(input?.scopes?.length)}`); + return issuedLink; + }), + ), + revokeOtherAccess: () => Effect.sync(() => void events.push("revoked")), + }, + }, + ({ link }) => + Effect.gen(function* () { + const mint = yield* link.mintCredential(yield* mintRequest).pipe(Effect.forkChild); + yield* Effect.promise(() => atMint); + const change = yield* link + .setSharingMode("view-only", callerSession) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.deepEqual(events, []); + release(); + yield* Fiber.join(mint); + yield* Fiber.join(change); + assert.deepEqual(events, [`minted:${AuthStandardClientScopes.length}`, "revoked"]); + }), + ); +}); + +it.effect("a mint during a mode change waits and gets the new scopes", () => { + const minted: Array> = []; + let release!: () => void; + let started!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const atRevoke = new Promise((resolve) => { + started = resolve; + }); + return withService( + { + auth: { + createPairingLink: (input) => + Effect.sync(() => { + minted.push(input?.scopes ?? []); + return issuedLink; + }), + revokeOtherAccess: () => + Effect.promise(() => { + started(); + return released; + }), + }, + }, + ({ link }) => + Effect.gen(function* () { + const request = yield* mintRequest; + const change = yield* link + .setSharingMode("view-only", callerSession) + .pipe(Effect.forkChild); + yield* Effect.promise(() => atRevoke); + const mint = yield* link.mintCredential(request).pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.deepEqual(minted, []); + release(); + yield* Fiber.join(change); + yield* Fiber.join(mint); + assert.deepEqual(minted, [AuthViewOnlyClientScopes]); + }), + ); +}); diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 99530612463e1..85bf788f04e2d 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -1,11 +1,14 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPublicKey. import * as NodeCrypto from "node:crypto"; import { + type AuthSessionId, AuthStandardClientScopes, + AuthViewOnlyClientScopes, EnvironmentAuthenticatedPrincipal, type EnvironmentCloudLinkStateResult, type EnvironmentCloudPreferencesRequest, type EnvironmentCloudRelayConfigResult, + type EnvironmentSharingMode, } from "@t3tools/contracts"; import { RelayCloudEnvironmentHealthProofPayload, @@ -82,6 +85,8 @@ import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINK_REVOKED, + CLOUD_SHARING_MODE, + CLOUD_SHARING_MODE_CHANGE_PENDING, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, decodeConfirmedOrigin, @@ -252,6 +257,7 @@ const INTERNAL_OPERATION_MESSAGES = { "update-webhook-settings": "Could not update T3 Connect webhook settings.", "read-preferences": "Could not read environment cloud preferences.", "persist-preferences": "Could not persist environment cloud preferences.", + "change-sharing-mode": "Could not finish changing the sharing mode.", "answer-health": "Could not answer cloud health request.", "issue-credential": "Could not issue cloud connection credential.", } as const; @@ -278,6 +284,10 @@ const internalError = (cause: unknown): Effect.Effect => Effect.fail(new CloudLinkInternalError({ operation, cause })); +// Anything but an explicit Full control is read-only, so a bad value can't widen access. +const decodeSharingMode = (bytes: Uint8Array): EnvironmentSharingMode => + bytesToString(bytes) === "full-control" ? "full-control" : "view-only"; + const isPermanentLinkError = Schema.is( Schema.Union([ CloudLinkRelayConfigInvalidError, @@ -459,6 +469,11 @@ export class CloudLink extends Context.Service< EnvironmentCloudLinkStateResult, CloudLinkNotLinkedError | CloudLinkInternalError >; + /** Signs out everyone but the caller, so nobody keeps what the previous mode gave them. */ + readonly setSharingMode: ( + mode: EnvironmentSharingMode, + callerSessionId: AuthSessionId, + ) => Effect.Effect; readonly answerHealthRequest: ( request: RelayCloudEnvironmentHealthRequest, ) => Effect.Effect< @@ -1272,6 +1287,8 @@ const make = Effect.gen(function* () { holdWebhooks, revoked, latestReachability, + sharingMode, + sharingModeChangePending, ] = yield* Effect.all( [ secrets.get(CLOUD_LINKED_USER_ID), @@ -1282,8 +1299,10 @@ const make = Effect.gen(function* () { secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), secrets.get(CLOUD_LINK_REVOKED), reachability.latest, + secrets.get(CLOUD_SHARING_MODE), + secrets.get(CLOUD_SHARING_MODE_CHANGE_PENDING), ], - { concurrency: 8 }, + { concurrency: 10 }, ); return { linked: Option.isSome(cloudUserId), @@ -1298,6 +1317,8 @@ const make = Effect.gen(function* () { holdWebhooksWhileOffline: Option.isSome(holdWebhooks) && bytesToString(holdWebhooks.value) === "true", ...(Option.isSome(latestReachability) ? { reachability: latestReachability.value } : {}), + ...(Option.isSome(sharingMode) ? { sharingMode: decodeSharingMode(sharingMode.value) } : {}), + sharingModeChangePending: Option.isSome(sharingModeChangePending), } satisfies EnvironmentCloudLinkStateResult; }); @@ -1407,6 +1428,25 @@ const make = Effect.gen(function* () { Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")), ); + // Held across choosing a credential's scopes and issuing it, so no mint straddles a mode change. + const sharingModeLock = yield* Semaphore.make(1); + + const setSharingMode = Effect.fn("environment.cloud.setSharingMode")( + function* (mode: EnvironmentSharingMode, callerSessionId: AuthSessionId) { + // Pending goes first and clears last, so a failure at any step still shows on the phone. + yield* secrets.set(CLOUD_SHARING_MODE_CHANGE_PENDING, stringToBytes("true")); + yield* secrets.set(CLOUD_SHARING_MODE, stringToBytes(mode)); + yield* environmentAuth.revokeOtherAccess(callerSessionId); + yield* secrets.remove(CLOUD_SHARING_MODE_CHANGE_PENDING); + return yield* readCloudLinkState(); + }, + // Finish even if the phone's app walks away mid-change. + Effect.uninterruptible, + sharingModeLock.withPermits(1), + Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("change-sharing-mode")), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, internalError("change-sharing-mode")), + ); + const answerHealthRequest = Effect.fn("environment.cloud.health")( function* (request: RelayCloudEnvironmentHealthRequest) { const cloudMintPublicKey = yield* secrets @@ -1577,13 +1617,22 @@ const make = Effect.gen(function* () { } const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); - const issued = yield* environmentAuth.createPairingLink({ - scopes: AuthStandardClientScopes, - subject: "cloud-connect", - ttl: Duration.minutes(2), - label: "T3 Connect connect", - proofKeyThumbprint: proof.clientProofKeyThumbprint, - }); + const issued = yield* sharingModeLock.withPermits(1)( + secrets.get(CLOUD_SHARING_MODE).pipe( + Effect.flatMap((mode) => + environmentAuth.createPairingLink({ + scopes: + Option.isSome(mode) && decodeSharingMode(mode.value) === "view-only" + ? AuthViewOnlyClientScopes + : AuthStandardClientScopes, + subject: "cloud-connect", + ttl: Duration.minutes(2), + label: "T3 Connect connect", + proofKeyThumbprint: proof.clientProofKeyThumbprint, + }), + ), + ), + ); const responsePayload = { iss: `t3-env:${environmentId}`, aud: normalizeRelayIssuer(relayIssuer), @@ -1624,6 +1673,7 @@ const make = Effect.gen(function* () { linkState, unlink, updatePreferences, + setSharingMode, answerHealthRequest, mintCredential, reconcileDesiredLinkIfStillDesired, diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 5dac9e2db905f..ad2a7c3c13fad 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -18,6 +18,8 @@ export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-cred export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity"; export const HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET = "cloud-hold-webhooks-while-offline"; export const CLOUD_LINK_REVOKED = "cloud-link-revoked"; +export const CLOUD_SHARING_MODE = "cloud-sharing-mode"; +export const CLOUD_SHARING_MODE_CHANGE_PENDING = "cloud-sharing-mode-change-pending"; export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 094204561cb38..b39752a632588 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -117,6 +117,7 @@ const withLinkProofClient = async ( headers?: Record, origin?: string, ) => Promise; + get: (path: string, headers?: Record) => Promise; exchange: (credential: string, scope?: string) => Promise; }) => Promise, ) => { @@ -136,6 +137,8 @@ const withLinkProofClient = async ( body: JSON.stringify(payload), }), ); + const get = (path: string, headers?: Record) => + handler(new Request(`${NON_LOOPBACK_ORIGIN}${path}`, headers ? { headers } : {})); const exchange = async (credential: string, scope?: string) => { const response = await handler( new Request(`${NON_LOOPBACK_ORIGIN}/oauth/token`, { @@ -154,7 +157,7 @@ const withLinkProofClient = async ( return payload.access_token; }; try { - await body({ post, exchange }); + await body({ post, get, exchange }); } finally { await dispose(); } @@ -274,3 +277,84 @@ describe("link proof over a non-loopback desktop transport", () => { expect(response.status).toBe(401); })); }); + +describe("sharing mode", () => { + it("changes the mode, signs out other clients and keeps the caller", () => + withLinkProofClient(async ({ post, get, exchange }) => { + const phone = await exchange(DESKTOP_BOOTSTRAP_TOKEN); + const headers = { authorization: `Bearer ${phone}` }; + const paired = await post( + "/api/auth/pairing-token", + { scopes: ["orchestration:read"] }, + headers, + ); + const { credential } = (await paired.json()) as { credential: string }; + const other = await exchange(credential); + + const changed = await post("/api/connect/sharing-mode", { mode: "view-only" }, headers); + expect(changed.status).toBe(200); + expect(await changed.json()).toMatchObject({ + sharingMode: "view-only", + sharingModeChangePending: false, + }); + + const state = await get("/api/connect/link-state", headers); + expect(await state.json()).toMatchObject({ sharingMode: "view-only" }); + const otherSession = await get("/api/auth/session", { + authorization: `Bearer ${other}`, + }); + expect(await otherSession.json()).toMatchObject({ authenticated: false }); + })); + + it("reports no mode for a host that never set one", () => + withLinkProofClient(async ({ get, exchange }) => { + const token = await exchange(DESKTOP_BOOTSTRAP_TOKEN); + const state = await get("/api/connect/link-state", { authorization: `Bearer ${token}` }); + expect(await state.json()).not.toHaveProperty("sharingMode"); + })); + + it.each([ + { path: "/api/connect/sharing-mode", payload: { mode: "full-control" }, scope: "relay:write" }, + { path: "/api/connect/unlink", payload: {}, scope: "relay:write" }, + { path: "/api/connect/link-proof", payload: proofRequest, scope: "relay:write" }, + { + path: "/api/auth/pairing-token", + payload: { scopes: ["orchestration:read"] }, + scope: "access:write", + }, + { path: "/api/auth/clients/revoke-others", payload: {}, scope: "access:write" }, + ])("refuses a View only session at $path", ({ path, payload, scope }) => + withLinkProofClient(async ({ post, exchange }) => { + const phone = await exchange(DESKTOP_BOOTSTRAP_TOKEN); + const paired = await post( + "/api/auth/pairing-token", + { scopes: ["orchestration:read"] }, + { authorization: `Bearer ${phone}` }, + ); + const { credential } = (await paired.json()) as { credential: string }; + const viewer = await exchange(credential); + + const refused = await post(path, payload, { authorization: `Bearer ${viewer}` }); + expect(refused.status).toBe(403); + expect(await refused.json()).toMatchObject({ requiredPermission: scope }); + }), + ); + + it("needs both relay and access write, since it signs clients out", () => + withLinkProofClient(async ({ post, exchange }) => { + const phone = await exchange(DESKTOP_BOOTSTRAP_TOKEN); + const paired = await post( + "/api/auth/pairing-token", + { scopes: ["relay:write"] }, + { authorization: `Bearer ${phone}` }, + ); + const { credential } = (await paired.json()) as { credential: string }; + const relayOnly = await exchange(credential); + const refused = await post( + "/api/connect/sharing-mode", + { mode: "view-only" }, + { authorization: `Bearer ${relayOnly}` }, + ); + expect(refused.status).toBe(403); + })); +}); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 52612f22825d4..772be9ea26994 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -1,4 +1,5 @@ import { + AuthAccessWriteScope, AuthRelayReadScope, AuthRelayWriteScope, EnvironmentCloudEndpointUnavailableError, @@ -142,6 +143,14 @@ export const layer = HttpApiBuilder.group( Effect.andThen(toHttpError(cloudLink.updatePreferences(payload))), ), ) + .handle("sharingMode", ({ payload }) => + requireEnvironmentScope(AuthRelayWriteScope).pipe( + Effect.andThen(requireEnvironmentScope(AuthAccessWriteScope)), + Effect.flatMap((session) => + toHttpError(cloudLink.setSharingMode(payload.mode, session.sessionId)), + ), + ), + ) .handle("health", ({ payload }) => toHttpError(cloudLink.answerHealthRequest(payload)).pipe( Effect.tap(() => appendCloudCredentialResponseHeaders), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index c8085be773a63..64257044da0f2 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5129, "codexAdditionalContextBytes": 6859, - "mcpToolListJsonBytes": 46007 + "mcpToolListJsonBytes": 46039 } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 22ccaaaf807ee..f12975fbd4189 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3462,7 +3462,10 @@ export default function ChatView(props: ChatViewProps) { // Side chat runs keep no checkpoints to diff, and a fork would promote the side chat. {...(embedded ? {} - : { onOpenTurnDiff: onOpenTurnDiffStable, onForkFromRun: onForkFromRunStable })} + : { + onOpenTurnDiff: onOpenTurnDiffStable, + ...(canOperateThread ? { onForkFromRun: onForkFromRunStable } : {}), + })} onOpenThread={onOpenRelatedThreadStable} parentThreadLink={parentThreadLink} workflowOpening={workflowOpening} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c423773fb6fc6..72e117e355632 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -38,6 +38,7 @@ import type { import { EnvironmentRequestInvalidError, AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -262,7 +263,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useSoftwareKeyboardOpen } from "../../hooks/useSoftwareKeyboardOpen"; import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; -import { readEnvironmentScope } from "../../state/session"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import type { ReviewCommentContext } from "../../reviewCommentContext"; import { useComposerRestingTransition } from "./useComposerRestingTransition"; import { ComposerCommandMenuLayer } from "./ComposerCommandMenuLayer"; @@ -1180,6 +1181,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile; const showComposerAttachAction = + canOperateThread && fileStagingLimit !== null && (!activePendingProgress || (supportsQuestionAttachments && @@ -3089,6 +3091,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer={focusComposer} /> ) : null; + // Read scope also proves the session has loaded, so this doesn't flash while connecting. + const viewOnly = + useEnvironmentScope(environmentId, AuthOrchestrationReadScope) && !canOperateThread; + return (
+ {viewOnly ? ( +

+ This connection can only view threads. You can't send messages or answer requests here. +

+ ) : null}
); }); diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 5c648016ff502..742c5fc699c27 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -1,14 +1,16 @@ -import type { - EditorId, - EnvironmentId, - ProjectId, - ProjectScript, - ResolvedKeybindingsConfig, - ThreadId, +import { + AuthOrchestrationOperateScope, + type EditorId, + type EnvironmentId, + type ProjectId, + type ProjectScript, + type ResolvedKeybindingsConfig, + type ThreadId, } from "@t3tools/contracts"; import type { DraftId } from "../../composerDraftStore"; import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts"; +import { useEnvironmentScope } from "../../state/session"; import { type EnvMode, type EnvironmentOption } from "../BranchToolbar.logic"; import { BranchToolbar } from "../BranchToolbar"; import BaseGitActionsControl from "../GitActionsControl"; @@ -87,6 +89,10 @@ interface ThreadDetailsPanelProps extends Pick< } export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { + const canEditProjectScripts = useEnvironmentScope( + props.environmentId, + AuthOrchestrationOperateScope, + ); const visible = useClientSettings(resolveThreadDetailsVisibility); const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); const fileScripts = useT3ProjectFileScripts( @@ -195,7 +201,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { /> ) : null} - {show.scripts && props.activeProjectScripts ? ( + {show.scripts && canEditProjectScripts && props.activeProjectScripts ? ( ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + ) : !settlementOffered ? null : variantAction === "unsettle" ? ( )} - {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( + {settlementOffered || showSnoozeButton || hasUnsentDraft ? ( ) : null} - {props.settlementSupported ? ( + {settlementOffered ? ( { expect(sessionGrantsScope(session, "settings:write")).toBe(false); }); + it("doesn't call a View only session outdated, unlike an old session with more scopes", () => { + const session = (permissions: ReadonlyArray) => ({ + authenticated: true, + permissions, + }); + expect(sessionHasLegacyPermissions(session(AuthViewOnlyClientScopes))).toBe(false); + expect( + sessionHasLegacyPermissions(session(["orchestration:read", "orchestration:operate"])), + ).toBe(true); + }); + it.each([ { label: "exact permissions over the legacy presentation", diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 4037e6cc128bf..01fde82b16efd 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -140,6 +140,8 @@ export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean return ( session.authenticated && permissions !== undefined && + // View only grants exactly this on purpose, so pairing again wouldn't add anything. + !(permissions.length === 1 && permissions[0] === AuthOrchestrationReadScope) && permissions.every((scope) => legacyScopes.has(scope)) && Object.values(legacyParents).some((parent) => permissions.includes(parent)) ); @@ -165,6 +167,9 @@ export const AuthStandardClientScopes = [ AuthRelayWriteScope, ] as const; +/** What T3 Connect devices get from a server shared View only. */ +export const AuthViewOnlyClientScopes = [AuthOrchestrationReadScope] as const; + export const AuthTokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" as const; export const AuthAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 30c33deebd55c..51a7f68a90ef5 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -490,6 +490,9 @@ export function connectReachabilityHelpUrl(cause: EnvironmentConnectReachability return t3ConnectTroubleshootingUrl(REACHABILITY_SECTION[cause]); } +export const EnvironmentSharingMode = Schema.Literals(["view-only", "full-control"]); +export type EnvironmentSharingMode = typeof EnvironmentSharingMode.Type; + export const EnvironmentCloudLinkStateResult = Schema.Struct({ linked: Schema.Boolean, cloudUserId: Schema.NullOr(Schema.String), @@ -501,6 +504,10 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ linkRevoked: Schema.optional(Schema.Boolean), /** The latest check the server ran on its own, absent until one finishes. */ reachability: Schema.optional(EnvironmentConnectReachability), + /** What T3 Connect devices get. Absent when never set, which means full control. */ + sharingMode: Schema.optional(EnvironmentSharingMode), + /** The last mode change didn't finish: devices signed in before it may keep their old access. */ + sharingModeChangePending: Schema.optional(Schema.Boolean), }); export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; @@ -510,6 +517,11 @@ export const EnvironmentCloudPreferencesRequest = Schema.Struct({ }); export type EnvironmentCloudPreferencesRequest = typeof EnvironmentCloudPreferencesRequest.Type; +export const EnvironmentCloudSharingModeRequest = Schema.Struct({ + mode: EnvironmentSharingMode, +}); +export type EnvironmentCloudSharingModeRequest = typeof EnvironmentCloudSharingModeRequest.Type; + export const AuthPairingLinkRevokeResult = Schema.Struct({ revoked: Schema.Boolean, }); @@ -800,6 +812,14 @@ class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") error: EnvironmentHttpCloudErrors, }).middleware(EnvironmentAuthenticatedAuth), ) + .add( + HttpApiEndpoint.post("sharingMode", "/api/connect/sharing-mode", { + headers: OptionalBearerHeaders, + payload: EnvironmentCloudSharingModeRequest, + success: EnvironmentCloudLinkStateResult, + error: EnvironmentHttpCloudErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) .add( HttpApiEndpoint.post("health", "/api/t3-connect/health", { payload: RelayCloudEnvironmentHealthRequest, From 7d9f713fd640b9f234395f7a0d24f20fb5bc859a Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 08:00:29 +0000 Subject: [PATCH 11/19] fix(server): preview and device sockets end with their session 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 --- .../src/device/DeviceHubProxy.stream.test.ts | 75 ++++++++++++++++ apps/server/src/device/DeviceHubProxy.test.ts | 7 ++ apps/server/src/device/DeviceHubProxy.ts | 54 +++++++----- .../src/preview/ServerBrowserStream.test.ts | 85 ++++++++++++++++--- .../server/src/preview/ServerBrowserStream.ts | 21 ++++- 5 files changed, 206 insertions(+), 36 deletions(-) diff --git a/apps/server/src/device/DeviceHubProxy.stream.test.ts b/apps/server/src/device/DeviceHubProxy.stream.test.ts index 72340560d4f39..f193f1141bf10 100644 --- a/apps/server/src/device/DeviceHubProxy.stream.test.ts +++ b/apps/server/src/device/DeviceHubProxy.stream.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest"; import { NodeWS } from "@effect/platform-node/NodeSocket"; import * as NodeNet from "node:net"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -14,9 +15,82 @@ import { LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import * as DeviceService from "./DeviceService.ts"; import * as DeviceHubProxy from "./DeviceHubProxy.ts"; +const layerSessionStore = (sessionEnded: Effect.Effect = Effect.never) => + Layer.succeed(SessionStore.SessionStore, { + awaitInvalidation: () => sessionEnded, + } as unknown as SessionStore.SessionStore["Service"]); + +it.effect("closes a client's device socket when its session is revoked", () => + Effect.gen(function* () { + const ended = yield* Deferred.make(); + const hub = yield* Effect.acquireRelease( + Effect.sync(() => new NodeWS.WebSocketServer({ port: 0 })), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + for (const client of server.clients) client.terminate(); + server.close(() => resolve()); + }), + ), + ); + if (!hub.address()) + yield* Effect.promise(() => new Promise((resolve) => hub.once("listening", resolve))); + const address = hub.address(); + if (typeof address !== "object" || !address) throw new Error("Missing hub port"); + const atHub = Promise.withResolvers(); + hub.on("connection", () => atHub.resolve()); + const services = yield* Layer.build( + HttpRouter.serve(DeviceHubProxy.layer, { disableListenLog: true }).pipe( + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide(layerSessionStore(Deferred.await(ended))), + Layer.provide( + Layer.succeed(EnvironmentAuth.EnvironmentAuth, { + authenticateWebSocketUpgrade: () => + Effect.succeed({ + sessionId: AuthSessionId.make("test"), + subject: "test", + method: "bearer-access-token", + scopes: [AuthOrchestrationOperateScope], + }), + } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]), + ), + Layer.provide( + Layer.succeed(DeviceService.DeviceService, { + currentReadiness: () => + Effect.succeed({ + hostId: LOCAL_DEVICE_HOST_ID, + hub: { origin: `http://127.0.0.1:${address.port}` }, + }), + } as DeviceService.DeviceService["Service"]), + ), + ), + ); + const origin = HttpServer.formatAddress( + Context.get(services, HttpServer.HttpServer).address, + ).replace(/^http/, "ws"); + const client = yield* Effect.acquireRelease( + Effect.sync( + () => + new WebSocket( + `${origin}/api/device-hub/vendor/serve-sim/helper/ws?device=test&wsTicket=test`, + ), + ), + (socket) => Effect.sync(() => socket.close()), + ); + const closed = Promise.withResolvers(); + client.addEventListener("close", ({ code }) => closed.resolve(code)); + yield* Effect.promise(() => atHub.promise); + + yield* Deferred.succeed(ended, undefined); + expect(yield* Effect.promise(() => closed.promise)).toBe(1008); + }), +); + it.effect("preserves binary input and admission frames through the real socket proxy", () => Effect.gen(function* () { const hub = yield* Effect.acquireRelease( @@ -41,6 +115,7 @@ it.effect("preserves binary input and admission frames through the real socket p const services = yield* Layer.build( HttpRouter.serve(DeviceHubProxy.layer, { disableListenLog: true }).pipe( Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide(layerSessionStore()), Layer.provide( Layer.succeed(EnvironmentAuth.EnvironmentAuth, { authenticateWebSocketUpgrade: () => diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 72c8f9e0e5d56..1018d7709f460 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -9,6 +9,7 @@ import { import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http"; +import * as SessionStore from "../auth/SessionStore.ts"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import type * as DeviceHost from "./DeviceHost.ts"; import * as DeviceService from "./DeviceService.ts"; @@ -68,6 +69,12 @@ const fixture = ( } as DeviceService.DeviceService["Service"]), ), Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)), + Layer.provideMerge( + Layer.succeed( + SessionStore.SessionStore, + {} as unknown as SessionStore.SessionStore["Service"], + ), + ), ), { disableLogger: true }, ); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index c1aaab3c61219..988b40cc872c0 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -14,6 +14,7 @@ import { import * as Socket from "effect/socket/Socket"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import * as SessionStore from "../auth/SessionStore.ts"; import { authenticateMediaRequest } from "../auth/http.ts"; import * as DeviceService from "./DeviceService.ts"; import { repeatStillFrame } from "./mjpegStillFrame.ts"; @@ -93,9 +94,38 @@ export const upstreamWebSocketConstructor = Layer.effect( }), ).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor)); +const pumpFrames = + /* Frame pumping runs continuously; a span per frame stream adds no diagnostic value. */ + Effect.fnUntraced(function* (source: Socket.Socket, sink: Socket.Writer) { + const { pull } = yield* source.reader; + while (true) { + yield* sink.writeAll(yield* pull); + } + }); + +/** Device input was authorized at connect, so a bridge mustn't outlive its session. */ +const bridgeWebSocket = ( + client: Socket.Socket, + upstream: Socket.Socket, + sessionEnded: Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const writeToClient = yield* client.writer; + const writeToUpstream = yield* upstream.writer; + return yield* Effect.raceFirst( + Effect.raceFirst(pumpFrames(upstream, writeToClient), pumpFrames(client, writeToUpstream)), + sessionEnded.pipe( + Effect.andThen(writeToClient.write(new Socket.CloseEvent(1008, "session ended"))), + ), + ); + }), + ).pipe(Effect.ignoreCause); + const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( request: HttpServerRequest.HttpServerRequest, upstreamUrl: string, + sessionEnded: Effect.Effect, protocols: ReadonlyArray = [], headers: Readonly> = {}, ) { @@ -105,28 +135,10 @@ const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( ...(protocols.length > 0 ? { protocols: [...protocols] } : {}), ...(Object.keys(headers).length > 0 ? { headers: { ...headers } } : {}), }).pipe(Effect.provide(upstreamWebSocketConstructor)); - yield* Effect.scoped( - Effect.gen(function* () { - const writeToClient = yield* client.writer; - const writeToUpstream = yield* upstream.writer; - return yield* Effect.raceFirst( - pumpFrames(upstream, writeToClient), - pumpFrames(client, writeToUpstream), - ); - }), - ).pipe(Effect.ignoreCause); + yield* bridgeWebSocket(client, upstream, sessionEnded); return HttpServerResponse.empty(); }); -const pumpFrames = - /* Frame pumping runs continuously; a span per frame stream adds no diagnostic value. */ - Effect.fnUntraced(function* (source: Socket.Socket, sink: Socket.Writer) { - const { pull } = yield* source.reader; - while (true) { - yield* sink.writeAll(yield* pull); - } - }); - const proxyHttp = Effect.fn("DeviceHubProxy.proxyHttp")(function* ( request: HttpServerRequest.HttpServerRequest, upstreamUrl: string, @@ -195,7 +207,7 @@ const handler = Effect.gen(function* () { const controlsDevice = (upgrade && hubPath !== "/api/devices/ws") || (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath)); - yield* authenticateMediaRequest( + const session = yield* authenticateMediaRequest( controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope, ); const devices = yield* DeviceService.DeviceService; @@ -215,9 +227,11 @@ const handler = Effect.gen(function* () { } const upstreamUrl = routed?.url ?? `${ready.hub.origin}${hubPath}${search}`; if (upgrade) { + const sessions = yield* SessionStore.SessionStore; return yield* proxyWebSocket( request, upstreamUrl.replace(/^http/, "ws"), + sessions.awaitInvalidation(session.sessionId, session.proofKeyThumbprint), routed?.protocols, routed?.headers, ); diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 5f2c685fa685c..db238aed20d51 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -12,6 +12,7 @@ import { type AuthEnvironmentScope, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -22,6 +23,7 @@ import { HttpRouter, HttpServer } from "effect/http"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import * as ServerBrowser from "./ServerBrowser.ts"; import { routeLayer } from "./ServerBrowserStream.ts"; @@ -30,21 +32,27 @@ const platformLayer = NodeHttpPlatform.layer.pipe(Layer.provideMerge(NodeService const makeAuth = ( scopes: ReadonlyArray, error?: EnvironmentAuth.ServerAuthCredentialError, + sessionEnded: Effect.Effect = Effect.never, ) => { const requests: string[] = []; - const layer = Layer.mock(EnvironmentAuth.EnvironmentAuth, { - authenticateWebSocketUpgrade: (request) => { - requests.push(request.originalUrl); - return error - ? Effect.fail(error) - : Effect.succeed({ - sessionId: AuthSessionId.make("stream-test"), - subject: "stream-test", - method: "bearer-access-token", - scopes, - }); - }, - }); + const layer = Layer.merge( + Layer.mock(EnvironmentAuth.EnvironmentAuth, { + authenticateWebSocketUpgrade: (request) => { + requests.push(request.originalUrl); + return error + ? Effect.fail(error) + : Effect.succeed({ + sessionId: AuthSessionId.make("stream-test"), + subject: "stream-test", + method: "bearer-access-token", + scopes, + }); + }, + }), + Layer.succeed(SessionStore.SessionStore, { + awaitInvalidation: () => sessionEnded, + } as unknown as SessionStore.SessionStore["Service"]), + ); return { layer, requests }; }; @@ -267,6 +275,57 @@ it.effect.each([ }).pipe(Effect.scoped), ); +it.effect("closes a viewer's socket when its session is revoked", () => + Effect.gen(function* () { + const ended = yield* Deferred.make(); + const input: unknown[] = []; + const output = yield* Queue.make(); + const browser = ServerBrowser.ServerBrowser.of({ + clearProfile: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), + openDownload: () => Effect.succeedNone, + answerFileChooser: () => Effect.succeed(false), + attachViewer: () => + Effect.succeed({ + output, + input: (message) => Effect.sync(() => void input.push(message)), + }), + }); + const services = yield* Layer.build( + HttpRouter.serve( + routeLayer.pipe( + Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)), + Layer.provide(platformLayer), + ), + { disableListenLog: true }, + ).pipe( + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide( + makeAuth( + [AuthOrchestrationReadScope, AuthPreviewOperateScope], + undefined, + Deferred.await(ended), + ).layer, + ), + ), + ); + const server = Context.get(services, HttpServer.HttpServer); + const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws"); + const socket = new WebSocket( + `${origin}/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket`, + ); + const opened = Promise.withResolvers(); + const closed = Promise.withResolvers(); + socket.addEventListener("open", () => opened.resolve()); + socket.addEventListener("close", (event) => closed.resolve(event.code)); + yield* Effect.promise(() => opened.promise); + + yield* Deferred.succeed(ended, undefined); + expect(yield* Effect.promise(() => closed.promise)).toBe(1008); + expect(input).toEqual([]); + }).pipe(Effect.scoped), +); + it.effect("serves a tab's download only to an authorized session", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 8d8164cd911c4..28d84f4e0dd23 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -21,6 +21,7 @@ import { import * as Socket from "effect/socket/Socket"; import { authenticateMediaRequest } from "../auth/http.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import { assetResponseHeaders } from "../http.ts"; import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; import * as ServerBrowser from "./ServerBrowser.ts"; @@ -51,7 +52,10 @@ const parseMessage = (chunk: Uint8Array | string): unknown => { }; // Request middleware owns tracing, including long-lived WebSocket sessions. -const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBrowser["Service"]) { +const makeHandler = Effect.fnUntraced(function* ( + browser: ServerBrowser.ServerBrowser["Service"], + sessions: SessionStore.SessionStore["Service"], +) { const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); @@ -197,7 +201,17 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr const receiveInput = reader.pull.pipe( Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), ); - return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); + // `canOperate` was decided at connect, so this socket mustn't outlive its session. + const sessionEnded = sessions + .awaitInvalidation(session.sessionId, session.proofKeyThumbprint) + .pipe( + Effect.andThen(writer.write(new Socket.CloseEvent(1008, "session ended"))), + Effect.as(HttpServerResponse.empty()), + ); + return yield* Effect.raceFirst( + Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)), + sessionEnded, + ); }), ).pipe( Effect.catchTags({ @@ -287,10 +301,11 @@ const receiveUpload = Effect.fnUntraced( export const routeLayer = HttpRouter.use((router) => Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; + const sessions = yield* SessionStore.SessionStore; const platform = yield* Effect.context< HttpPlatform.HttpPlatform | FileSystem.FileSystem | Path.Path >(); - const handler = makeHandler(browser).pipe(Effect.provideContext(platform)); + const handler = makeHandler(browser, sessions).pipe(Effect.provideContext(platform)); yield* router.add("GET", `${PREVIEW_STREAM_ROUTE_PREFIX}/*`, handler); yield* router.add("POST", `${PREVIEW_STREAM_ROUTE_PREFIX}/upload`, handler); }), From 7d66dc51489713126b53e2eb5d45084c62b21d20 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 08:12:05 +0000 Subject: [PATCH 12/19] fix(server): MCP approvals from before a mode change can't be redeemed 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 --- apps/server/src/auth/EnvironmentAuth.ts | 37 ++++++++++++++++++++++++- apps/server/src/auth/McpOAuth.test.ts | 32 +++++++++++++++++++++ apps/server/src/auth/McpOAuth.ts | 10 +++++-- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 1f3f4bc561923..a2da921c5f18d 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -32,6 +32,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; @@ -554,6 +555,23 @@ export class EnvironmentAuth extends Context.Service< }, ServerAuthInternalError >; + /** Advances when other access is revoked, so an approval from before it can be refused. */ + readonly accessGeneration: Effect.Effect; + /** Issues a session for an approval, unless other access was revoked since it was given. */ + readonly redeemMcpApproval: (input: { + readonly label: string; + readonly access: AuthMcpClientAccess; + readonly peerLink: boolean; + readonly client: AuthClientMetadata; + readonly approvedAtGeneration: number; + }) => Effect.Effect< + { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, + ServerAuthInternalError | ServerAuthScopeNotGrantedError + >; /** Spends a code covering all granted scopes. Rejects desktop grants and leaves refused proof-bound codes unspent. */ readonly consumeMcpApprovalCode: ( code: string, @@ -710,6 +728,7 @@ export const make = Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; // Spending a credential and issuing its session are separate steps; revocation must not fit between. const credentialExchangeLock = yield* Semaphore.make(1); + const accessGenerationRef = yield* Ref.make(0); const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); @@ -1208,7 +1227,8 @@ export const make = Effect.gen(function* () { const revokeOtherAccess: EnvironmentAuth["Service"]["revokeOtherAccess"] = (currentSessionId) => credentialExchangeLock.withPermits(1)( - listPairingLinks().pipe( + Ref.update(accessGenerationRef, (generation) => generation + 1).pipe( + Effect.andThen(listPairingLinks()), Effect.flatMap((pairingLinks) => Effect.forEach(pairingLinks, (link) => revokePairingLink(link.id), { discard: true }), ), @@ -1361,6 +1381,19 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), ); + const redeemMcpApproval: EnvironmentAuth["Service"]["redeemMcpApproval"] = ({ + approvedAtGeneration, + ...input + }) => + credentialExchangeLock.withPermits(1)( + Effect.gen(function* () { + if ((yield* Ref.get(accessGenerationRef)) !== approvedAtGeneration) { + return yield* new ServerAuthScopeNotGrantedError({}); + } + return yield* issueMcpClientSession(input); + }), + ); + const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = ( request, ) => { @@ -1421,6 +1454,8 @@ export const make = Effect.gen(function* () { issueStartupPairingUrl, authenticateMcpClient, issueMcpClientSession, + accessGeneration: Ref.get(accessGenerationRef), + redeemMcpApproval, consumeMcpApprovalCode, authenticateBrowserSession, }); diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 7b1d1f4c7d415..bdcca5c1b1f6e 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -377,6 +377,38 @@ it.live("signs in a hosted agent with an https callback that asked for a client ), ); +it.live("a code approved before other access is revoked can't be redeemed after it", () => + withRoutes((handler, auth) => + Effect.gen(function* () { + const clientId = yield* registeredClientId(handler); + const pairing = yield* auth.issuePairingCredential(); + const params = authorizeParams(clientId); + const approved = yield* decide(handler, params, { + _tag: "pairing-code", + access: "approval-required", + code: pairing.credential, + }); + const phone = yield* auth.issueSession({ label: "phone" }); + yield* auth.revokeOtherAccess(phone.sessionId); + + const late = yield* handler( + at( + "/oauth/mcp/token", + form({ + grant_type: "authorization_code", + code: new URL(approved.redirectTo!).searchParams.get("code")!, + redirect_uri: params.redirect_uri, + client_id: clientId, + code_verifier: verifier, + }), + ), + ); + expect(late.status).toBe(400); + expect(yield* json<{ error: string }>(late)).toMatchObject({ error: "invalid_grant" }); + }), + ), +); + it.live("rejects a wrong PKCE verifier and spends the code", () => withRoutes((handler, auth) => Effect.gen(function* () { diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 7416f391ed66d..724a21acce7fd 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -184,6 +184,7 @@ interface PendingCode { readonly codeChallenge: string; readonly resource: string; readonly access: AuthMcpClientAccess; + readonly approvedAtGeneration: number; readonly expiresAtMs: number; } @@ -413,6 +414,7 @@ const make = Effect.gen(function* () { const mintCode = Effect.fn("McpOAuth.mintCode")(function* ( authorization: AuthorizationRequest, access: AuthMcpClientAccess, + approvedAtGeneration: number, ) { const now = yield* Clock.currentTimeMillis; const code = Buffer.from(yield* crypto.randomBytes(32).pipe(Effect.orDie)).toString( @@ -428,6 +430,7 @@ const make = Effect.gen(function* () { codeChallenge: authorization.codeChallenge, resource: authorization.resource, access, + approvedAtGeneration, expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, }); return next; @@ -437,6 +440,8 @@ const make = Effect.gen(function* () { const approve: McpOAuth["Service"]["approve"] = Effect.fn("McpOAuth.approve")(function* (input) { const { decision } = input; + // Read before the approver is checked: a revocation after that check must void this code. + const approvedAtGeneration = yield* environmentAuth.accessGeneration; if (decision._tag === "pairing-code") { yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => @@ -463,7 +468,7 @@ const make = Effect.gen(function* () { }); } } - const code = yield* mintCode(input.authorization, decision.access); + const code = yield* mintCode(input.authorization, decision.access, approvedAtGeneration); yield* Effect.logInfo("Approved an MCP client sign-in.", { client: input.authorization.client.name, access: decision.access, @@ -523,11 +528,12 @@ const make = Effect.gen(function* () { return yield* fail("invalid_grant", "The authorization code is invalid or expired."); } const issued = yield* environmentAuth - .issueMcpClientSession({ + .redeemMcpApproval({ label: pending.clientName, access: pending.access, peerLink: pending.peerLink, client: deriveAuthClientMetadata({ request }), + approvedAtGeneration: pending.approvedAtGeneration, }) .pipe( Effect.catch((cause) => From 6a6dd476b535113ec7e1c6bf39529f10c8e3bc3f Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 08:23:21 +0000 Subject: [PATCH 13/19] fix(server): a request in flight during a mode change can't keep old 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 --- apps/server/src/auth/EnvironmentAuth.ts | 13 ++++++++ .../src/auth/EnvironmentAuthAdmin.test.ts | 32 +++++++++++++++++++ apps/server/src/auth/McpOAuth.ts | 4 +-- apps/server/src/auth/http.ts | 9 +++++- apps/server/src/cloud/CloudLink.test.ts | 25 ++++++++++++++- apps/server/src/cloud/CloudLink.ts | 6 ++++ 6 files changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index a2da921c5f18d..b59e8e6be3221 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -521,6 +521,12 @@ export class EnvironmentAuth extends Context.Service< readonly revokeOtherClientSessions: ( currentSessionId: AuthSessionId, ) => Effect.Effect; + /** Runs a step that grants access so it can't straddle `revokeOtherAccess`. Not reentrant. */ + readonly withGrantLock: (effect: Effect.Effect) => Effect.Effect; + /** For a request that waited: its session may have been revoked since it was authenticated. */ + readonly isSessionActive: ( + sessionId: AuthSessionId, + ) => Effect.Effect; /** Ends every other session and pairing link, including credentials being exchanged now. */ readonly revokeOtherAccess: ( currentSessionId: AuthSessionId, @@ -1225,6 +1231,11 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); + const isSessionActive: EnvironmentAuth["Service"]["isSessionActive"] = (sessionId) => + listSessions().pipe( + Effect.map((active) => active.some((session) => session.sessionId === sessionId)), + ); + const revokeOtherAccess: EnvironmentAuth["Service"]["revokeOtherAccess"] = (currentSessionId) => credentialExchangeLock.withPermits(1)( Ref.update(accessGenerationRef, (generation) => generation + 1).pipe( @@ -1447,6 +1458,8 @@ export const make = Effect.gen(function* () { listClientSessions, revokeClientSession, revokeOtherClientSessions, + withGrantLock: credentialExchangeLock.withPermits(1), + isSessionActive, revokeOtherAccess, authenticateHttpRequest, authenticateWebSocketUpgrade, diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index f679b118c387c..607fa6b9b4834 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -101,6 +101,38 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = }).pipe(Effect.provide(layerEnvironmentAuth())), ); + it.effect("a link created inside the grant lock can't outlive a revocation waiting on it", () => + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const caller = yield* environmentAuth.issueSession({ label: "phone" }); + const other = yield* environmentAuth.issueSession({ label: "laptop" }); + expect(yield* environmentAuth.isSessionActive(other.sessionId)).toBe(true); + const inLock = yield* Deferred.make(); + const release = yield* Deferred.make(); + + const issuing = yield* environmentAuth + .withGrantLock( + Deferred.succeed(inLock, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(environmentAuth.createPairingLink({ label: "late" })), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(inLock); + const revocation = yield* environmentAuth + .revokeOtherAccess(caller.sessionId) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(issuing); + yield* Fiber.join(revocation); + + expect(yield* environmentAuth.listPairingLinks()).toEqual([]); + expect(yield* environmentAuth.isSessionActive(other.sessionId)).toBe(false); + expect(yield* environmentAuth.isSessionActive(caller.sessionId)).toBe(true); + }).pipe(Effect.provide(layerEnvironmentAuth())), + ); + it.effect("a credential already being exchanged can't outlive the revocation", () => Effect.gen(function* () { const hold = { diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 724a21acce7fd..9b6f8bad8c41c 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -440,7 +440,7 @@ const make = Effect.gen(function* () { const approve: McpOAuth["Service"]["approve"] = Effect.fn("McpOAuth.approve")(function* (input) { const { decision } = input; - // Read before the approver is checked: a revocation after that check must void this code. + // Held from checking the approver to minting, so a revocation either stops this or voids its code. const approvedAtGeneration = yield* environmentAuth.accessGeneration; if (decision._tag === "pairing-code") { yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( @@ -479,7 +479,7 @@ const make = Effect.gen(function* () { state: input.authorization.state, iss: input.authorization.issuer, }); - }); + }, environmentAuth.withGrantLock); const deny: McpOAuth["Service"]["deny"] = (authorization) => redirectWith(authorization.redirectUri, { diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index e570be1b4793a..a07c6edbe5ad4 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -456,7 +456,14 @@ export const layer = HttpApiBuilder.group( return yield* failEnvironmentScopeRequired(delegatedScope); } } - return yield* serverAuth.issuePairingCredential(args.payload); + return yield* serverAuth.withGrantLock( + Effect.gen(function* () { + if (!(yield* serverAuth.isSessionActive(session.sessionId))) { + return yield* failEnvironmentAuthInvalid("invalid_credential"); + } + return yield* serverAuth.issuePairingCredential(args.payload); + }), + ); }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_credential_issuance_failed", error), diff --git a/apps/server/src/cloud/CloudLink.test.ts b/apps/server/src/cloud/CloudLink.test.ts index 32ca7245a080e..94c26e18d2847 100644 --- a/apps/server/src/cloud/CloudLink.test.ts +++ b/apps/server/src/cloud/CloudLink.test.ts @@ -154,7 +154,10 @@ const withService = ( Layer.mock(ConnectReachability.ConnectReachability)({ latest: Effect.succeedNone, }), - Layer.mock(EnvironmentAuth.EnvironmentAuth)(options.auth ?? {}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + isSessionActive: () => Effect.succeed(true), + ...options.auth, + }), Layer.mock(CliTokenManager.CloudCliTokenManager)({}), Layer.mock(HttpServer.HttpServer)({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773), @@ -396,6 +399,26 @@ it.effect("a mode change stores the mode before signing anyone out", () => { ); }); +it.effect("a caller signed out while its change waited can't undo the mode", () => { + const revoked: Array = []; + return withService( + { + initialSecrets: [[CLOUD_SHARING_MODE, "view-only"]], + auth: { + isSessionActive: () => Effect.succeed(false), + revokeOtherAccess: (sessionId) => Effect.sync(() => void revoked.push(sessionId)), + }, + }, + ({ link, stored }) => + Effect.gen(function* () { + const failure = yield* link.setSharingMode("full-control", callerSession).pipe(Effect.flip); + assert.equal(failure._tag, "CloudLinkInternalError"); + assert.equal(decode(stored.get(CLOUD_SHARING_MODE)), "view-only"); + assert.deepEqual(revoked, []); + }), + ); +}); + it.effect("a failed sign-out keeps the new mode and says the change isn't finished", () => { let failing = true; return withService( diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 85bf788f04e2d..754dbecee0e12 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -1433,6 +1433,12 @@ const make = Effect.gen(function* () { const setSharingMode = Effect.fn("environment.cloud.setSharingMode")( function* (mode: EnvironmentSharingMode, callerSessionId: AuthSessionId) { + // A change this one waited behind may have signed this caller out. + if (!(yield* environmentAuth.isSessionActive(callerSessionId))) { + return yield* internalError("change-sharing-mode")( + new Error("The session asking for this change was signed out while it waited."), + ); + } // Pending goes first and clears last, so a failure at any step still shows on the phone. yield* secrets.set(CLOUD_SHARING_MODE_CHANGE_PENDING, stringToBytes("true")); yield* secrets.set(CLOUD_SHARING_MODE, stringToBytes(mode)); From b4834f0ef6ab872ed1621541af9f4a40da6d2dcb Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 08:34:35 +0000 Subject: [PATCH 14/19] fix(server): reading an MCP app's resources needs operate scope 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 --- apps/mobile/src/features/phone/PhoneSharingSection.tsx | 9 ++++++++- apps/server/src/auth/RpcAuthorization.test.ts | 1 + apps/server/src/auth/RpcAuthorization.ts | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/phone/PhoneSharingSection.tsx b/apps/mobile/src/features/phone/PhoneSharingSection.tsx index e20ea50f0640a..445f1767a46f5 100644 --- a/apps/mobile/src/features/phone/PhoneSharingSection.tsx +++ b/apps/mobile/src/features/phone/PhoneSharingSection.tsx @@ -191,7 +191,14 @@ function ConfiguredPhoneSharingSection(props: { readonly environmentId: Environm }; const changeMode = async (mode: EnvironmentSharingMode) => { - setActionError(await changePhoneSharingMode(connection, mode)); + const failure = await changePhoneSharingMode(connection, mode); + setActionError(failure); + // The server took it, so a failed read below mustn't leave the old mode on screen. + if (failure === null) { + setSharing((current) => + current?.shared ? { ...current, mode, modeChangePending: false } : current, + ); + } finish(); check(); }; diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 2f95282ca4f13..c3df427ce0f4f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -344,6 +344,7 @@ it("a View only session reads threads and reaches nothing that changes them", () WS_METHODS.guestShareCreate, WS_METHODS.peerLinksAnswerRequest, WS_METHODS.mcpAppsCallTool, + WS_METHODS.mcpAppsReadResource, WS_METHODS.shellOpenInEditor, WS_METHODS.scheduledTasksUpsert, ]; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6049121b41a5d..83c81d01f07ef 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -163,7 +163,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.mcpAppsCallTool]: AuthOrchestrationOperateScope, [WS_METHODS.mcpAppsToolInfo]: AuthOrchestrationReadScope, - [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope, + // Takes any URI on the app's server, which can hold more than the thread shows. + [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationOperateScope, [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, From 8ed72d5c1af6449baf819cbadb6c3670d3c58bec Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 08:45:25 +0000 Subject: [PATCH 15/19] fix(server): read-only sessions can't ready device hosts 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 --- apps/server/src/auth/RpcAuthorization.test.ts | 11 +++++++++++ apps/server/src/auth/RpcAuthorization.ts | 7 ++++++- apps/server/src/ws.ts | 8 ++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index c3df427ce0f4f..561ccbc5e7f82 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -27,6 +27,7 @@ import * as RpcTest from "effect/rpc/RpcTest"; import { RPC_REQUIRED_SCOPES, + deviceListIsPassive, requiredScopeForRpcMethod, requiredScopeForDeviceList, } from "./RpcAuthorization.ts"; @@ -320,6 +321,15 @@ describe("thread context authorization", () => { ); }); +it("lists devices passively for a session that can't ready their hosts", () => { + expect(deviceListIsPassive({}, false)).toBe(true); + expect(deviceListIsPassive({}, true)).toBe(false); + expect(deviceListIsPassive({ inspectOnly: true }, true)).toBe(true); + // These stay active so the scope check refuses them instead of quietly answering. + expect(deviceListIsPassive({ updateTool: "hub" }, false)).toBe(false); + expect(deviceListIsPassive({ retryHostId: "local" }, false)).toBe(false); +}); + it("a View only session reads threads and reaches nothing that changes them", () => { const viewerMethods = [ ORCHESTRATION_V2_WS_METHODS.dispatchCommand, @@ -345,6 +355,7 @@ it("a View only session reads threads and reaches nothing that changes them", () WS_METHODS.peerLinksAnswerRequest, WS_METHODS.mcpAppsCallTool, WS_METHODS.mcpAppsReadResource, + WS_METHODS.deviceDetail, WS_METHODS.shellOpenInEditor, WS_METHODS.scheduledTasksUpsert, ]; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 83c81d01f07ef..4578b930a3d49 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -203,7 +203,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, [WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope, - [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope, + // Reading a device first readies its host, which can install tools and start the hub. + [WS_METHODS.deviceDetail]: AuthOrchestrationOperateScope, [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, @@ -292,6 +293,10 @@ export const layer = ( }); /** Retrying can install or restart tools even though ordinary listing is readable. */ +/** A full listing also readies hosts, so a session that can't operate gets the passive view. */ +export const deviceListIsPassive = (input: DeviceListInput, canOperate: boolean): boolean => + !input.updateTool && (input.inspectOnly === true || (!canOperate && !input.retryHostId)); + export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => input.retryHostId || input.updateTool ? AuthOrchestrationOperateScope diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..a37bf706d9b24 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -228,7 +228,11 @@ import * as DefectReporter from "./observability/DefectReporter.ts"; import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; +import { + deviceListIsPassive, + requiredScopeForDeviceList, + rpcAuthorizationError, +} from "./auth/RpcAuthorization.ts"; import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; import { RpcInstrumentation, rpcInstrumentationLayer } from "./observability/RpcInstrumentation.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -3048,7 +3052,7 @@ export const layerWsRpc = ( [WS_METHODS.deviceEasAccount]: (input) => deviceService.easAccount(input), [WS_METHODS.deviceSimlockIdentity]: (input) => deviceService.simlockIdentity(input), [WS_METHODS.deviceList]: (input) => - input.inspectOnly && !input.updateTool + deviceListIsPassive(input, currentSession.scopes.includes(AuthOrchestrationOperateScope)) ? deviceService.inspect : authorizeEffect( requiredScopeForDeviceList(input), From 41fffa7fb79b01044871925b528697a5a71ec906 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 1 Nov 2026 09:01:00 +0000 Subject: [PATCH 16/19] fix(server): read-only sessions can't start providers or auto-pull 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 --- apps/server/src/auth/RpcAuthorization.test.ts | 9 +++++++++ apps/server/src/auth/RpcAuthorization.ts | 13 +++++++++++-- apps/server/src/ws.test.ts | 3 ++- apps/server/src/ws.ts | 19 +++++++++++++++++-- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 561ccbc5e7f82..3f8fbf3a2780c 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -28,6 +28,7 @@ import * as RpcTest from "effect/rpc/RpcTest"; import { RPC_REQUIRED_SCOPES, deviceListIsPassive, + readOnlyVcsStatusInput, requiredScopeForRpcMethod, requiredScopeForDeviceList, } from "./RpcAuthorization.ts"; @@ -321,6 +322,12 @@ describe("thread context authorization", () => { ); }); +it("drops remote VCS polling, which can auto-pull, for a session that can't operate", () => { + const input = { cwd: "/repo", includeRemote: true }; + expect(readOnlyVcsStatusInput(input, true)).toEqual(input); + expect(readOnlyVcsStatusInput(input, false)).toEqual({ cwd: "/repo", includeRemote: false }); +}); + it("lists devices passively for a session that can't ready their hosts", () => { expect(deviceListIsPassive({}, false)).toBe(true); expect(deviceListIsPassive({}, true)).toBe(false); @@ -356,6 +363,8 @@ it("a View only session reads threads and reaches nothing that changes them", () WS_METHODS.mcpAppsCallTool, WS_METHODS.mcpAppsReadResource, WS_METHODS.deviceDetail, + WS_METHODS.serverListAcpRegistrySessions, + WS_METHODS.serverListAcpRegistryProviders, WS_METHODS.shellOpenInEditor, WS_METHODS.scheduledTasksUpsert, ]; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 4578b930a3d49..5a018eeb44850 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -91,10 +91,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope, [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope, [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthProvidersManageScope, - [WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationReadScope, + // Listing resolves the agent, which can install it and start it. + [WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationOperateScope, [WS_METHODS.serverImportAcpRegistrySession]: AuthOrchestrationOperateScope, [WS_METHODS.serverDeleteAcpRegistrySession]: AuthOrchestrationOperateScope, - [WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationReadScope, + [WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverSetAcpRegistryProvider]: AuthProvidersManageScope, [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope, [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope, @@ -293,6 +294,14 @@ export const layer = ( }); /** Retrying can install or restart tools even though ordinary listing is readable. */ +/** Remote polling can auto-pull into the workspace, so it's only for a session that can operate. */ +export const readOnlyVcsStatusInput = < + Input extends { readonly includeRemote?: boolean | undefined }, +>( + input: Input, + canOperate: boolean, +): Input => (canOperate ? input : { ...input, includeRemote: false }); + /** A full listing also readies hosts, so a session that can't operate gets the passive view. */ export const deviceListIsPassive = (input: DeviceListInput, canOperate: boolean): boolean => !input.updateTool && (input.inspectOnly === true || (!canOperate && !input.retryHostId)); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 641845f322ea6..1b1ee70419838 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -2,6 +2,7 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { + AuthOrchestrationOperateScope, AuthSessionId, EnvironmentId, ProjectId, @@ -827,7 +828,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( sessionId: AuthSessionId.make("config-test"), subject: "config-test", method: "browser-session-cookie", - scopes: [], + scopes: [AuthOrchestrationOperateScope], }, {}, yield* ServerBrowser.ServerBrowser, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a37bf706d9b24..83bc01847c4b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -230,6 +230,7 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { deviceListIsPassive, + readOnlyVcsStatusInput, requiredScopeForDeviceList, rpcAuthorizationError, } from "./auth/RpcAuthorization.ts"; @@ -2286,6 +2287,10 @@ export const layerWsRpc = ( [WS_METHODS.serverRefreshProviders]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (input) { + // Refreshing can install or start a provider, so a read-only session gets what's known. + if (!currentSession.scopes.includes(AuthOrchestrationOperateScope)) { + return { providers: yield* providerRegistry.getProviders }; + } if (input.refreshModels) { yield* modelManifest.forceRefresh; const instances = yield* providerInstances.listInstances; @@ -2941,14 +2946,24 @@ export const layerWsRpc = ( }), [WS_METHODS.assetsPersistChatAttachments]: (input) => persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), - [WS_METHODS.subscribeVcsStatus]: (input) => vcsStatusBroadcaster.streamStatus(input), + [WS_METHODS.subscribeVcsStatus]: (input) => + vcsStatusBroadcaster.streamStatus( + readOnlyVcsStatusInput( + input, + currentSession.scopes.includes(AuthOrchestrationOperateScope), + ), + ), [WS_METHODS.subscribeWorktreeSetup]: (input) => worktreeSetupTracker.stream(input.threadId), [WS_METHODS.worktreeSetupCancel]: (input) => worktreeSetupTracker .cancel(input.threadId) .pipe(Effect.map((cancelled) => ({ cancelled }))), [WS_METHODS.vcsRefreshStatus]: (input) => - vcsStatusBroadcaster.refreshStatus(input.cwd, { localOnly: input.localOnly ?? false }), + vcsStatusBroadcaster.refreshStatus(input.cwd, { + localOnly: + input.localOnly === true || + !currentSession.scopes.includes(AuthOrchestrationOperateScope), + }), [WS_METHODS.vcsPull]: (input) => gitWorkflow.pullCurrentBranch(input.cwd).pipe( Effect.matchCauseEffect({ From 759d56d73182d159b77ad97881dbeaa93d1c905c Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Thu, 5 Nov 2026 08:36:28 +0000 Subject: [PATCH 17/19] fix(mobile): Off signs other devices out before stopping the tunnel With a desktop connected through the tunnel, cloudflared drains its open connection for up to 30 seconds before it exits, so the 20-second unlink step timed out and Off reported it couldn't stop sharing. Off now signs other devices out first, which closes their connections, gives unlink 45 seconds, and signs everyone out again afterwards. Co-Authored-By: Claude Opus 5.5 --- .../src/features/phone/phone-sharing.test.ts | 48 ++++++++++++++++++- .../src/features/phone/phone-sharing.ts | 9 +++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/phone/phone-sharing.test.ts b/apps/mobile/src/features/phone/phone-sharing.test.ts index f2104ea559062..931d695b66602 100644 --- a/apps/mobile/src/features/phone/phone-sharing.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { phoneSharingFromLinkState, @@ -31,10 +33,15 @@ const linkState = { holdWebhooksWhileOffline: false, }; -function layerServer(failing: ReadonlySet = new Set()) { +function layerServer( + failing: ReadonlySet = new Set(), + failingOnce = new Set(), + holdUnlink?: { readonly reached: () => void; readonly released: Promise }, +) { const requests: Array = []; const respond = (path: string) => { if (failing.has(path)) return Response.json({ error: "no" }, { status: 500 }); + if (failingOnce.delete(path)) return Response.json({ error: "no" }, { status: 500 }); if (path === "/api/connect/unlink") { return Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }); } @@ -45,6 +52,10 @@ function layerServer(failing: ReadonlySet = new Set()) { const layer = layerRemoteHttpClient((input) => { const path = new URL(String(input)).pathname; requests.push(path); + if (holdUnlink && path === "/api/connect/unlink") { + holdUnlink.reached(); + return holdUnlink.released.then(() => respond(path)); + } return Promise.resolve(respond(path)); }); return { requests, layer }; @@ -97,11 +108,12 @@ describe("phoneSharingFromLinkState", () => { }); describe("stopServerSharing", () => { - it.effect("stops the tunnel, then signs out other clients and their pairing links", () => { + it.effect("signs others out around stopping the tunnel, then revokes pairing links", () => { const server = layerServer(); return Effect.gen(function* () { yield* stopServerSharing(connection, Effect.succeed(["link-1"])); expect(server.requests).toEqual([ + "/api/auth/clients/revoke-others", "/api/connect/unlink", "/api/auth/clients/revoke-others", "/api/auth/pairing-links/revoke", @@ -109,6 +121,38 @@ describe("stopServerSharing", () => { }).pipe(Effect.provide(server.layer)); }); + it.effect("still stops the tunnel when the first sign-out fails", () => { + const server = layerServer(new Set(), new Set(["/api/auth/clients/revoke-others"])); + return Effect.gen(function* () { + yield* stopServerSharing(connection, Effect.succeed([])); + expect(server.requests).toEqual([ + "/api/auth/clients/revoke-others", + "/api/connect/unlink", + "/api/auth/clients/revoke-others", + ]); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("waits out the 30 seconds the tunnel takes to drain a connection", () => { + let reached = () => {}; + const unlinkReached = new Promise((resolve) => { + reached = resolve; + }); + let release = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + const server = layerServer(new Set(), new Set(), { reached, released }); + return Effect.gen(function* () { + const stop = yield* stopServerSharing(connection, Effect.succeed([])).pipe(Effect.forkChild); + yield* Effect.promise(() => unlinkReached); + yield* TestClock.adjust("30 seconds"); + release(); + yield* Fiber.join(stop); + expect(server.requests.at(-1)).toBe("/api/auth/clients/revoke-others"); + }).pipe(Effect.provide(server.layer)); + }); + it.effect("fails when a pairing link can't be read, so the stop stays unfinished", () => { const server = layerServer(); return Effect.gen(function* () { diff --git a/apps/mobile/src/features/phone/phone-sharing.ts b/apps/mobile/src/features/phone/phone-sharing.ts index d696050124ab3..f9dd8c51ac23b 100644 --- a/apps/mobile/src/features/phone/phone-sharing.ts +++ b/apps/mobile/src/features/phone/phone-sharing.ts @@ -62,6 +62,8 @@ const REACHABILITY_WAIT = Duration.seconds(60); const REACHABILITY_CHECK_TIMEOUT = Duration.seconds(30); const REACHABILITY_RETRY = Duration.seconds(5); const STOP_STEP_TIMEOUT = Duration.seconds(20); +// The relay client lets open requests run for up to 30 seconds before it exits. +const UNLINK_TIMEOUT = Duration.seconds(45); const READ_TIMEOUT = Duration.seconds(10); export const failWith = (message: string) => (cause: unknown) => @@ -133,10 +135,15 @@ export const stopServerSharing = Effect.fn("PhoneSharing.stopServerSharing")(fun pairingLinkIds: Effect.Effect, E, R>, ) { const { api, headers } = yield* phoneServerApi(connection); + // Signing others out first ends their live connections, which the tunnel would wait on. + // Nothing here may block the stop, so defects are dropped too. + yield* api.auth + .revokeOtherClients({ headers }) + .pipe(Effect.timeout(STOP_STEP_TIMEOUT), Effect.ignoreCause); yield* api.connect .unlink({ headers }) .pipe( - Effect.timeout(STOP_STEP_TIMEOUT), + Effect.timeout(UNLINK_TIMEOUT), Effect.mapError(failWith("Couldn't stop sharing this phone.")), ); yield* Effect.gen(function* () { From 318279d6c7396bc111839047297f39914f490616 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Fri, 6 Nov 2026 00:45:50 +0000 Subject: [PATCH 18/19] fix(server): a relay client that won't exit no longer hangs unlink Stopping cloudflared sent SIGTERM and waited for it to exit with no limit, while holding the link-state lock. On a phone, cloudflared never exited, so Off hung and every later stop queued behind it. cloudflared is now force-killed 5 seconds after SIGTERM, on scope close and on the readiness restart. Co-Authored-By: Claude Opus 5.5 --- .../src/cloud/ManagedEndpointRuntime.test.ts | 65 +++++++++++++++---- .../src/cloud/ManagedEndpointRuntime.ts | 8 ++- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index ad7761f7c4549..32cb0427fe7cf 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -22,6 +22,7 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HttpClient } from "effect/http"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -29,20 +30,23 @@ import * as ServerConfig from "../config.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; -const layerRelayClientAvailable = Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "available", - executablePath: "cloudflared", - source: "path", - version: RelayClient.CLOUDFLARED_VERSION, +const layerRelayClientAt = (executablePath: string) => + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "available", + executablePath, + source: "path", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused"), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, }), - install: Effect.die("unused"), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), -); + ); + +const layerRelayClientAvailable = layerRelayClientAt("cloudflared"); const unusedHttpClient = HttpClient.make(() => Effect.die("unused")); @@ -118,6 +122,41 @@ function makeHandle(input: { } describe("CloudManagedEndpointRuntime", () => { + // Windows has no SIGTERM to ignore: taskkill ends the process outright. + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "stops a relay client that ignores SIGTERM", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const executablePath = path.join(yield* fs.makeTempDirectoryScoped(), "cloudflared"); + yield* fs.writeFileString( + executablePath, + [ + "#!/usr/bin/env node", + "process.on('SIGTERM', () => {});", + "console.error('2026-10-06T00:00:01Z INF Registered tunnel connection connIndex=0');", + "setInterval(() => {}, 1000);", + ].join("\n"), + ); + yield* fs.chmod(executablePath, 0o755); + const runtime = yield* buildCloudManagedEndpointRuntime( + yield* ChildProcessSpawner.ChildProcessSpawner, + layerRelayClientAt(executablePath), + ); + const started = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }); + expect(started.status).toBe("running"); + // Logged once the script ignores SIGTERM. + yield* Stream.runHead(runtime.tunnelConnected); + + expect((yield* runtime.applyConfig(null)).status).toBe("disabled"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("serializes updates to persisted cloud link state", () => Effect.gen(function* () { const firstEntered = yield* Deferred.make(); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index ca0699787612d..5f8c23d2aa8de 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -101,6 +101,9 @@ const CONNECTOR_READINESS_INTERVAL = Duration.seconds(30); const CONNECTOR_READINESS_REQUEST_TIMEOUT = Duration.seconds(5); const CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART = 6; const CONNECTOR_NOT_READY_CHECKS_MAX = 60; +// cloudflared drains open requests for 30 seconds after SIGTERM, and one that never exits would +// hold every later stop, unlink included. +const RELAY_CLIENT_FORCE_KILL_AFTER = Duration.seconds(5); const RELAY_CLIENT_METRICS_LINE = /\bStarting metrics server on (127\.0\.0\.1:\d+)\/metrics\b/u; const RELAY_CLIENT_PROTOCOL = /\bprotocol=(\w+)/u; // cloudflared's metrics server: `/ready` answers 200 or 503 with this body, and @@ -526,7 +529,9 @@ export const make = Effect.gen(function* () { tunnelId: connector.config.tunnelId, tunnelName: connector.config.tunnelName, }); - yield* connector.child.kill().pipe(Effect.ignore); + yield* connector.child + .kill({ forceKillAfter: RELAY_CLIENT_FORCE_KILL_AFTER }) + .pipe(Effect.ignore); } }); @@ -720,6 +725,7 @@ export const make = Effect.gen(function* () { ], { detached: false, + forceKillAfter: RELAY_CLIENT_FORCE_KILL_AFTER, env: { ...process.env, TUNNEL_TOKEN: config.connectorToken, From 2ff1a17bfe5b99f73deb6d73b8f3b67828f485a8 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Fri, 6 Nov 2026 01:54:49 +0000 Subject: [PATCH 19/19] test(server): run the fake relay client with this Node, not one on PATH CI runs tests with a node that isn't on PATH, so the fixture's env shebang exited 127 and the test waited out its timeout. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/ManagedEndpointRuntime.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 4cc43444056f0..207b38ca26977 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -137,11 +137,10 @@ describe("CloudManagedEndpointRuntime", () => { const executablePath = path.join(yield* fs.makeTempDirectoryScoped(), "cloudflared"); yield* fs.writeFileString( executablePath, + // CI runs tests with a node that isn't on PATH. [ - "#!/usr/bin/env node", - "process.on('SIGTERM', () => {});", - "console.error('2026-10-06T00:00:01Z INF Registered tunnel connection connIndex=0');", - "setInterval(() => {}, 1000);", + "#!/bin/sh", + `exec '${process.execPath}' -e "process.on('SIGTERM', () => {}); console.error('2026-10-06T00:00:01Z INF Registered tunnel connection connIndex=0'); setInterval(() => {}, 1000);"`, ].join("\n"), ); yield* fs.chmod(executablePath, 0o755);