From c1a3ff46006a0428eec7d8340de4ffc1425ee0a0 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:19:32 +0100 Subject: [PATCH 01/11] feat: T3CODE_CONNECT_ENABLED=false turns T3 Connect off Telemetry can be switched off with T3CODE_TELEMETRY_ENABLED=false, but T3 Connect has no equivalent, so official builds always ship with Clerk sign-in and the relay available. With T3CODE_CONNECT_ENABLED=false the server skips restoring a saved link at startup (still releasing activation), refuses link proofs and relay configs, and treats the link as absent for agent activity publishing; `t3 connect` is hidden. The desktop app forwards the variable from the shell profile and to WSL, and tells the web UI through a new desktop bridge flag, so Connect settings and the Clerk auth shell stay out. Any other value changes nothing. Co-Authored-By: Claude Opus 5.5 --- .../DesktopBackendConfiguration.test.ts | 5 ++++ .../backend/DesktopBackendConfiguration.ts | 1 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 ++ apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 20 +++++++++++++ apps/desktop/src/ipc/methods/window.ts | 9 ++++++ apps/desktop/src/preload.ts | 1 + .../src/shell/DesktopShellEnvironment.ts | 2 ++ apps/server/src/binCli.ts | 4 +-- .../src/cloud/CloudLink.lifecycle.test.ts | 30 +++++++++++++++++++ apps/server/src/cloud/CloudLink.ts | 14 ++++++++- apps/server/src/cloud/publicConfig.ts | 7 +++++ .../src/relay/AgentAwarenessRelay.test.ts | 13 ++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 2 ++ apps/server/src/server.ts | 7 ++++- apps/web/src/cloud/publicConfig.test.ts | 16 ++++++---- apps/web/src/cloud/publicConfig.ts | 3 ++ docs/user/remote-access.md | 7 +++++ packages/contracts/src/ipc.ts | 1 + packages/shared/src/connectAuth.ts | 4 +++ 20 files changed, 140 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 7464d8fbe9e2f..518d453225667 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -688,11 +688,13 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousConnect = process.env.T3CODE_CONNECT_ENABLED; const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.T3CODE_CONNECT_ENABLED = "false"; process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { @@ -703,6 +705,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.T3CODE_CONNECT_ENABLED, "false"); + assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_CONNECT_ENABLED"); assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( @@ -726,6 +730,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("T3CODE_CONNECT_ENABLED", previousConnect); restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 492903139547b..cf153b52a1713 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -85,6 +85,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_CONNECT_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 35333ac3d33a4..3fcd2cba947a7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -34,6 +34,7 @@ import { } from "./methods/updates.ts"; import { getAppBranding, + getConnectEnabled, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getRelayTelemetryEnabled, @@ -92,6 +93,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getRelayTelemetryEnabled); + yield* ipc.handleSync(getConnectEnabled); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 538dd06b0b42a..97e089c5d0e18 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -32,6 +32,7 @@ export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; +export const GET_CONNECT_ENABLED_CHANNEL = "desktop:get-connect-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index ed821165b40f7..7fa75b1864f23 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -25,6 +25,7 @@ import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, + getConnectEnabled, getRelayTelemetryEnabled, pickProjectFavicon, probeRemoteEditors, @@ -196,6 +197,25 @@ describe("getRelayTelemetryEnabled", () => { ); }); +describe("getConnectEnabled", () => { + it.effect.each([ + [undefined, true], + ["true", true], + ["0", true], + ["false", false], + [" FALSE ", false], + ] as const)("T3CODE_CONNECT_ENABLED=%s turns T3 Connect on: %s", ([value, expected]) => + Effect.gen(function* () { + assert.equal( + yield* getConnectEnabled + .handler() + .pipe(Effect.provideService(HostProcess.Environment, { T3CODE_CONNECT_ENABLED: value })), + expected, + ); + }), + ); +}); + describe("pickProjectFavicon", () => { const pickerLayer = ( pickFiles: () => Effect.Effect>, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index f0d661b58bc9a..3c41e8b7cfe93 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -17,6 +17,7 @@ import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; +import { isConnectEnabled } from "@t3tools/shared/connectAuth"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -80,6 +81,14 @@ export const getRelayTelemetryEnabled = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getConnectEnabled = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_CONNECT_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getConnectEnabled")(function* () { + return isConnectEnabled((yield* HostProcess.Environment).T3CODE_CONNECT_ENABLED); + }), +}); + export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, result: Schema.String, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 950ed972c6453..d353ecbe868aa 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -82,6 +82,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getRelayTelemetryEnabled: () => ipcRenderer.sendSync(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL) === true, + getConnectEnabled: () => ipcRenderer.sendSync(IpcChannels.GET_CONNECT_ENABLED_CHANNEL) === true, getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 6e0fdbd38050f..7abab5f89384a 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_CONNECT_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", ] as const; @@ -451,6 +452,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_CONNECT_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 7c136ae53268c..295464a6f005d 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -35,7 +35,7 @@ import { connectCommand, statusCommand, upCommand } from "./cli/up.ts"; const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer); const connectPublicConfigMissingMessage = - "T3 Connect commands are unavailable: this build is missing T3 Connect public configuration."; + "T3 Connect commands are unavailable: T3CODE_CONNECT_ENABLED=false, or this build is missing T3 Connect public configuration."; class ConnectPublicConfigMissingError extends CliError.UserError { override get message() { @@ -46,7 +46,7 @@ class ConnectPublicConfigMissingError extends CliError.UserError { const connectUnavailableCommand = Command.make("connect", { command: Argument.String("command").pipe(Argument.variadic), }).pipe( - Command.withDescription("T3 Connect is unavailable in builds without public configuration."), + Command.withDescription("T3 Connect is turned off or missing from this build."), Command.unlisted, Command.withHandler(() => Effect.fail( diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index facd9f8912e53..87a74880799e8 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -626,6 +627,35 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls: [], requests: [] })); }); + it.effect("refuses to link while T3 Connect is off", () => { + const { store, values } = makeMemorySecretStore([]); + const cloudMintPublicKey = + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + const error = yield* Effect.flip( + link.applyRelayConfig({ + relayUrl: "https://relay.example.test", + cloudUserId: "user-123", + environmentCredential: "credential", + cloudMintPublicKey, + endpointRuntime: null, + }), + ); + expect(error).toMatchObject({ + _tag: "CloudLinkInternalError", + operation: "connect-disabled", + }); + expect(values.size).toBe(0); + }).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_CONNECT_ENABLED: "false" } })), + ), + provideReleaseHarness({ store, applyConfigCalls: [], requests: [] }), + ); + }); + it.effect( "starts a connector with a marker for the current origin without contacting relay", () => { diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 99530612463e1..40a0327e340d8 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -98,7 +98,7 @@ import { import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; -import { relayUrlConfig } from "./publicConfig.ts"; +import { connectEnabledConfig, relayUrlConfig } from "./publicConfig.ts"; import { filterRelayResponse, relayRequestError, @@ -238,6 +238,7 @@ export class CloudLinkEndpointUnavailableError extends Schema.TaggedError + enabled + ? Effect.void + : Effect.fail(new CloudLinkInternalError({ operation: "connect-disabled" })), + ), +); + function bytesToString(bytes: Uint8Array): string { return new TextDecoder().decode(bytes); } @@ -627,6 +637,7 @@ const make = Effect.gen(function* () { const linkProof = Effect.fn("environment.cloud.linkProof")( function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) { + yield* requireConnectEnabled; const requestUrl = requestAbsoluteUrl(httpRequest); if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); @@ -1162,6 +1173,7 @@ const make = Effect.gen(function* () { const applyRelayConfig = Effect.fn("environment.cloud.relayConfig")( function* (payload: RelayEnvironmentConfigRequest) { + yield* requireConnectEnabled; const result = yield* applyCloudRelayConfig(payload); if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { const address = httpServer.address; diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 15594f1164e08..eeaf177b8e49c 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -2,6 +2,7 @@ import { connectLoopbackRedirectUri, CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL, + isConnectEnabled, } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; @@ -204,7 +205,13 @@ export function makeCloudCliOAuthConfig({ export const cloudCliOAuthConfig = makeCloudCliOAuthConfig(); +export const connectEnabledConfig = Config.String("T3CODE_CONNECT_ENABLED").pipe( + Config.withDefault(""), + Config.map(isConnectEnabled), +); + export const hasCloudPublicConfig = Boolean( + isConnectEnabled(process.env.T3CODE_CONNECT_ENABLED) && (normalizeSecureRelayUrl(process.env.T3CODE_RELAY_URL ?? "") ?? buildTimeRelayUrl) && (process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || buildTimeClerkPublishableKey) && (process.env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || buildTimeClerkCliOAuthClientId), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 4bbaa85fc3b16..eeb1b3ffab4ae 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -15,6 +15,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -357,6 +358,18 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("publishes nothing through a saved link while T3 Connect is off", () => + Effect.gen(function* () { + const { relay, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 0); + }).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_CONNECT_ENABLED: "false" } })), + ), + ), + ); + it.effect("republishes unchanged state with fresh credentials after relinking", () => Effect.gen(function* () { const { relay, secrets, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f3c7b893afa83..5920e73863473 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -47,6 +47,7 @@ import { RELAY_URL_SECRET, } from "../cloud/config.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; +import { connectEnabledConfig } from "../cloud/publicConfig.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -382,6 +383,7 @@ export const make = Effect.gen(function* () { ); const readRelayConfig = Effect.gen(function* () { + if (!(yield* connectEnabledConfig.pipe(Effect.orDie))) return null; const [url, issuer, environmentCredential] = yield* Effect.all([ readSecretString(RELAY_URL_SECRET), readSecretString(RELAY_ISSUER_SECRET), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 452addfb4a667..f4bc52ea2c722 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -92,7 +92,7 @@ import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; -import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; +import { connectEnabledConfig, hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; @@ -881,6 +881,11 @@ const layerMakeServer = Layer.unwrap( : Layer.empty; const layerCloudDesiredLinkReconcile = Layer.effectDiscard( Effect.gen(function* () { + if (!(yield* connectEnabledConfig.pipe(Effect.orDie))) { + yield* Effect.logInfo("T3 Connect is off (T3CODE_CONNECT_ENABLED=false)"); + // Activation waits for this even when there is no link to restore. + return yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); + } const cloudLink = yield* CloudLink.CloudLink; const releaseManagedTunnel = cloudLink.releaseManagedTunnelOnShutdown().pipe( Effect.timeout("10 seconds"), diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index 182367a410de2..6b32fa4f4b647 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -69,10 +69,16 @@ describe("hasCloudPublicConfig", () => { expect(hasCloudPublicConfig()).toBe(expected); }); - it("keeps production keys available in the Electron auth shell", () => { - vi.stubGlobal("window", { location: new URL("file:///app/index.html"), desktopBridge: {} }); - - expect(hasCloudPublicConfig()).toBe(true); - }); + it.each([true, false])( + "follows the desktop T3 Connect switch in the Electron auth shell: %s", + (connectEnabled) => { + vi.stubGlobal("window", { + location: new URL("file:///app/index.html"), + desktopBridge: { getConnectEnabled: () => connectEnabled }, + }); + + expect(hasCloudPublicConfig()).toBe(connectEnabled); + }, + ); }); }); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index f7f53ca2c3e4f..7234a3d00823e 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -81,6 +81,9 @@ export function hasCloudPublicConfig(): boolean { if (!config.clerkPublishableKey || !config.clerkJwtTemplate || !config.relayUrl) { return false; } + if (typeof window !== "undefined" && window.desktopBridge?.getConnectEnabled() === false) { + return false; + } // Electron has its own auth runtime; development keys also support private browser origins. if ( diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c7e69989e0493..b9c3c5e09c96b 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -39,6 +39,13 @@ connection. Pull request diffs and provider settings keep working after the previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. +To turn T3 Connect off on a machine, set `T3CODE_CONNECT_ENABLED=false` in the +server's environment before starting it. Sign-in and T3 Connect settings disappear, +`t3 connect` is unavailable, and the server does not link, restore a saved link, or +publish agent activity. Pairing over a LAN, Tailscale, or SSH keeps working. The +desktop app reads the variable from your shell profile, like +[`T3CODE_TELEMETRY_ENABLED`](./telemetry.md). + ### Scripts and agents `t3 up` asks nothing when you name a choice: `--connect`, `--lan`, or their diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f14452fec5a92..090e41a81bf09 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1014,6 +1014,7 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getRelayTelemetryEnabled: () => boolean; + getConnectEnabled: () => boolean; getPathForFile: (file: File) => string; getClientPlatform: () => string; setNotificationBadge: (badge: { count: number; image: string | null }) => Promise; diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts index 41a31a14b64ec..a2049a6d2578b 100644 --- a/packages/shared/src/connectAuth.ts +++ b/packages/shared/src/connectAuth.ts @@ -11,6 +11,10 @@ export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const; +/** Reads `T3CODE_CONNECT_ENABLED`: only `false` turns T3 Connect off. */ +export const isConnectEnabled = (value: string | undefined) => + value?.trim().toLowerCase() !== "false"; + export interface ConnectAuthorizeRequest { readonly state: string; readonly challenge: string; From daa00c0cf0aca6d8fde17407f9eb94ae5cf0ee0f Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:29:23 +0100 Subject: [PATCH 02/11] refactor: simplify the T3 Connect off switch and gate the remaining relay paths Share one readConnectEnabled effect instead of repeating orDie, read the desktop flag once per renderer instead of on every render-path call, and move the value parse table to the shared helper. With Connect off the server also stops advertising agent activity publishing and relay webhook URLs. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc/methods/window.test.ts | 20 ------------------- apps/desktop/src/preload.ts | 6 +++++- .../src/cloud/CloudLink.lifecycle.test.ts | 14 ++++++------- apps/server/src/cloud/CloudLink.ts | 12 +++++------ apps/server/src/cloud/config.ts | 2 ++ apps/server/src/cloud/publicConfig.ts | 3 ++- .../src/environment/ServerEnvironment.test.ts | 9 +++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 4 ++-- apps/server/src/server.ts | 6 +++--- packages/shared/src/connectAuth.test.ts | 12 +++++++++++ 10 files changed, 46 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 7fa75b1864f23..ed821165b40f7 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -25,7 +25,6 @@ import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, - getConnectEnabled, getRelayTelemetryEnabled, pickProjectFavicon, probeRemoteEditors, @@ -197,25 +196,6 @@ describe("getRelayTelemetryEnabled", () => { ); }); -describe("getConnectEnabled", () => { - it.effect.each([ - [undefined, true], - ["true", true], - ["0", true], - ["false", false], - [" FALSE ", false], - ] as const)("T3CODE_CONNECT_ENABLED=%s turns T3 Connect on: %s", ([value, expected]) => - Effect.gen(function* () { - assert.equal( - yield* getConnectEnabled - .handler() - .pipe(Effect.provideService(HostProcess.Environment, { T3CODE_CONNECT_ENABLED: value })), - expected, - ); - }), - ); -}); - describe("pickProjectFavicon", () => { const pickerLayer = ( pickFiles: () => Effect.Effect>, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d353ecbe868aa..daf314f8a966d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -72,6 +72,9 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { return result as Awaited>; } +// Web reads this in render paths; the environment cannot change while the app runs. +let connectEnabled: boolean | undefined; + contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); @@ -82,7 +85,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getRelayTelemetryEnabled: () => ipcRenderer.sendSync(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL) === true, - getConnectEnabled: () => ipcRenderer.sendSync(IpcChannels.GET_CONNECT_ENABLED_CHANNEL) === true, + getConnectEnabled: () => + (connectEnabled ??= ipcRenderer.sendSync(IpcChannels.GET_CONNECT_ENABLED_CHANNEL) === true), getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 87a74880799e8..cf905fe58b8bf 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -56,6 +56,9 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }), }); +const CLOUD_MINT_PUBLIC_KEY_PEM = + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; + const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ @@ -493,8 +496,7 @@ describe("releaseManagedTunnelOnShutdown", () => { relayUrl: "https://relay.example.test", cloudUserId: "user-123", environmentCredential: "environment-credential", - cloudMintPublicKey: - "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----", + cloudMintPublicKey: CLOUD_MINT_PUBLIC_KEY_PEM, endpointRuntime: config, }), ).toMatchObject({ ok: true, endpointRuntimeStatus: { status: "running" } }); @@ -610,8 +612,6 @@ describe("releaseManagedTunnelOnShutdown", () => { [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "revoked-credential"], [CLOUD_LINK_REVOKED, "true"], ]); - const cloudMintPublicKey = - "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; return Effect.gen(function* () { const link = yield* CloudLink.CloudLink; @@ -620,7 +620,7 @@ describe("releaseManagedTunnelOnShutdown", () => { relayUrl: "https://relay.example.test", cloudUserId: "user-123", environmentCredential: "fresh-credential", - cloudMintPublicKey, + cloudMintPublicKey: CLOUD_MINT_PUBLIC_KEY_PEM, endpointRuntime: null, }); expect(yield* link.linkState()).toMatchObject({ linked: true, linkRevoked: false }); @@ -629,8 +629,6 @@ describe("releaseManagedTunnelOnShutdown", () => { it.effect("refuses to link while T3 Connect is off", () => { const { store, values } = makeMemorySecretStore([]); - const cloudMintPublicKey = - "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; return Effect.gen(function* () { const link = yield* CloudLink.CloudLink; @@ -639,7 +637,7 @@ describe("releaseManagedTunnelOnShutdown", () => { relayUrl: "https://relay.example.test", cloudUserId: "user-123", environmentCredential: "credential", - cloudMintPublicKey, + cloudMintPublicKey: CLOUD_MINT_PUBLIC_KEY_PEM, endpointRuntime: null, }), ); diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 40a0327e340d8..61f25aa8c4e5f 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -98,7 +98,7 @@ import { import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; -import { connectEnabledConfig, relayUrlConfig } from "./publicConfig.ts"; +import { readConnectEnabled, relayUrlConfig } from "./publicConfig.ts"; import { filterRelayResponse, relayRequestError, @@ -306,12 +306,10 @@ const requireRelayUrl = relayUrlConfig.pipe( ), ); -const requireConnectEnabled = connectEnabledConfig.pipe( - Effect.orDie, - Effect.flatMap((enabled) => - enabled - ? Effect.void - : Effect.fail(new CloudLinkInternalError({ operation: "connect-disabled" })), +const requireConnectEnabled = readConnectEnabled.pipe( + Effect.filterOrFail( + (enabled) => enabled, + () => new CloudLinkInternalError({ operation: "connect-disabled" }), ), ); diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 5dac9e2db905f..bc4321d51d7f2 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -7,6 +7,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { readConnectEnabled } from "./publicConfig.ts"; export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; @@ -60,6 +61,7 @@ export const readAgentActivityPublishingActive: ( Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, ), ); + if (!(yield* readConnectEnabled)) return false; const [enabled, url, environmentCredential] = yield* Effect.all([ readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET), readSecretString(RELAY_URL_SECRET), diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index eeaf177b8e49c..2bc84ae5da14a 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -205,9 +205,10 @@ export function makeCloudCliOAuthConfig({ export const cloudCliOAuthConfig = makeCloudCliOAuthConfig(); -export const connectEnabledConfig = Config.String("T3CODE_CONNECT_ENABLED").pipe( +export const readConnectEnabled = Config.String("T3CODE_CONNECT_ENABLED").pipe( Config.withDefault(""), Config.map(isConnectEnabled), + Effect.orDie, ); export const hasCloudPublicConfig = Boolean( diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..ddf917548fd44 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -259,6 +260,14 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example")); const linked = yield* serverEnvironment.getDescriptor; expect(linked.capabilities.agentActivityPublishing).toBe(true); + const connectOff = yield* serverEnvironment.getDescriptor.pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_CONNECT_ENABLED: "false" } }), + ), + ), + ); + expect(connectOff.capabilities.agentActivityPublishing).toBe(false); yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")); const disabled = yield* serverEnvironment.getDescriptor; diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5920e73863473..47fae592d281e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -47,7 +47,7 @@ import { RELAY_URL_SECRET, } from "../cloud/config.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; -import { connectEnabledConfig } from "../cloud/publicConfig.ts"; +import { readConnectEnabled } from "../cloud/publicConfig.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -383,7 +383,7 @@ export const make = Effect.gen(function* () { ); const readRelayConfig = Effect.gen(function* () { - if (!(yield* connectEnabledConfig.pipe(Effect.orDie))) return null; + if (!(yield* readConnectEnabled)) return null; const [url, issuer, environmentCredential] = yield* Effect.all([ readSecretString(RELAY_URL_SECRET), readSecretString(RELAY_ISSUER_SECRET), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f4bc52ea2c722..5ab60a00717ea 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -92,7 +92,7 @@ import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; -import { connectEnabledConfig, hasCloudPublicConfig } from "./cloud/publicConfig.ts"; +import { hasCloudPublicConfig, readConnectEnabled } from "./cloud/publicConfig.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; @@ -504,7 +504,7 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( secrets.get(RELAY_URL_SECRET), secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), ]).pipe(Effect.orElseSucceed(() => [Option.none(), Option.none()] as const)); - if (Option.isNone(relayUrl) || Option.isNone(tunnelConfig)) { + if (Option.isNone(relayUrl) || Option.isNone(tunnelConfig) || !(yield* readConnectEnabled)) { return { relayHookBaseUrl: null }; } const config = decodeRuntimeConfig(new TextDecoder().decode(tunnelConfig.value)); @@ -881,7 +881,7 @@ const layerMakeServer = Layer.unwrap( : Layer.empty; const layerCloudDesiredLinkReconcile = Layer.effectDiscard( Effect.gen(function* () { - if (!(yield* connectEnabledConfig.pipe(Effect.orDie))) { + if (!(yield* readConnectEnabled)) { yield* Effect.logInfo("T3 Connect is off (T3CODE_CONNECT_ENABLED=false)"); // Activation waits for this even when there is no link to restore. return yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts index 7bbab61992d3f..2c2e9a01d39bc 100644 --- a/packages/shared/src/connectAuth.test.ts +++ b/packages/shared/src/connectAuth.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildConnectAuthorizeRequestUrl, connectLoopbackRedirectUri, + isConnectEnabled, readConnectAuthorizeRequest, } from "./connectAuth.ts"; @@ -48,4 +49,15 @@ describe("connectAuth", () => { expect(readConnectAuthorizeRequest(url), port).toBeNull(); } }); + + it.each([ + [undefined, true], + ["", true], + ["true", true], + ["0", true], + ["false", false], + [" FALSE ", false], + ] as const)("T3CODE_CONNECT_ENABLED=%j keeps T3 Connect on: %s", (value, expected) => { + expect(isConnectEnabled(value)).toBe(expected); + }); }); From b137b6eceb8f15e040c5d21a6821824aa9dfa776 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:46:45 +0100 Subject: [PATCH 03/11] refactor: tighten the T3 Connect off switch after review Read the desktop flag once when the preload loads, keep the existing lifecycle tests untouched, guard the advertised publishing capability first, word the refusal for remote clients, and shorten the user doc note. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preload.ts | 6 ++---- apps/server/src/cloud/CloudLink.lifecycle.test.ts | 13 +++++++------ apps/server/src/cloud/CloudLink.ts | 3 ++- apps/server/src/cloud/config.ts | 2 +- docs/user/remote-access.md | 10 +++++----- packages/shared/src/connectAuth.test.ts | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index daf314f8a966d..03dbbf3e49669 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -72,8 +72,7 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { return result as Awaited>; } -// Web reads this in render paths; the environment cannot change while the app runs. -let connectEnabled: boolean | undefined; +const connectEnabled = ipcRenderer.sendSync(IpcChannels.GET_CONNECT_ENABLED_CHANNEL) === true; contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { @@ -85,8 +84,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getRelayTelemetryEnabled: () => ipcRenderer.sendSync(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL) === true, - getConnectEnabled: () => - (connectEnabled ??= ipcRenderer.sendSync(IpcChannels.GET_CONNECT_ENABLED_CHANNEL) === true), + getConnectEnabled: () => connectEnabled, getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index cf905fe58b8bf..3dfd117e9239f 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -56,9 +56,6 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }), }); -const CLOUD_MINT_PUBLIC_KEY_PEM = - "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; - const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ @@ -496,7 +493,8 @@ describe("releaseManagedTunnelOnShutdown", () => { relayUrl: "https://relay.example.test", cloudUserId: "user-123", environmentCredential: "environment-credential", - cloudMintPublicKey: CLOUD_MINT_PUBLIC_KEY_PEM, + cloudMintPublicKey: + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----", endpointRuntime: config, }), ).toMatchObject({ ok: true, endpointRuntimeStatus: { status: "running" } }); @@ -612,6 +610,8 @@ describe("releaseManagedTunnelOnShutdown", () => { [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "revoked-credential"], [CLOUD_LINK_REVOKED, "true"], ]); + const cloudMintPublicKey = + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; return Effect.gen(function* () { const link = yield* CloudLink.CloudLink; @@ -620,7 +620,7 @@ describe("releaseManagedTunnelOnShutdown", () => { relayUrl: "https://relay.example.test", cloudUserId: "user-123", environmentCredential: "fresh-credential", - cloudMintPublicKey: CLOUD_MINT_PUBLIC_KEY_PEM, + cloudMintPublicKey, endpointRuntime: null, }); expect(yield* link.linkState()).toMatchObject({ linked: true, linkRevoked: false }); @@ -637,7 +637,8 @@ describe("releaseManagedTunnelOnShutdown", () => { relayUrl: "https://relay.example.test", cloudUserId: "user-123", environmentCredential: "credential", - cloudMintPublicKey: CLOUD_MINT_PUBLIC_KEY_PEM, + cloudMintPublicKey: + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----", endpointRuntime: null, }), ); diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 61f25aa8c4e5f..38c259a5404ae 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -238,7 +238,8 @@ export class CloudLinkEndpointUnavailableError extends Schema.TaggedError Effect.Effect = Effect.fn("config.readAgentActivityPublishingActive")( function* (secrets: ServerSecretStore.ServerSecretStore["Service"]) { + if (!(yield* readConnectEnabled)) return false; const readSecretString = (name: string) => secrets .get(name) @@ -61,7 +62,6 @@ export const readAgentActivityPublishingActive: ( Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, ), ); - if (!(yield* readConnectEnabled)) return false; const [enabled, url, environmentCredential] = yield* Effect.all([ readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET), readSecretString(RELAY_URL_SECRET), diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b9c3c5e09c96b..7c6382b00ad9d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -40,11 +40,11 @@ previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. To turn T3 Connect off on a machine, set `T3CODE_CONNECT_ENABLED=false` in the -server's environment before starting it. Sign-in and T3 Connect settings disappear, -`t3 connect` is unavailable, and the server does not link, restore a saved link, or -publish agent activity. Pairing over a LAN, Tailscale, or SSH keeps working. The -desktop app reads the variable from your shell profile, like -[`T3CODE_TELEMETRY_ENABLED`](./telemetry.md). +server's environment before starting it. T3 Connect sign-in, settings, and +`t3 connect` go away, and the server stops linking and publishing. A saved link is +kept and resumes when you remove the variable, so unlink first if you want it gone. +Pairing over a LAN, Tailscale, or SSH keeps working. The desktop app reads the +variable from your shell profile, like [`T3CODE_TELEMETRY_ENABLED`](./telemetry.md). ### Scripts and agents diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts index 2c2e9a01d39bc..57d034eaaabc4 100644 --- a/packages/shared/src/connectAuth.test.ts +++ b/packages/shared/src/connectAuth.test.ts @@ -57,7 +57,7 @@ describe("connectAuth", () => { ["0", true], ["false", false], [" FALSE ", false], - ] as const)("T3CODE_CONNECT_ENABLED=%j keeps T3 Connect on: %s", (value, expected) => { + ] as const)("isConnectEnabled(%j) is %s", (value, expected) => { expect(isConnectEnabled(value)).toBe(expected); }); }); From cda16f894eeb359ce8b38bc1329fc0f13917d3e4 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:15:55 +0100 Subject: [PATCH 04/11] docs: say where T3 Connect is hidden when it is turned off Co-Authored-By: Claude Opus 5.5 --- docs/user/remote-access.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 7c6382b00ad9d..bb351b652498a 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -40,11 +40,12 @@ previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. To turn T3 Connect off on a machine, set `T3CODE_CONNECT_ENABLED=false` in the -server's environment before starting it. T3 Connect sign-in, settings, and -`t3 connect` go away, and the server stops linking and publishing. A saved link is -kept and resumes when you remove the variable, so unlink first if you want it gone. -Pairing over a LAN, Tailscale, or SSH keeps working. The desktop app reads the -variable from your shell profile, like [`T3CODE_TELEMETRY_ENABLED`](./telemetry.md). +server's environment before starting it. In the desktop app, T3 Connect sign-in and +settings are hidden. On the server, `t3 connect` is unavailable, and linking and +agent-activity publishing stop. A saved link is kept and resumes when you remove the +variable, so unlink first if you want it gone. Pairing over a LAN, Tailscale, or SSH +keeps working. The desktop app reads the variable from your shell profile, like +[`T3CODE_TELEMETRY_ENABLED`](./telemetry.md). ### Scripts and agents From e7890212f211f3dbbdd37dbeb4ab8a0c4ecce2c1 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:34:58 +0100 Subject: [PATCH 05/11] feat: read T3CODE_CONNECT_ENABLED like T3CODE_TELEMETRY_ENABLED `false`, `no`, `off`, `0`, and `n` now turn T3 Connect off, matching the telemetry variable. The helper moves next to the telemetry parsing so it reuses the same literals and leaves connectAuth untouched. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc/methods/window.ts | 3 +-- apps/server/src/cloud/publicConfig.ts | 3 +-- packages/shared/src/connectAuth.test.ts | 12 ------------ packages/shared/src/connectAuth.ts | 4 ---- packages/shared/src/otelEnvironment.test.ts | 15 +++++++++++++++ packages/shared/src/otelEnvironment.ts | 4 ++++ 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 3c41e8b7cfe93..020d6e358e4f8 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -16,8 +16,7 @@ import { import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; -import { isConnectEnabled } from "@t3tools/shared/connectAuth"; +import { isConnectEnabled, isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 2bc84ae5da14a..06cd06f81d2aa 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -2,10 +2,9 @@ import { connectLoopbackRedirectUri, CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL, - isConnectEnabled, } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; -import { isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; +import { isConnectEnabled, isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts index 57d034eaaabc4..7bbab61992d3f 100644 --- a/packages/shared/src/connectAuth.test.ts +++ b/packages/shared/src/connectAuth.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { buildConnectAuthorizeRequestUrl, connectLoopbackRedirectUri, - isConnectEnabled, readConnectAuthorizeRequest, } from "./connectAuth.ts"; @@ -49,15 +48,4 @@ describe("connectAuth", () => { expect(readConnectAuthorizeRequest(url), port).toBeNull(); } }); - - it.each([ - [undefined, true], - ["", true], - ["true", true], - ["0", true], - ["false", false], - [" FALSE ", false], - ] as const)("isConnectEnabled(%j) is %s", (value, expected) => { - expect(isConnectEnabled(value)).toBe(expected); - }); }); diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts index a2049a6d2578b..41a31a14b64ec 100644 --- a/packages/shared/src/connectAuth.ts +++ b/packages/shared/src/connectAuth.ts @@ -11,10 +11,6 @@ export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const; -/** Reads `T3CODE_CONNECT_ENABLED`: only `false` turns T3 Connect off. */ -export const isConnectEnabled = (value: string | undefined) => - value?.trim().toLowerCase() !== "false"; - export interface ConnectAuthorizeRequest { readonly state: string; readonly challenge: string; diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index 21df52609ff29..0a705782428a5 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -428,3 +428,18 @@ describe("isRelayTracingEnabled", () => { }), ); }); + +describe("isConnectEnabled", () => { + it.each([ + [undefined, true], + ["", true], + ["true", true], + ["maybe", true], + ["false", false], + [" FALSE ", false], + ["0", false], + ["off", false], + ] as const)("isConnectEnabled(%j) is %s", (value, expected) => { + assert.equal(OtelEnvironment.isConnectEnabled(value), expected); + }); +}); diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 95392206a8c7c..14e11510b1e29 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -331,6 +331,10 @@ export const isRelayTracingEnabled = (env: Readonly + !T3CODE_FALSE.includes(value?.trim().toLowerCase() ?? ""); + export type SignalName = "traces" | "metrics" | "logs"; export interface SignalEndpoint { From a9c7f816a7d926d57d916db44cf07ede2aeda98a Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:48:08 +0100 Subject: [PATCH 06/11] fix(server): refuse relay health and credential requests while T3 Connect is off A saved link with a manually reachable endpoint could still answer relay health checks and mint cloud-connect credentials with the switch off. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/CloudLink.lifecycle.test.ts | 8 +++++++- apps/server/src/cloud/CloudLink.ts | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 3dfd117e9239f..96ad7ab558a2f 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -627,7 +627,7 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls: [], requests: [] })); }); - it.effect("refuses to link while T3 Connect is off", () => { + it.effect("refuses to link or answer the relay while T3 Connect is off", () => { const { store, values } = makeMemorySecretStore([]); return Effect.gen(function* () { @@ -647,6 +647,12 @@ describe("releaseManagedTunnelOnShutdown", () => { operation: "connect-disabled", }); expect(values.size).toBe(0); + expect(yield* Effect.flip(link.answerHealthRequest({ proof: "proof" }))).toMatchObject({ + operation: "connect-disabled", + }); + expect(yield* Effect.flip(link.mintCredential({ proof: "proof" }))).toMatchObject({ + operation: "connect-disabled", + }); }).pipe( Effect.provide( ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_CONNECT_ENABLED: "false" } })), diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 38c259a5404ae..77d2fe47748bf 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -1420,6 +1420,7 @@ const make = Effect.gen(function* () { const answerHealthRequest = Effect.fn("environment.cloud.health")( function* (request: RelayCloudEnvironmentHealthRequest) { + yield* requireConnectEnabled; const cloudMintPublicKey = yield* secrets .get(CLOUD_MINT_PUBLIC_KEY) .pipe( @@ -1524,6 +1525,7 @@ const make = Effect.gen(function* () { const mintCredential = Effect.fn("environment.cloud.mintCredential")( function* (request: RelayCloudMintCredentialRequest) { + yield* requireConnectEnabled; const cloudMintPublicKey = yield* secrets .get(CLOUD_MINT_PUBLIC_KEY) .pipe( From 96f75e6a73a482b65ffbfc851602863c72d00e8f Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:04:56 +0100 Subject: [PATCH 07/11] test(server): cover the link-proof refusal while T3 Connect is off Co-Authored-By: Claude Opus 5.5 --- .../src/cloud/CloudLink.lifecycle.test.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 96ad7ab558a2f..70bee05d3b1cb 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -9,10 +9,21 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse, HttpServer, type HttpClientRequest } from "effect/http"; +import { + HttpClient, + HttpClientResponse, + HttpServer, + HttpServerRequest, + type HttpClientRequest, +} from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; -import { EnvironmentId } from "@t3tools/contracts"; +import { + AuthSessionId, + AuthStandardClientScopes, + EnvironmentAuthenticatedPrincipal, + EnvironmentId, +} from "@t3tools/contracts"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfigModule from "../config.ts"; @@ -653,6 +664,29 @@ describe("releaseManagedTunnelOnShutdown", () => { expect(yield* Effect.flip(link.mintCredential({ proof: "proof" }))).toMatchObject({ operation: "connect-disabled", }); + const linkProof = link.linkProof( + { + challenge: "challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + providerKind: "cloudflare_tunnel", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }, + HttpServerRequest.fromWeb(new Request("http://127.0.0.1:3773/api/connect/link-proof")), + ); + expect( + yield* Effect.flip(linkProof).pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make("session"), + subject: "test", + method: "bearer-access-token", + scopes: new Set(AuthStandardClientScopes), + }), + ), + ).toMatchObject({ operation: "connect-disabled" }); }).pipe( Effect.provide( ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_CONNECT_ENABLED: "false" } })), From 3dd6f1ef17bd294e1531e2de99b3be6c2273f18f Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:26:27 +0100 Subject: [PATCH 08/11] fix(server): name the off switch when t3 up refuses T3 Connect Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 3 ++- docs/user/remote-access.md | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 7f307aec4b3a3..e8694ca029ec1 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -198,7 +198,8 @@ const setUp = Effect.fn("cli.up")(function* (flags: { if (before.connect === null && Option.getOrElse(flags.connect, () => false)) { return yield* new CliError.UserError({ cause: "connect", - userMessage: "T3 Connect is unavailable in this build.", + userMessage: + "T3 Connect is unavailable: T3CODE_CONNECT_ENABLED=false, or this build is missing T3 Connect public configuration.", }); } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index bb351b652498a..10e4ccca95500 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -41,8 +41,8 @@ disconnect an otherwise healthy conversation. To turn T3 Connect off on a machine, set `T3CODE_CONNECT_ENABLED=false` in the server's environment before starting it. In the desktop app, T3 Connect sign-in and -settings are hidden. On the server, `t3 connect` is unavailable, and linking and -agent-activity publishing stop. A saved link is kept and resumes when you remove the +settings are hidden. On the server, `t3 up` no longer offers T3 Connect, `t3 connect` +is unavailable, and linking and agent-activity publishing stop. A saved link is kept and resumes when you remove the variable, so unlink first if you want it gone. Pairing over a LAN, Tailscale, or SSH keeps working. The desktop app reads the variable from your shell profile, like [`T3CODE_TELEMETRY_ENABLED`](./telemetry.md). From 901db9ab2da22fe2de8982e16b94b4255f0087e5 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:46:55 +0100 Subject: [PATCH 09/11] docs: say where Windows users set T3CODE_CONNECT_ENABLED Co-Authored-By: Claude Opus 5.5 --- docs/user/remote-access.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 10e4ccca95500..b1726215f8b8d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -40,12 +40,14 @@ previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. To turn T3 Connect off on a machine, set `T3CODE_CONNECT_ENABLED=false` in the -server's environment before starting it. In the desktop app, T3 Connect sign-in and -settings are hidden. On the server, `t3 up` no longer offers T3 Connect, `t3 connect` -is unavailable, and linking and agent-activity publishing stop. A saved link is kept and resumes when you remove the -variable, so unlink first if you want it gone. Pairing over a LAN, Tailscale, or SSH -keeps working. The desktop app reads the variable from your shell profile, like -[`T3CODE_TELEMETRY_ENABLED`](./telemetry.md). +server's environment before starting it. In the desktop app, T3 Connect sign-in +and settings are hidden. On the server, `t3 up` no longer offers T3 Connect, +`t3 connect` is unavailable, and linking and agent-activity publishing stop. A +saved link is kept and resumes when you remove the variable, so unlink first if +you want it gone. Pairing over a LAN, Tailscale, or SSH keeps working. Set it where you set +[`T3CODE_TELEMETRY_ENABLED`](./telemetry.md): the desktop app reads it from your +shell profile on macOS and Linux, and from your user environment variables on +Windows. ### Scripts and agents From f857274ff9598246dbda059d08ec38d64485560b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 04:30:31 +0000 Subject: [PATCH 10/11] fix(server): honor connect off switch for service routes and preferences --- apps/server/src/cli/up.test.ts | 19 +++++++++++++++++++ apps/server/src/cli/up.ts | 7 ++++--- .../src/cloud/CloudLink.lifecycle.test.ts | 6 ++++++ apps/server/src/cloud/CloudLink.ts | 1 + 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 9fbe515944a83..816186b619840 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -19,6 +19,11 @@ import * as BootService from "../cloud/bootService.ts"; import { upsertServiceEnv } from "./service.ts"; import { connectEnabled, planRoutes, upCommand } from "./up.ts"; +vi.mock("../cloud/publicConfig.ts", async (importOriginal) => ({ + ...(await importOriginal()), + hasCloudPublicConfig: true, +})); + const none = Option.none(); it.each([ @@ -163,12 +168,23 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => /** Runs as if in a terminal, answering each question with this text. */ readonly answer?: string; readonly prints?: string; + readonly omits?: string; readonly env: string; readonly args: ReadonlyArray; readonly installs: number; readonly restarts: number; readonly envAfter: string; }>([ + { + label: "the service disables T3 Connect", + installed: true, + env: "T3CODE_CONNECT_ENABLED=false\nT3CODE_LAN=false\n", + args: ["--no-lan"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_CONNECT_ENABLED=false\nT3CODE_LAN=false\n", + omits: "T3 Connect", + }, { label: "nothing changed", installed: true, @@ -337,6 +353,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => if (input.prints !== undefined) { expect(lines.join("\n")).toContain(input.prints); } + if (input.omits !== undefined) { + expect(lines.join("\n")).not.toContain(input.omits); + } expect(exit._tag).toBe(input.restartFails === true ? "Failure" : "Success"); expect({ installs, restarts }).toEqual({ installs: input.installs, diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index e8694ca029ec1..f4dae2253c34d 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -1,4 +1,5 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isConnectEnabled } from "@t3tools/shared/otelEnvironment"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Console from "effect/Console"; @@ -66,9 +67,9 @@ const readSetup = Effect.fn("cli.up.read_setup")(function* () { .pipe(Config.option) .parse(ConfigProvider.fromEnv({ env: entries })); const secrets = yield* ServerSecretStore.ServerSecretStore; - const connectRevoked = - hasCloudPublicConfig && Option.isSome(yield* secrets.get(CLOUD_LINK_REVOKED)); - const connect = hasCloudPublicConfig + const connectAvailable = hasCloudPublicConfig && isConnectEnabled(entries.T3CODE_CONNECT_ENABLED); + const connectRevoked = connectAvailable && Option.isSome(yield* secrets.get(CLOUD_LINK_REVOKED)); + const connect = connectAvailable ? connectEnabled({ cliDesired: yield* CliState.readCliDesiredCloudLink, cliMode: yield* CliState.readCliDesiredLinkMode, diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 70bee05d3b1cb..7fa1dde52c546 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -664,6 +664,12 @@ describe("releaseManagedTunnelOnShutdown", () => { expect(yield* Effect.flip(link.mintCredential({ proof: "proof" }))).toMatchObject({ operation: "connect-disabled", }); + expect( + yield* Effect.flip( + link.updatePreferences({ publishAgentActivity: false, holdWebhooksWhileOffline: true }), + ), + ).toMatchObject({ operation: "connect-disabled" }); + expect(values.size).toBe(0); const linkProof = link.linkProof( { challenge: "challenge", diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 77d2fe47748bf..b2641b1201a4a 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -1412,6 +1412,7 @@ const make = Effect.gen(function* () { const updatePreferences = Effect.fn("environment.cloud.preferences")( function* (input: EnvironmentCloudPreferencesRequest) { + yield* requireConnectEnabled; yield* savePreferences(input); return yield* readCloudLinkState(); }, From 899257d4c2f6ffac62d2f6284885f9caadc16bfa Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 04:38:49 +0000 Subject: [PATCH 11/11] fix(server): block connect setup subcommands when service disables connect --- apps/server/src/cli/connect.test.ts | 26 ++++++++++++++++++++++++++ apps/server/src/cli/connect.ts | 18 +++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 6693d214fabac..47e22efb548b5 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,9 +1,16 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { Command } from "effect/cli"; import { acquireRelayClientForLink } from "./connect.ts"; +import { connectCommand } from "./up.ts"; const managedExecutable = { status: "available", @@ -40,3 +47,22 @@ it.effect("does not install the relay client when the user declines the managed assert.equal(installCalls, 0); }), ); + +it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("disabled service", (it) => { + it.effect.each([ + { args: ["login", "--headless"] }, + { args: ["link", "--publish-only", "--headless"] }, + { args: ["link", "--headless"] }, + { args: ["publish"] }, + ])("refuses enabling Connect through $args", ({ args }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-connect-disabled-" }); + yield* fs.writeFileString(`${baseDir}/service.env`, "T3CODE_CONNECT_ENABLED=false\n"); + const error = yield* Effect.flip( + Command.runWith(connectCommand, { version: "0.0.0" })([...args, "--base-dir", baseDir]), + ); + assert.include(String(error), "T3CODE_CONNECT_ENABLED=false"); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); +}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index e0aea76ef04c2..f578317e3394d 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { isConnectEnabled } from "@t3tools/shared/otelEnvironment"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -26,6 +27,7 @@ import * as Path from "effect/Path"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; +import * as CliError from "effect/cli/CliError"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as HttpApiClient from "effect/http-api/HttpApiClient"; @@ -43,7 +45,7 @@ import { PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_URL_SECRET, } from "../cloud/config.ts"; -import { relayUrlConfig } from "../cloud/publicConfig.ts"; +import { readConnectEnabled, relayUrlConfig } from "../cloud/publicConfig.ts"; import * as RelayTracing from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -94,6 +96,17 @@ function formatDeviceAuthorizationPrompt( ].join("\n"); } +const requireConnectEnabled = Effect.fn("cloud.cli.require_connect_enabled")(function* () { + const { baseDir } = yield* ServerConfig.ServerConfig; + const { entries } = yield* CliService.readServiceEnv(baseDir); + if (!(yield* readConnectEnabled) || !isConnectEnabled(entries.T3CODE_CONNECT_ENABLED)) { + return yield* new CliError.UserError({ + cause: "connect", + userMessage: "T3 Connect is turned off for this environment (T3CODE_CONNECT_ENABLED=false).", + }); + } +}); + const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { readonly headless: boolean; }) { @@ -561,6 +574,7 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi /** For `t3 up`, where the user already chose T3 Connect: install it quietly, without asking. */ readonly installRelayClient?: boolean; }) { + yield* requireConnectEnabled(); const publishOnly = options.publishOnly ?? false; if (!publishOnly) { const relayClient = yield* RelayClient.RelayClient; @@ -605,6 +619,7 @@ const connectLoginCommand = Command.make("login", { runCloudCommand( flags, Effect.gen(function* () { + yield* requireConnectEnabled(); yield* Console.log("T3 Connect\n"); const identity = yield* authorizeCli(flags); yield* Console.log(`✓ Signed in${connectedAs(identity)}`); @@ -722,6 +737,7 @@ const connectPublishCommand = Command.make("publish", { const secrets = yield* ServerSecretStore.ServerSecretStore; const tokens = yield* CliTokenManager.CloudCliTokenManager; const enabled = !flags.disable; + if (enabled) yield* requireConnectEnabled(); yield* secrets.set( PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes(enabled ? "true" : "false"),