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")
+ );
+}