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..2cbfaafb210f1 --- /dev/null +++ b/apps/web/src/components/settings/LinkedDeviceRow.tsx @@ -0,0 +1,179 @@ +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 { connectDevice } 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(connectDevice, { 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); + } + }; + + const open = async () => { + setBusy(true); + const minted = await openDevice({ + environmentId: hostEnvironmentId, + input: { environmentId: device.environmentId }, + }); + const result = minted._tag === "Success" ? await connect(minted.value) : minted; + setBusy(false); + if (result._tag === "Success") { + toastManager.add({ type: "success", title: `Added ${device.label} to this app` }); + } 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 ( +
+
+
+
+ +

{row.label}

+
+

+ {[ + 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) + .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..0d909de5dc31e 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -2,10 +2,17 @@ import { useAtomValue } from "@effect/atom-react"; import { linkRequestFailureMessage } from "@t3tools/client-runtime/link-request"; import { AuthAccessWriteScope, - type AuthMcpClientAccess, + AuthMcpClientAccess, type EnvironmentId, type PeerLinkSummary, + TAILNET_DEVICE_CAPABILITY, + type TailnetDevicesSettings, + type TailnetDevicesUpdateSettingsInput, } from "@t3tools/contracts"; +import { + isTailnetDevicesForbidden, + linkedDeviceRows, +} from "@t3tools/client-runtime/tailnet-devices"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -20,7 +27,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 +47,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 +71,137 @@ 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" + /> + } + /> + update({ requireCapability: Boolean(checked) })} + aria-label="Only devices with the T3 capability" + /> + } + /> + { + 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 +211,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 +246,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/connection/onboarding.ts b/apps/web/src/connection/onboarding.ts index c4368b0b77e9a..a51cfed363847 100644 --- a/apps/web/src/connection/onboarding.ts +++ b/apps/web/src/connection/onboarding.ts @@ -3,7 +3,10 @@ import { createAtomCommandScheduler, createRuntimeCommand, } from "@t3tools/client-runtime/state/runtime"; -import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import type { + DesktopSshEnvironmentTarget, + TailnetDeviceClientCredential, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { connectionAtomRuntime } from "./runtime"; @@ -23,6 +26,19 @@ export const connectPairing = createRuntimeCommand(connectionAtomRuntime, { ), }); +export const connectDevice = createRuntimeCommand(connectionAtomRuntime, { + label: "web:connection:connect-device", + scheduler: onboardingScheduler, + concurrency: { + mode: "singleFlight", + key: (device: TailnetDeviceClientCredential) => 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/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/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/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..b4cf977a2c8bf --- /dev/null +++ b/packages/client-runtime/src/tailnetDevices.test.ts @@ -0,0 +1,152 @@ +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"; + +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 }, + ]); + }); +}); + +// 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, + }, + ], +}; + +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 = decodeWireSnapshot(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); + 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") + ); +}