From f2e718755845201a438517a510d14dfcb96183b3 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:13:42 +0700 Subject: [PATCH 1/3] feat(web,mobile): your devices in Linked environments Linked environments listed only this machine's links, so your other tailnet devices and their access were invisible. The section now merges the server's device list with its links by environmentId: each device shows how it is linked, its status, an access level, Revoke/Restore, and on web Open, which adds it to this app without a pairing code. The header holds automatic linking and the default access. Servers that cannot list devices yet show a quiet "not available" note instead of an error. Co-Authored-By: Claude Opus 5.5 --- .../connection/LinkedEnvironmentsSection.tsx | 160 +++++++++++++- .../src/features/threads/LinkRequestCard.tsx | 15 +- .../threads/thread-settings-options.ts | 16 +- apps/mobile/src/state/tailnetDevices.ts | 5 + .../components/settings/LinkedDeviceRow.tsx | 195 ++++++++++++++++++ .../settings/LinkedEnvironmentsSettings.tsx | 181 ++++++++++++---- apps/web/src/state/tailnetDevices.ts | 5 + packages/client-runtime/package.json | 8 + .../src/state/tailnetDevices.ts | 36 ++++ .../client-runtime/src/tailnetDevices.test.ts | 70 +++++++ packages/client-runtime/src/tailnetDevices.ts | 59 ++++++ 11 files changed, 694 insertions(+), 56 deletions(-) create mode 100644 apps/mobile/src/state/tailnetDevices.ts create mode 100644 apps/web/src/components/settings/LinkedDeviceRow.tsx create mode 100644 apps/web/src/state/tailnetDevices.ts create mode 100644 packages/client-runtime/src/state/tailnetDevices.ts create mode 100644 packages/client-runtime/src/tailnetDevices.test.ts create mode 100644 packages/client-runtime/src/tailnetDevices.ts diff --git a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx index 9153c9fa6a1c8..aad7b91cceeb1 100644 --- a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx +++ b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx @@ -1,16 +1,33 @@ +import { useAtomValue } from "@effect/atom-react"; import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request"; +import { + isTailnetDevicesForbidden, + linkedDeviceRows, +} from "@t3tools/client-runtime/tailnet-devices"; import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; import { useFocusEffect } from "@react-navigation/native"; -import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts"; +import { + AuthAccessReadScope, + type AuthMcpClientAccess, + type EnvironmentId, + type TailnetDevice, + type TailnetDeviceAccess, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { useCallback, useState } from "react"; -import { AppState, View } from "react-native"; +import { AppState, Pressable, View } from "react-native"; +import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; +import * as Dialog from "../../components/ui/Dialog"; +import { ListChoiceRow } from "../../components/ui/ListChoiceRow"; import { useEnvironmentServerConfig } from "../../state/entities"; import { peerLinkEnvironment } from "../../state/peerLinks"; import { useEnvironmentQuery } from "../../state/query"; import { useEnvironmentScope } from "../../state/session"; +import { tailnetDevices } from "../../state/tailnetDevices"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ACCESS_CHOICES } from "../threads/thread-settings-options"; import { SettingsSection } from "../settings/components/SettingsSection"; export function LinkedEnvironmentsSection(props: { @@ -23,6 +40,9 @@ export function LinkedEnvironmentsSection(props: { const links = useEnvironmentQuery( supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null, ); + const devices = useEnvironmentQuery( + supported ? tailnetDevices.list({ environmentId: props.environmentId, input: {} }) : null, + ); const [nowMs, setNowMs] = useState(Date.now); const hasLinks = supported && (links.data?.links.length ?? 0) > 0; useFocusEffect( @@ -40,12 +60,31 @@ export function LinkedEnvironmentsSection(props: { }; }, [hasLinks]), ); - if (!supported || links.error || !links.data || links.data.links.length === 0) return null; + if (!supported || links.error || !links.data) return null; + const rows = linkedDeviceRows({ + devices: devices.data?.devices ?? [], + links: links.data.links, + savedEnvironmentIds: new Set(), + }); + if (rows.length === 0) return null; + // Older servers, and this one until it can link devices, fail the list; that is not an error. + const devicesUnavailable = devices.error !== null && !isTailnetDevicesForbidden(devices.failure); const now = DateTime.makeUnsafe(nowMs); return ( - {links.data.links.map((link) => { + {rows.map(({ device, link }) => { + if (device !== null) { + return ( + + ); + } + if (link === null) return null; const health = peerLinkHealth(link, now); const status = health.kind === "expired" @@ -74,6 +113,119 @@ export function LinkedEnvironmentsSection(props: { ); })} + {devicesUnavailable ? ( + + Your devices are not available on this server yet. + + ) : null} + + ); +} + +const DEVICE_STATUS: Record = { + online: "Online", + offline: "Offline", + revoked: "Revoked", +}; + +/** One of your devices, with its access on the host; Revoked is one of the choices. */ +function DeviceRow(props: { + readonly hostEnvironmentId: EnvironmentId; + readonly device: TailnetDevice; + readonly restoreAccess: AuthMcpClientAccess; +}) { + const { device } = props; + const canSetAccess = useAtomValue( + tailnetDevices.setAccess.permissionAtom(props.hostEnvironmentId), + ); + const setAccess = useAtomCommand(tailnetDevices.setAccess, { reportFailure: false }); + const [expanded, setExpanded] = useState(false); + const [saving, setSaving] = useState(false); + const revoked = device.access === "revoked"; + const accessLabel = revoked + ? "Revoked" + : (ACCESS_CHOICES.find((choice) => choice.access === device.access)?.label ?? device.access); + const save = (access: TailnetDeviceAccess) => { + setSaving(true); + void setAccess({ + environmentId: props.hostEnvironmentId, + input: { environmentId: device.environmentId, access }, + }).then((result) => { + setSaving(false); + if (result._tag === "Failure") { + Dialog.alert(`Could not change ${device.label}'s access`, "Try again in a moment."); + } else { + setExpanded(false); + } + }); + }; + const choose = (access: TailnetDeviceAccess) => { + if (access === device.access) return; + if (access !== "revoked") return save(access); + Dialog.confirm({ + title: `Revoke ${device.label}?`, + message: "It loses access on every one of your devices until someone restores it.", + confirmText: "Revoke", + destructive: true, + onConfirm: () => save("revoked"), + }); + }; + return ( + + setExpanded((open) => !open)} + > + + {device.label} + + {[ + device.outgoing === "paired" ? "Paired" : "Tailnet", + DEVICE_STATUS[device.status], + ].join(" · ")} + + + {accessLabel} + {canSetAccess ? ( + + ) : null} + + {expanded ? ( + <> + {ACCESS_CHOICES.map((choice) => ( + choose(choice.access)} + /> + ))} + choose("revoked")} + /> + + ) : null} ); } diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 4150c96a34e5c..30dd70004cb74 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -37,21 +37,8 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSection } from "../settings/components/SettingsSection"; import { Button } from "../../components/ui/Button"; -import { RUNTIME_MODE_CHOICES } from "./thread-settings-options"; +import { ACCESS_CHOICES } from "./thread-settings-options"; -/** The same five levels as web's access picker: read-only, then the runtime modes. */ -const ACCESS_CHOICES: ReadonlyArray<{ - readonly access: AuthMcpClientAccess; - readonly label: string; - readonly description: string; -}> = [ - { - access: "read-only", - label: "Read only", - description: "Read projects and threads. Cannot start, message or change anything.", - }, - ...RUNTIME_MODE_CHOICES.map((choice) => ({ ...choice, access: choice.mode })), -]; const accessLabel = (access: AuthMcpClientAccess) => ACCESS_CHOICES.find((choice) => choice.access === access)?.label ?? access; diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts index 1fa369d072ab8..104bf8a7459fb 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.ts @@ -1,4 +1,4 @@ -import type { RuntimeMode } from "@t3tools/contracts"; +import type { AuthMcpClientAccess, RuntimeMode } from "@t3tools/contracts"; export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ readonly mode: RuntimeMode; @@ -45,3 +45,17 @@ export function compatibleRuntimeModeForChoices( } export { selectableProviderOptionChoices as selectableChoices } from "@t3tools/client-runtime/model-traits"; + +/** The same five levels as web's access picker: read-only, then the runtime modes. */ +export const ACCESS_CHOICES: ReadonlyArray<{ + readonly access: AuthMcpClientAccess; + readonly label: string; + readonly description: string; +}> = [ + { + access: "read-only", + label: "Read only", + description: "Read projects and threads. Cannot start, message or change anything.", + }, + ...RUNTIME_MODE_CHOICES.map((choice) => ({ ...choice, access: choice.mode })), +]; diff --git a/apps/mobile/src/state/tailnetDevices.ts b/apps/mobile/src/state/tailnetDevices.ts new file mode 100644 index 0000000000000..7471fb976f15f --- /dev/null +++ b/apps/mobile/src/state/tailnetDevices.ts @@ -0,0 +1,5 @@ +import { createTailnetDeviceAtoms } from "@t3tools/client-runtime/state/tailnetDevices"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const tailnetDevices = createTailnetDeviceAtoms(connectionAtomRuntime); diff --git a/apps/web/src/components/settings/LinkedDeviceRow.tsx b/apps/web/src/components/settings/LinkedDeviceRow.tsx new file mode 100644 index 0000000000000..c9a3834e177bf --- /dev/null +++ b/apps/web/src/components/settings/LinkedDeviceRow.tsx @@ -0,0 +1,195 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { LinkedDeviceRow as LinkedDeviceRowModel } from "@t3tools/client-runtime/tailnet-devices"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthMcpClientAccess, + type EnvironmentId, + type TailnetDevice, + type TailnetDeviceAccess, +} from "@t3tools/contracts"; +import { type ReactNode, useState } from "react"; + +import { connectPairing } from "~/connection/onboarding"; +import { requestConfirmDialog } from "~/confirmDialog"; +import { tailnetDevices } from "~/state/tailnetDevices"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { accessConfig } from "../auth/ConnectAgentSurface"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; + +const failureToast = (title: string, result: Parameters[0]) => + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: String(squashAtomCommandFailure(result)), + }), + ); + +const STATUS_DOT: Record = { + online: "bg-success", + offline: "bg-muted-foreground/30", + revoked: "bg-destructive", +}; + +/** One of your devices as `hostEnvironmentId` sees it: its access there, and opening it here. */ +export function LinkedDeviceRow({ + hostEnvironmentId, + row, + device, + restoreAccess, + forget, +}: { + readonly hostEnvironmentId: EnvironmentId; + readonly row: LinkedDeviceRowModel; + readonly device: TailnetDevice; + /** What Restore gives back: the default access for new devices. */ + readonly restoreAccess: AuthMcpClientAccess; + /** Drops this environment's paired link to the device, when it has one. */ + readonly forget: ReactNode; +}) { + const canSetAccess = useAtomValue(tailnetDevices.setAccess.permissionAtom(hostEnvironmentId)); + const canOpen = useAtomValue(tailnetDevices.openDevice.permissionAtom(hostEnvironmentId)); + const setAccess = useAtomCommand(tailnetDevices.setAccess, { reportFailure: false }); + const openDevice = useAtomCommand(tailnetDevices.openDevice, { reportFailure: false }); + const connect = useAtomCommand(connectPairing, { reportFailure: false }); + const [busy, setBusy] = useState(false); + const revoked = device.access === "revoked"; + const linkedVia = (device.outgoing ?? row.link?.origin) === "paired" ? "Paired" : "Tailnet"; + + const changeAccess = async (access: TailnetDeviceAccess) => { + if (access === device.access) return; + if (access === "revoked") { + const confirmed = await (requestConfirmDialog( + `Revoke ${device.label}? It loses access on every one of your devices until someone restores it.`, + { variant: "destructive" }, + ) ?? Promise.resolve(false)); + if (!confirmed) return; + } + setBusy(true); + const result = await setAccess({ + environmentId: hostEnvironmentId, + input: { environmentId: device.environmentId, access }, + }); + setBusy(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast(`Could not change ${device.label}'s access`, result); + } + }; + + // The credential is one-time, so the first address that pairs uses it up. + const open = async () => { + setBusy(true); + const minted = await openDevice({ + environmentId: hostEnvironmentId, + input: { environmentId: device.environmentId }, + }); + if (minted._tag === "Failure") { + setBusy(false); + if (!isAtomCommandInterrupted(minted)) failureToast(`Could not open ${device.label}`, minted); + return; + } + const { credential, urls, environmentId } = minted.value; + let last: Awaited> | null = null; + for (const host of urls) { + last = await connect({ host, pairingCode: credential, expectedEnvironmentId: environmentId }); + if (last._tag === "Success") break; + } + setBusy(false); + if (last?._tag === "Success") { + toastManager.add({ type: "success", title: `Added ${device.label} to this app` }); + } else if (last !== null && !isAtomCommandInterrupted(last)) { + failureToast(`Could not open ${device.label}`, last); + } else if (last === null) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not open ${device.label}`, + description: "It reported no address this app can reach.", + }), + ); + } + }; + + return ( +
+
+
+
+ +

{row.label}

+
+

+ {[ + linkedVia, + revoked ? "Revoked" : device.status === "online" ? "Online" : "Offline", + row.savedInApp ? "In this app" : null, + device.tailnetName, + ] + .filter((part): part is string => part !== null) + .join(" · ")} +

+
+
+ {revoked ? null : ( + + )} + {!row.savedInApp && !revoked && canOpen ? ( + + ) : null} + {canSetAccess ? ( + revoked ? ( + + ) : ( + + ) + ) : null} + {forget} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index 3b19489dd1e97..c1260e2508bd2 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -2,10 +2,16 @@ import { useAtomValue } from "@effect/atom-react"; import { linkRequestFailureMessage } from "@t3tools/client-runtime/link-request"; import { AuthAccessWriteScope, - type AuthMcpClientAccess, + AuthMcpClientAccess, type EnvironmentId, type PeerLinkSummary, + type TailnetDevicesSettings, + type TailnetDevicesUpdateSettingsInput, } from "@t3tools/contracts"; +import { + isTailnetDevicesForbidden, + linkedDeviceRows, +} from "@t3tools/client-runtime/tailnet-devices"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -20,7 +26,9 @@ import { useId, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { mintPairingCodeOn } from "~/state/peerPairingCode"; import { peerLinkEnvironment, useLinkableMachines } from "~/state/peerLinks"; +import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; +import { tailnetDevices } from "~/state/tailnetDevices"; import { useEnvironmentScope, usePreparedConnection } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { accessConfig } from "../auth/ConnectAgentSurface"; @@ -38,9 +46,12 @@ import { DialogTrigger, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { LinkAccessPicker } from "./LinkAccessPicker"; +import { LinkedDeviceRow } from "./LinkedDeviceRow"; import { ANOTHER_ADDRESS, LinkMachineSelect } from "./LinkMachineSelect"; import { SettingsRow, SettingsSection } from "./settingsLayout"; import { useNowMinute } from "../../hooks/useNowMinute"; @@ -59,36 +70,125 @@ export function LinkedEnvironmentsSettings({ readonly isSearchTarget: boolean; }) { const links = useEnvironmentQuery(peerLinkEnvironment.list({ environmentId, input: {} })); + const devices = useEnvironmentQuery(tailnetDevices.list({ environmentId, input: {} })); const canLink = useAtomValue(peerLinkEnvironment.link.permissionAtom(environmentId)); + const { environments } = useEnvironments(); const search = searchableSetting("linked-environments"); + // Older servers, and this one until it can link devices, fail the list; that is not an error. + const devicesUnavailable = devices.error !== null && !isTailnetDevicesForbidden(devices.failure); + const rows = linkedDeviceRows({ + devices: devices.data?.devices ?? [], + links: links.data?.links ?? [], + savedEnvironmentIds: new Set(environments.map((environment) => environment.environmentId)), + }); return ( : null} > + {devices.data ? ( + + ) : null} {links.error ? ( ) : !links.data ? ( - ) : links.data.links.length === 0 ? ( + ) : rows.length === 0 ? ( ) : ( - links.data.links.map((link) => ( - - )) + rows.map((row) => + row.device !== null ? ( + + ) + } + /> + ) : row.link !== null ? ( + + ) : null, + ) )} + {devicesUnavailable ? ( + + ) : null} ); } +/** Settings every one of your devices shares, changed from any of them. */ +function YourDevicesSettings({ + environmentId, + settings, +}: { + readonly environmentId: EnvironmentId; + readonly settings: TailnetDevicesSettings; +}) { + const canUpdate = useAtomValue(tailnetDevices.updateSettings.permissionAtom(environmentId)); + const updateSettings = useAtomCommand(tailnetDevices.updateSettings, { + label: "update your devices settings", + }); + const update = (input: TailnetDevicesUpdateSettingsInput) => + void updateSettings({ environmentId, input }); + return ( + <> + update({ autoLink: Boolean(checked) })} + aria-label="Link my tailnet devices automatically" + /> + } + /> + { + if (next !== null) update({ defaultAccess: next as AuthMcpClientAccess }); + }} + > + + {accessConfig[settings.defaultAccess].label} + + + {AuthMcpClientAccess.literals.map((option) => ( + + {accessConfig[option].label} + + ))} + + + } + /> + + ); +} + function LinkedEnvironmentRow({ environmentId, link, @@ -98,22 +198,6 @@ function LinkedEnvironmentRow({ }) { const nowMinute = useNowMinute(); const health = peerLinkHealth(link, DateTime.makeUnsafe(nowMinute)); - const unlink = useAtomCommand(peerLinkEnvironment.unlink, { label: "unlink environment" }); - const canUnlink = useAtomValue(peerLinkEnvironment.unlink.permissionAtom(environmentId)); - const [busy, setBusy] = useState(false); - const forget = async () => { - const confirmed = await (requestConfirmDialog( - `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Connections to end the session there too.`, - { variant: "destructive" }, - ) ?? Promise.resolve(false)); - if (!confirmed) return; - setBusy(true); - const result = await unlink({ environmentId, input: { environmentId: link.environmentId } }); - setBusy(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - failureToast("Could not forget the link", String(squashAtomCommandFailure(result))); - } - }; const status = health.kind === "expired" ? "Expired. Link it again with a new pairing code." @@ -149,23 +233,46 @@ function LinkedEnvironmentRow({

{link.urls.join(", ")}

- {canUnlink ? ( -
- -
- ) : null} +
+ +
); } +/** Drops `environmentId`'s link to `link`, after asking. Hidden without permission. */ +function ForgetLinkButton({ + environmentId, + link, +}: { + readonly environmentId: EnvironmentId; + readonly link: PeerLinkSummary; +}) { + const unlink = useAtomCommand(peerLinkEnvironment.unlink, { label: "unlink environment" }); + const canUnlink = useAtomValue(peerLinkEnvironment.unlink.permissionAtom(environmentId)); + const [busy, setBusy] = useState(false); + const forget = async () => { + const confirmed = await (requestConfirmDialog( + `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Environments settings to end the session there too.`, + { variant: "destructive" }, + ) ?? Promise.resolve(false)); + if (!confirmed) return; + setBusy(true); + const result = await unlink({ environmentId, input: { environmentId: link.environmentId } }); + setBusy(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Could not forget the link", String(squashAtomCommandFailure(result))); + } + }; + if (!canUnlink) return null; + return ( + + ); +} + function LinkEnvironmentAction({ environmentId }: { readonly environmentId: EnvironmentId }) { const [open, setOpen] = useState(false); const machines = useLinkableMachines(environmentId, open); diff --git a/apps/web/src/state/tailnetDevices.ts b/apps/web/src/state/tailnetDevices.ts new file mode 100644 index 0000000000000..7471fb976f15f --- /dev/null +++ b/apps/web/src/state/tailnetDevices.ts @@ -0,0 +1,5 @@ +import { createTailnetDeviceAtoms } from "@t3tools/client-runtime/state/tailnetDevices"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const tailnetDevices = createTailnetDeviceAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ae095a33c8743..9c4a19932ac77 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -71,6 +71,10 @@ "types": "./src/linkRequest.ts", "default": "./src/linkRequest.ts" }, + "./tailnet-devices": { + "types": "./src/tailnetDevices.ts", + "default": "./src/tailnetDevices.ts" + }, "./scheduled-task-webhook": { "types": "./src/scheduledTaskWebhook.ts", "default": "./src/scheduledTaskWebhook.ts" @@ -231,6 +235,10 @@ "types": "./src/state/peerLinks.ts", "default": "./src/state/peerLinks.ts" }, + "./state/tailnetDevices": { + "types": "./src/state/tailnetDevices.ts", + "default": "./src/state/tailnetDevices.ts" + }, "./state/peerPairingCode": { "types": "./src/state/peerPairingCode.ts", "default": "./src/state/peerPairingCode.ts" diff --git a/packages/client-runtime/src/state/tailnetDevices.ts b/packages/client-runtime/src/state/tailnetDevices.ts new file mode 100644 index 0000000000000..26298f25297ff --- /dev/null +++ b/packages/client-runtime/src/state/tailnetDevices.ts @@ -0,0 +1,36 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type { Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +/** Your tailnet devices as one environment sees them, and the changes it can make. */ +export function createTailnetDeviceAtoms( + runtime: Atom.AtomRuntime, +) { + const list = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:tailnet-devices:list", + tag: WS_METHODS.tailnetDevicesList, + staleTimeMs: 10_000, + }); + return { + list, + setAccess: createEnvironmentRpcCommand(runtime, { + label: "environment-data:tailnet-devices:set-access", + tag: WS_METHODS.tailnetDevicesSetAccess, + onSuccess: ({ environmentId }, registry) => + Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))), + }), + updateSettings: createEnvironmentRpcCommand(runtime, { + label: "environment-data:tailnet-devices:update-settings", + tag: WS_METHODS.tailnetDevicesUpdateSettings, + onSuccess: ({ environmentId }, registry) => + Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))), + }), + openDevice: createEnvironmentRpcCommand(runtime, { + label: "environment-data:tailnet-devices:open-device", + tag: WS_METHODS.tailnetDevicesOpenDevice, + }), + }; +} diff --git a/packages/client-runtime/src/tailnetDevices.test.ts b/packages/client-runtime/src/tailnetDevices.test.ts new file mode 100644 index 0000000000000..d9b60ed71ab01 --- /dev/null +++ b/packages/client-runtime/src/tailnetDevices.test.ts @@ -0,0 +1,70 @@ +import { EnvironmentId, type PeerLinkSummary, type TailnetDevice } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { isTailnetDevicesForbidden, linkedDeviceRows } from "./tailnetDevices.ts"; + +const studio = EnvironmentId.make("env-studio"); +const box = EnvironmentId.make("env-box"); +const vps = EnvironmentId.make("env-vps"); + +const device = (environmentId: EnvironmentId, label: string): TailnetDevice => ({ + environmentId, + label, + urls: [], + status: "online", + access: "full-access", + policy: null, + outgoing: "tailnet", + incoming: true, + tailnetName: null, + lastSeenAt: null, +}); +const link = (environmentId: EnvironmentId, label: string): PeerLinkSummary => ({ + environmentId, + label, + urls: [], + access: "approval-required", + origin: "paired", + linkedAt: DateTime.makeUnsafe(0), + expiresAt: DateTime.makeUnsafe(0), + lastReachedAt: null, + lastError: null, + status: "reachable", +}); + +describe("linkedDeviceRows", () => { + it("gives each environment one row, devices first, and marks those this app saved", () => { + const rows = linkedDeviceRows({ + devices: [device(studio, "Studio"), device(box, "Box")], + links: [link(vps, "VPS"), link(studio, "studio-01")], + savedEnvironmentIds: new Set([box, vps]), + }); + expect( + rows.map((row) => ({ + id: row.environmentId, + label: row.label, + device: row.device !== null, + link: row.link?.label ?? null, + savedInApp: row.savedInApp, + })), + ).toEqual([ + { id: studio, label: "Studio", device: true, link: "studio-01", savedInApp: false }, + { id: box, label: "Box", device: true, link: null, savedInApp: true }, + { id: vps, label: "VPS", device: false, link: "VPS", savedInApp: true }, + ]); + }); +}); + +describe("isTailnetDevicesForbidden", () => { + it("hides rows only when the session may not see them", () => { + expect(isTailnetDevicesForbidden({ _tag: "EnvironmentAuthorizationError" })).toBe(true); + expect(isTailnetDevicesForbidden({ _tag: "TailnetDevicesError", reason: "forbidden" })).toBe( + true, + ); + expect(isTailnetDevicesForbidden({ _tag: "TailnetDevicesError", reason: "unreachable" })).toBe( + false, + ); + expect(isTailnetDevicesForbidden(null)).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/tailnetDevices.ts b/packages/client-runtime/src/tailnetDevices.ts new file mode 100644 index 0000000000000..2f18edb9a572a --- /dev/null +++ b/packages/client-runtime/src/tailnetDevices.ts @@ -0,0 +1,59 @@ +/** + * The rows the Linked environments section shows: your tailnet devices and + * this environment's links, one row per environmentId. + */ +import type { EnvironmentId, PeerLinkSummary, TailnetDevice } from "@t3tools/contracts"; + +export interface LinkedDeviceRow { + readonly environmentId: EnvironmentId; + readonly label: string; + /** Present when the environment reports it among your devices. */ + readonly device: TailnetDevice | null; + /** This environment's link to it, from the links list. */ + readonly link: PeerLinkSummary | null; + /** This app already has it saved, so Open has nothing to add. */ + readonly savedInApp: boolean; +} + +/** Your devices in the order the environment lists them, then links to anything else. */ +export function linkedDeviceRows(input: { + readonly devices: ReadonlyArray; + readonly links: ReadonlyArray; + readonly savedEnvironmentIds: ReadonlySet; +}): ReadonlyArray { + const links = new Map(input.links.map((link) => [link.environmentId, link] as const)); + const deviceIds = new Set(input.devices.map((device) => device.environmentId)); + return [ + ...input.devices.map((device) => ({ + environmentId: device.environmentId, + label: device.label, + device, + link: links.get(device.environmentId) ?? null, + savedInApp: input.savedEnvironmentIds.has(device.environmentId), + })), + ...input.links + .filter((link) => !deviceIds.has(link.environmentId)) + .map((link) => ({ + environmentId: link.environmentId, + label: link.label, + device: null, + link, + savedInApp: input.savedEnvironmentIds.has(link.environmentId), + })), + ]; +} + +/** + * Whether listing failed because this session may not see devices, which hides + * the rows, rather than because the server cannot list them yet (older or not + * yet updated servers), which the UI reports as unavailable. + */ +export function isTailnetDevicesForbidden(failure: unknown): boolean { + if (typeof failure !== "object" || failure === null || !("_tag" in failure)) return false; + return ( + failure._tag === "EnvironmentAuthorizationError" || + (failure._tag === "TailnetDevicesError" && + "reason" in failure && + failure.reason === "forbidden") + ); +} From 2db2f96394c0bb241e75260db5ed2ffaf3ef4aba Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:52:53 +0700 Subject: [PATCH 2/3] feat(web): open your devices through registerDevice, and document them Open now asks the host for a credential and hands it to ConnectionOnboarding.registerDevice, the one path that redeems it at the first address answering as that device. Rows note when a device below full access opens read-only. The section also offers the stricter capability rule, and the remote-access guide covers your tailnet devices. Co-Authored-By: Claude Opus 5.5 --- .../components/settings/LinkedDeviceRow.tsx | 34 ++------ .../settings/LinkedEnvironmentsSettings.tsx | 13 +++ apps/web/src/connection/onboarding.ts | 18 +++- docs/user/remote-access.md | 41 ++++++++-- .../client-runtime/src/tailnetDevices.test.ts | 82 ++++++++++++++++++- 5 files changed, 156 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/settings/LinkedDeviceRow.tsx b/apps/web/src/components/settings/LinkedDeviceRow.tsx index c9a3834e177bf..2cbfaafb210f1 100644 --- a/apps/web/src/components/settings/LinkedDeviceRow.tsx +++ b/apps/web/src/components/settings/LinkedDeviceRow.tsx @@ -12,7 +12,7 @@ import { } from "@t3tools/contracts"; import { type ReactNode, useState } from "react"; -import { connectPairing } from "~/connection/onboarding"; +import { connectDevice } from "~/connection/onboarding"; import { requestConfirmDialog } from "~/confirmDialog"; import { tailnetDevices } from "~/state/tailnetDevices"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -58,7 +58,7 @@ export function LinkedDeviceRow({ const canOpen = useAtomValue(tailnetDevices.openDevice.permissionAtom(hostEnvironmentId)); const setAccess = useAtomCommand(tailnetDevices.setAccess, { reportFailure: false }); const openDevice = useAtomCommand(tailnetDevices.openDevice, { reportFailure: false }); - const connect = useAtomCommand(connectPairing, { reportFailure: false }); + const connect = useAtomCommand(connectDevice, { reportFailure: false }); const [busy, setBusy] = useState(false); const revoked = device.access === "revoked"; const linkedVia = (device.outgoing ?? row.link?.origin) === "paired" ? "Paired" : "Tailnet"; @@ -83,39 +83,22 @@ export function LinkedDeviceRow({ } }; - // The credential is one-time, so the first address that pairs uses it up. const open = async () => { setBusy(true); const minted = await openDevice({ environmentId: hostEnvironmentId, input: { environmentId: device.environmentId }, }); - if (minted._tag === "Failure") { - setBusy(false); - if (!isAtomCommandInterrupted(minted)) failureToast(`Could not open ${device.label}`, minted); - return; - } - const { credential, urls, environmentId } = minted.value; - let last: Awaited> | null = null; - for (const host of urls) { - last = await connect({ host, pairingCode: credential, expectedEnvironmentId: environmentId }); - if (last._tag === "Success") break; - } + const result = minted._tag === "Success" ? await connect(minted.value) : minted; setBusy(false); - if (last?._tag === "Success") { + if (result._tag === "Success") { toastManager.add({ type: "success", title: `Added ${device.label} to this app` }); - } else if (last !== null && !isAtomCommandInterrupted(last)) { - failureToast(`Could not open ${device.label}`, last); - } else if (last === null) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not open ${device.label}`, - description: "It reported no address this app can reach.", - }), - ); + } else if (!isAtomCommandInterrupted(result)) { + failureToast(`Could not open ${device.label}`, result); } }; + // The server opens the app with standard permissions only at full access (`TailnetDevices.ts`). + const opensReadOnly = device.access !== "full-access"; return (
@@ -134,6 +117,7 @@ export function LinkedDeviceRow({ linkedVia, revoked ? "Revoked" : device.status === "online" ? "Online" : "Offline", row.savedInApp ? "In this app" : null, + !row.savedInApp && !revoked && opensReadOnly ? "Opens read-only" : null, device.tailnetName, ] .filter((part): part is string => part !== null) diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index c1260e2508bd2..0d909de5dc31e 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -5,6 +5,7 @@ import { AuthMcpClientAccess, type EnvironmentId, type PeerLinkSummary, + TAILNET_DEVICE_CAPABILITY, type TailnetDevicesSettings, type TailnetDevicesUpdateSettingsInput, } from "@t3tools/contracts"; @@ -161,6 +162,18 @@ function YourDevicesSettings({ /> } /> + update({ requireCapability: Boolean(checked) })} + aria-label="Only devices with the T3 capability" + /> + } + /> device.environmentId, + }, + execute: (device: TailnetDeviceClientCredential) => + ConnectionOnboarding.ConnectionOnboarding.pipe( + Effect.flatMap((onboarding) => onboarding.registerDevice(device)), + ), +}); + export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime, { label: "web:connection:connect-ssh", scheduler: onboardingScheduler, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 69c355e0208d7..95892b710234a 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -278,11 +278,42 @@ Link two machines, such as your laptop and a VPS, and agents on one can work on the other: launch, message, wait on and stop its threads, answer their questions, and manage its queues, projects and scheduled tasks. -Linked environments is a beta. Turn it on for both machines in **Settings → -Labs**; on mobile, **Settings → Labs** lists each connected machine. While it is -off on either machine, agents start nothing over a link between them. A task -already running on the other machine still finishes, and if that machine has it -off, its result arrives once it is back on. +Linked environments is on by default; turn it off for a machine in **Settings → +Labs**, or on mobile in **Settings → Labs**. While it is off on either machine, +agents start nothing over a link between them. A task already running on the +other machine still finishes, and if that machine has it off, its result +arrives once it is back on. + +### Your tailnet devices + +Machines running T3 Code that are signed in to the same Tailscale account link +to each other on their own, both ways, with no pairing code. Install T3 Code on +a new machine, sign in to Tailscale, and within ten minutes it appears in +**Settings → Environments → Linked environments** on your other machines. +Tagged nodes and machines shared from someone else's tailnet never link this +way. + +- Each device's access there is the most it may do on your machines. Change it + from any of them and the change reaches all of them. **Revoke** cuts the + device off everywhere; **Restore** gives it back the default access. +- New devices get the default access set in the same section, and you can turn + automatic linking off there. +- **Open** adds a device to this app without a code. Below full access, it + opens read-only. +- Other people's machines, and anything off your tailnet, still link with a + pairing code as described below. + +To count only machines you choose, turn on **Only devices with the T3 +capability** and grant `t3.codes/device` in your Tailscale policy file, from +the machines that should count to your T3 machines: + +```json +"grants": [ + { "src": ["group:me"], "dst": ["group:me"], "app": { "t3.codes/device": [{}] } } +] +``` + +### Link with a pairing code On the machine you will work from, open **Settings → Environments → Linked environments**, choose **Link**, and pick the other machine or enter its address. The list diff --git a/packages/client-runtime/src/tailnetDevices.test.ts b/packages/client-runtime/src/tailnetDevices.test.ts index d9b60ed71ab01..d8219e2b97be3 100644 --- a/packages/client-runtime/src/tailnetDevices.test.ts +++ b/packages/client-runtime/src/tailnetDevices.test.ts @@ -1,4 +1,10 @@ -import { EnvironmentId, type PeerLinkSummary, type TailnetDevice } from "@t3tools/contracts"; +import { + EnvironmentId, + type PeerLinkSummary, + type TailnetDevice, + TailnetDevicesSnapshot, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; @@ -56,6 +62,80 @@ describe("linkedDeviceRows", () => { }); }); +// What `TailnetDevices.list` sends: a paired link, a tailnet device it links to, +// and one that only holds a session here, as JSON on the wire. +const WIRE_SNAPSHOT = { + self: { environmentId: "env-here", label: "Here", urls: ["https://here.ts.net"] }, + settings: { + autoLink: true, + defaultAccess: "full-access", + requireCapability: false, + updatedAt: null, + updatedBy: null, + }, + devices: [ + { + environmentId: "env-box", + label: "Box", + urls: ["https://box.ts.net"], + status: "revoked", + access: "revoked", + policy: { + environmentId: "env-box", + access: "revoked", + updatedAt: { wallMs: 1, counter: 0, node: "env-here" }, + updatedBy: "env-here", + }, + outgoing: null, + incoming: true, + tailnetName: "box", + lastSeenAt: null, + }, + { + environmentId: "env-studio", + label: "studio-01", + urls: ["https://studio-01.ts.net"], + status: "online", + access: "full-access", + policy: null, + outgoing: "tailnet", + incoming: true, + tailnetName: "studio-01", + lastSeenAt: "2026-10-30T10:00:00.000Z", + }, + { + environmentId: "env-vps", + label: "VPS", + urls: ["https://vps.example.com"], + status: "offline", + access: "approval-required", + policy: null, + outgoing: "paired", + incoming: false, + tailnetName: null, + lastSeenAt: null, + }, + ], +}; + +describe("linkedDeviceRows over the server's snapshot", () => { + it("keeps every listed device, joined to the link it came from", () => { + const snapshot = Schema.decodeSync(Schema.toCodecJson(TailnetDevicesSnapshot))(WIRE_SNAPSHOT); + const rows = linkedDeviceRows({ + devices: snapshot.devices, + links: [link(vps, "VPS"), link(studio, "studio-01")], + savedEnvironmentIds: new Set([studio]), + }); + expect( + rows.map((row) => [row.environmentId, row.device?.access, row.link !== null, row.savedInApp]), + ).toEqual([ + [box, "revoked", false, false], + [studio, "full-access", true, true], + [vps, "approval-required", true, false], + ]); + }); +}); + describe("isTailnetDevicesForbidden", () => { it("hides rows only when the session may not see them", () => { expect(isTailnetDevicesForbidden({ _tag: "EnvironmentAuthorizationError" })).toBe(true); From af71d17c78557c6dab2c5758beb6132e5acf44b9 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:00:04 +0700 Subject: [PATCH 3/3] test(client-runtime): hoist the wire snapshot decoder Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/tailnetDevices.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/tailnetDevices.test.ts b/packages/client-runtime/src/tailnetDevices.test.ts index d8219e2b97be3..b4cf977a2c8bf 100644 --- a/packages/client-runtime/src/tailnetDevices.test.ts +++ b/packages/client-runtime/src/tailnetDevices.test.ts @@ -118,9 +118,11 @@ const WIRE_SNAPSHOT = { ], }; +const decodeWireSnapshot = Schema.decodeSync(Schema.toCodecJson(TailnetDevicesSnapshot)); + describe("linkedDeviceRows over the server's snapshot", () => { it("keeps every listed device, joined to the link it came from", () => { - const snapshot = Schema.decodeSync(Schema.toCodecJson(TailnetDevicesSnapshot))(WIRE_SNAPSHOT); + const snapshot = decodeWireSnapshot(WIRE_SNAPSHOT); const rows = linkedDeviceRows({ devices: snapshot.devices, links: [link(vps, "VPS"), link(studio, "studio-01")],