diff --git a/apps/server/src/cli/servicePreflight.ts b/apps/server/src/cli/servicePreflight.ts index 60a457f388a6..b335277bc0b1 100644 --- a/apps/server/src/cli/servicePreflight.ts +++ b/apps/server/src/cli/servicePreflight.ts @@ -10,8 +10,8 @@ export const servicePreflightCommand = Command.make("__service-preflight", { }).pipe( Command.withHidden, Command.withHandler(({ databasePath, launcherProtocol }) => - Console.log(JSON.stringify(runServicePreflight({ databasePath, launcherProtocol }))).pipe( - Effect.asVoid, + Effect.promise(() => runServicePreflight({ databasePath, launcherProtocol })).pipe( + Effect.flatMap((result) => Console.log(JSON.stringify(result))), ), ), ); diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index f34f0f5cf4d7..c811980daeee 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -57,6 +57,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { validatedDirectory = staging.versionDir; assert.isFalse(yield* fs.exists(finalPaths.versionDir)); assert.isTrue(yield* fs.exists(staging.entryPath)); + // The manifest npm read while installing must let node-pty build. + const manifest = yield* fs.readFileString( + path.join(staging.versionDir, "package.json"), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - staged file written by the code under test. + assert.deepEqual((JSON.parse(manifest) as { allowScripts: unknown }).allowScripts, { + "node-pty": true, + "msgpackr-extract": true, + }); }).pipe(Effect.orDie), }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 06628d5cc12f..762688fdfdd5 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -18,6 +18,15 @@ import * as ProcessRunner from "../processRunner.ts"; const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +// npm 12 skips dependency install scripts not covered by allowScripts and +// still exits 0, silently dropping node-pty's native build. It rejects +// --allow-scripts for project installs (unlike the global installs in +// providerMaintenance.ts), so the staged manifest is the only place to grant this. +const PINNED_RUNTIME_PACKAGE_JSON = `${JSON.stringify({ + private: true, + // Keep this native subset aligned with pnpm-workspace.yaml#allowBuilds. + allowScripts: { "node-pty": true, "msgpackr-extract": true }, +})}\n`; // Boot-service setup and remote update can construct separate layers. Serialize // the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); @@ -151,6 +160,14 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( }; return yield* Effect.gen(function* () { + yield* fs + .writeFileString(input.path.join(stagingDir, "package.json"), PINNED_RUNTIME_PACKAGE_JSON) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "configuring native dependency builds", cause }), + ), + ); const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; yield* runner .run({ diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 2eb2e02015d0..d8d40869c70a 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -3,24 +3,34 @@ import { expect, it } from "@effect/vitest"; import { runServicePreflight } from "./servicePreflight.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; -it("requires the database-snapshot launcher protocol", () => { - expect( +it("requires the database-snapshot launcher protocol", async () => { + await expect( runServicePreflight({ databasePath: "/missing/state.sqlite", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL - 1, version: "1.2.3", }), - ).toMatchObject({ status: "blocked", version: "1.2.3" }); + ).resolves.toMatchObject({ status: "blocked", version: "1.2.3" }); - expect( + await expect( runServicePreflight({ databasePath: "/missing/state.sqlite", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, version: "1.2.3", }), - ).toEqual({ + ).resolves.toEqual({ status: "ready", version: "1.2.3", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, }); }); + +it("blocks a runtime whose node-pty does not load", async () => { + const result = await runServicePreflight( + { databasePath: "/missing/state.sqlite", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }, + () => Promise.reject(new Error("Cannot find module 'pty.node'")), + ); + + expect(result).toMatchObject({ status: "blocked" }); + expect(result.status === "blocked" && result.reason).toContain("Cannot find module 'pty.node'"); +}); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index ee0f972baa3f..5ebf220d4bc8 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -13,12 +13,17 @@ export type ServicePreflightResult = readonly reason: string; }; -export function runServicePreflight(input: { - /** Older servers always pass this flag when invoking a staged preflight. */ - readonly databasePath: string; - readonly launcherProtocol: number; - readonly version?: string; -}): ServicePreflightResult { +// Loading node-pty is part of the proof: npm can skip its native build and +// still exit 0, leaving a runtime that boots but cannot open terminals. +export async function runServicePreflight( + input: { + /** Older servers always pass this flag when invoking a staged preflight. */ + readonly databasePath: string; + readonly launcherProtocol: number; + readonly version?: string; + }, + loadNodePty: () => Promise = () => import("node-pty"), +): Promise { const version = input.version ?? packageJson.version; if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) { return { @@ -28,6 +33,16 @@ export function runServicePreflight(input: { "This release requires a newer T3 Code service launcher. Update it on the server machine.", }; } + try { + await loadNodePty(); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + return { + status: "blocked", + version, + reason: `node-pty's native binary is missing from this runtime (${detail}). On Linux it compiles during install; check that npm was allowed to run install scripts and that build tools are present.`, + }; + } return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; }