diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 404d765ba74c..40878f5168ec 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -22,14 +22,14 @@ import { // POSIX exec bits that NTFS never reports; the win32 branch skips that check. const windowsHost = HostProcessPlatform.defaultValue() === "win32"; -const hostRuntimeLayer = (env: Record = {}) => +const hostRuntimeLayer = (env: Record = {}, platform: NodeJS.Platform = "linux") => Layer.mergeAll( - Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessArchitecture, "x64"), ConfigProvider.layer(ConfigProvider.fromEnv({ env })), ); -function makeHandle(exitCode = 0) { +function makeHandle(exitCode = 0, output = "") { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(100), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), @@ -37,7 +37,7 @@ function makeHandle(exitCode = 0) { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.empty, + stdout: Stream.make(new TextEncoder().encode(output)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -55,15 +55,17 @@ const makeHttpClientLayer = (bytes: Uint8Array) => ), ); -const makeSpawnerLayer = (commands: Array) => +const makeSpawnerLayer = (commands: Array, versions: Record = {}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.sync(() => { commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); // The pinned Windows executable rejects --version but accepts the version subcommand. + if (!ChildProcess.isStandardCommand(command)) return makeHandle(); return makeHandle( - ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0, + command.args.includes("--version") ? 1 : 0, + `cloudflared version ${versions[command.command] ?? CLOUDFLARED_VERSION} (built test)\n`, ); }), ), @@ -114,13 +116,19 @@ describe("RelayClient", () => { ); it.effect.skipIf(windowsHost)( - "downloads, verifies, validates, and atomically installs the managed executable", - () => - Effect.gen(function* () { + "downloads, verifies, and installs the managed executable despite PATH", + () => { + const env = { PATH: "" }; + return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-", }); + const binDir = `${baseDir}/bin`; + yield* fileSystem.makeDirectory(binDir); + yield* fileSystem.writeFileString(`${binDir}/cloudflared`, "old cloudflared"); + yield* fileSystem.chmod(`${binDir}/cloudflared`, 0o755); + env.PATH = binDir; const bytes = new TextEncoder().encode("test-cloudflared-binary"); const manager = yield* makeCloudflaredRelayClient({ baseDir, @@ -166,10 +174,11 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), makeSpawnerLayer([]), - hostRuntimeLayer(), + hostRuntimeLayer(env), ), ), - ), + ); + }, ); it.effect("rejects downloads whose checksum does not match the pinned manifest", () => @@ -239,8 +248,9 @@ describe("RelayClient", () => { }); it.effect.skipIf(windowsHost)( - "observes PATH changes after the manager has been constructed", + "requires the managed release even when PATH has cloudflared", () => { + const commands: Array = []; const env = { PATH: "" }; return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -263,11 +273,100 @@ describe("RelayClient", () => { yield* fileSystem.chmod(executablePath, 0o755); env.PATH = binDir; + expect(yield* manager.resolve).toEqual({ status: "missing", version: CLOUDFLARED_VERSION }); + expect(commands).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(new Uint8Array()), + makeSpawnerLayer(commands), + hostRuntimeLayer(env), + ), + ), + ); + }, + ); + + it.effect.skipIf(windowsHost)( + "uses only compatible PATH binaries without a managed asset", + () => { + const env = { PATH: "" }; + const versions: Record = {}; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const oldDir = `${baseDir}/old`; + const newDir = `${baseDir}/new`; + yield* fileSystem.makeDirectory(oldDir); + yield* fileSystem.makeDirectory(newDir); + const oldPath = `${oldDir}/cloudflared`; + const newPath = `${newDir}/cloudflared`; + versions[oldPath] = "2023.8.2"; + versions[newPath] = "2025.6.1"; + for (const executablePath of [oldPath, newPath]) { + yield* fileSystem.writeFileString(executablePath, "cloudflared"); + yield* fileSystem.chmod(executablePath, 0o755); + } + const manager = yield* makeCloudflaredRelayClient({ baseDir }); + env.PATH = oldDir; + expect(yield* manager.resolve).toEqual({ + status: "unsupported", + platform: "freebsd", + arch: "x64", + version: CLOUDFLARED_VERSION, + }); + env.PATH = `${oldDir}:${newDir}`; + expect(yield* manager.resolve).toEqual({ + status: "available", + executablePath: newPath, + source: "path", + version: "2025.6.1", + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(new Uint8Array()), + makeSpawnerLayer([], versions), + hostRuntimeLayer(env, "freebsd"), + ), + ), + ); + }, + ); + + it.effect.skipIf(windowsHost)( + "rejects an outdated override and reports a valid override's version", + () => { + const env = { PATH: "", T3CODE_CLOUDFLARED_PATH: "" }; + const versions: Record = {}; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const executablePath = `${baseDir}/cloudflared`; + yield* fileSystem.writeFileString(executablePath, "cloudflared"); + yield* fileSystem.chmod(executablePath, 0o755); + env.T3CODE_CLOUDFLARED_PATH = executablePath; + const manager = yield* makeCloudflaredRelayClient({ baseDir }); + + versions[executablePath] = "2023.8.2"; + expect(yield* manager.resolve).toEqual({ status: "missing", version: CLOUDFLARED_VERSION }); + const error = yield* manager.install.pipe(Effect.flip); + expect(error.reason).toBe("override_missing"); + + versions[executablePath] = "2025.6.1"; expect(yield* manager.resolve).toEqual({ status: "available", executablePath, - source: "path", - version: CLOUDFLARED_VERSION, + source: "override", + version: "2025.6.1", }); }).pipe( Effect.scoped, @@ -275,7 +374,7 @@ describe("RelayClient", () => { Layer.mergeAll( NodeServices.layer, makeHttpClientLayer(new Uint8Array()), - makeSpawnerLayer([]), + makeSpawnerLayer([], versions), hostRuntimeLayer(env), ), ), diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 4d1ce988086d..2288d6939955 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -15,11 +15,13 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; +const MIN_EXTERNAL_CLOUDFLARED_VERSION = [2025, 6, 1] as const; const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; export type RelayClientExecutableSource = "override" | "managed" | "path"; @@ -207,6 +209,36 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return platform === "win32" || (info.value.mode & 0o111) !== 0; }); + const compatibleExternalVersion = (executablePath: string) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(executablePath, ["version"], { + shell: false, + stdout: "pipe", + stderr: "ignore", + }), + ); + const output = yield* child.stdout.pipe( + Stream.take(16), + Stream.map((chunk) => chunk.subarray(0, 64)), + Stream.decodeText(), + Stream.mkString, + ); + if (Number(yield* child.exitCode) !== 0) return null; + const match = /^cloudflared version (\d+)\.(\d+)\.(\d+)\b/mu.exec(output); + if (!match) return null; + const version = [Number(match[1]), Number(match[2]), Number(match[3])]; + for (let index = 0; index < version.length; index += 1) { + if (version[index]! > MIN_EXTERNAL_CLOUDFLARED_VERSION[index]!) break; + if (version[index]! < MIN_EXTERNAL_CLOUDFLARED_VERSION[index]!) return null; + } + return `${version[0]}.${version[1]}.${version[2]}`; + }).pipe( + Effect.scoped, + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => null), + ); + const resolvePathExecutable = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; const pathValue = Option.getOrUndefined(config.path); @@ -216,7 +248,9 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const trimmed = directory.trim().replace(/^"|"$/gu, ""); if (trimmed.length === 0) continue; const candidate = path.join(trimmed, executableFileName(platform)); - if (yield* isExecutableFile(candidate)) return candidate; + if (!(yield* isExecutableFile(candidate))) continue; + const version = yield* compatibleExternalVersion(candidate); + if (version) return { executablePath: candidate, version }; } return null; }); @@ -224,12 +258,15 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { - return (yield* isExecutableFile(config.executableOverride.value)) + const version = (yield* isExecutableFile(config.executableOverride.value)) + ? yield* compatibleExternalVersion(config.executableOverride.value) + : null; + return version ? { status: "available", executablePath: config.executableOverride.value, source: "override", - version: CLOUDFLARED_VERSION, + version, } : { status: "missing", version: CLOUDFLARED_VERSION }; } @@ -241,23 +278,17 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function version: CLOUDFLARED_VERSION, }; } + if (releaseAsset) return { status: "missing", version: CLOUDFLARED_VERSION }; const pathExecutable = yield* resolvePathExecutable; if (pathExecutable) { return { status: "available", - executablePath: pathExecutable, + executablePath: pathExecutable.executablePath, source: "path", - version: CLOUDFLARED_VERSION, + version: pathExecutable.version, }; } - return releaseAsset - ? { status: "missing", version: CLOUDFLARED_VERSION } - : { - status: "unsupported", - platform, - arch, - version: CLOUDFLARED_VERSION, - }; + return { status: "unsupported", platform, arch, version: CLOUDFLARED_VERSION }; }); const runCommand = Effect.fn("cloudflared.runCommand")(function* ( @@ -360,7 +391,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function if (Option.isSome(config.executableOverride)) { return yield* new RelayClientInstallError({ reason: "override_missing", - message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, + message: `${CLOUDFLARED_PATH_ENV_NAME} must point to an executable cloudflared 2025.6.1 or newer.`, }); } if (!releaseAsset) {