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