diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
index de3799ac8a8..eb01da878f8 100644
--- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
+++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
@@ -2,8 +2,13 @@ import { CameraView, useCameraPermissions } from "expo-camera";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { AsyncResult } from "effect/unstable/reactivity";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, ScrollView, View } from "react-native";
+import {
+ MATRIX_OS_CONNECT_URL,
+ MATRIX_OS_SETUP_DESCRIPTION,
+ MATRIX_OS_SETUP_MOBILE_ACTION_LABEL,
+} from "@t3tools/shared/matrixOsConnect";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
@@ -14,6 +19,7 @@ import { ConnectionSheetButton } from "./ConnectionSheetButton";
import { extractPairingUrlFromQrPayload } from "./pairing";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
import { buildPairingUrl, parsePairingUrl } from "./pairing";
+import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
type ConnectionsNewRouteParams = {
readonly mode?: string;
@@ -34,9 +40,11 @@ export function ConnectionsNewRouteScreen({
const [hostInput, setHostInput] = useState("");
const [codeInput, setCodeInput] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isOpeningMatrixOs, setIsOpeningMatrixOs] = useState(false);
const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
const [scannerLocked, setScannerLocked] = useState(false);
+ const matrixOsLaunchInFlightRef = useRef(false);
const headerIconColor = useThemeColor("--color-icon");
@@ -133,6 +141,27 @@ export function ConnectionsNewRouteScreen({
}
}, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);
+ const handleConnectMatrixOs = useCallback(async () => {
+ if (matrixOsLaunchInFlightRef.current) {
+ return;
+ }
+
+ matrixOsLaunchInFlightRef.current = true;
+ setIsOpeningMatrixOs(true);
+ try {
+ const opened = await tryOpenExternalUrl(MATRIX_OS_CONNECT_URL, "matrix-os-connect");
+ if (!opened) {
+ Alert.alert(
+ "Unable to open Matrix OS",
+ "Open app.matrix-os.com and connect T3 Code from its Terminal.",
+ );
+ }
+ } finally {
+ matrixOsLaunchInFlightRef.current = false;
+ setIsOpeningMatrixOs(false);
+ }
+ }, []);
+
return (
)}
+ {!showScanner ? (
+
+
+ Matrix OS
+
+ {MATRIX_OS_SETUP_DESCRIPTION}
+
+
+ {
+ void handleConnectMatrixOs();
+ }}
+ />
+
+ ) : null}
diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts
index 5a69cbdd43b..018db2fa0b3 100644
--- a/apps/mobile/src/lib/openExternalUrl.test.ts
+++ b/apps/mobile/src/lib/openExternalUrl.test.ts
@@ -2,6 +2,7 @@ import { Linking } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { tryOpenExternalUrl } from "./openExternalUrl";
+import { MATRIX_OS_CONNECT_URL } from "@t3tools/shared/matrixOsConnect";
vi.mock("react-native", () => ({
Linking: { openURL: vi.fn() },
@@ -56,3 +57,14 @@ describe("tryOpenExternalUrl", () => {
expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel");
});
});
+
+describe("Matrix OS handoff", () => {
+ it("opens the fixed Matrix OS setup URL", async () => {
+ openURL.mockResolvedValueOnce(undefined);
+
+ await expect(tryOpenExternalUrl(MATRIX_OS_CONNECT_URL, "matrix-os-connect")).resolves.toBe(
+ true,
+ );
+ expect(openURL).toHaveBeenCalledExactlyOnceWith(MATRIX_OS_CONNECT_URL);
+ });
+});
diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts
index 10e6378bc00..0ceafab9191 100644
--- a/apps/mobile/src/lib/openExternalUrl.ts
+++ b/apps/mobile/src/lib/openExternalUrl.ts
@@ -1,7 +1,12 @@
import * as Schema from "effect/Schema";
import { Linking } from "react-native";
-const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]);
+const ExternalUrlTarget = Schema.Literals([
+ "file-preview",
+ "markdown-link",
+ "pull-request",
+ "matrix-os-connect",
+]);
export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts
index eb056342140..950cfc4fc87 100644
--- a/apps/server/src/auth/EnvironmentAuth.ts
+++ b/apps/server/src/auth/EnvironmentAuth.ts
@@ -28,12 +28,13 @@ import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
+import * as ServerConfig from "../config.ts";
+import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";
import * as SessionStore from "./SessionStore.ts";
import { verifyRequestDpopProof } from "./dpop.ts";
-import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
@@ -555,6 +556,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string |
}
export const make = Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig.ServerConfig;
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
const sessions = yield* SessionStore.SessionStore;
@@ -613,6 +615,7 @@ export const make = Effect.gen(function* () {
request,
expectedThumbprint: session.proofKeyThumbprint,
expectedAccessToken: dpopToken,
+ ...(serverConfig.pairingBaseUrl ? { pairingBaseUrl: serverConfig.pairingBaseUrl } : {}),
}).pipe(
Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore),
Effect.provideService(Crypto.Crypto, crypto),
diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts
index fa75c407b0c..2f06f7a1ce9 100644
--- a/apps/server/src/auth/dpop.test.ts
+++ b/apps/server/src/auth/dpop.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import * as PlatformError from "effect/PlatformError";
import { SecretStorePersistError } from "./ServerSecretStore.ts";
-import { mapDpopReplayStoreError } from "./dpop.ts";
+import { mapDpopReplayStoreError, resolveDpopRequestUrl } from "./dpop.ts";
const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
new SecretStorePersistError({
@@ -35,3 +35,15 @@ describe("mapDpopReplayStoreError", () => {
}
});
});
+
+describe("resolveDpopRequestUrl", () => {
+ it("uses the configured reverse-proxy base instead of forwarded headers", () => {
+ expect(
+ resolveDpopRequestUrl({
+ localUrl: new URL("http://127.0.0.1:3773/oauth/token?code=one-time"),
+ originalUrl: "/oauth/token?code=one-time",
+ pairingBaseUrl: new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/"),
+ }),
+ ).toBe("https://app.matrix-os.com/vm/alice/api/integrations/t3/oauth/token?code=one-time");
+ });
+});
diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts
index f19984eb369..94a60642956 100644
--- a/apps/server/src/auth/dpop.ts
+++ b/apps/server/src/auth/dpop.ts
@@ -26,10 +26,20 @@ export const mapDpopReplayStoreError = (
cause: error,
});
+export function resolveDpopRequestUrl(input: {
+ readonly localUrl: URL;
+ readonly originalUrl: string;
+ readonly pairingBaseUrl?: URL;
+}): string {
+ if (!input.pairingBaseUrl) return input.localUrl.href;
+ return new URL(input.originalUrl.replace(/^\/+/, ""), input.pairingBaseUrl).href;
+}
+
export const verifyRequestDpopProof = (input: {
readonly request: HttpServerRequest.HttpServerRequest;
readonly expectedThumbprint?: string;
readonly expectedAccessToken?: string;
+ readonly pairingBaseUrl?: URL;
}) =>
Effect.gen(function* () {
const proof = input.request.headers.dpop;
@@ -39,11 +49,16 @@ export const verifyRequestDpopProof = (input: {
diagnostic: "Invalid DPoP request URL.",
});
}
+ const requestUrl = resolveDpopRequestUrl({
+ localUrl: url.value,
+ originalUrl: input.request.originalUrl,
+ ...(input.pairingBaseUrl ? { pairingBaseUrl: input.pairingBaseUrl } : {}),
+ });
const now = yield* DateTime.now;
const result = verifyDpopProof({
proof,
method: input.request.method,
- url: url.value.href,
+ url: requestUrl,
nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}),
...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
index 780aaabde25..0977b9a3aa7 100644
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -37,6 +37,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import * as SessionStore from "./SessionStore.ts";
import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts";
+import * as ServerConfig from "../config.ts";
import { deriveAuthClientMetadata } from "./utils.ts";
import { verifyRequestDpopProof } from "./dpop.ts";
@@ -203,6 +204,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
Effect.fnUntraced(function* (handlers) {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
+ const serverConfig = yield* ServerConfig.ServerConfig;
return handlers
.handle(
@@ -277,7 +279,12 @@ export const authHttpApiLayer = HttpApiBuilder.group(
return yield* failEnvironmentInvalidRequest("invalid_scope");
}
const proofKeyThumbprint = args.headers.dpop
- ? yield* verifyRequestDpopProof({ request }).pipe(
+ ? yield* verifyRequestDpopProof({
+ request,
+ ...(serverConfig.pairingBaseUrl
+ ? { pairingBaseUrl: serverConfig.pairingBaseUrl }
+ : {}),
+ }).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>
appendDpopChallengeHeader.pipe(
Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts
index cc1821dd10d..950d7b7d492 100644
--- a/apps/server/src/cli/config.test.ts
+++ b/apps/server/src/cli/config.test.ts
@@ -16,7 +16,9 @@ import {
import * as NetService from "@t3tools/shared/Net";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { deriveServerPaths } from "../config.ts";
-import { resolveServerConfig } from "./config.ts";
+import { PairingBaseUrl, resolveServerConfig } from "./config.ts";
+
+const decodePairingBaseUrl = Schema.decodeUnknownSync(PairingBaseUrl);
const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });
@@ -155,6 +157,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.some(true),
tailscaleServeEnabled: Option.some(true),
tailscaleServePort: Option.some(8443),
+ pairingBaseUrl: Option.some(
+ new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3"),
+ ),
},
Option.some("Debug"),
).pipe(
@@ -194,15 +199,26 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
+ desktopTelemetryFd: undefined,
+ desktopTelemetryControlFd: undefined,
+ resourceMonitorPath: undefined,
autoBootstrapProjectFromCwd: true,
logWebSocketEvents: true,
tailscaleServeEnabled: true,
tailscaleServePort: 8443,
+ pairingBaseUrl: new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/"),
});
assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite"));
}),
);
+ it("accepts only HTTP(S) pairing base URLs", () => {
+ expect(decodePairingBaseUrl("https://example.com/proxy")).toEqual(
+ new URL("https://example.com/proxy"),
+ );
+ expect(() => decodePairingBaseUrl("ftp://example.com/proxy")).toThrow(/HTTP\(S\)/);
+ });
+
it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () =>
Effect.gen(function* () {
const { join } = yield* Path.Path;
diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts
index 6814d251a37..3888e29941a 100644
--- a/apps/server/src/cli/config.ts
+++ b/apps/server/src/cli/config.ts
@@ -74,6 +74,29 @@ export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(
Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
Flag.optional,
);
+export const PairingBaseUrl = Schema.URLFromString.pipe(
+ Schema.refine((url): url is URL => url.protocol === "http:" || url.protocol === "https:", {
+ message: "Expected an HTTP(S) URL",
+ }),
+);
+
+export const normalizePairingBaseUrl = (value: URL): URL => {
+ const normalized = new URL(value);
+ if (!normalized.pathname.endsWith("/")) {
+ normalized.pathname = `${normalized.pathname}/`;
+ }
+ normalized.search = "";
+ normalized.hash = "";
+ return normalized;
+};
+
+export const pairingBaseUrlFlag = Flag.string("pairing-base-url").pipe(
+ Flag.withSchema(PairingBaseUrl),
+ Flag.withDescription(
+ "Public HTTP(S) base URL advertised in the headless pairing link when a trusted reverse proxy fronts this server.",
+ ),
+ Flag.optional,
+);
const EnvServerConfig = Config.all({
logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
@@ -139,6 +162,10 @@ const EnvServerConfig = Config.all({
Config.option,
Config.map(Option.getOrUndefined),
),
+ pairingBaseUrl: Config.schema(PairingBaseUrl, "T3CODE_PAIRING_BASE_URL").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
});
export interface CliServerFlags {
@@ -154,6 +181,7 @@ export interface CliServerFlags {
readonly logWebSocketEvents: Option.Option;
readonly tailscaleServeEnabled: Option.Option;
readonly tailscaleServePort: Option.Option;
+ readonly pairingBaseUrl?: Option.Option;
}
export interface CliAuthLocationFlags {
@@ -188,6 +216,7 @@ export const sharedServerCommandFlags = {
logWebSocketEvents: logWebSocketEventsFlag,
tailscaleServeEnabled: tailscaleServeFlag,
tailscaleServePort: tailscaleServePortFlag,
+ pairingBaseUrl: pairingBaseUrlFlag,
} as const;
export const authLocationFlags = sharedServerLocationFlags;
@@ -233,6 +262,7 @@ export const resolveServerConfig = (
logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
+ pairingBaseUrl: flags.pairingBaseUrl ?? Option.none(),
} satisfies CliServerFlags;
const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
const bootstrapEnvelope =
@@ -338,6 +368,14 @@ export const resolveServerConfig = (
),
() => 443,
);
+ const pairingBaseUrlValue = Option.getOrUndefined(
+ resolveOptionPrecedence(
+ normalizedFlags.pairingBaseUrl,
+ Option.fromUndefinedOr(env.pairingBaseUrl),
+ ),
+ );
+ const pairingBaseUrl =
+ pairingBaseUrlValue === undefined ? undefined : normalizePairingBaseUrl(pairingBaseUrlValue);
const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir();
const host = Option.getOrElse(
resolveOptionPrecedence(
@@ -386,6 +424,7 @@ export const resolveServerConfig = (
logWebSocketEvents,
tailscaleServeEnabled,
tailscaleServePort,
+ ...(pairingBaseUrl ? { pairingBaseUrl } : {}),
};
return config;
@@ -409,6 +448,7 @@ export const resolveCliAuthConfig = (
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
+ pairingBaseUrl: Option.none(),
},
cliLogLevel,
);
diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts
index c4f321a5a61..b39110240f9 100644
--- a/apps/server/src/cli/pair.test.ts
+++ b/apps/server/src/cli/pair.test.ts
@@ -173,6 +173,112 @@ describe("t3 pair", () => {
).pipe(Effect.provide(NodeServices.layer)),
);
+ it.effect("rejects a pairing URL that differs from the running server", () =>
+ withDescriptorServer((origin) =>
+ Effect.gen(function* () {
+ const baseDir = NodeFS.mkdtempSync(
+ NodePath.join(NodeOS.tmpdir(), "t3-pair-proxy-mismatch-test-"),
+ );
+ const port = Number(new URL(origin).port);
+ const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
+ yield* persistServerRuntimeState({
+ path: statePath,
+ state: yield* makePersistedServerRuntimeState({
+ config: {
+ host: "127.0.0.1",
+ devUrl: undefined,
+ pairingBaseUrl: new URL("https://example.com/expected/"),
+ },
+ port,
+ }),
+ });
+
+ const error = yield* provideCliTestLayers(
+ runCli([
+ "pair",
+ "--base-dir",
+ baseDir,
+ "--pairing-base-url",
+ "https://example.com/different/",
+ ]).pipe(Effect.flip),
+ );
+ const rendered = String(
+ typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
+ );
+ assert.include(rendered, "does not match the running server");
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("advertises a trusted reverse-proxy base URL", () =>
+ withDescriptorServer((origin) =>
+ Effect.gen(function* () {
+ const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-proxy-test-"));
+ const port = Number(new URL(origin).port);
+ const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
+ yield* persistServerRuntimeState({
+ path: statePath,
+ state: yield* makePersistedServerRuntimeState({
+ config: {
+ host: "127.0.0.1",
+ devUrl: undefined,
+ pairingBaseUrl: new URL("https://example.com/vm/alice/api/integrations/t3/"),
+ },
+ port,
+ }),
+ });
+
+ const output = yield* captureStdout(
+ runCli([
+ "pair",
+ "--base-dir",
+ baseDir,
+ "--pairing-base-url",
+ "https://example.com/vm/alice/api/integrations/t3/",
+ ]),
+ );
+
+ assert.include(
+ output,
+ "Pairing URL: https://app.t3.codes/pair?host=https%3A%2F%2Fexample.com%2Fvm%2Falice%2Fapi%2Fintegrations%2Ft3%2F#token=",
+ );
+ assert.notInclude(output, "only reachable from this machine");
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("rejects Tailscale pairing for a server configured behind another proxy", () =>
+ withDescriptorServer((origin) =>
+ Effect.gen(function* () {
+ const baseDir = NodeFS.mkdtempSync(
+ NodePath.join(NodeOS.tmpdir(), "t3-pair-proxy-tailscale-test-"),
+ );
+ const port = Number(new URL(origin).port);
+ const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
+ yield* persistServerRuntimeState({
+ path: statePath,
+ state: yield* makePersistedServerRuntimeState({
+ config: {
+ host: "127.0.0.1",
+ devUrl: undefined,
+ pairingBaseUrl: new URL("https://example.com/proxy/"),
+ },
+ port,
+ }),
+ });
+
+ const error = yield* provideCliTestLayers(
+ runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(Effect.flip),
+ );
+ const rendered = String(
+ typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
+ );
+ assert.include(rendered, "cannot use --tailscale");
+ assert.include(rendered, "Restart the server without --pairing-base-url");
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
it.effect("pairs through the recorded dev web URL for dev servers", () =>
withDescriptorServer((origin) =>
Effect.gen(function* () {
diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts
index 38fa3be8bb5..8860276711c 100644
--- a/apps/server/src/cli/pair.ts
+++ b/apps/server/src/cli/pair.ts
@@ -15,6 +15,7 @@ import {
PortSchema,
} from "@t3tools/contracts";
import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
+import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
import {
buildTailscaleHttpsBaseUrl,
DEFAULT_TAILSCALE_SERVE_PORT,
@@ -53,7 +54,12 @@ import {
renderTerminalQrCode,
resolveHeadlessConnectionString,
} from "../startupAccess.ts";
-import { baseDirFlag, DurationFromString } from "./config.ts";
+import {
+ baseDirFlag,
+ DurationFromString,
+ normalizePairingBaseUrl,
+ pairingBaseUrlFlag,
+} from "./config.ts";
const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
@@ -83,6 +89,28 @@ export class NoRunningServerError extends Schema.TaggedErrorClass()(
+ "PairingBaseUrlMismatchError",
+ {
+ requestedBaseUrl: Schema.String,
+ runningBaseUrl: Schema.optional(Schema.String),
+ },
+) {
+ override get message(): string {
+ const running = this.runningBaseUrl ?? "no public pairing URL";
+ return `The requested pairing base URL ${this.requestedBaseUrl} does not match the running server (${running}). Restart the server with the same --pairing-base-url before pairing.`;
+ }
+}
+
+export class TailscalePairingConflictError extends Schema.TaggedErrorClass()(
+ "TailscalePairingConflictError",
+ { runningBaseUrl: Schema.String },
+) {
+ override get message(): string {
+ return `This server cannot use --tailscale while configured behind ${this.runningBaseUrl}. Restart the server without --pairing-base-url before pairing through Tailscale.`;
+ }
+}
+
// Each tailscale failure gets its own class (same reasoning as
// scripts/lib/dev-share.ts): distinct caller-visible message, distinct remedy.
export class TailscaleUnavailableError extends Schema.TaggedErrorClass()(
@@ -316,6 +344,10 @@ const makePairServerConfig = Effect.fn(function* (input: {
// an explicit home and therefore lands in `userdata`. The recorded devUrl is
// what actually marks a dev server.
const devUrl = state.devUrl !== undefined ? new URL(state.devUrl) : undefined;
+ const pairingBaseUrl =
+ state.pairingBaseUrl !== undefined
+ ? normalizePairingBaseUrl(new URL(state.pairingBaseUrl))
+ : undefined;
const derivedPaths = yield* ServerConfig.deriveServerPaths(
baseDir,
variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined,
@@ -351,6 +383,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
logWebSocketEvents: false,
tailscaleServeEnabled: false,
tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
+ ...(pairingBaseUrl ? { pairingBaseUrl } : {}),
});
});
@@ -485,6 +518,7 @@ export const pairCommand = Command.make("pair", {
baseDir: baseDirFlag,
ttl: ttlFlag,
label: labelFlag,
+ pairingBaseUrl: pairingBaseUrlFlag,
tailscale: tailscaleFlag,
tailscaleServePort: tailscaleServePortFlag,
}).pipe(
@@ -502,13 +536,40 @@ export const pairCommand = Command.make("pair", {
const notes: Array = [];
let pairingBaseUrl: string;
- if (flags.tailscale) {
+ let useHostedApp = false;
+ const explicitPairingBaseUrlValue = Option.getOrUndefined(flags.pairingBaseUrl);
+ const explicitPairingBaseUrl =
+ explicitPairingBaseUrlValue === undefined
+ ? undefined
+ : normalizePairingBaseUrl(explicitPairingBaseUrlValue).toString();
+ const runningPairingBaseUrl =
+ target.state.pairingBaseUrl === undefined
+ ? undefined
+ : normalizePairingBaseUrl(new URL(target.state.pairingBaseUrl)).toString();
+ if (explicitPairingBaseUrl !== undefined) {
+ if (explicitPairingBaseUrl !== runningPairingBaseUrl) {
+ return yield* new PairingBaseUrlMismatchError({
+ requestedBaseUrl: explicitPairingBaseUrl,
+ ...(runningPairingBaseUrl ? { runningBaseUrl: runningPairingBaseUrl } : {}),
+ });
+ }
+ pairingBaseUrl = explicitPairingBaseUrl;
+ useHostedApp = true;
+ } else if (flags.tailscale) {
+ if (runningPairingBaseUrl !== undefined) {
+ return yield* new TailscalePairingConflictError({
+ runningBaseUrl: runningPairingBaseUrl,
+ });
+ }
const resolved = yield* resolveTailscalePairingBase({
target,
servePort: flags.tailscaleServePort,
});
pairingBaseUrl = resolved.baseUrl;
notes.push(...resolved.notes);
+ } else if (runningPairingBaseUrl !== undefined) {
+ pairingBaseUrl = runningPairingBaseUrl;
+ useHostedApp = true;
} else {
pairingBaseUrl = resolveDirectPairingBaseUrl(target.state);
if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) {
@@ -525,7 +586,11 @@ export const pairCommand = Command.make("pair", {
const config = yield* makePairServerConfig({ target, logLevel });
const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label });
- const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential);
+ const pairingUrl = buildPairingUrl(
+ pairingBaseUrl,
+ issued.credential,
+ useHostedApp ? DEFAULT_HOSTED_APP_URL : undefined,
+ );
yield* Console.log(
formatPairOutput({
diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
index e678264dde5..56b5cade70c 100644
--- a/apps/server/src/config.ts
+++ b/apps/server/src/config.ts
@@ -83,6 +83,7 @@ export class ServerConfig extends Context.Service<
readonly logWebSocketEvents: boolean;
readonly tailscaleServeEnabled: boolean;
readonly tailscaleServePort: number;
+ readonly pairingBaseUrl?: URL | undefined;
}
>()("t3/config/ServerConfig") {
/** @deprecated Import and use `layerTest` from this module. */
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 74d3fd2d594..e4838e64c52 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -1689,6 +1689,47 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("validates token exchange DPoP against the configured reverse-proxy URL", () =>
+ Effect.gen(function* () {
+ const pairingBaseUrl = new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/");
+ yield* buildAppUnderTest({ config: { pairingBaseUrl } });
+
+ const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
+ const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
+ headers: { cookie: ownerCookie },
+ body: yield* HttpBody.json({}),
+ });
+ const credential = (yield* credentialResponse.json) as { readonly credential: string };
+ const localTokenUrl = yield* getHttpServerUrl("/oauth/token");
+ const now = yield* DateTime.now;
+ const tokenProof = makeDpopProof({
+ method: "POST",
+ url: new URL("oauth/token", pairingBaseUrl).href,
+ iat: Math.floor(now.epochMilliseconds / 1_000),
+ jti: "reverse-proxy-token-exchange-proof",
+ });
+
+ const tokenResponse = yield* fetchEffect(localTokenUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ dpop: tokenProof.proof,
+ },
+ body: new URLSearchParams({
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
+ subject_token: credential.credential,
+ subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap",
+ requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ scope: "orchestration:read orchestration:operate terminal:operate review:write",
+ }).toString(),
+ });
+
+ assert.equal(tokenResponse.status, 200);
+ const token = yield* responseJsonEffect<{ readonly token_type: string }>(tokenResponse);
+ assert.equal(token.token_type, "DPoP");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("rejects replayed DPoP proofs across token exchanges", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts
index 4c2375b29a7..ca038ce19b8 100644
--- a/apps/server/src/serverRuntimeState.test.ts
+++ b/apps/server/src/serverRuntimeState.test.ts
@@ -34,6 +34,7 @@ describe("serverRuntimeState", () => {
port: 4_971,
origin: "http://127.0.0.1:4971",
devUrl: "http://localhost:5733/",
+ pairingBaseUrl: "https://example.com/proxy/",
startedAt: "2026-06-20T00:00:00.000Z",
};
@@ -47,18 +48,24 @@ describe("serverRuntimeState", () => {
it.effect("records the dev web URL when the server fronts a dev server", () =>
Effect.gen(function* () {
const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({
- config: { host: undefined, devUrl: new URL("http://localhost:5733") },
+ config: {
+ host: undefined,
+ devUrl: new URL("http://localhost:5733"),
+ pairingBaseUrl: new URL("https://example.com/proxy/"),
+ },
port: 13_773,
});
assert.equal(state.devUrl, "http://localhost:5733/");
+ assert.equal(state.pairingBaseUrl, "https://example.com/proxy/");
assert.equal(state.origin, "http://127.0.0.1:13773");
const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({
- config: { host: undefined, devUrl: undefined },
+ config: { host: undefined, devUrl: undefined, pairingBaseUrl: undefined },
port: 13_773,
});
assert.isFalse("devUrl" in withoutDev);
+ assert.isFalse("pairingBaseUrl" in withoutDev);
}),
);
diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts
index b32f3814547..5827fd6feec 100644
--- a/apps/server/src/serverRuntimeState.ts
+++ b/apps/server/src/serverRuntimeState.ts
@@ -17,6 +17,8 @@ export const PersistedServerRuntimeState = Schema.Struct({
// Present when the server fronts a dev web server (VITE_DEV_SERVER_URL).
// Dev is single-origin: browsers must pair through this URL, not `origin`.
devUrl: Schema.optional(Schema.String),
+ // Public reverse-proxy base used by the running server for DPoP URL binding.
+ pairingBaseUrl: Schema.optional(Schema.String),
startedAt: Schema.String,
});
export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;
@@ -48,7 +50,8 @@ const runtimeOriginForConfig = (
};
export const makePersistedServerRuntimeState = (input: {
- readonly config: Pick;
+ readonly config: Pick &
+ Partial>;
readonly port: number;
}): Effect.Effect =>
Effect.map(DateTime.now, (now) => ({
@@ -58,6 +61,9 @@ export const makePersistedServerRuntimeState = (input: {
port: input.port,
origin: runtimeOriginForConfig(input.config, input.port),
...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),
+ ...(input.config.pairingBaseUrl
+ ? { pairingBaseUrl: input.config.pairingBaseUrl.toString() }
+ : {}),
startedAt: DateTime.formatIso(now),
}));
diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts
index 03c01170f15..a80c284f061 100644
--- a/apps/server/src/startupAccess.test.ts
+++ b/apps/server/src/startupAccess.test.ts
@@ -58,6 +58,18 @@ it("builds a pairing URL that embeds the token in the hash", () => {
);
});
+it("builds a hosted pairing URL from an advertised reverse-proxy base", () => {
+ expect(
+ buildPairingUrl(
+ "https://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ "PAIRCODE",
+ "https://app.t3.codes",
+ ),
+ ).toBe(
+ "https://app.t3.codes/pair?host=https%3A%2F%2Fapp.matrix-os.com%2Fvm%2Falice%2Fapi%2Fintegrations%2Ft3%2F#token=PAIRCODE",
+ );
+});
+
it("renders terminal QR codes as a multi-line unicode block grid", () => {
const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE");
diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts
index 7df131669ba..5902746266b 100644
--- a/apps/server/src/startupAccess.ts
+++ b/apps/server/src/startupAccess.ts
@@ -1,5 +1,6 @@
import * as NodeOS from "node:os";
+import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
import { QrCode } from "@t3tools/shared/qrCode";
import * as Effect from "effect/Effect";
import { HttpServer } from "effect/unstable/http";
@@ -89,9 +90,20 @@ export const resolveListeningPort = (address: unknown, fallbackPort: number): nu
return fallbackPort;
};
-export const buildPairingUrl = (connectionString: string, token: string): string => {
+export const buildPairingUrl = (
+ connectionString: string,
+ token: string,
+ hostedAppUrl?: string,
+): string => {
+ if (hostedAppUrl) {
+ const url = new URL("/pair", hostedAppUrl);
+ url.searchParams.set("host", connectionString);
+ url.hash = new URLSearchParams([["token", token]]).toString();
+ return url.toString();
+ }
const url = new URL(connectionString);
- url.pathname = "/pair";
+ const basePath = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`;
+ url.pathname = `${basePath}pair`;
url.searchParams.delete("token");
url.hash = new URLSearchParams([["token", token]]).toString();
return url.toString();
@@ -138,11 +150,16 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI
serverConfig.host,
resolveListeningPort(httpServer.address, serverConfig.port),
);
+ const pairingBaseUrl = serverConfig.pairingBaseUrl?.toString() ?? connectionString;
const issued = yield* serverAuth.issueStartupPairingCredential();
return {
connectionString,
token: issued.credential,
- pairingUrl: buildPairingUrl(connectionString, issued.credential),
+ pairingUrl: buildPairingUrl(
+ pairingBaseUrl,
+ issued.credential,
+ serverConfig.pairingBaseUrl ? DEFAULT_HOSTED_APP_URL : undefined,
+ ),
} satisfies HeadlessServeAccessInfo;
});
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 640412e4a6d..bd7887302d6 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -43,6 +43,7 @@ import { cn } from "../../lib/utils";
import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
+import { MatrixOsConnectRow } from "./MatrixOsConnectRow";
import {
SettingsPageContainer,
SettingsRow,
@@ -3415,6 +3416,7 @@ export function ConnectionsSettings() {
}
>
+
{savedEnvironments.map((environment) => (
{
+ const api = readLocalApi();
+ if (!api) {
+ toastManager.add({ type: "error", title: "Link opening is unavailable." });
+ return;
+ }
+
+ setIsOpening(true);
+ try {
+ await openMatrixOsConnect(api.shell);
+ } catch (error) {
+ console.error("Failed to open the Matrix OS setup link.", error);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open Matrix OS",
+ description: "Open app.matrix-os.com and connect T3 Code from its Terminal.",
+ }),
+ );
+ } finally {
+ setIsOpening(false);
+ }
+ };
+
+ return (
+ void handleConnect()}
+ >
+
+ {isOpening ? "Opening…" : MATRIX_OS_SETUP_ACTION_LABEL}
+
+ }
+ />
+ );
+}
diff --git a/apps/web/src/components/settings/openMatrixOsConnect.test.ts b/apps/web/src/components/settings/openMatrixOsConnect.test.ts
new file mode 100644
index 00000000000..73b82854799
--- /dev/null
+++ b/apps/web/src/components/settings/openMatrixOsConnect.test.ts
@@ -0,0 +1,14 @@
+import { MATRIX_OS_CONNECT_URL } from "@t3tools/shared/matrixOsConnect";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { openMatrixOsConnect } from "./openMatrixOsConnect";
+
+describe("openMatrixOsConnect", () => {
+ it("opens the fixed Matrix OS handoff in the external browser", async () => {
+ const openExternal = vi.fn(async () => undefined);
+
+ await openMatrixOsConnect({ openExternal });
+
+ expect(openExternal).toHaveBeenCalledExactlyOnceWith(MATRIX_OS_CONNECT_URL);
+ });
+});
diff --git a/apps/web/src/components/settings/openMatrixOsConnect.ts b/apps/web/src/components/settings/openMatrixOsConnect.ts
new file mode 100644
index 00000000000..622c0e3ae3e
--- /dev/null
+++ b/apps/web/src/components/settings/openMatrixOsConnect.ts
@@ -0,0 +1,8 @@
+import type { LocalApi } from "@t3tools/contracts";
+import { MATRIX_OS_CONNECT_URL } from "@t3tools/shared/matrixOsConnect";
+
+export async function openMatrixOsConnect(
+ shell: Pick,
+): Promise {
+ await shell.openExternal(MATRIX_OS_CONNECT_URL);
+}
diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts
index 260256c1250..5e68b91ed66 100644
--- a/apps/web/src/localApi.test.ts
+++ b/apps/web/src/localApi.test.ts
@@ -77,6 +77,44 @@ describe("LocalApi", () => {
expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
});
+ it("rejects external links when the browser blocks the popup", async () => {
+ Object.defineProperty(testWindow(), "open", {
+ configurable: true,
+ value: vi.fn(() => null),
+ });
+ const { createLocalApi } = await import("./localApi");
+
+ await expect(
+ createLocalApi().shell.openExternal("https://app.matrix-os.com/?launch=__terminal__"),
+ ).rejects.toThrow("Unable to open link.");
+ });
+
+ it("opens browser external links without exposing the opener", async () => {
+ const targetUrl = "https://app.matrix-os.com/?launch=__terminal__";
+ const click = vi.fn();
+ const anchor = { href: "", rel: "", click };
+ const createElement = vi.fn(() => anchor);
+ const popup = {
+ opener: testWindow(),
+ document: { createElement },
+ };
+ const open = vi.fn(() => popup);
+ Object.defineProperty(testWindow(), "open", {
+ configurable: true,
+ value: open,
+ });
+ const { createLocalApi } = await import("./localApi");
+
+ await expect(createLocalApi().shell.openExternal(targetUrl)).resolves.toBeUndefined();
+
+ expect(open).toHaveBeenCalledExactlyOnceWith("about:blank", "_blank");
+ expect(popup.opener).toBeNull();
+ expect(createElement).toHaveBeenCalledExactlyOnceWith("a");
+ expect(anchor.href).toBe(targetUrl);
+ expect(anchor.rel).toBe("noopener noreferrer");
+ expect(click).toHaveBeenCalledOnce();
+ });
+
it("delegates host capabilities and persistence to the desktop bridge", async () => {
const showContextMenu = vi.fn().mockResolvedValue("delete");
const pickFolder = vi.fn().mockResolvedValue("/tmp/project");
diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts
index b42702c7a4a..e0771944a03 100644
--- a/apps/web/src/localApi.ts
+++ b/apps/web/src/localApi.ts
@@ -30,7 +30,19 @@ function createBrowserLocalApi(): LocalApi {
return;
}
- window.open(url, "_blank", "noopener,noreferrer");
+ // Browsers may return null for successful `noopener` launches, which is
+ // indistinguishable from a blocked popup. Open a blank tab first so we
+ // can detect blocking, sever its opener synchronously, then navigate
+ // through a noreferrer link created inside the new tab.
+ const opened = window.open("about:blank", "_blank");
+ if (!opened) {
+ throw new Error("Unable to open link.");
+ }
+ opened.opener = null;
+ const link = opened.document.createElement("a");
+ link.href = url;
+ link.rel = "noopener noreferrer";
+ link.click();
},
},
contextMenu: {
diff --git a/docs/internals/remote.md b/docs/internals/remote.md
index afce95f725b..7464f5336c0 100644
--- a/docs/internals/remote.md
+++ b/docs/internals/remote.md
@@ -140,6 +140,16 @@ how the server got started or who manages the process.
It works for desktop, mobile, and web with no client-side process management. Browser security rules
are part of it: a hosted HTTPS client cannot connect to plain `ws://` or `http://` LAN backends.
+A trusted reverse proxy may publish the server below a URL path prefix, for example
+`https://host.example/vm/alice/t3/`. Pairing preserves that prefix and resolves the environment
+descriptor, OAuth exchange, API calls, assets, and WebSocket endpoint beneath it. Start a headless
+server with `--pairing-base-url` to advertise that public base while still binding the server to a
+private or loopback interface. `t3 pair --pairing-base-url ...` mints another one-time link for an
+already-running server. Those links open the hosted T3 client with the public backend base encoded
+as the target, so the proxy does not need to publish T3's static web assets or `/pair` page. The
+reverse proxy is responsible for forwarding the complete T3 protocol namespace and preserving T3
+authorization headers and WebSocket tickets.
+
### Relay-tunneled access
Managed T3 Connect relay tunnels use `RelayConnectionTarget` and are the answer when the host is
@@ -189,6 +199,9 @@ it separate from access.
- **Client-managed local publish.** A local server is published through the relay with
`t3 connect link`, exposing a desktop-hosted environment to mobile without router or firewall
changes.
+- **Trusted reverse-proxy publish.** The operator starts T3 on a private interface and advertises a
+ public, possibly path-prefixed base URL with `--pairing-base-url`; the proxy owns reachability and
+ T3 continues to own pairing and session authentication.
The same `ExecutionEnvironment` can be reached several of these ways. Only the launch and access
paths differ.
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 05418022d23..b8717db6fcf 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -84,6 +84,16 @@ For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A p
Use this when you want to run the server without a GUI, for example on a remote machine over SSH.
+If your remote machine runs Matrix OS, choose **Settings** → **Connections** → **Matrix OS** in
+the desktop or web client, or **Add Environment** → **Open Matrix OS setup** on mobile. T3 Code
+opens the Matrix OS Terminal with a pinned T3 CLI setup ready to run. Review and confirm the
+command. Matrix OS either prints a fresh one-time pairing link for the running T3 server or starts
+the server and prints its pairing link and QR code. Scan the QR code on mobile, or paste the full
+pairing URL into the desktop or web client. No T3 account is required: Matrix OS exposes the local
+server through a scoped reverse proxy while T3 Code continues to authenticate the pairing link,
+device session, and WebSocket connection. Keep the Matrix Terminal session running to keep the
+environment reachable.
+
Run the server with `t3 serve`.
```bash
@@ -106,6 +116,16 @@ From there, connect from another device in either of these ways:
Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument.
+If a trusted reverse proxy exposes the server at a different or path-prefixed public URL, keep T3
+bound to the private interface and advertise the public base explicitly. The printed link opens the
+hosted T3 client and points it at that backend, so the proxy only needs to expose the T3 protocol:
+
+```bash
+npx t3 serve --host 127.0.0.1 --pairing-base-url https://example.com/path/to/t3/
+```
+
+Use the same `--pairing-base-url` with `t3 pair` to print a fresh link for that running server.
+
For hosted web pairing over Tailscale HTTPS, opt in to Tailscale Serve:
```bash
diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts
index 6e6ccc86052..de98dfaeef7 100644
--- a/packages/client-runtime/src/authorization/remote.test.ts
+++ b/packages/client-runtime/src/authorization/remote.test.ts
@@ -126,6 +126,34 @@ describe("remote environment authorization", () => {
}),
);
+ it.effect("appends auth and websocket endpoints beneath a reverse-proxy base path", () =>
+ Effect.gen(function* () {
+ const fetch = recordedFetch(
+ Response.json({
+ ticket: "ws-ticket",
+ expiresAt: "2026-05-01T12:05:00.000Z",
+ }),
+ );
+
+ const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
+ httpBaseUrl: "https://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ wsBaseUrl: "wss://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ bearerToken: "bearer-token",
+ }).pipe(provideRemoteHttp(fetch.fetchFn));
+
+ expectFetchCall(fetch.calls, 1, {
+ url: "https://app.matrix-os.com/vm/alice/api/integrations/t3/api/auth/websocket-ticket",
+ method: "POST",
+ headers: {
+ authorization: "Bearer bearer-token",
+ },
+ });
+ expect(socketUrl).toBe(
+ "wss://app.matrix-os.com/vm/alice/api/integrations/t3/ws?wsTicket=ws-ticket",
+ );
+ }),
+ );
+
it.effect("exchanges managed credentials and admits websocket requests with DPoP", () =>
Effect.gen(function* () {
const fetch = recordedFetch(
diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts
index 69c157d0e50..f631d92b957 100644
--- a/packages/client-runtime/src/authorization/remote.ts
+++ b/packages/client-runtime/src/authorization/remote.ts
@@ -7,7 +7,7 @@ import {
} from "@t3tools/contracts";
import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
import * as Effect from "effect/Effect";
-import { environmentEndpointUrl } from "../environment/endpoint.ts";
+import { environmentEndpointUrl, environmentWebSocketUrl } from "../environment/endpoint.ts";
import {
executeEnvironmentHttpRequest,
makeEnvironmentHttpApiClient,
@@ -182,10 +182,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
});
- const url = new URL(input.wsBaseUrl);
- if (url.pathname === "" || url.pathname === "/") {
- url.pathname = "/ws";
- }
+ const url = environmentWebSocketUrl(input.wsBaseUrl);
url.searchParams.set("wsTicket", issued.ticket);
return url.toString();
});
@@ -205,10 +202,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
dpopProof: input.dpopProof,
...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
});
- const url = new URL(input.wsBaseUrl);
- if (url.pathname === "" || url.pathname === "/") {
- url.pathname = "/ws";
- }
+ const url = environmentWebSocketUrl(input.wsBaseUrl);
url.searchParams.set("wsTicket", issued.ticket);
return url.toString();
});
diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts
index 9bee0dad6fb..d4951610aeb 100644
--- a/packages/client-runtime/src/connection/onboarding.test.ts
+++ b/packages/client-runtime/src/connection/onboarding.test.ts
@@ -118,6 +118,27 @@ describe("connection onboarding", () => {
}),
);
+ it.effect("pairs through a reverse-proxy base path", () =>
+ Effect.gen(function* () {
+ const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
+ const registration = yield* preparePairingRegistration({
+ pairingUrl:
+ "https://app.matrix-os.com/vm/alice/api/integrations/t3/pair#token=pairing-token",
+ }).pipe(Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls))));
+
+ expect(calls.map((call) => new URL(call.url).pathname)).toEqual([
+ "/vm/alice/api/integrations/t3/.well-known/t3/environment",
+ "/vm/alice/api/integrations/t3/oauth/token",
+ ]);
+ expect(registration.profile.httpBaseUrl).toBe(
+ "https://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ );
+ expect(registration.profile.wsBaseUrl).toBe(
+ "wss://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ );
+ }),
+ );
+
it.effect("does not consume a pairing credential when descriptor discovery fails", () =>
Effect.gen(function* () {
const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts
index c219bde092c..b044698e434 100644
--- a/packages/client-runtime/src/connection/resolver.ts
+++ b/packages/client-runtime/src/connection/resolver.ts
@@ -30,6 +30,7 @@ import type {
RelayConnectionTarget,
SshConnectionTarget,
} from "./model.ts";
+import { environmentWebSocketUrl } from "../environment/endpoint.ts";
import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
import * as ConnectionProfileStore from "./profileStore.ts";
@@ -47,11 +48,7 @@ const isSshProfile = Schema.is(SshConnectionProfile);
const isBearerCredential = Schema.is(BearerConnectionCredential);
function primarySocketUrl(target: PrimaryConnectionTarget): string {
- const url = new URL(target.wsBaseUrl);
- if (url.pathname === "" || url.pathname === "/") {
- url.pathname = "/ws";
- }
- return url.toString();
+ return environmentWebSocketUrl(target.wsBaseUrl).toString();
}
const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () {
diff --git a/packages/client-runtime/src/environment/endpoint.test.ts b/packages/client-runtime/src/environment/endpoint.test.ts
index d26201dc4f7..ae13508f26c 100644
--- a/packages/client-runtime/src/environment/endpoint.test.ts
+++ b/packages/client-runtime/src/environment/endpoint.test.ts
@@ -4,6 +4,8 @@ import {
classifyHostedHttpsCompatibility,
createAdvertisedEndpoint,
deriveWsBaseUrl,
+ environmentEndpointUrl,
+ environmentWebSocketUrl,
normalizeHttpBaseUrl,
} from "./endpoint.ts";
@@ -22,6 +24,24 @@ describe("advertised endpoint helpers", () => {
expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/");
});
+ it("resolves environment endpoints beneath a reverse-proxy base path", () => {
+ expect(
+ environmentEndpointUrl(
+ "https://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ "/api/auth/session",
+ ),
+ ).toBe("https://app.matrix-os.com/vm/alice/api/integrations/t3/api/auth/session");
+ expect(
+ environmentWebSocketUrl(
+ "wss://app.matrix-os.com/vm/alice/api/integrations/t3/?stale=1#fragment",
+ ).toString(),
+ ).toBe("wss://app.matrix-os.com/vm/alice/api/integrations/t3/ws");
+ expect(environmentWebSocketUrl("wss://example.com/ws").toString()).toBe("wss://example.com/ws");
+ expect(environmentWebSocketUrl("wss://example.com/proxy/ws/").toString()).toBe(
+ "wss://example.com/proxy/ws",
+ );
+ });
+
it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => {
expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe(
"mixed-content-blocked",
diff --git a/packages/client-runtime/src/environment/endpoint.ts b/packages/client-runtime/src/environment/endpoint.ts
index 4178259361e..05d6924231b 100644
--- a/packages/client-runtime/src/environment/endpoint.ts
+++ b/packages/client-runtime/src/environment/endpoint.ts
@@ -2,8 +2,18 @@ export * from "@t3tools/shared/advertisedEndpoint";
export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
const url = new URL(httpBaseUrl);
- url.pathname = pathname;
+ const basePath = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`;
+ url.pathname = `${basePath}${pathname.replace(/^\/+/, "")}`;
url.search = "";
url.hash = "";
return url.toString();
};
+
+export const environmentWebSocketUrl = (wsBaseUrl: string): URL => {
+ const url = new URL(wsBaseUrl);
+ const basePath = url.pathname.replace(/\/+$/, "");
+ url.pathname = basePath === "/ws" || basePath.endsWith("/ws") ? basePath : `${basePath}/ws`;
+ url.search = "";
+ url.hash = "";
+ return url;
+};
diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts
index 87495918598..75e810c44f2 100644
--- a/packages/client-runtime/src/rpc/http.ts
+++ b/packages/client-runtime/src/rpc/http.ts
@@ -88,7 +88,9 @@ export const remoteHttpClientLayer = (
const remoteApiBaseUrl = (httpBaseUrl: string): string => {
const url = new URL(httpBaseUrl);
- url.pathname = "/";
+ if (!url.pathname.endsWith("/")) {
+ url.pathname = `${url.pathname}/`;
+ }
url.search = "";
url.hash = "";
return url.toString();
diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts
index 58add31d6bb..61566a408e2 100644
--- a/packages/client-runtime/src/state/assets.test.ts
+++ b/packages/client-runtime/src/state/assets.test.ts
@@ -8,6 +8,7 @@ import {
createAssetEnvironmentAtoms,
InvalidAssetCollectionKeyError,
parseAssetCollectionKey,
+ resolveAssetUrl,
} from "./assets.ts";
describe("asset collection keys", () => {
@@ -32,6 +33,17 @@ describe("asset collection keys", () => {
});
});
+describe("asset urls", () => {
+ it("keeps server-relative assets beneath a reverse-proxy base path", () => {
+ expect(
+ resolveAssetUrl(
+ "https://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ "/api/assets/asset-1",
+ ),
+ ).toBe("https://app.matrix-os.com/vm/alice/api/integrations/t3/api/assets/asset-1");
+ });
+});
+
describe("createAssetEnvironmentAtoms", () => {
it("keys asset URL queries by environment and resource", () => {
const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts
index e407f5d0028..466569f898f 100644
--- a/packages/client-runtime/src/state/assets.ts
+++ b/packages/client-runtime/src/state/assets.ts
@@ -3,6 +3,7 @@ import * as Schema from "effect/Schema";
import { Atom } from "effect/unstable/reactivity";
import type { EnvironmentRegistry } from "../connection/registry.ts";
+import { environmentEndpointUrl } from "../environment/endpoint.ts";
import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
@@ -37,6 +38,9 @@ export function parseAssetCollectionKey(
export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {
try {
+ if (relativeUrl.startsWith("/")) {
+ return environmentEndpointUrl(httpBaseUrl, relativeUrl);
+ }
return new URL(relativeUrl, httpBaseUrl).toString();
} catch {
return null;
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 8cdae3e5160..0834c24be79 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -206,6 +206,10 @@
"./devProxy": {
"types": "./src/devProxy.ts",
"import": "./src/devProxy.ts"
+ },
+ "./matrixOsConnect": {
+ "types": "./src/matrixOsConnect.ts",
+ "import": "./src/matrixOsConnect.ts"
}
},
"scripts": {
diff --git a/packages/shared/src/matrixOsConnect.test.ts b/packages/shared/src/matrixOsConnect.test.ts
new file mode 100644
index 00000000000..0e1826847ae
--- /dev/null
+++ b/packages/shared/src/matrixOsConnect.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ MATRIX_OS_CONNECT_URL,
+ MATRIX_OS_SETUP_ACTION_LABEL,
+ MATRIX_OS_SETUP_DESCRIPTION,
+ MATRIX_OS_SETUP_MOBILE_ACTION_LABEL,
+} from "./matrixOsConnect.js";
+
+describe("MATRIX_OS_CONNECT_URL", () => {
+ it("targets the canonical Matrix OS Terminal with the fixed T3 action", () => {
+ const url = new URL(MATRIX_OS_CONNECT_URL);
+
+ expect(url.origin).toBe("https://app.matrix-os.com");
+ expect(url.pathname).toBe("/");
+ expect(url.searchParams.get("launch")).toBe("__terminal__");
+ expect(url.searchParams.get("terminal_action")).toBe("t3-connect");
+ expect(new Set(url.searchParams.keys())).toEqual(new Set(["launch", "terminal_action"]));
+ });
+});
+
+describe("Matrix OS setup copy", () => {
+ it("describes an onboarding action instead of claiming connection status", () => {
+ expect(MATRIX_OS_SETUP_ACTION_LABEL).toBe("Set up");
+ expect(MATRIX_OS_SETUP_MOBILE_ACTION_LABEL).toBe("Open Matrix OS setup");
+ expect(MATRIX_OS_SETUP_DESCRIPTION).toContain("one-time pairing link");
+ expect(MATRIX_OS_SETUP_DESCRIPTION).toContain("No T3 account required");
+ });
+});
diff --git a/packages/shared/src/matrixOsConnect.ts b/packages/shared/src/matrixOsConnect.ts
new file mode 100644
index 00000000000..b438e87258b
--- /dev/null
+++ b/packages/shared/src/matrixOsConnect.ts
@@ -0,0 +1,7 @@
+export const MATRIX_OS_CONNECT_URL =
+ "https://app.matrix-os.com/?launch=__terminal__&terminal_action=t3-connect";
+
+export const MATRIX_OS_SETUP_ACTION_LABEL = "Set up";
+export const MATRIX_OS_SETUP_MOBILE_ACTION_LABEL = "Open Matrix OS setup";
+export const MATRIX_OS_SETUP_DESCRIPTION =
+ "Start T3 Code on a Matrix OS computer, then scan or paste its one-time pairing link here. No T3 account required.";
diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts
index 5b7a9973652..01d27df8276 100644
--- a/packages/shared/src/remote.test.ts
+++ b/packages/shared/src/remote.test.ts
@@ -21,6 +21,32 @@ describe("remote", () => {
});
});
+ it("preserves a reverse-proxy base path while removing the pairing page", () => {
+ expect(
+ resolveRemotePairingTarget({
+ pairingUrl:
+ "https://app.matrix-os.com/vm/alice/api/integrations/t3/pair#token=pairing-token",
+ }),
+ ).toEqual({
+ credential: "pairing-token",
+ httpBaseUrl: "https://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ wsBaseUrl: "wss://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ });
+ });
+
+ it("preserves a reverse-proxy base path from a hosted pairing request", () => {
+ expect(
+ resolveRemotePairingTarget({
+ pairingUrl:
+ "https://app.t3.codes/pair?host=https%3A%2F%2Fapp.matrix-os.com%2Fvm%2Falice%2Fapi%2Fintegrations%2Ft3%2F#token=pairing-token",
+ }),
+ ).toEqual({
+ credential: "pairing-token",
+ httpBaseUrl: "https://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ wsBaseUrl: "wss://app.matrix-os.com/vm/alice/api/integrations/t3/",
+ });
+ });
+
it("accepts pairing urls that still use a query token", () => {
expect(
resolveRemotePairingTarget({
diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts
index 1b5d1c586e2..573efa8668e 100644
--- a/packages/shared/src/remote.ts
+++ b/packages/shared/src/remote.ts
@@ -72,6 +72,24 @@ export type RemotePairingTargetError = typeof RemotePairingTargetError.Type;
const hasSupportedRemoteBackendProtocol = (url: URL): boolean =>
SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol);
+const normalizeRemoteBasePath = (url: URL): void => {
+ const pathname = url.pathname.replace(/\/+$/, "");
+ url.pathname = pathname.length > 0 ? `${pathname}/` : "/";
+};
+
+const removePairingPageFromPath = (url: URL): void => {
+ const pathname = url.pathname.replace(/\/+$/, "");
+ if (pathname === "/pair") {
+ url.pathname = "/";
+ return;
+ }
+ if (pathname.endsWith("/pair")) {
+ url.pathname = `${pathname.slice(0, -"/pair".length)}/`;
+ return;
+ }
+ normalizeRemoteBasePath(url);
+};
+
const normalizeRemoteBaseUrl = (
rawValue: string,
source: RemoteBackendUrlInvalidError["source"],
@@ -97,7 +115,7 @@ const normalizeRemoteBaseUrl = (
protocol: url.protocol,
});
}
- url.pathname = "/";
+ normalizeRemoteBasePath(url);
url.search = "";
url.hash = "";
return url;
@@ -110,7 +128,7 @@ const toHttpBaseUrl = (url: URL): string => {
} else if (next.protocol === "wss:") {
next.protocol = "https:";
}
- next.pathname = "/";
+ normalizeRemoteBasePath(next);
next.search = "";
next.hash = "";
return next.toString();
@@ -123,7 +141,7 @@ const toWsBaseUrl = (url: URL): string => {
} else if (next.protocol === "https:") {
next.protocol = "wss:";
}
- next.pathname = "/";
+ normalizeRemoteBasePath(next);
next.search = "";
next.hash = "";
return next.toString();
@@ -220,6 +238,7 @@ export const resolveRemotePairingTarget = (input: {
if (!credential) {
throw new RemotePairingTokenMissingError({ host: url.host });
}
+ removePairingPageFromPath(url);
return {
credential,
httpBaseUrl: toHttpBaseUrl(url),