| 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 | 1665 | ) { |
| 1666 | 1666 | setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); |
| 1667 | 1667 | } |
| 1668 | const prepared = usePreparedConnection(environmentId); | |
| 1669 | const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null; | |
| 1668 | 1670 | const mcpUrl = environmentMcpUrl({ |
| 1669 | 1671 | entry: environment.entry, |
| 1670 | 1672 | relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, |
| 1673 | connectedTarget, | |
| 1671 | 1674 | }); |
| 1672 | 1675 | const machineKind = resolveEnvironmentMachineKind( |
| 1673 | 1676 | environment.serverConfig ?? |
| 1674 | 1677 | (lastDescriptor === undefined ? null : { environment: lastDescriptor }), |
| 1675 | 1678 | ); |
| 1676 | const prepared = usePreparedConnection(environmentId); | |
| 1677 | 1679 | const routeCount = connectionRoutes(environment.entry).length; |
| 1678 | 1680 | const subtitleText = [ |
| 1679 | environmentTransportLabel( | |
| 1680 | environment, | |
| 1681 | isConnected && prepared._tag === "Some" ? prepared.value.target : null, | |
| 1682 | ), | |
| 1681 | environmentTransportLabel(environment, connectedTarget), | |
| 1683 | 1682 | resumingServerUpdate ? "Restarting" : status.text, |
| 1684 | 1683 | enabled && versionMismatch ? serverVersion : null, |
| 1685 | 1684 | ] |
docs/user/outside-agents.md+5-4| ... | ... | @@ -16,11 +16,12 @@ example: |
| 16 | 16 | https://<environment-address>/mcp |
| 17 | 17 | ``` |
| 18 | 18 | |
| 19 | The address must use HTTPS, or `localhost` when the agent runs on the host | |
| 20 | itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address. | |
| 19 | The copied URL uses the route this device is connected over, so an agent on the | |
| 20 | same device can reach it. | |
| 21 | 21 | |
| 22 | - **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or | |
| 23 | `localhost` on the host. See [remote access](./remote-access.md). | |
| 22 | - **An agent on your own computers** can use any address that computer reaches | |
| 23 | the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on | |
| 24 | the host. See [remote access](./remote-access.md). | |
| 24 | 25 | - **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach |
| 25 | 26 | the environment from the internet. Use the T3 Connect address. A Tailscale |
| 26 | 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 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 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 | 10 | import { |
| 7 | 11 | BearerConnectionTarget, |
| 8 | 12 | ConnectionBlockedError, |
| 9 | 13 | ConnectionTransientError, |
| 14 | RelayConnectionTarget, | |
| 10 | 15 | type SupervisorConnectionState, |
| 11 | 16 | } from "./model.ts"; |
| 12 | 17 | import { |
| ... | ... | @@ -72,25 +77,62 @@ describe("connection presentation", () => { |
| 72 | 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", () => { | |
| 76 | expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp"); | |
| 77 | const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({ | |
| 80 | it("copies the MCP address of the route this device is connected over", () => { | |
| 81 | const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => { | |
| 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 | 100 | ...ENTRY, |
| 79 | profile: Option.some( | |
| 80 | new BearerConnectionProfile({ | |
| 81 | connectionId: TARGET.connectionId, | |
| 82 | environmentId: TARGET.environmentId, | |
| 83 | label: TARGET.label, | |
| 84 | httpBaseUrl, | |
| 85 | wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), | |
| 86 | }), | |
| 87 | ), | |
| 101 | target: lan.target, | |
| 102 | profile: lan.profile, | |
| 103 | alternateRoutes: [tailnet, serve], | |
| 104 | }; | |
| 105 | ||
| 106 | expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe( | |
| 107 | "http://100.115.1.44:3773/mcp", | |
| 108 | ); | |
| 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( | |
| 90 | "http://127.0.0.1:3773/mcp", | |
| 118 | const entry: ConnectionCatalogEntry = { | |
| 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. | |
| 93 | expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull(); | |
| 129 | expect( | |
| 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 | 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 | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | |
| 4 | 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 | 8 | export type EnvironmentConnectionPhase = |
| 8 | 9 | | "available" |
| ... | ... | @@ -94,31 +95,39 @@ export function presentEnvironmentConnection( |
| 94 | 95 | |
| 95 | 96 | /** |
| 96 | 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 | * clients refuse to sign in through a plain-http token endpoint elsewhere. | |
| 99 | * SSH connections ride a local forward that disappears with the client, so | |
| 100 | * they have no stable address to hand out. | |
| 98 | * environment's MCP server: the route this device is connected over, since an | |
| 99 | * agent beside this client can reach it too, else the first route in | |
| 100 | * preference order that has an address. SSH connections ride a local forward | |
| 101 | * that disappears with the client, so they have no stable address. | |
| 101 | 102 | */ |
| 102 | 103 | export function environmentMcpUrl(input: { |
| 103 | 104 | readonly entry: ConnectionCatalogEntry; |
| 104 | 105 | readonly relayHttpBaseUrl?: string | undefined; |
| 106 | readonly connectedTarget?: ConnectionTarget | null | undefined; | |
| 105 | 107 | }): string | null { |
| 106 | const httpBaseUrl = | |
| 107 | input.entry.target._tag === "RelayConnectionTarget" | |
| 108 | ? (input.relayHttpBaseUrl ?? null) | |
| 109 | : input.entry.target._tag === "SshConnectionTarget" | |
| 110 | ? null | |
| 111 | : connectionCatalogDisplayUrl(input.entry); | |
| 112 | if (httpBaseUrl === null) return null; | |
| 108 | const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null; | |
| 109 | const routes = connectionRoutes(input.entry); | |
| 110 | const connectedRoute = routes.find( | |
| 111 | (route) => connectionRouteId(route.target) === connectedRouteId, | |
| 112 | ); | |
| 113 | for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) { | |
| 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 | 125 | let url: URL; |
| 114 | 126 | try { |
| 115 | 127 | url = new URL(httpBaseUrl); |
| 116 | 128 | } catch { |
| 117 | 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 | 131 | url.pathname = "/mcp"; |
| 123 | 132 | url.search = ""; |
| 124 | 133 | url.hash = ""; |