| author | |
| committer | |
| log | 7ac5ec6b15593f5b682fe73b483bc8e24de76665 |
| tree | b65d67bc436f606c747c96b31553a61c6ef0226e |
| parent | 7f3628e4153d5be4a241c4d54f73a6d7087014ce |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 92 insertions(+), 41 deletions(-)
apps/web/src/components/settings/ConnectionsSettings.tsx+4-5| ... | @@ -1665,21 +1665,20 @@ function SavedBackendListRow({ | ... | @@ -1665,21 +1665,20 @@ function SavedBackendListRow({ |
| 1665 | ) { | 1665 | ) { |
| 1666 | setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); | 1666 | setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); |
| 1667 | } | 1667 | } |
| 1668 | const prepared = usePreparedConnection(environmentId); | ||
| 1669 | const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null; | ||
| 1668 | const mcpUrl = environmentMcpUrl({ | 1670 | const mcpUrl = environmentMcpUrl({ |
| 1669 | entry: environment.entry, | 1671 | entry: environment.entry, |
| 1670 | relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, | 1672 | relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, |
| 1673 | connectedTarget, | ||
| 1671 | }); | 1674 | }); |
| 1672 | const machineKind = resolveEnvironmentMachineKind( | 1675 | const machineKind = resolveEnvironmentMachineKind( |
| 1673 | environment.serverConfig ?? | 1676 | environment.serverConfig ?? |
| 1674 | (lastDescriptor === undefined ? null : { environment: lastDescriptor }), | 1677 | (lastDescriptor === undefined ? null : { environment: lastDescriptor }), |
| 1675 | ); | 1678 | ); |
| 1676 | const prepared = usePreparedConnection(environmentId); | ||
| 1677 | const routeCount = connectionRoutes(environment.entry).length; | 1679 | const routeCount = connectionRoutes(environment.entry).length; |
| 1678 | const subtitleText = [ | 1680 | const subtitleText = [ |
| 1679 | environmentTransportLabel( | 1681 | environmentTransportLabel(environment, connectedTarget), |
| 1680 | environment, | ||
| 1681 | isConnected && prepared._tag === "Some" ? prepared.value.target : null, | ||
| 1682 | ), | ||
| 1683 | resumingServerUpdate ? "Restarting" : status.text, | 1682 | resumingServerUpdate ? "Restarting" : status.text, |
| 1684 | enabled && versionMismatch ? serverVersion : null, | 1683 | enabled && versionMismatch ? serverVersion : null, |
| 1685 | ] | 1684 | ] |
docs/user/outside-agents.md+5-4| ... | @@ -16,11 +16,12 @@ example: | ... | @@ -16,11 +16,12 @@ example: |
| 16 | https://<environment-address>/mcp | 16 | https://<environment-address>/mcp |
| 17 | ``` | 17 | ``` |
| 18 | 18 | ||
| 19 | The address must use HTTPS, or `localhost` when the agent runs on the host | 19 | The copied URL uses the route this device is connected over, so an agent on the |
| 20 | itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address. | 20 | same device can reach it. |
| 21 | 21 | ||
| 22 | - **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or | 22 | - **An agent on your own computers** can use any address that computer reaches |
| 23 | `localhost` on the host. See [remote access](./remote-access.md). | 23 | the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on |
| 24 | the host. See [remote access](./remote-access.md). | ||
| 24 | - **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach | 25 | - **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach |
| 25 | the environment from the internet. Use the T3 Connect address. A Tailscale | 26 | the environment from the internet. Use the T3 Connect address. A Tailscale |
| 26 | address only works from your own tailnet. | 27 | address only works from your own tailnet. |
packages/client-runtime/src/connection/presentation.test.ts+59-17| ... | @@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts"; | ... | @@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 4 | 4 | ||
| 5 | import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; | 5 | import { |
| 6 | BearerConnectionProfile, | ||
| 7 | type ConnectionCatalogEntry, | ||
| 8 | type ConnectionRoute, | ||
| 9 | } from "./catalog.ts"; | ||
| 6 | import { | 10 | import { |
| 7 | BearerConnectionTarget, | 11 | BearerConnectionTarget, |
| 8 | ConnectionBlockedError, | 12 | ConnectionBlockedError, |
| 9 | ConnectionTransientError, | 13 | ConnectionTransientError, |
| 14 | RelayConnectionTarget, | ||
| 10 | type SupervisorConnectionState, | 15 | type SupervisorConnectionState, |
| 11 | } from "./model.ts"; | 16 | } from "./model.ts"; |
| 12 | import { | 17 | import { |
| ... | @@ -72,25 +77,62 @@ describe("connection presentation", () => { | ... | @@ -72,25 +77,62 @@ describe("connection presentation", () => { |
| 72 | expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); | 77 | expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); |
| 73 | }); | 78 | }); |
| 74 | 79 | ||
| 75 | it("offers an MCP address only where an MCP client can sign in", () => { | 80 | it("copies the MCP address of the route this device is connected over", () => { |
| 76 | expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp"); | 81 | const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => { |
| 77 | const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({ | 82 | const target = new BearerConnectionTarget({ ...TARGET, connectionId }); |
| 83 | return { | ||
| 84 | target, | ||
| 85 | profile: Option.some( | ||
| 86 | new BearerConnectionProfile({ | ||
| 87 | connectionId, | ||
| 88 | environmentId: TARGET.environmentId, | ||
| 89 | label: TARGET.label, | ||
| 90 | httpBaseUrl, | ||
| 91 | wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), | ||
| 92 | }), | ||
| 93 | ), | ||
| 94 | }; | ||
| 95 | }; | ||
| 96 | const lan = route("lan", "http://192.168.4.53:3773/"); | ||
| 97 | const tailnet = route("tailnet", "http://100.115.1.44:3773/"); | ||
| 98 | const serve = route("serve", "https://machine.tailnet.ts.net/"); | ||
| 99 | const entry: ConnectionCatalogEntry = { | ||
| 78 | ...ENTRY, | 100 | ...ENTRY, |
| 79 | profile: Option.some( | 101 | target: lan.target, |
| 80 | new BearerConnectionProfile({ | 102 | profile: lan.profile, |
| 81 | connectionId: TARGET.connectionId, | 103 | alternateRoutes: [tailnet, serve], |
| 82 | environmentId: TARGET.environmentId, | 104 | }; |
| 83 | label: TARGET.label, | 105 | |
| 84 | httpBaseUrl, | 106 | expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe( |
| 85 | wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), | 107 | "http://100.115.1.44:3773/mcp", |
| 86 | }), | 108 | ); |
| 87 | ), | 109 | // Not connected: the preferred route, plain http or not. |
| 110 | expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp"); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("passes over routes without an address of their own", () => { | ||
| 114 | const relay = new RelayConnectionTarget({ | ||
| 115 | environmentId: TARGET.environmentId, | ||
| 116 | label: TARGET.label, | ||
| 88 | }); | 117 | }); |
| 89 | expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( | 118 | const entry: ConnectionCatalogEntry = { |
| 90 | "http://127.0.0.1:3773/mcp", | 119 | ...ENTRY, |
| 120 | target: relay, | ||
| 121 | profile: Option.none(), | ||
| 122 | alternateRoutes: [{ target: ENTRY.target, profile: ENTRY.profile }], | ||
| 123 | }; | ||
| 124 | |||
| 125 | // Relay discovery has not reported the tunnel address yet. | ||
| 126 | expect(environmentMcpUrl({ entry, connectedTarget: relay })).toBe( | ||
| 127 | "https://environment.example.test/mcp", | ||
| 91 | ); | 128 | ); |
| 92 | // A plain-http LAN or tailnet address is refused by MCP clients' token checks. | 129 | expect( |
| 93 | expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull(); | 130 | environmentMcpUrl({ |
| 131 | entry, | ||
| 132 | connectedTarget: relay, | ||
| 133 | relayHttpBaseUrl: "https://tunnel.example.test", | ||
| 134 | }), | ||
| 135 | ).toBe("https://tunnel.example.test/mcp"); | ||
| 94 | }); | 136 | }); |
| 95 | 137 | ||
| 96 | it("distinguishes initial connection, reconnect, and retry errors", () => { | 138 | it("distinguishes initial connection, reconnect, and retry errors", () => { |
packages/client-runtime/src/connection/presentation.ts+24-15| ... | @@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts"; | ... | @@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | ||
| 4 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | 4 | import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 5 | import type { SupervisorConnectionState } from "./model.ts"; | 5 | import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; |
| 6 | import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; | ||
| 6 | 7 | ||
| 7 | export type EnvironmentConnectionPhase = | 8 | export type EnvironmentConnectionPhase = |
| 8 | | "available" | 9 | | "available" |
| ... | @@ -94,31 +95,39 @@ export function presentEnvironmentConnection( | ... | @@ -94,31 +95,39 @@ export function presentEnvironmentConnection( |
| 94 | 95 | ||
| 95 | /** | 96 | /** |
| 96 | * The address an agent outside T3 (Claude Code, Codex) uses to reach this | 97 | * The address an agent outside T3 (Claude Code, Codex) uses to reach this |
| 97 | * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP | 98 | * environment's MCP server: the route this device is connected over, since an |
| 98 | * clients refuse to sign in through a plain-http token endpoint elsewhere. | 99 | * agent beside this client can reach it too, else the first route in |
| 99 | * SSH connections ride a local forward that disappears with the client, so | 100 | * preference order that has an address. SSH connections ride a local forward |
| 100 | * they have no stable address to hand out. | 101 | * that disappears with the client, so they have no stable address. |
| 101 | */ | 102 | */ |
| 102 | export function environmentMcpUrl(input: { | 103 | export function environmentMcpUrl(input: { |
| 103 | readonly entry: ConnectionCatalogEntry; | 104 | readonly entry: ConnectionCatalogEntry; |
| 104 | readonly relayHttpBaseUrl?: string | undefined; | 105 | readonly relayHttpBaseUrl?: string | undefined; |
| 106 | readonly connectedTarget?: ConnectionTarget | null | undefined; | ||
| 105 | }): string | null { | 107 | }): string | null { |
| 106 | const httpBaseUrl = | 108 | const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null; |
| 107 | input.entry.target._tag === "RelayConnectionTarget" | 109 | const routes = connectionRoutes(input.entry); |
| 108 | ? (input.relayHttpBaseUrl ?? null) | 110 | const connectedRoute = routes.find( |
| 109 | : input.entry.target._tag === "SshConnectionTarget" | 111 | (route) => connectionRouteId(route.target) === connectedRouteId, |
| 110 | ? null | 112 | ); |
| 111 | : connectionCatalogDisplayUrl(input.entry); | 113 | for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) { |
| 112 | if (httpBaseUrl === null) return null; | 114 | const httpBaseUrl = |
| 115 | route.target._tag === "RelayConnectionTarget" | ||
| 116 | ? (input.relayHttpBaseUrl ?? null) | ||
| 117 | : routeHttpBaseUrl(route); | ||
| 118 | const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl); | ||
| 119 | if (mcpUrl !== null) return mcpUrl; | ||
| 120 | } | ||
| 121 | return null; | ||
| 122 | } | ||
| 123 | |||
| 124 | function mcpUrlFromBase(httpBaseUrl: string): string | null { | ||
| 113 | let url: URL; | 125 | let url: URL; |
| 114 | try { | 126 | try { |
| 115 | url = new URL(httpBaseUrl); | 127 | url = new URL(httpBaseUrl); |
| 116 | } catch { | 128 | } catch { |
| 117 | return null; | 129 | return null; |
| 118 | } | 130 | } |
| 119 | const loopback = | ||
| 120 | url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; | ||
| 121 | if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null; | ||
| 122 | url.pathname = "/mcp"; | 131 | url.pathname = "/mcp"; |
| 123 | url.search = ""; | 132 | url.search = ""; |
| 124 | url.hash = ""; | 133 | url.hash = ""; |