1import {
2 type AuthMcpClientAccess,
3 type EnvironmentId,
4 type EnvironmentMachineKind,
5 type OrchestrationV2TurnItem,
6 type PeerLinkRequestAnswerInput,
7 type PeerLinkSummary,
8 resolveEnvironmentMachineKind,
9 type ServerConfig,
10} from "@t3tools/contracts";
11import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";
12import * as Option from "effect/Option";
13
14import type { EnvironmentPresentation } from "./connection/presentation.ts";
15import { connectionRoutes, routeHttpBaseUrl } from "./connection/routes.ts";
16import type { RelayDiscoveredEnvironment } from "./relay/discovery.ts";
17
18export type LinkRequestItem = Extract<OrchestrationV2TurnItem, { readonly type: "link_request" }>;
19
20/** Whether an environment offers links: it supports them and its Linked environments beta is on. */
21export const offersLinkedEnvironments = (
22 config: Pick<ServerConfig, "environment" | "settings"> | null | undefined,
23): boolean =>
24 config?.environment.capabilities.peerLinks === true && config.settings.linkedEnvironmentsEnabled;
25
26/**
27 * The machines an environment's agents can work on, by name: its links that have not
28 * expired, named as this client names them, else as the link does.
29 */
30export function linkedEnvironmentNames(input: {
31 readonly links: ReadonlyArray<Pick<PeerLinkSummary, "environmentId" | "label" | "status">>;
32 readonly environments: ReadonlyArray<{
33 readonly environmentId: EnvironmentId;
34 readonly label: string;
35 }>;
36}): ReadonlyMap<EnvironmentId, string> {
37 const names = new Map(
38 input.environments.map((environment) => [environment.environmentId, environment.label]),
39 );
40 return new Map(
41 input.links.flatMap((link) =>
42 link.status === "expired"
43 ? []
44 : [[link.environmentId, names.get(link.environmentId) ?? link.label] as const],
45 ),
46 );
47}
48
49/** The access a request opens with: the agent's suggestion, else supervised. */
50export const linkRequestDefaultAccess = (
51 item: Pick<LinkRequestItem, "requestedAccess">,
52): AuthMcpClientAccess => item.requestedAccess ?? "approval-required";
53
54const parsedUrl = (url: string) => {
55 try {
56 return new URL(url);
57 } catch {
58 return undefined;
59 }
60};
61
62/** The http or https origin of an address: what the server links, and nothing else in it. */
63const originOf = (url: string) => {
64 const parsed = parsedUrl(url);
65 return parsed?.protocol === "http:" || parsed?.protocol === "https:" ? parsed.origin : undefined;
66};
67
68/** Where a link to `address` sends its pairing code, which a username or path in it can hide. */
69export const linkDestination = (address: string): string => originOf(address) ?? address.trim();
70
71/** What the card calls the other environment: where the address asked for leads, never a name it answers with. */
72export const linkRequestTargetName = (item: Pick<LinkRequestItem, "url">): string =>
73 item.url === undefined ? "a machine" : linkDestination(item.url);
74
75/** A machine the user could link a thread's environment to. */
76export interface LinkableMachine {
77 readonly environmentId: EnvironmentId;
78 readonly label: string;
79 readonly machine: EnvironmentMachineKind;
80 readonly connected: boolean;
81 /** Found on the user's T3 Connect account rather than saved in this client. */
82 readonly connectOnly: boolean;
83 /** Whether the thread's environment already has a link to it. */
84 readonly linked: boolean;
85 /**
86 * Where the client knows it answers, most preferred first: the addresses it
87 * reports, then its saved routes, then its T3 Connect address. The server
88 * keeps only those that answer as this machine. Empty for a machine reached
89 * only over SSH or loopback, which name this client's device.
90 */
91 readonly urls: ReadonlyArray<string>;
92}
93
94/** A loopback address names whichever machine opens it: the linking server, not this one. */
95const isLoopbackUrl = (url: string) => {
96 const hostname = parsedUrl(url)?.hostname;
97 return hostname !== undefined && isLocalLoopbackHost(hostname);
98};
99
100/**
101 * The catalog's machines other than the thread's own, in catalog order, then those only T3
102 * Connect knows. `links` are the thread environment's; an expired one no longer counts as
103 * linked. `connect` is what relay discovery found, and a server reaches a machine there as a
104 * browser does, so its Connect address is usable.
105 */
106export function linkableMachines(input: {
107 readonly environments: ReadonlyArray<
108 EnvironmentPresentation & { readonly environmentId: EnvironmentId }
109 >;
110 readonly threadEnvironmentId: EnvironmentId;
111 readonly links: ReadonlyArray<Pick<PeerLinkSummary, "environmentId" | "status">>;
112 readonly connect: ReadonlyMap<string, RelayDiscoveredEnvironment>;
113}): ReadonlyArray<LinkableMachine> {
114 const linkedIds = new Set(
115 input.links.flatMap((link) => (link.status === "expired" ? [] : [link.environmentId])),
116 );
117 const saved = input.environments
118 .filter((environment) => environment.environmentId !== input.threadEnvironmentId)
119 .map((environment) => {
120 const reported = (environment.serverConfig?.directEndpoints ?? []).map(
121 (endpoint) => endpoint.httpBaseUrl,
122 );
123 const routes = connectionRoutes(environment.entry).flatMap((route) => {
124 const url = route.target._tag === "RelayConnectionTarget" ? null : routeHttpBaseUrl(route);
125 return url === null ? [] : [url];
126 });
127 const connect = input.connect.get(environment.environmentId)?.environment.endpoint;
128 return {
129 environmentId: environment.environmentId,
130 label: environment.entry.target.label,
131 machine: resolveEnvironmentMachineKind(environment.serverConfig),
132 connected: environment.connection.phase === "connected",
133 connectOnly: false,
134 linked: linkedIds.has(environment.environmentId),
135 urls: [
136 ...new Set([
137 ...reported,
138 ...routes,
139 ...(connect === undefined ? [] : [connect.httpBaseUrl]),
140 ]),
141 ].filter((url) => !isLoopbackUrl(url)),
142 };
143 });
144 const savedIds = new Set<string>(saved.map((machine) => machine.environmentId));
145 const connectOnly = [...input.connect.values()]
146 .filter(
147 ({ environment }) =>
148 environment.environmentId !== input.threadEnvironmentId &&
149 !savedIds.has(environment.environmentId),
150 )
151 .map(({ environment, status }) => {
152 const descriptor = Option.getOrUndefined(status)?.descriptor;
153 return {
154 environmentId: environment.environmentId,
155 label: environment.label,
156 machine: resolveEnvironmentMachineKind(
157 descriptor === undefined ? null : { environment: descriptor },
158 ),
159 connected: false,
160 connectOnly: true,
161 linked: linkedIds.has(environment.environmentId),
162 urls: [environment.endpoint.httpBaseUrl].filter((url) => !isLoopbackUrl(url)),
163 };
164 });
165 return [...saved, ...connectOnly];
166}
167
168const words = (text: string) =>
169 ` ${text
170 .toLowerCase()
171 .split(/[^\p{L}\p{N}]+/u)
172 .join(" ")
173 .trim()} `;
174const NO_MATCH = 4;
175
176/**
177 * How well a machine's name matches the agent's hint, which is often the
178 * user's own words ("my box"): exact, prefix, substring, then a hint that
179 * names it among other words ("the box on hetzner").
180 */
181const hintRank = (label: string, hint: string) => {
182 const name = label.trim().toLowerCase();
183 const wanted = hint
184 .trim()
185 .toLowerCase()
186 .replace(/^(?:my|our|the)\s+/, "");
187 if (name === wanted) return 0;
188 if (name.startsWith(wanted)) return 1;
189 if (name.includes(wanted)) return 2;
190 if (words(wanted).includes(words(name))) return 3;
191 return NO_MATCH;
192};
193
194/** The machines best match for the agent's hint first, each rank in catalog order. */
195export function rankMachinesByHint<T extends { readonly label: string }>(
196 machines: ReadonlyArray<T>,
197 hint: string | undefined,
198): ReadonlyArray<T> {
199 if (hint === undefined || hint.trim().length === 0) return machines;
200 return machines
201 .map((machine, index) => ({ machine, index, rank: hintRank(machine.label, hint) }))
202 .sort((a, b) => a.rank - b.rank || a.index - b.index)
203 .map(({ machine }) => machine);
204}
205
206/** The machine a card opens on: the one it names, else the hint's best match. */
207export function linkRequestInitialMachine(
208 item: Pick<LinkRequestItem, "environmentId" | "hint">,
209 machines: ReadonlyArray<LinkableMachine>,
210): EnvironmentId | null {
211 if (item.environmentId !== undefined) return item.environmentId;
212 if (item.hint === undefined) return null;
213 const [best] = rankMachinesByHint(machines, item.hint);
214 return best !== undefined && hintRank(best.label, item.hint) < NO_MATCH
215 ? best.environmentId
216 : null;
217}
218
219/** The machine choice for linking by an address typed in. */
220export const ANOTHER_ADDRESS = "another-address";
221
222/**
223 * What a link request links: a machine from the client's catalog, or an address.
224 * `urls` is empty when the client knows no direct address for the machine,
225 * so the user types one.
226 */
227export type LinkRequestTarget =
228 | { readonly kind: "none" }
229 | {
230 readonly kind: "machine";
231 readonly environmentId: EnvironmentId;
232 readonly label: string;
233 readonly urls: ReadonlyArray<string>;
234 readonly linked: boolean;
235 }
236 | { readonly kind: "address" };
237
238/**
239 * Anything can answer at an address as any machine, so an address the agent
240 * gave is a machine only where this client already reaches that machine. Its
241 * pairing code is minted for, and sent to, the client's own addresses alone.
242 */
243export function linkRequestTarget(
244 item: Pick<LinkRequestItem, "environmentId" | "url">,
245 machines: ReadonlyArray<LinkableMachine>,
246 choice: string | null,
247): LinkRequestTarget {
248 const asked = item.url === undefined ? undefined : originOf(item.url);
249 // Two machines on separate networks can share a private address.
250 const [reached, ...others] =
251 asked === undefined
252 ? []
253 : machines.filter((machine) => machine.urls.some((url) => originOf(url) === asked));
254 const chosen =
255 item.url === undefined
256 ? (item.environmentId ?? choice)
257 : reached !== undefined && others.length === 0
258 ? reached.environmentId
259 : ANOTHER_ADDRESS;
260 if (chosen === null) return { kind: "none" };
261 if (chosen === ANOTHER_ADDRESS) return { kind: "address" };
262 const machine = machines.find((candidate) => candidate.environmentId === chosen);
263 return {
264 kind: "machine",
265 environmentId: machine?.environmentId ?? (chosen as EnvironmentId),
266 label: machine?.label ?? linkRequestTargetName(item),
267 urls: machine?.urls ?? [],
268 linked: machine?.linked ?? false,
269 };
270}
271
272/** What a link request card shows: the actions while pending, otherwise a one-line outcome. */
273export type LinkRequestDisplay =
274 | { readonly kind: "pending" }
275 | { readonly kind: "pending-elsewhere"; readonly label: string }
276 | {
277 readonly kind: "answered";
278 readonly outcome: "linked" | "declined" | "ended" | "failed";
279 readonly label: string;
280 };
281
282/**
283 * `visibility` is the projected row's: a request inherited from another
284 * thread (a fork) can only be answered where it was asked. `accessLabel`
285 * names an access level the way the client's access picker does.
286 */
287export function linkRequestDisplay(
288 item: Pick<LinkRequestItem, "linkStatus" | "url" | "linkedLabel" | "linkedAccess" | "failure">,
289 visibility: "local" | "inherited" | "synthetic",
290 accessLabel: (access: AuthMcpClientAccess) => string,
291): LinkRequestDisplay {
292 switch (item.linkStatus) {
293 case "pending":
294 return visibility === "local"
295 ? { kind: "pending" }
296 : { kind: "pending-elsewhere", label: "Waiting for an answer in the original thread" };
297 case "linked":
298 return {
299 kind: "answered",
300 outcome: "linked",
301 label: `Linked ${item.linkedLabel ?? linkRequestTargetName(item)}${
302 item.linkedAccess === undefined ? "" : ` · ${accessLabel(item.linkedAccess)}`
303 }`,
304 };
305 case "declined":
306 return { kind: "answered", outcome: "declined", label: "Declined" };
307 case "failed":
308 return {
309 kind: "answered",
310 outcome: "failed",
311 label: `Could not link: ${item.failure ?? "the other environment refused"}`,
312 };
313 case "cancelled":
314 return { kind: "answered", outcome: "ended", label: "Request ended" };
315 }
316}
317
318/**
319 * Builds the RPC payload for an answer. A link with a blank code returns null,
320 * since the server rejects it; callers keep Link disabled instead.
321 */
322export function linkRequestAnswerInput(
323 item: Pick<LinkRequestItem, "id" | "threadId">,
324 answer:
325 | {
326 readonly type: "link";
327 /** The machine picked, or an address typed in its place. */
328 readonly target:
329 | Pick<LinkableMachine, "environmentId" | "label" | "urls">
330 | { readonly url: string };
331 readonly access: AuthMcpClientAccess;
332 readonly pairingCode: string;
333 }
334 | { readonly type: "use-existing"; readonly environmentId: EnvironmentId }
335 | { readonly type: "decline" },
336): PeerLinkRequestAnswerInput | null {
337 const base = { threadId: item.threadId, turnItemId: item.id };
338 if (answer.type !== "link") return { ...base, answer };
339 const pairingCode = answer.pairingCode.trim();
340 if (pairingCode.length === 0) return null;
341 const { target } = answer;
342 if ("url" in target) {
343 const url = target.url.trim();
344 if (url.length === 0) return null;
345 return { ...base, answer: { type: "link", urls: [url], access: answer.access, pairingCode } };
346 }
347 const [first, ...rest] = target.urls;
348 if (first === undefined) return null;
349 return {
350 ...base,
351 answer: {
352 type: "link",
353 environmentId: target.environmentId,
354 label: target.label,
355 urls: [first, ...rest],
356 access: answer.access,
357 pairingCode,
358 },
359 };
360}
361
362/** Failures whose message is written for the user and never echoes the request payload. */
363const USER_FACING_FAILURE_TAGS = new Set([
364 "PeerLinkRequestError",
365 "EnvironmentAuthorizationError",
366 "PeerPairingCodeError",
367]);
368
369/**
370 * Inline error copy for a failed answer. Only known errors pass their message
371 * through: anything else (transport or encoding failures) gets the generic
372 * copy, so the pairing code can never surface in the UI.
373 */
374export function linkRequestFailureMessage(failure: unknown): string {
375 if (
376 typeof failure === "object" &&
377 failure !== null &&
378 "_tag" in failure &&
379 typeof failure._tag === "string" &&
380 USER_FACING_FAILURE_TAGS.has(failure._tag) &&
381 "message" in failure &&
382 typeof failure.message === "string" &&
383 failure.message.trim().length > 0
384 ) {
385 return failure.message;
386 }
387 return "Could not answer the request. Try again.";
388}