diff --git a/FORK.md b/FORK.md index b2ac91bb9c7fb..901559213762f 100644 --- a/FORK.md +++ b/FORK.md @@ -50,6 +50,21 @@ adds the feature, and remove it in the PR that drops the feature. `apps/mobile/src/features/device-runtime/`, `apps/mobile/src/features/phone/`, `apps/mobile/src/persistence/mobile-preferences.ts` (`phoneServer`). +- #19 The phone's server can be shared with the user's other devices through + T3 Connect: the app installs cloudflared in the phone's Ubuntu and links the + server with its own T3 Connect sign-in. View only or Full control sets what the + server's T3 Connect credentials grant, and changing it signs out other clients. + Off and signing out end the tunnel and other clients' sessions, Ongoing activity + skips the phone's server, and the Android bridge asks on the phone for each Ask + each time command: `apps/server/src/cloud/CloudLink.ts` (`setSharingMode`), + `apps/server/src/auth/EnvironmentAuth.ts` (`revokeOtherAccess`), + `packages/contracts/src/auth.ts` (`AuthViewOnlyClientScopes`), + `apps/mobile/src/features/phone/phone-sharing.ts`, + `apps/mobile/src/features/phone/phone-sharing-controller.ts`, + `apps/mobile/src/features/phone/phone-sharing-commands.ts`, + `apps/mobile/src/features/phone/PhoneSharingSection.tsx`, + `apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts`, + `apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt`. - #21 Open in VS Code and other SSH editor links use the host the client reached directly when sshd is listening, instead of the server's first advertised host: `apps/web/src/remoteOpen.ts`, `apps/server/src/environment/RemoteOpenTargets.ts` (`sshd`). 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 @@ + + match.value.toCharArray().joinToString("") { "\\u%04X".format(it.code) } + } + if (details.length > DETAILS_LIMIT) throw BridgeError("This request is too long to show on the phone for approval.", 413) + return details +} + +private class WaitingApproval(val title: String, val message: String, val answer: CompletableFuture) + +/** + * 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 slots = Semaphore(MAX_WAITING) + + /** Whether the user allowed it. Nobody answering in time counts as no. */ + fun request( + context: Context, + category: AccessCategory, + command: String, + details: String, + show: (Intent) -> Unit, + dismiss: () -> Unit, + ): Boolean { + // Requests wait on bridge connections, which unrelated commands need too. + if (!slots.tryAcquire()) throw BridgeError("Too many requests are waiting for an answer on the phone.", 429) + try { + return waitForAnswer(context, category, command, details, show, dismiss) + } finally { + slots.release() + } + } + + private fun waitForAnswer( + context: Context, + category: AccessCategory, + command: String, + details: String, + show: (Intent) -> Unit, + dismiss: () -> Unit, + ): Boolean { + val id = UUID.randomUUID().toString() + val waiting = waitingFor(category, command, details) + pending[id] = waiting + try { + show( + Intent(context, AccessApprovalActivity::class.java) + // A distinct intent per request: Android would otherwise reuse a waiting prompt's. + .setData(Uri.parse("t3-access://$id")) + .putExtra(EXTRA_REQUEST, id) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + return waiting.answer.get(APPROVAL_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch (error: TimeoutException) { + throw BridgeError("Nobody answered T3 Code's ${category.label} prompt on the phone.", 403) + } finally { + pending.remove(id) + // Closes a prompt that's still open, so a late tap can't look like it counted. + waiting.answer.complete(false) + dismiss() + } + } + + private fun waitingFor(category: AccessCategory, command: String, details: String): WaitingApproval { + val message = buildString { + append("termux-$command") + if (details.isNotEmpty()) append("\n\n$details") + append("\n\nAn agent or another device using this phone's server asked for this.") + } + return WaitingApproval("Allow ${category.label}?", message, CompletableFuture()) + } + + fun prompt(id: String): Pair? = pending[id]?.let { it.title to it.message } + + fun answer(id: String, allowed: Boolean) { + pending[id]?.answer?.complete(allowed) + } + + /** Runs once the request ends, or at once when it already has, as for an old notification. */ + fun whenEnded(id: String, action: () -> Unit) { + val waiting = pending[id] + if (waiting == null) action() else waiting.answer.whenComplete { _, _ -> action() } + } +} + +/** A see-through activity holding one approval dialog, over whatever is on screen. */ +class AccessApprovalActivity : Activity() { + private var request: String? = null + private var dialog: AlertDialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val id = intent.getStringExtra(EXTRA_REQUEST) ?: return finish() + val (title, message) = AccessApproval.prompt(id) ?: return finish() + request = id + val shown = AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setTitle(title) + .setMessage(message) + .setPositiveButton("Allow") { _, _ -> answer(true) } + .setNegativeButton("Don't allow") { _, _ -> answer(false) } + .setOnCancelListener { answer(false) } + .create() + shown.setCanceledOnTouchOutside(false) + shown.show() + // Another app's overlay shouldn't be able to trick the user into tapping Allow. + shown.getButton(DialogInterface.BUTTON_POSITIVE).filterTouchesWhenObscured = true + dialog = shown + AccessApproval.whenEnded(id) { runOnUiThread { if (!isFinishing) finish() } } + } + + private fun answer(allowed: Boolean) { + request?.let { AccessApproval.answer(it, allowed) } + finish() + } + + override fun onDestroy() { + dialog?.dismiss() + // Closed without an answer. A fold or rotation only recreates it, and it asks again. + if (isFinishing) request?.let { AccessApproval.answer(it, false) } + super.onDestroy() + } +} 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..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 @@ -32,9 +32,11 @@ 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 a termux-* command is spelled. It covers + * this bridge only: guest processes run as this app's Android user, so it isn't a hard barrier + * against a terminal in the guest. Claude Code's managed settings add allow and deny rules so it + * doesn't ask again or try a refused command. */ object AgentAccess { val categories = listOf( @@ -133,7 +135,10 @@ object AgentAccess { for (list in AccessPolicy.entries) { val existing = permissions.optJSONArray(list.id) ?: JSONArray() val kept = (0 until existing.length()).map { existing.get(it) }.filterNot { it is String && it in ours } - val added = categories.filter { policy(context, it) == list }.flatMap(::rules) + // An ask rule would only add a thread prompt, which a remote client can answer. + val added = + if (list == AccessPolicy.Ask) emptyList() + else categories.filter { policy(context, it) == list }.flatMap(::rules) if (kept.isEmpty() && added.isEmpty()) permissions.remove(list.id) else permissions.put(list.id, JSONArray(kept + added)) } 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 3f1fe66908e93..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, }), ); @@ -116,14 +117,39 @@ describe("liveActivityPreferences", () => { ...connection, environmentId: "read-only" as EnvironmentId, }; + const phoneConnection: SavedRemoteConnection = { + ...connection, + environmentId: "phone" as EnvironmentId, + displayUrl: "http://127.0.0.1:3774/", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/ws", + }; + // A relay route preferred over the loopback one that's in use. + const phoneOnFallback: SavedRemoteConnection = { + ...phoneConnection, + environmentId: "phone-fallback" as EnvironmentId, + displayUrl: "https://phone.example.test/", + }; + const configuredPhone: SavedRemoteConnection = { + ...connection, + environmentId: "configured-phone" as EnvironmentId, + }; return Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, previousEnabled: false, clerkToken: "clerk-token", - connections: [readOnlyConnection, connection], - canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId, + connections: [ + readOnlyConnection, + phoneConnection, + phoneOnFallback, + configuredPhone, + connection, + ], + canConfigureEnvironment: (environmentId) => + environmentId !== readOnlyConnection.environmentId, + phoneServerEnvironmentId: configuredPhone.environmentId, }); expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ @@ -152,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 826edaf839b91..d7b6eab62fc49 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")( @@ -12,9 +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. displayUrl follows the preferred route, which may not be the loopback one. const linkedConnections = input.connections.filter( - (connection) => connection.bearerToken !== null, + (connection) => + connection.bearerToken !== null && + connection.environmentId !== input.phoneServerEnvironmentId && + phoneServerPort(connection.displayUrl) === null && + phoneServerPort(connection.httpBaseUrl) === null, ); const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( 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..dec1677f5987d 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,10 @@ const cleanUpCloudAccount = async ( accountId: string | null, wasActive: boolean, ) => { + // 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, { 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..f19f44485e840 100644 --- a/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx +++ b/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx @@ -33,10 +33,10 @@ 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 command runs.", }, { policy: "allow", label: "Always allow", description: "Agents use it without asking." }, - { policy: "deny", label: "Never", description: "Agents can't use it." }, + { policy: "deny", label: "Never", description: "These commands are refused." }, ]; /** Asks Android for what a category needs; notification access is a system settings page. */ @@ -87,7 +87,9 @@ 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. 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/device-runtime/DeviceRuntimeSetupScreen.tsx b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx index c3104c0f33476..0b83e9542fe4d 100644 --- a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx +++ b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx @@ -16,6 +16,8 @@ import { forgetPhoneEnvironments, stopPhoneServerWhenSettled, } from "../phone/phone-server"; +import { releasePhoneSharing } from "../phone/phone-sharing-commands"; +import { PhoneSharingSection } from "../phone/PhoneSharingSection"; import { StepRow, StepText } from "../phone/SetupStep"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { SettingsSection } from "../settings/components/SettingsSection"; @@ -160,11 +162,13 @@ export function DeviceRuntimeSetupScreen() { style: "destructive", onPress: () => void run("install", async () => { + const stillListed = await releasePhoneSharing(); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); setInspection(null); await installRuntime(); + if (stillListed) setError(stillListed); }), }, ], @@ -181,9 +185,11 @@ export function DeviceRuntimeSetupScreen() { onPress: () => void run("remove", async () => { if (settings) setPhoneServerSettings({ ...settings, autoStart: false }); + const stillListed = await releasePhoneSharing(); await stopPhoneServerWhenSettled(); await native.remove(); await forgetDeviceServer(); + if (stillListed) Dialog.alert("Removed from this phone", stillListed); navigation.goBack(); }), }, @@ -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..445f1767a46f5 --- /dev/null +++ b/apps/mobile/src/features/phone/PhoneSharingSection.tsx @@ -0,0 +1,325 @@ +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, + 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"; +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 { PHONE_STILL_LISTED, type PhoneSharing } from "./phone-sharing"; +import { + changePhoneSharingMode, + checkPhoneReachability, + readPhoneSharing, + reconcilePhoneSharing, + sharePhone, + 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; +} + +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."; + } + 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 `${mode}, but other devices can't connect yet: ${reachability.reason}`; + } + if (reachability?.status === "reachable") { + return `${mode}. ${formatConnectReachable(reachability)}.`; + } + 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. */ +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, userId } = useAuth({ treatPendingAsSignedOut: false }); + const preferences = useAtomValue(mobilePreferencesAtom); + const record = usePhoneSharingRecord(); + const stopUnfinished = record?.phase === "stopping"; + 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" | "mode" | null>(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 || acting.current) return; + latest.current += 1; + const id = latest.current; + setVerifying(false); + void runAtomCommand(appAtomRegistry, readPhoneSharing, connection, { + reportFailure: false, + }).then((result) => { + if (id !== latest.current) return; + if (AsyncResult.isSuccess(result)) { + setReadError(null); + setSharing(result.value); + if (!result.value.shared) void reconcilePhoneSharing(); + } 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 (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."); + finish(); + return; + } + const failure = await sharePhone({ + connection, + accountId: userId, + 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, + mode, + modeChangePending: false, + }); + verify(); + } else { + setActionError(failure); + check(); + } + }; + + const stopSharing = async () => { + setActionError(await stopPhoneSharing()); + finish(); + refreshManagedRelayEnvironments(); + check(); + }; + + const changeMode = async (mode: EnvironmentSharingMode) => { + 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(); + }; + + const run = (kind: "share" | "stop" | "mode", action: () => Promise) => { + acting.current = true; + latest.current += 1; + setBusy(kind); + setVerifying(false); + setActionError(null); + void action(); + }; + + 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.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Stop sharing", style: "destructive", onPress: () => run("stop", stopSharing) }, + ], + ); + return; + } + 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( + 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: "Share", onPress: () => run("share", () => share(choice)) }, + ], + ); + return; + } + if (!sharing?.shared) return; + if (choice === selected) { + if (sharing.modeChangePending) run("mode", () => changeMode(choice)); + return; + } + Dialog.alert( + 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: 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-server.ts b/apps/mobile/src/features/phone/phone-server.ts index 20336d5fdf652..9bbed5702d4b3 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,9 +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 { 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. @@ -273,11 +276,26 @@ export function usePhoneServerWorker() { useEffect(() => { if (deviceRuntime !== null && down) void autoStartPhoneServer(); }, [down]); + const connected = phone?.connectionState === "connected"; + 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)) { + 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 new file mode 100644 index 0000000000000..6627210bcd8bb --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing-commands.ts @@ -0,0 +1,261 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { + deregisterManagedRelayEnvironment, + managedRelaySessionAtom, +} from "@t3tools/client-runtime/relay"; +import { request, runStream, subscribe } from "@t3tools/client-runtime/rpc"; +import { + createRuntimeCommand, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +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"; +import * as Stream from "effect/Stream"; +import { AsyncResult, AtomRegistry } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../../connection/runtime"; +import type { SavedRemoteConnection } from "../../lib/connection"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { + phoneServerSettingsAtom, + phoneSharingRecordAtom, + setPhoneSharingRecord, +} 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, + MODE_CHANGE_UNFINISHED, + PhoneSharingError, + readSharing, + setServerSharingMode, + stopServerSharing, + 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); +// 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, +) { + 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 = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:ensure-relay-client", + execute: 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.", + }); + } + }, Effect.timeout(RELAY_CLIENT_TIMEOUT)), +}); + +const linkServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:link", + execute: (input: { + readonly connection: SavedRemoteConnection; + readonly clerkToken: string; + readonly liveActivitiesEnabled: boolean; + }) => linkEnvironmentToCloudWithPreference(input).pipe(Effect.timeout(LINK_TIMEOUT)), +}); + +const stopOnServer = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:stop-sharing", + execute: (connection: SavedRemoteConnection) => + 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: ( + input: { readonly accountId: string; readonly environmentId: EnvironmentId }, + registry, + ) => + 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, { + label: "phone-server:read-sharing", + execute: readSharing, +}); + +export const checkPhoneReachability = createRuntimeCommand(connectionAtomRuntime, { + label: "phone-server:check-reachability", + execute: waitForReachability, +}); + +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; +}; + +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.asVoid), + ), + loadRecord: () => appAtomRegistry.get(phoneSharingRecordAtom), + saveRecord: async (record) => { + setPhoneSharingRecord(record); + await Effect.runPromise( + AtomRegistry.getResult(appAtomRegistry, updateMobilePreferencesAtom, { + suspendOnWaiting: true, + }).pipe(Effect.timeout(SAVE_TIMEOUT)), + ); + }, + // 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) => { + 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.", + ), + // 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 }), + "Couldn't stop sharing this phone.", + ), + deregister: async (accountId, environmentId) => + failureText( + await runAtomCommand( + appAtomRegistry, + deregisterPhone, + { accountId, environmentId }, + { reportFailure: false }, + ), + "Couldn't remove this phone from T3 Connect.", + ), +}); + +/** + * 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 sharePhone = (input: { + readonly connection: SavedRemoteConnection; + readonly accountId: string; + readonly clerkToken: string; + readonly liveActivitiesEnabled: boolean; + readonly mode: EnvironmentSharingMode; +}) => + 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(); +export const stopPhoneSharingBeforeForgetting = () => controller.stopBeforeForgetting(); + +/** + * 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 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..dbf6cc403a345 --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts @@ -0,0 +1,398 @@ +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, + mode: "view-only", + modeChangePending: false, +}); +const on: PhoneSharingRecord = { environmentId, accountId: "user_a", phase: "on" }; + +function setup(initial: PhoneSharingRecord | null = null, hydrated = async () => {}) { + 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, + 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; + }); + const deregister = vi.fn(async () => state.deregisterFailure); + const loadRecord = vi.fn(() => state.record); + const controller = createPhoneSharingController({ + hydrated, + loadRecord, + saveRecord: async (record) => { + if (state.saveFailure !== null) throw new Error(state.saveFailure); + state.record = record; + }, + phoneEnvironmentId: () => environmentId, + connectionFor: () => (state.connected ? connection : null), + signedInAccount: () => state.account, + readSharing: async () => (state.connected ? state.sharing : null), + ensureRelayClient: async () => null, + setSharingMode, + stopOnServer, + deregister, + }); + return { state, controller, setSharingMode, stopOnServer, deregister, loadRecord }; +} + +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", + "view-only", + () => + new Promise((resolve) => { + finishLink = resolve; + }), + ); + 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 stopped; + expect(stopOnServer).toHaveBeenCalled(); + 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"); + 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(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", "view-only", 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", "view-only", link)).toContain( + "already shared", + ); + state.sharing = null; + expect(await controller.share(connection, "user_a", "view-only", 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; + 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("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 = "relay down"; + 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"); + + state.deregisterFailure = null; + expect(await controller.reconcile()).toBeNull(); + expect(deregister).toHaveBeenCalledTimes(2); + expect(state.record).toBeNull(); + }); + + 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).toHaveBeenCalledOnce(); + expect(state.record).toBeNull(); + }); + + 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); + }); + + 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("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("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("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", "view-only", 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 = () => {}; + 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(); + }); +}); 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..456c0830e4f7f --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -0,0 +1,207 @@ +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, 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."; + +export interface PhoneSharingEffects { + /** 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. */ + 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. 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 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. */ + 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, + * 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(); + // 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 { + await effects.hydrated(); + return await task(); + } catch (error) { + return error instanceof Error ? error.message : "Couldn't update phone sharing."; + } + }); + queue = run; + 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); + if (failure !== null) return failure; + record = { ...record, phase: "listed" }; + await effects.saveRecord(record); + } + const owner = record.accountId; + if (owner !== null) { + if (effects.signedInAccount() !== owner) return PHONE_STILL_LISTED; + if ((await effects.deregister(owner, record.environmentId)) !== null) { + return PHONE_STILL_LISTED; + } + } + await effects.saveRecord(null); + return null; + }; + + /** 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 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", + }); + } + } else if (sharing?.shared === true) { + await effects.saveRecord({ + ...current, + accountId: sharing.cloudUserId ?? current.accountId, + phase: "stopping", + }); + } + }; + + return { + /** Null when the share took; otherwise why it didn't, with its setup already undone. */ + share: ( + connection: SavedRemoteConnection, + accountId: string, + mode: EnvironmentSharingMode, + 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); + 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(); + 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."; + }), + + /** 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; + 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 + * up or still open to an account this app has since left. + */ + reconcile: () => + enqueue(async () => { + await honourOwedStop(); + 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 ((account !== null && account !== record.accountId) || sharing?.shared === false) { + await effects.saveRecord({ ...record, phase: "stopping" }); + } + } + 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") { + await 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 ?? 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 new file mode 100644 index 0000000000000..931d695b66602 --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing.test.ts @@ -0,0 +1,173 @@ +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, + setServerSharingMode, + 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 = { + sharingModeChangePending: false, + linked: false, + linkRevoked: false, + cloudUserId: null, + relayUrl: null, + relayIssuer: null, + managedTunnelActive: false, + publishAgentActivity: false, + holdWebhooksWhileOffline: false, +}; + +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" } }); + } + 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) => { + 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 }; +} + +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( + phoneSharingFromLinkState({ + ...linkState, + linked: true, + linkRevoked: true, + cloudUserId: "user_a", + managedTunnelActive: true, + }), + ).toEqual({ + shared: true, + revoked: true, + cloudUserId: "user_a", + reachability: null, + mode: "full-control", + modeChangePending: false, + }); + expect(phoneSharingFromLinkState({ ...linkState, managedTunnelActive: true }).shared).toBe( + true, + ); + }); +}); + +describe("stopServerSharing", () => { + 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", + ]); + }).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* () { + 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)); + }); +}); + +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 new file mode 100644 index 0000000000000..ed23ed4b81511 --- /dev/null +++ b/apps/mobile/src/features/phone/phone-sharing.ts @@ -0,0 +1,162 @@ +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import type { + EnvironmentCloudLinkStateResult, + EnvironmentConnectReachability, + EnvironmentSharingMode, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; + +import type { SavedRemoteConnection } from "../../lib/connection"; + +export class PhoneSharingError extends Schema.TaggedError()( + "PhoneSharingError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +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 = ( + | { readonly shared: false } + | { + 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; + 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 { + 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, + }; +} + +// `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 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) => + 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}` } }; +}); + +export const readSharing = Effect.fn("PhoneSharing.read")(function* ( + connection: SavedRemoteConnection, +) { + const { api, headers } = yield* phoneServerApi(connection); + const state = yield* api.connect + .linkState({ headers }) + .pipe( + Effect.timeout(READ_TIMEOUT), + Effect.mapError(failWith("Couldn't check whether this phone is shared.")), + ); + 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, +) { + 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); +}); + +/** + * 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. + */ +export const stopServerSharing = Effect.fn("PhoneSharing.stopServerSharing")(function* ( + connection: SavedRemoteConnection, + 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(UNLINK_TIMEOUT), + Effect.mapError(failWith("Couldn't stop sharing this phone.")), + ); + yield* Effect.gen(function* () { + yield* api.auth.revokeOtherClients({ headers }); + const ids = yield* pairingLinkIds; + yield* Effect.forEach(ids, (id) => api.auth.revokePairingLink({ headers, payload: { id } }), { + discard: true, + }); + }).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/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index cabe229f9a262..2c1f0d1ce226c 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -42,6 +42,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"; @@ -167,6 +168,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.liveActivitiesEnabled !== false; + const phoneServerEnvironmentId = usePhoneServerSettings()?.environmentId ?? null; const connections = Object.values(savedConnectionsById); const environmentCount = connections.length; @@ -293,6 +295,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ canConfigureEnvironment: canLinkEnvironmentToConnect, + phoneServerEnvironmentId, enabled: true, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, @@ -371,6 +374,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ canConfigureEnvironment: canLinkEnvironmentToConnect, + 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 52d381bf20676..f49d7a95d37b9 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -76,6 +76,19 @@ export const PhoneServerSettings = Schema.Struct({ }); 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"]), +}); +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. @@ -110,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, @@ -161,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; } @@ -242,6 +257,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { pullRequestListFilters?: PullRequestListPreferences; environmentPower?: Readonly>; phoneServer?: PhoneServerSettings; + phoneSharing?: PhoneSharingRecord; workspaceSelections?: WorkspaceSelections; } = {}; @@ -401,6 +417,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 0ca29625fd916..8ae3480a59edd 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -115,6 +115,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); @@ -158,6 +160,70 @@ 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 { + const phoneServer = await loadPhoneServer(); + 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,6 +260,14 @@ export function useRemoteConnections() { void controller.removeEnvironment(environmentId); }, } 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) { + confirmRemovePhone(environment.environmentLabel, () => { + setPhoneServerSettings(null); + void controller.removeEnvironment(environmentId); + }); + return; + } if (environment.isRelayManaged) { const registration = Discovery.relayEnvironmentRegistration( appAtomRegistry.get(relayEnvironmentDiscovery.stateValueAtom), @@ -212,53 +286,6 @@ export function useRemoteConnections() { ); return; } - 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 = () => { - setPhoneServerSettings(null); - void controller.removeEnvironment(environmentId); - }; - 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 { - const phoneServer = await loadPhoneServer(); - await phoneServer.stopPhoneServerWhenSettled(); - forgetPhone(); - } 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: forgetPhone }, - ], - ); - } - }; - 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: forgetPhone }, - { - text: "Remove and stop server", - style: "destructive", - onPress: () => void stopThenForget(), - }, - ], - ); - 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.`, diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 39a333d611679..76ec45d6e77b9 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -33,7 +33,9 @@ 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"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -529,6 +531,16 @@ 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, + ) => Effect.Effect; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; @@ -559,6 +571,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, @@ -713,6 +742,9 @@ 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 accessGenerationRef = yield* Ref.make(0); const secretStore = yield* ServerSecretStore.ServerSecretStore; const digest = yield* Digest.Digest; const descriptor = yield* policy.getDescriptor(); @@ -878,23 +910,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, @@ -955,64 +993,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 toPairingCredential = (issued: IssuedPairingLink): AuthPairingCredentialResult => ({ @@ -1198,6 +1241,24 @@ 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( + Effect.andThen(listPairingLinks()), + 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) => { @@ -1341,6 +1402,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, ) => { @@ -1399,12 +1473,17 @@ export const make = Effect.gen(function* () { listClientSessions, revokeClientSession, revokeOtherClientSessions, + withGrantLock: credentialExchangeLock.withPermits(1), + isSessionActive, + revokeOtherAccess, authenticateHttpRequest, authenticateWebSocketUpgrade, issueWebSocketTicket, issueStartupPairingUrl, authenticateMcpClient, issueMcpClientSession, + accessGeneration: Ref.get(accessGenerationRef), + redeemMcpApproval, consumeMcpApprovalCode, authenticateBrowserSession, }); @@ -1455,7 +1534,9 @@ const revokePairedClientsThePolicyDisallows = Effect.fn( } }); -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 22e7c4446b8ab..f62d485d7503f 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -2,13 +2,16 @@ import { AuthStandardClientScopes, type ManagedPolicyDocument } from "@t3tools/c import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, 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 ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; @@ -52,6 +55,39 @@ const desktopAndLocalOnly: ManagedPolicyDocument = { limits: { clients: ["desktop", "local-browser"] }, }; +// 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.provide(ManagedPolicy.layerTest()), + 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(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( "EnvironmentAuth administrative operations", (it) => { @@ -126,6 +162,96 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( }).pipe(Effect.provide(layerEnvironmentAuth())), ); + 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 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 = { + 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 d2d6c1d3b9967..30e6295bd8a0f 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -382,6 +382,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* () { @@ -560,6 +592,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/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 0be7c1ebb2624..a44dde3915400 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -189,6 +189,7 @@ interface PendingCode { readonly codeChallenge: string; readonly resource: string; readonly access: AuthMcpClientAccess; + readonly approvedAtGeneration: number; readonly expiresAtMs: number; } @@ -425,6 +426,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* random.randomBytes(32)).toString("base64url"); @@ -438,6 +440,7 @@ const make = Effect.gen(function* () { codeChallenge: authorization.codeChallenge, resource: authorization.resource, access, + approvedAtGeneration, expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, }); return next; @@ -447,6 +450,8 @@ const make = Effect.gen(function* () { const approve: McpOAuth["Service"]["approve"] = Effect.fn("McpOAuth.approve")(function* (input) { const { decision } = input; + // 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( Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => @@ -473,7 +478,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, @@ -484,7 +489,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, { @@ -533,11 +538,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) => diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 8044740a7e2ee..3f8fbf3a2780c 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, @@ -25,6 +27,8 @@ import * as RpcTest from "effect/rpc/RpcTest"; import { RPC_REQUIRED_SCOPES, + deviceListIsPassive, + readOnlyVcsStatusInput, requiredScopeForRpcMethod, requiredScopeForDeviceList, } from "./RpcAuthorization.ts"; @@ -318,6 +322,61 @@ 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); + 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, + 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.mcpAppsReadResource, + WS_METHODS.deviceDetail, + WS_METHODS.serverListAcpRegistrySessions, + WS_METHODS.serverListAcpRegistryProviders, + 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/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3ba7af66de98a..5870a340fb702 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -93,10 +93,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, @@ -165,7 +166,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, @@ -204,7 +206,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, @@ -293,6 +296,18 @@ 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)); + export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => input.retryHostId || input.updateTool ? AuthOrchestrationOperateScope diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index c02da89d7d46d..0d3c450340003 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -458,7 +458,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 8de291e3fdac3..2bf1fce17a54a 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"; @@ -7,7 +17,7 @@ import * as Option from "effect/Option"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import { HttpClient, HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as EnvironmentKeys from "./environmentKeys.ts"; @@ -22,6 +32,9 @@ 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, @@ -30,6 +43,36 @@ import { import * as ManagedPolicy from "../policy/ManagedPolicy.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, Effect.provide(NodePlatformCrypto.layerSignature)); const withService = ( options: { @@ -42,9 +85,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, @@ -55,6 +102,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( @@ -75,7 +127,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, @@ -93,6 +146,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")), @@ -103,7 +158,10 @@ const withService = ( Layer.mock(ConnectReachability.ConnectReachability)({ latest: Effect.succeedNone, }), - Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + 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), @@ -114,16 +172,26 @@ const withService = ( HttpClient.make(() => Effect.die("unused")), ), NodeServices.layer, - NodeCrypto.layer, + NodePlatformCrypto.layer, ); 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( - // These tests never sign proofs; the mocked secret store cannot create a key pair. - Layer.provide(Layer.mock(EnvironmentKeys.EnvironmentSigningKey)({ publicKey: "" })), + // The mocked secret store can't create a key pair, and no test checks a signature. + Layer.provide( + Layer.mock(EnvironmentKeys.EnvironmentSigningKey)({ + publicKey: "", + sign: () => Effect.succeed("signed"), + }), + ), Layer.provide(layerDependencies), ), ), @@ -257,6 +325,275 @@ 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 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( + { + 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]); + }), + ); +}); + it.effect("unlinks on startup and refuses to link when the policy turns Connect off", () => Effect.gen(function* () { const stored = new Map([ @@ -297,7 +634,7 @@ it.effect("unlinks on startup and refuses to link when the policy turns Connect HttpClient.make(() => Effect.die("unused")), ), NodeServices.layer, - NodeCrypto.layer, + NodePlatformCrypto.layer, ); yield* Effect.gen(function* () { const link = yield* CloudLink.CloudLink; @@ -306,8 +643,10 @@ it.effect("unlinks on startup and refuses to link when the policy turns Connect const applyError = yield* link.applyRelayConfig({} as never).pipe(Effect.flip); const mintError = yield* link.mintCredential({} as never).pipe(Effect.flip); + const modeError = yield* link.setSharingMode("view-only", callerSession).pipe(Effect.flip); assert.equal(applyError.message, "Example Corp's policy does not allow T3 Connect."); assert.equal(mintError._tag, "ManagedPolicyRefusedError"); + assert.equal(modeError._tag, "ManagedPolicyRefusedError"); }).pipe( Effect.provide( CloudLink.layer.pipe( diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 37ac9d9b743c1..b1bd480752b91 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -1,9 +1,12 @@ import { + type AuthSessionId, AuthStandardClientScopes, + AuthViewOnlyClientScopes, EnvironmentAuthenticatedPrincipal, type EnvironmentCloudLinkStateResult, type EnvironmentCloudPreferencesRequest, type EnvironmentCloudRelayConfigResult, + type EnvironmentSharingMode, } from "@t3tools/contracts"; import { RelayCloudEnvironmentHealthProofPayload, @@ -81,6 +84,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, @@ -253,6 +258,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; @@ -279,6 +285,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, @@ -454,6 +464,14 @@ export class CloudLink extends Context.Service< EnvironmentCloudLinkStateResult, CloudLinkNotLinkedError | CloudLinkInternalError | ManagedLimits.ManagedPolicyRefusedError >; + /** Signs out everyone but the caller, so nobody keeps what the previous mode gave them. */ + readonly setSharingMode: ( + mode: EnvironmentSharingMode, + callerSessionId: AuthSessionId, + ) => Effect.Effect< + EnvironmentCloudLinkStateResult, + CloudLinkInternalError | ManagedLimits.ManagedPolicyRefusedError + >; readonly answerHealthRequest: ( request: RelayCloudEnvironmentHealthRequest, ) => Effect.Effect< @@ -1270,6 +1288,8 @@ const make = Effect.gen(function* () { holdWebhooks, revoked, latestReachability, + sharingMode, + sharingModeChangePending, ] = yield* Effect.all( [ secrets.get(CLOUD_LINKED_USER_ID), @@ -1280,8 +1300,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 }, ); // A not-linked check predates the stored tunnel config; the next check replaces it. const currentReachability = Option.filter( @@ -1303,6 +1325,8 @@ const make = Effect.gen(function* () { holdWebhooksWhileOffline: Option.isSome(holdWebhooks) && bytesToString(holdWebhooks.value) === "true", ...(Option.isSome(currentReachability) ? { reachability: currentReachability.value } : {}), + ...(Option.isSome(sharingMode) ? { sharingMode: decodeSharingMode(sharingMode.value) } : {}), + sharingModeChangePending: Option.isSome(sharingModeChangePending), } satisfies EnvironmentCloudLinkStateResult; }); @@ -1412,6 +1436,31 @@ 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) { + // 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)); + 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 @@ -1578,13 +1627,22 @@ const make = Effect.gen(function* () { return yield* new CloudLinkProofReplayedError({ request: "mint" }); } - 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), @@ -1641,6 +1699,7 @@ const make = Effect.gen(function* () { linkState, unlink, updatePreferences: refused, + setSharingMode: refused, answerHealthRequest: refused, mintCredential: refused, reconcileDesiredLinkIfStillDesired: notDesired, @@ -1657,6 +1716,7 @@ const make = Effect.gen(function* () { linkState, unlink, updatePreferences, + setSharingMode, answerHealthRequest, mintCredential, reconcileDesiredLinkIfStillDesired, diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index d1f95140fa0a6..207b38ca26977 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -31,20 +31,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")); @@ -124,6 +127,40 @@ 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, + // CI runs tests with a node that isn't on PATH. + [ + "#!/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); + 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 66ef48f547730..7a26992308d2d 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -114,6 +114,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 @@ -550,7 +553,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); } }); @@ -744,6 +749,7 @@ export const make = Effect.gen(function* () { ], { detached: false, + forceKillAfter: RELAY_CLIENT_FORCE_KILL_AFTER, env: { ...env, TUNNEL_TOKEN: config.connectorToken, 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 2d3dee882dcd3..885f515624d21 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -156,7 +156,11 @@ const linkProofClient = Effect.gen(function* () { const payload = (yield* responseJson(response)) as { access_token: string }; return payload.access_token; }); - return { post, exchange }; + const get = (path: string, headers?: Record) => + Effect.promise(() => + handler(new Request(`${NON_LOOPBACK_ORIGIN}${path}`, headers ? { headers } : {})), + ); + return { post, get, exchange }; }); describe("link proof over a non-loopback desktop transport", () => { @@ -290,3 +294,82 @@ describe("link proof over a non-loopback desktop transport", () => { }), ); }); + +describe("sharing mode", () => { + const pairedSession = Effect.fnUntraced(function* ( + { post, exchange }: Effect.Success, + scopes: ReadonlyArray, + ) { + const phone = yield* exchange(DESKTOP_BOOTSTRAP_TOKEN); + const paired = yield* post( + "/api/auth/pairing-token", + { scopes }, + { authorization: `Bearer ${phone}` }, + ); + const { credential } = (yield* responseJson(paired)) as { credential: string }; + return { phone, other: yield* exchange(credential) }; + }); + + it.effect("changes the mode, signs out other clients and keeps the caller", () => + Effect.gen(function* () { + const client = yield* linkProofClient; + const { post, get } = client; + const { phone, other } = yield* pairedSession(client, ["orchestration:read"]); + const headers = { authorization: `Bearer ${phone}` }; + + const changed = yield* post("/api/connect/sharing-mode", { mode: "view-only" }, headers); + expect(changed.status).toBe(200); + expect(yield* responseJson(changed)).toMatchObject({ + sharingMode: "view-only", + sharingModeChangePending: false, + }); + + const state = yield* get("/api/connect/link-state", headers); + expect(yield* responseJson(state)).toMatchObject({ sharingMode: "view-only" }); + const otherSession = yield* get("/api/auth/session", { authorization: `Bearer ${other}` }); + expect(yield* responseJson(otherSession)).toMatchObject({ authenticated: false }); + }), + ); + + it.effect("reports no mode for a host that never set one", () => + Effect.gen(function* () { + const { get, exchange } = yield* linkProofClient; + const token = yield* exchange(DESKTOP_BOOTSTRAP_TOKEN); + const state = yield* get("/api/connect/link-state", { authorization: `Bearer ${token}` }); + expect(yield* responseJson(state)).not.toHaveProperty("sharingMode"); + }), + ); + + it.effect.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 }) => + Effect.gen(function* () { + const client = yield* linkProofClient; + const { other: viewer } = yield* pairedSession(client, ["orchestration:read"]); + const refused = yield* client.post(path, payload, { authorization: `Bearer ${viewer}` }); + expect(refused.status).toBe(403); + expect(yield* responseJson(refused)).toMatchObject({ requiredScope: scope }); + }), + ); + + it.effect("needs both relay and access write, since it signs clients out", () => + Effect.gen(function* () { + const client = yield* linkProofClient; + const { other: relayOnly } = yield* pairedSession(client, ["relay:write"]); + const refused = yield* client.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 857cb235f267e..393e3b13aa89a 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -1,4 +1,5 @@ import { + AuthAccessWriteScope, AuthRelayReadScope, AuthRelayWriteScope, EnvironmentCloudEndpointUnavailableError, @@ -146,6 +147,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/device/DeviceHubProxy.stream.test.ts b/apps/server/src/device/DeviceHubProxy.stream.test.ts index 6ff2a3e30c4cd..cb89774990fe4 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 * as NodeWS from "@effect/platform-node/NodeWS"; 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 06105032a5a3f..0480f8e2c1645 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -16,6 +16,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"; @@ -95,9 +96,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> = {}, ) { @@ -107,28 +137,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, @@ -200,7 +212,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; @@ -220,9 +232,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 c4a709ed8acc4..e79adadb929eb 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.url); - 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.url); + 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 e46a0c76adb53..5e52bbc53dd9a 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"; @@ -52,7 +53,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 = requestUrl(request); if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); @@ -198,7 +202,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({ @@ -288,10 +302,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); }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index bb586d5b9f4b4..a9c36bbda636c 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5133, "codexAdditionalContextBytes": 6863, - "mcpToolListJsonBytes": 47498 + "mcpToolListJsonBytes": 47530 } diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ca53e5e394304..522ac03c03057 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { + AuthOrchestrationOperateScope, AuthSessionId, EnvironmentId, ProjectId, @@ -1102,7 +1103,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 98c14b4861d54..a3fda447accc5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,12 @@ 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, + readOnlyVcsStatusInput, + 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"; @@ -2436,6 +2441,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* providerRegistry.invalidateModelCaches({ instanceId: input.instanceId, @@ -3071,14 +3080,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({ @@ -3182,7 +3201,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), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 192505ea45408..b68ab8b98c1a9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3450,7 +3450,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 95fa71d95323b..268184d8e5024 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, clampManagedRuntimeMode, managedRuntimeModeDenial, ProviderDriverKind, @@ -261,7 +262,11 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useSoftwareKeyboardOpen } from "../../hooks/useSoftwareKeyboardOpen"; import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; -import { readEnvironmentScope, readPreparedConnection } from "../../state/session"; +import { + readEnvironmentScope, + readPreparedConnection, + useEnvironmentScope, +} from "../../state/session"; import type { ReviewCommentContext } from "../../reviewCommentContext"; import { useComposerRestingTransition } from "./useComposerRestingTransition"; import { ComposerCommandMenuLayer } from "./ComposerCommandMenuLayer"; @@ -1198,6 +1203,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile; const showComposerAttachAction = + canOperateThread && fileStagingLimit !== null && (!activePendingProgress || (supportsQuestionAttachments && @@ -3134,6 +3140,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 555fa7e90b268..0c8cad1225b47 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -141,6 +141,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)) ); @@ -166,6 +168,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 160b82664885d..625ab3cf3adf5 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -492,6 +492,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), @@ -503,6 +506,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; @@ -512,6 +519,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, }); @@ -802,6 +814,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,