From 95949ed3905798f9d48c8baa30ea1e43d2bab70f Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 13:26:12 +0100 Subject: [PATCH] fix(server): self-heal a non-executable bundled resource-monitor binary npm only preserves the executable bit for files listed in package.json's bin field, so the Rust resource-monitor sidecar we bundle under dist/resource-monitor/-/ always lands on disk as 0644 after `npm pack`/`publish`, regardless of the chmod +x the release workflow already runs beforehand. ResourceMonitorBinary.resolve then failed closed and native process telemetry never started. Bundled candidates are paths we ship ourselves, so repair the exec bit at resolve time instead of staying permanently unavailable. A user-supplied override path (env var / config) is left untouched and still fails closed, since auto-chmod'ing an arbitrary path we didn't build isn't safe. Fixes #7736 --- .../ResourceMonitorBinary.test.ts | 32 ++++++++++++++++++ .../ResourceMonitorBinary.ts | 33 +++++++++++++++---- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 243556b6e3b0..a9bea9908329 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -7,6 +7,7 @@ import { import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { ServerConfig } from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; @@ -115,6 +116,37 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("repairs a bundled binary that lost its executable bit in npm packaging", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bundledRoot = path.resolve(import.meta.dirname, "resource-monitor"); + const bundledDir = path.resolve(bundledRoot, "linux-x64"); + const binaryPath = path.resolve(bundledDir, "t3-resource-monitor"); + yield* Effect.acquireRelease(fileSystem.makeDirectory(bundledDir, { recursive: true }), () => + fileSystem.remove(bundledRoot, { recursive: true }).pipe(Effect.ignore), + ); + yield* fileSystem.writeFileString(binaryPath, "binary"); + yield* fileSystem.chmod(binaryPath, 0o644); + + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "gnu"), + Effect.provideService(HostProcessEnvironment, {}), + ); + + assert.equal(yield* service.resolve, binaryPath); + const stat = yield* fileSystem.stat(binaryPath); + assert.notEqual(stat.mode & 0o111, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("rejects unsupported platform and architecture pairs", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index c93bc54a1fba..05eb0260ade6 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -193,30 +193,51 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu }); } - const candidates = [...overrideCandidates, ...bundledCandidates]; + const candidates = [ + ...overrideCandidates.map((path) => ({ path, owned: false })), + ...bundledCandidates.map((path) => ({ path, owned: true })), + ]; const resolve: ResourceMonitorBinary["Service"]["resolve"] = Effect.gen(function* () { for (const candidate of candidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + const exists = yield* fileSystem + .exists(candidate.path) + .pipe(Effect.orElseSucceed(() => false)); if (!exists) continue; if (platform !== "win32") { - const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); + const stat = yield* fileSystem.stat(candidate.path).pipe(Effect.option); if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) { + // npm only preserves the executable bit for files listed in + // package.json's `bin` field, so our bundled sidecars land on disk + // as 0644 no matter what CI chmods before publish. We own these + // paths, so repair the bit instead of leaving telemetry disabled + // forever. A user-supplied override path is left alone: staying + // fail-closed there prevents chmod'ing an arbitrary file we didn't + // build. + if (candidate.owned) { + const repaired = yield* fileSystem.chmod(candidate.path, stat.value.mode | 0o111).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (repaired) { + return candidate.path; + } + } return yield* new ResourceMonitorBinaryNotExecutable({ - path: candidate, + path: candidate.path, mode: stat.value.mode, }); } } - return candidate; + return candidate.path; } return yield* new ResourceMonitorBinaryNotFound({ platform, architecture, - candidates, + candidates: candidates.map((candidate) => candidate.path), }); });