| author | |
| committer | |
| log | fbe5df2d4b630d13adc8fe2d38cab354e6d66d67 |
| tree | 25bb6c9de44a26b632d1c9920ea5c059357113ba |
| parent | 2c8be5893eb3754071162e344a8c8e8a67bef597 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 120 insertions(+), 0 deletions(-)
apps/web/src/components/settings/ConnectionsSettings.tsx+37| ... | @@ -48,6 +48,7 @@ import { | ... | @@ -48,6 +48,7 @@ import { |
| 48 | RelayConnectionTarget, | 48 | RelayConnectionTarget, |
| 49 | connectionRoutes, | 49 | connectionRoutes, |
| 50 | connectionStatusText, | 50 | connectionStatusText, |
| 51 | environmentMcpUrl, | ||
| 51 | } from "@t3tools/client-runtime/connection"; | 52 | } from "@t3tools/client-runtime/connection"; |
| 52 | import { | 53 | import { |
| 53 | isAtomCommandInterrupted, | 54 | isAtomCommandInterrupted, |
| ... | @@ -1526,6 +1527,25 @@ function SavedBackendListRow({ | ... | @@ -1526,6 +1527,25 @@ function SavedBackendListRow({ |
| 1526 | }, | 1527 | }, |
| 1527 | [copyTraceIdToClipboard], | 1528 | [copyTraceIdToClipboard], |
| 1528 | ); | 1529 | ); |
| 1530 | const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({ | ||
| 1531 | target: "MCP URL", | ||
| 1532 | onCopy: ({ url }) => { | ||
| 1533 | toastManager.add({ | ||
| 1534 | type: "success", | ||
| 1535 | title: "MCP URL copied", | ||
| 1536 | description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`, | ||
| 1537 | }); | ||
| 1538 | }, | ||
| 1539 | onError: (error) => { | ||
| 1540 | toastManager.add( | ||
| 1541 | stackedThreadToast({ | ||
| 1542 | type: "error", | ||
| 1543 | title: "Could not copy MCP URL", | ||
| 1544 | description: error.message, | ||
| 1545 | }), | ||
| 1546 | ); | ||
| 1547 | }, | ||
| 1548 | }); | ||
| 1529 | const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); | 1549 | const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); |
| 1530 | const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); | 1550 | const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); |
| 1531 | const resumingServerUpdate = | 1551 | const resumingServerUpdate = |
| ... | @@ -1545,6 +1565,20 @@ function SavedBackendListRow({ | ... | @@ -1545,6 +1565,20 @@ function SavedBackendListRow({ |
| 1545 | if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) { | 1565 | if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) { |
| 1546 | setLastDescriptor(discoveredDescriptor); | 1566 | setLastDescriptor(discoveredDescriptor); |
| 1547 | } | 1567 | } |
| 1568 | // Held for the same reason as the descriptor, so Copy MCP URL survives a refresh. | ||
| 1569 | const discoveredRelayHttpBaseUrl = | ||
| 1570 | relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl; | ||
| 1571 | const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl); | ||
| 1572 | if ( | ||
| 1573 | discoveredRelayHttpBaseUrl !== undefined && | ||
| 1574 | discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl | ||
| 1575 | ) { | ||
| 1576 | setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); | ||
| 1577 | } | ||
| 1578 | const mcpUrl = environmentMcpUrl({ | ||
| 1579 | entry: environment.entry, | ||
| 1580 | relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, | ||
| 1581 | }); | ||
| 1548 | const machineKind = resolveEnvironmentMachineKind( | 1582 | const machineKind = resolveEnvironmentMachineKind( |
| 1549 | environment.serverConfig ?? | 1583 | environment.serverConfig ?? |
| 1550 | (lastDescriptor === undefined ? null : { environment: lastDescriptor }), | 1584 | (lastDescriptor === undefined ? null : { environment: lastDescriptor }), |
| ... | @@ -1711,6 +1745,9 @@ function SavedBackendListRow({ | ... | @@ -1711,6 +1745,9 @@ function SavedBackendListRow({ |
| 1711 | <RouteIcon /> | 1745 | <RouteIcon /> |
| 1712 | {routesOpen ? "Hide routes" : "Routes"} | 1746 | {routesOpen ? "Hide routes" : "Routes"} |
| 1713 | </MenuItem> | 1747 | </MenuItem> |
| 1748 | {mcpUrl ? ( | ||
| 1749 | <MenuItem onClick={() => copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL</MenuItem> | ||
| 1750 | ) : null} | ||
| 1714 | {errorTraceId ? ( | 1751 | {errorTraceId ? ( |
| 1715 | <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem> | 1752 | <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem> |
| 1716 | ) : null} | 1753 | ) : null} |
docs/user/remote-access.md+28| ... | @@ -220,6 +220,34 @@ only reports what it would change. | ... | @@ -220,6 +220,34 @@ only reports what it would change. |
| 220 | The browser always runs in Chrome's sandbox. Where you cannot change the host, | 220 | The browser always runs in Chrome's sandbox. Where you cannot change the host, |
| 221 | set `T3CODE_SERVER_BROWSER_SANDBOX=0` for the environment to run without it. | 221 | set `T3CODE_SERVER_BROWSER_SANDBOX=0` for the environment to run without it. |
| 222 | 222 | ||
| 223 | ## Connect an outside agent | ||
| 224 | |||
| 225 | An agent T3 Code did not start, such as Claude Code in your own terminal, can | ||
| 226 | drive threads on an environment through its MCP server. In **Settings → | ||
| 227 | Connections**, open a saved environment's menu and choose **Copy MCP URL**, then | ||
| 228 | add it to the agent. For example: | ||
| 229 | |||
| 230 | ```sh | ||
| 231 | claude mcp add --transport http t3 https://<environment-address>/mcp | ||
| 232 | ``` | ||
| 233 | |||
| 234 | The first time the agent connects, it opens a sign-in page on the environment. | ||
| 235 | Enter a pairing code from **Settings → Connections** on a device that can manage | ||
| 236 | access, or from `t3 auth pairing create` on the host, and choose what the agent | ||
| 237 | may do. A browser already signed in to that environment as an administrator can | ||
| 238 | approve without a code. | ||
| 239 | |||
| 240 | - **Read only** lets the agent read projects and threads in every project, and | ||
| 241 | see which providers and models are available. It cannot change anything. | ||
| 242 | - **Supervised** through **Full access** also let it start, message and stop | ||
| 243 | threads in every project, but it cannot start or steer a thread with more | ||
| 244 | permissions than the mode you chose. | ||
| 245 | |||
| 246 | Use an HTTPS address: T3 Connect, Tailscale Serve, or `localhost` on the host | ||
| 247 | itself. Agents refuse to sign in through a plain `http://` LAN or tailnet | ||
| 248 | address. The agent appears under **Settings → Connections** like any other | ||
| 249 | client; revoke it there. Sign-ins last 30 days. | ||
| 250 | |||
| 223 | ## Manage or revoke access | 251 | ## Manage or revoke access |
| 224 | 252 | ||
| 225 | On the host, **Settings → Connections** lets authorized administrators create | 253 | On the host, **Settings → Connections** lets authorized administrators create |
packages/client-runtime/src/connection/presentation.test.ts+22| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | } from "./model.ts"; | 11 | } from "./model.ts"; |
| 12 | import { | 12 | import { |
| 13 | connectionCatalogDisplayUrl, | 13 | connectionCatalogDisplayUrl, |
| 14 | environmentMcpUrl, | ||
| 14 | connectionStatusText, | 15 | connectionStatusText, |
| 15 | connectionStatusTitle, | 16 | connectionStatusTitle, |
| 16 | presentEnvironmentConnection, | 17 | presentEnvironmentConnection, |
| ... | @@ -71,6 +72,27 @@ describe("connection presentation", () => { | ... | @@ -71,6 +72,27 @@ describe("connection presentation", () => { |
| 71 | expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); | 72 | expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); |
| 72 | }); | 73 | }); |
| 73 | 74 | ||
| 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 => ({ | ||
| 78 | ...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 | ), | ||
| 88 | }); | ||
| 89 | expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( | ||
| 90 | "http://127.0.0.1:3773/mcp", | ||
| 91 | ); | ||
| 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(); | ||
| 94 | }); | ||
| 95 | |||
| 74 | it("distinguishes initial connection, reconnect, and retry errors", () => { | 96 | it("distinguishes initial connection, reconnect, and retry errors", () => { |
| 75 | expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ | 97 | expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ |
| 76 | phase: "connecting", | 98 | phase: "connecting", |
packages/client-runtime/src/connection/presentation.ts+33| ... | @@ -92,6 +92,39 @@ export function presentEnvironmentConnection( | ... | @@ -92,6 +92,39 @@ export function presentEnvironmentConnection( |
| 92 | return presentConnectionState(state); | 92 | return presentConnectionState(state); |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | /** | ||
| 96 | * 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. | ||
| 101 | */ | ||
| 102 | export function environmentMcpUrl(input: { | ||
| 103 | readonly entry: ConnectionCatalogEntry; | ||
| 104 | readonly relayHttpBaseUrl?: string | undefined; | ||
| 105 | }): 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; | ||
| 113 | let url: URL; | ||
| 114 | try { | ||
| 115 | url = new URL(httpBaseUrl); | ||
| 116 | } catch { | ||
| 117 | return null; | ||
| 118 | } | ||
| 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"; | ||
| 123 | url.search = ""; | ||
| 124 | url.hash = ""; | ||
| 125 | return url.toString(); | ||
| 126 | } | ||
| 127 | |||
| 95 | export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { | 128 | export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { |
| 96 | switch (entry.target._tag) { | 129 | switch (entry.target._tag) { |
| 97 | case "PrimaryConnectionTarget": | 130 | case "PrimaryConnectionTarget": |