From ae39cfb3af91b5701e447282fc984864a8a5e26d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 23:06:25 -0700 Subject: [PATCH 1/4] fix(server): resolve Node for standalone helper scripts --- apps/server/src/device/AgentDeviceShim.ts | 5 +- .../src/device/AgentDeviceTarget.test.ts | 13 +- apps/server/src/device/DeviceHost.ts | 3 +- apps/server/src/device/DeviceService.ts | 19 +-- apps/server/src/device/DeviceToolchain.ts | 4 +- .../server/src/device/LocalDeviceHost.test.ts | 11 +- apps/server/src/device/LocalDeviceHost.ts | 42 +++++-- .../src/mcp/toolkits/device/handlers.ts | 7 +- .../provider/AntigravityInstallation.test.ts | 14 +++ .../src/provider/AntigravityInstallation.ts | 7 ++ .../Drivers/AntigravityDriver.test.ts | 16 +++ .../src/provider/Drivers/AntigravityDriver.ts | 12 +- .../provider/antigravityAuthSupport.test.ts | 44 ++++++- .../src/provider/antigravityAuthSupport.ts | 9 +- packages/shared/package.json | 4 + packages/shared/src/nodeRuntime.test.ts | 118 ++++++++++++++++++ packages/shared/src/nodeRuntime.ts | 47 +++++++ 17 files changed, 342 insertions(+), 33 deletions(-) create mode 100644 packages/shared/src/nodeRuntime.test.ts create mode 100644 packages/shared/src/nodeRuntime.ts diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index e3f92de492f3..a5a669d1d8ba 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -1,11 +1,12 @@ // @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript. /** * A directory holding an `agent-device` launcher that runs the pinned install - * with the server's Node. Prepended to provider subprocess PATHs so the agent + * with a Node runtime. Prepended to provider subprocess PATHs so the agent * types `agent-device …` and gets the version the injected instructions were * written for, regardless of what is or is not globally installed. */ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -20,9 +21,9 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; + const node = yield* resolveNodeExecutable("Device automation"); const shimDir = path.join(input.stateDir, SHIM_DIR); yield* fs.makeDirectory(shimDir, { recursive: true }); - const node = process.execPath; const launcherPath = path.join(shimDir, "agent-device-launcher.mjs"); yield* fs.writeFileString( launcherPath, diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 3552beaf3cde..46862f87d1b0 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -1,5 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses. -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; import * as NodeUtil from "node:util"; @@ -84,6 +88,11 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, ).rejects.toThrow("Call device_open first"), ); } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provide(NodeServices.layer), + ), ); }); diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 34ff2769362e..789804be79fe 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -23,9 +23,10 @@ export class DeviceHostError extends Schema.TaggedError()("Devi hostId: Schema.String, step: Schema.String, cause: Schema.Defect(), + detail: Schema.optional(Schema.String), }) { override get message(): string { - return `Device host ${this.hostId} failed while ${this.step}.`; + return this.detail ?? `Device host ${this.hostId} failed while ${this.step}.`; } } diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 32de0b69a33f..78d0df9d784d 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -36,6 +36,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as FileSystem from "effect/FileSystem"; +import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; import { ensureAgentDevice } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; @@ -1003,18 +1004,20 @@ export const make = Effect.gen(function* () { ); return { ...service, - agentCli: ensureAgentDevice(config.baseDir).pipe( + agentCli: resolveNodeExecutable("Device automation").pipe( + Effect.flatMap(() => ensureAgentDevice(config.baseDir)), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.map((tool) => tool.entryPath), - Effect.mapError( - (error) => - new DeviceOperationError({ - operation: "install agent CLI", - reason: "command_failed", - cause: error, - }), + Effect.mapError((error) => + error._tag === "NodeRuntimeUnavailableError" + ? new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, reason: error.message }) + : new DeviceOperationError({ + operation: "install agent CLI", + reason: "command_failed", + cause: error, + }), ), ), }; diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index fa8d8cd11d17..4d59cb462383 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -4,7 +4,7 @@ * `expo-device-hub` streams simulator and emulator screens and `agent-device` * drives them. Each is npm-installed separately after its matching consent * step into `/tools//` and executed from there with the - * server's own Node, never `npx`: an ephemeral + * resolved Node runtime, never `npx`: an ephemeral * npx cache would make every first `device_open` after a reboot depend on the * registry, and the pinned versions are part of the contract the injected * agent instructions describe. @@ -34,7 +34,7 @@ const installLock = Semaphore.makeUnsafe(1); export interface DeviceToolPaths { readonly installDir: string; - /** Absolute path of the tool's entry script, run with the server's Node. */ + /** Absolute path of the tool's entry script, run with a resolved Node runtime. */ readonly entryPath: string; readonly sentinelPath: string; } diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 4442b8f66ab0..5c9a6dcaf4d4 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodePath from "@effect/platform-node/NodePath"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessEnvironment, + HostProcessPlatform, + HostProcessIsExecutable, +} from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -157,6 +161,11 @@ it.effect( ), ); expect(yield* host.current).toBeNull(); + const error = yield* host + .ensureReady(() => Effect.die("Must not install without Node")) + .pipe(Effect.flip, Effect.provideService(HostProcessIsExecutable, true)); + expect(error.message).toContain("Local device support requires Node.js"); + expect(error.message).toContain("Install Node.js"); yield* host.stop; expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 2ad2961f0936..23be5c57a05b 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; import * as NetService from "@t3tools/shared/Net"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; @@ -76,6 +77,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); interface HubProcess { + readonly nodePath: string; readonly child: ChildProcessSpawner.ChildProcessHandle; readonly scope: Scope.Closeable; readonly origin: string; @@ -310,6 +312,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( hubTool: DeviceToolPaths, + nodePath: string, ): Effect.fn.Return { yield* reapStaleHub; yield* fs @@ -330,7 +333,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const child = yield* spawner .spawn( ChildProcess.make( - process.execPath, + nodePath, [ hubTool.entryPath, "--port", @@ -361,7 +364,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ), ); const startedAtMillis = yield* Clock.currentTimeMillis; - const hub: HubProcess = { child, scope, origin, startedAtMillis }; + const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; yield* Effect.forkIn(observeHubOutput(hub), scope); yield* waitForHttpReady({ baseUrl: origin, @@ -418,7 +421,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.gen(function* () { const current = yield* Ref.get(runningRef); if (current?.hub.child.pid !== hub.child.pid) return; - const replacement = yield* spawnHub(hubTool); + const replacement = yield* spawnHub(hubTool, hub.nodePath); yield* Ref.set(runningRef, { ...current, hub: replacement }); yield* Effect.forkDetach(superviseHub(replacement, hubTool)); }), @@ -441,6 +444,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { */ const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( agentTool: DeviceToolPaths, + nodePath: string, ): Effect.fn.Return { const stateDir = agentDeviceStateDir(path, config.stateDir); yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); @@ -481,7 +485,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { // daemon and blocks until it answers. `devices` is the cheapest one. yield* runner .run({ - command: process.execPath, + command: nodePath, args: [agentTool.entryPath, "devices", "--json"], env: daemonEnvironment, timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), @@ -502,11 +506,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } }); - const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) => + const stopAgentDeviceDaemon = ( + agentTool: { readonly entryPath: string; readonly nodePath: string } | null, + ) => agentTool ? runner .run({ - command: process.execPath, + command: agentTool.nodePath, args: [ agentTool.entryPath, "daemon", @@ -521,7 +527,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { .pipe(Effect.ignore) : Effect.void; - let agentToolRef: DeviceToolPaths | null = null; + let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting") => Effect.Effect, @@ -532,6 +538,20 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { if (alive) return running; yield* Ref.set(runningRef, null); } + const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, hostPlatform), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId, + step: "finding Node.js", + detail: cause.message, + cause, + }), + ), + ); const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -551,7 +571,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ), ); yield* onPhase("starting"); - const hub = yield* spawnHub(hubTool); + const hub = yield* spawnHub(hubTool, nodePath); const candidate = helperPaths(hubTool); const [axExists, cliExists] = yield* Effect.all([ fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), @@ -600,9 +620,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }), ), ); - agentToolRef = agentTool; + agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); - const agentDevice = yield* startAgentDeviceDaemon(agentTool); + const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); const next = { ...running, agentDevice }; yield* Ref.set(runningRef, next); return { ...toReady(next), agentDevice }; @@ -652,7 +672,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, - nodePath: process.execPath, + nodePath: running.hub.nodePath, run, helpers: running.helpers, }); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index feb69ec630d6..0d231fc13513 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -182,9 +182,12 @@ const handlers = { stateDir: config.stateDir, }).pipe( Effect.mapError( - () => + (error) => new DeviceToolUnavailableError({ - reason: "Could not prepare the agent-device launcher.", + reason: + error._tag === "NodeRuntimeUnavailableError" + ? error.message + : "Could not prepare the agent-device launcher.", }), ), ); diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index 0528cb4972c9..e2bab831712a 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import { HostProcessArchitecture, HostProcessEnvironment, + HostProcessIsExecutable, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; import * as Deferred from "effect/Deferred"; @@ -242,6 +243,19 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease") }); it.layer(NodeServices.layer)("Antigravity installation", (it) => { + it.effect("reports missing Node before downloading the standalone provider runtime", () => + Effect.gen(function* () { + const { installation, requests, validations } = yield* makeHarness(); + yield* installation.start; + expect(yield* terminalState(installation)).toMatchObject({ + phase: "failed", + message: expect.stringContaining("Install Node.js"), + }); + expect(requests).toEqual([]); + expect(validations).toEqual([]); + }).pipe(Effect.provideService(HostProcessIsExecutable, true)), + ); + it.effect("verifies both files before activating a streamed download", () => Effect.gen(function* () { const enteredValidation = yield* Deferred.make(); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index ebcbd496ee3e..38eb60613d8e 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -6,6 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -517,6 +518,12 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma const install = Effect.fn("AntigravityInstallation.install")( function* (asset: AntigravityReleaseAsset) { + yield* resolveNodeExecutable("Antigravity", environment).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + Effect.mapError((cause) => installationError("verify", cause.message, cause)), + ); const report = (phase: ProviderInstallState["phase"], message: string | null) => SubscriptionRef.update(state, (current) => ({ ...current, phase, message })); yield* fs.makeDirectory(versionsDirectory, { recursive: true }); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index c740e69e60fa..cc9ccb074b9e 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -8,6 +8,7 @@ import { import { HostProcessEnvironment, HostProcessExecutablePath, + HostProcessIsExecutable, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; @@ -260,6 +261,21 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { ); it.layer(testLayer)("AntigravityDriver", (it) => { + it.effect.skipIf(windowsHost)( + "preserves the Node install message when starting a standalone provider", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const error = yield* h.refresh().pipe(Effect.flip); + expect(error.detail).toContain("Install Node.js"); + expect(h.launches).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessEnvironment, { PATH: "" }), + ), + ); + it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1887659db186..dee80129377a 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -164,6 +164,15 @@ export const AntigravityDriver: ProviderDriver + new ProviderSetupError({ + instanceId, + operation: "start", + detail: cause.detail ?? cause.message, + cause, + }), + ), ); // Each process unpacks into its own directory that dies with the // runtime scope, after the child is killed. A shared directory would @@ -422,7 +431,8 @@ export const AntigravityDriver: ProviderDriver { }); it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { + it.effect("runs the browser helper with installed Node in standalone builds", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(directory, "profile"), + baseEnv: { PATH: path.dirname(process.execPath) }, + }); + expect(profile.browserCommand).not.toContain("/packaged/t3"); + expect(yield* fs.exists(profile.acpDirectory)).toBe(true); + }).pipe( + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + ), + ); + + it.effect("reports missing Node before creating the standalone sign-in profile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const profileDirectory = path.join(directory, "profile"); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + baseEnv: { PATH: "" }, + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: expect.stringContaining("Install Node.js"), + }); + } + expect(yield* fs.exists(profileDirectory)).toBe(false); + }).pipe(Effect.provideService(HostProcessIsExecutable, true)), + ); + it.effect("preflights the no-browser helper and creates private directories only", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 4f42c46f73a1..61da5a9ffaa3 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -5,7 +5,8 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; -import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -307,7 +308,11 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( const platform = input.platform ?? (yield* HostProcessPlatform); const userHome = input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); - const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); + const runtimeExecutablePath = + input.runtimeExecutablePath ?? + (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( + Effect.mapError((error) => authSupportError(error.message)), + )); const helperExecutable = platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; diff --git a/packages/shared/package.json b/packages/shared/package.json index b213261b0c3a..579a0cd3b113 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -267,6 +267,10 @@ "types": "./src/chatList.ts", "import": "./src/chatList.ts" }, + "./nodeRuntime": { + "types": "./src/nodeRuntime.ts", + "import": "./src/nodeRuntime.ts" + }, "./hostProcess": { "types": "./src/hostProcess.ts", "import": "./src/hostProcess.ts" diff --git a/packages/shared/src/nodeRuntime.test.ts b/packages/shared/src/nodeRuntime.test.ts new file mode 100644 index 000000000000..40a522461490 --- /dev/null +++ b/packages/shared/src/nodeRuntime.test.ts @@ -0,0 +1,118 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - FileSystem has no symbolic-link API. +import * as NodeFSP from "node:fs/promises"; + +import { + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "./hostProcess.ts"; +import { resolveNodeExecutable } from "./nodeRuntime.ts"; +import { symlinksSupported } from "./testing/symlinks.ts"; + +describe("Node runtime selection", () => { + it.effect("keeps the current Node or Electron runtime without requiring Node on PATH", () => + Effect.gen(function* () { + for (const executable of ["/runtime/node", "/Applications/T3 Code.app/Electron"]) { + expect( + yield* resolveNodeExecutable("Device support", { PATH: "" }).pipe( + Effect.provideService(HostProcessExecutablePath, executable), + Effect.provideService(HostProcessIsExecutable, false), + ), + ).toBe(executable); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("uses installed Node instead of the standalone T3 executable", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect( + yield* resolveNodeExecutable("Device support", { PATH: path.dirname(process.execPath) }), + ).toBe(process.execPath); + }).pipe( + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcessIsExecutable, true), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("explains how to install Node when a standalone helper has no runtime", () => + Effect.gen(function* () { + const error = yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe( + Effect.flip, + ); + expect(error._tag).toBe("NodeRuntimeUnavailableError"); + expect(error.message).toContain("Local device support requires Node.js"); + expect(error.message).toContain("Install Node.js"); + }).pipe( + Effect.provideService(HostProcessIsExecutable, true), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("finds a newly installed runtime immediately after a failed lookup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const platform = yield* HostProcessPlatform; + const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); + const env = { PATH: directory }; + expect( + Result.isFailure(yield* resolveNodeExecutable("Device support", env).pipe(Effect.result)), + ).toBe(true); + yield* fs.copyFile(process.execPath, node); + yield* fs.chmod(node, 0o755); + expect(yield* resolveNodeExecutable("Device support", env)).toBe(node); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcessIsExecutable, true), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect.skipIf(!symlinksSupported)("preserves the node alias used by runtime launchers", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const platform = yield* HostProcessPlatform; + const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); + yield* Effect.tryPromise(() => NodeFSP.symlink(process.execPath, node)); + expect(yield* resolveNodeExecutable("Device support", { PATH: directory })).toBe(node); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcessIsExecutable, true), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects a node alias pointing back at the standalone app", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const platform = yield* HostProcessPlatform; + const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); + yield* Effect.tryPromise(() => NodeFSP.symlink(process.execPath, node)); + const error = yield* resolveNodeExecutable("Device support", { PATH: directory }).pipe( + Effect.flip, + ); + expect(error.message).toContain("Install Node.js"); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessIsExecutable, true), + Effect.provide(NodeServices.layer), + ), + ); +}); diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts new file mode 100644 index 000000000000..8cb869b9d205 --- /dev/null +++ b/packages/shared/src/nodeRuntime.ts @@ -0,0 +1,47 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessIsExecutable, +} from "./hostProcess.ts"; +import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; + +export class NodeRuntimeUnavailableError extends Schema.TaggedError()( + "NodeRuntimeUnavailableError", + { feature: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `${this.feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`; + } +} + +/** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */ +export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutable")(function* ( + feature: string, + environment?: NodeJS.ProcessEnv, +) { + const executablePath = yield* HostProcessExecutablePath; + if (!(yield* HostProcessIsExecutable)) return executablePath; + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nodePath = yield* resolveCommandPath("node", { + env: environment ?? (yield* HostProcessEnvironment), + }).pipe( + // Refresh immediately after the user installs Node and retries setup. + Effect.provideService(CommandResolutionCache, new Map()), + Effect.map((commandPath) => path.resolve(commandPath)), + Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })), + ); + // A launcher or symlink named node must not point back at the standalone app. + const resolvedPath = yield* fs + .realPath(nodePath) + .pipe(Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause }))); + if (resolvedPath === executablePath) return yield* new NodeRuntimeUnavailableError({ feature }); + // Launchers such as Vite+ dispatch by argv[0]; keep the node name intact. + return nodePath; +}); From 36b0cce28ae13a5c196bfa12f47a94acf32f5244 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 23:22:51 -0700 Subject: [PATCH 2/4] fix(server): reject runtime hard links and preserve helper error causes --- apps/server/src/device/DeviceHost.ts | 11 +++-- apps/server/src/device/DeviceService.test.ts | 41 +++++++++++++++++++ apps/server/src/device/DeviceService.ts | 30 ++++++++++++-- apps/server/src/device/LocalDeviceHost.ts | 23 +++++------ .../src/mcp/toolkits/device/handlers.ts | 4 +- .../src/provider/AntigravityInstallation.ts | 6 ++- .../src/provider/Drivers/AntigravityDriver.ts | 22 ++++++---- .../provider/antigravityAuthSupport.test.ts | 4 ++ .../src/provider/antigravityAuthSupport.ts | 10 ++++- packages/contracts/src/device.ts | 2 + packages/shared/src/nodeRuntime.test.ts | 24 +++++++++++ packages/shared/src/nodeRuntime.ts | 22 +++++++++- 12 files changed, 165 insertions(+), 34 deletions(-) diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 789804be79fe..a947f8dc9e68 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -18,15 +18,15 @@ import type { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; export class DeviceHostError extends Schema.TaggedError()("DeviceHostError", { hostId: Schema.String, step: Schema.String, cause: Schema.Defect(), - detail: Schema.optional(Schema.String), }) { override get message(): string { - return this.detail ?? `Device host ${this.hostId} failed while ${this.step}.`; + return `Device host ${this.hostId} failed while ${this.step}.`; } } @@ -89,11 +89,14 @@ export class DeviceHost extends Context.Service< */ readonly ensureReady: ( onPhase: (phase: "installing" | "starting") => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; /** Installs and starts agent-device after the user grants agent access. */ readonly ensureAgentReady: ( onPhase: (phase: "installing" | "starting") => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect< + DeviceHostAgentReady, + DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError + >; /** Current endpoints when already running, without starting anything. */ readonly current: Effect.Effect; /** Stops only agent-device. Manual viewing through the hub stays available. */ diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 46e159dbfc33..f1ed9a7f0253 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -15,6 +15,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; @@ -61,6 +62,7 @@ const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string, failListAfterShutdown = false, + runtimeFailure?: NodeRuntimeUnavailableError, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -88,12 +90,14 @@ const fixture = Effect.fn("fixture")(function* ( platformAvailability: (platform) => Effect.succeed({ platform, available: true }), ensureReady: (onPhase) => Effect.gen(function* () { + if (runtimeFailure) return yield* runtimeFailure; starts.push("start"); yield* onPhase("starting"); return ready; }), ensureAgentReady: (onPhase) => Effect.gen(function* () { + if (runtimeFailure) return yield* runtimeFailure; agentStarts.push("start"); yield* onPhase("starting"); return { @@ -188,6 +192,43 @@ const fixture = Effect.fn("fixture")(function* ( }); describe("device setup consent", () => { + it.effect( + "preserves missing-runtime guidance and causes through manual and agent readiness", + () => + Effect.gen(function* () { + const underlying = new Error("private lookup diagnostics"); + const runtimeFailure = new NodeRuntimeUnavailableError({ + feature: "Local device support", + cause: underlying, + }); + const { service, settings, requests } = yield* fixture( + Effect.void, + undefined, + false, + runtimeFailure, + ); + yield* Ref.update(settings, (current) => ({ + ...current, + enableDeviceSupport: true, + enableAgentDeviceAccess: true, + })); + for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) { + const error = yield* readiness.pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceHostUnavailableError", + reason: expect.stringContaining("Install Node.js"), + cause: runtimeFailure, + }); + expect(error.message).not.toContain(underlying.message); + } + expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({ + status: "failed", + detail: expect.stringContaining("Install Node.js"), + }); + expect(requests).toEqual([]); + }).pipe(Effect.scoped), + ); + it.effect("listing and provider startup do not start helpers before consent", () => Effect.gen(function* () { const { service, starts, requests } = yield* fixture(); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 78d0df9d784d..30e1f18c0497 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -36,7 +36,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as FileSystem from "effect/FileSystem"; -import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; +import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; import { ensureAgentDevice } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; @@ -263,7 +263,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* setHostStatus(host.id, { status: "failed", detail: error.message }), ), Effect.mapError( - (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + (error) => + new DeviceHostUnavailableError({ + hostId: host.id, + reason: + error._tag === "NodeRuntimeUnavailableError" + ? nodeRuntimeUnavailableMessage("Local device support") + : `Device host ${error.hostId} failed while ${error.step}.`, + cause: error, + }), ), ); if (hosts.get(host.id) !== host) @@ -306,7 +314,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* setHostStatus(host.id, { status: "failed", detail: error.message }), ), Effect.mapError( - (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + (error) => + new DeviceHostUnavailableError({ + hostId: host.id, + reason: + error._tag === "NodeRuntimeUnavailableError" + ? nodeRuntimeUnavailableMessage("Local device support") + : error._tag === "DeviceHostTimeoutError" + ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.` + : `Device host ${error.hostId} failed while ${error.step}.`, + cause: error, + }), ), ); const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); @@ -1012,7 +1030,11 @@ export const make = Effect.gen(function* () { Effect.map((tool) => tool.entryPath), Effect.mapError((error) => error._tag === "NodeRuntimeUnavailableError" - ? new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, reason: error.message }) + ? new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + reason: nodeRuntimeUnavailableMessage("Device automation"), + cause: error, + }) : new DeviceOperationError({ operation: "install agent CLI", reason: "command_failed", diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 23be5c57a05b..b7341e04f8ad 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -18,7 +18,10 @@ import { } from "@t3tools/contracts"; import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; +import { + resolveNodeExecutable, + type NodeRuntimeUnavailableError, +} from "@t3tools/shared/nodeRuntime"; import * as NetService from "@t3tools/shared/Net"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; @@ -531,7 +534,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting") => Effect.Effect, - ): Effect.fn.Return { + ): Effect.fn.Return { const running = yield* Ref.get(runningRef); if (running) { const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); @@ -542,15 +545,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(HostProcessPlatform, hostPlatform), - Effect.mapError( - (cause) => - new DeviceHost.DeviceHostError({ - hostId, - step: "finding Node.js", - detail: cause.message, - cause, - }), - ), ); const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -597,7 +591,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) => startLock.withPermits(1)( Effect.gen(function* (): Generator< - Effect.Effect, + Effect.Effect< + unknown, + | DeviceHost.DeviceHostError + | DeviceHost.DeviceHostTimeoutError + | NodeRuntimeUnavailableError + >, DeviceHost.DeviceHostAgentReady > { const running = yield* ensureHubReady(onPhase); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 0d231fc13513..d2c23860c9b7 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -12,6 +12,7 @@ import * as Path from "effect/Path"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerConfig } from "../../../config.ts"; import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; +import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "../../../device/DeviceService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -186,8 +187,9 @@ const handlers = { new DeviceToolUnavailableError({ reason: error._tag === "NodeRuntimeUnavailableError" - ? error.message + ? nodeRuntimeUnavailableMessage("Device automation") : "Could not prepare the agent-device launcher.", + cause: error, }), ), ); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 38eb60613d8e..24eb4e3d6df8 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -6,7 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; -import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; +import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -522,7 +522,9 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(HostProcessPlatform, platform), - Effect.mapError((cause) => installationError("verify", cause.message, cause)), + Effect.mapError((cause) => + installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause), + ), ); const report = (phase: ProviderInstallState["phase"], message: string | null) => SubscriptionRef.update(state, (current) => ({ ...current, phase, message })); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index dee80129377a..1141ac5856fc 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,6 +1,10 @@ import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + NodeRuntimeUnavailableError, + nodeRuntimeUnavailableMessage, +} from "@t3tools/shared/nodeRuntime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -59,6 +63,7 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); +const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); export type AntigravityDriverEnv = | AntigravityInstallation @@ -164,14 +169,15 @@ export const AntigravityDriver: ProviderDriver - new ProviderSetupError({ - instanceId, - operation: "start", - detail: cause.detail ?? cause.message, - cause, - }), + Effect.mapError((cause) => + isNodeRuntimeUnavailableError(cause.cause) + ? new ProviderSetupError({ + instanceId, + operation: "start", + detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), + cause, + }) + : cause, ), ); // Each process unpacks into its own directory that dies with the diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index cf88ced378ba..01fe516454ea 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -567,6 +567,10 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { expect(result.failure).toMatchObject({ _tag: "AcpTransportError", detail: expect.stringContaining("Install Node.js"), + cause: { + _tag: "NodeRuntimeUnavailableError", + cause: { _tag: "CommandResolutionError" }, + }, }); } expect(yield* fs.exists(profileDirectory)).toBe(false); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 61da5a9ffaa3..b48368ad2a1d 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -6,7 +6,7 @@ import * as NodePath from "node:path"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; +import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -311,7 +311,13 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( - Effect.mapError((error) => authSupportError(error.message)), + Effect.mapError( + (cause) => + new AcpErrors.AcpTransportError({ + detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), + cause, + }), + ), )); const helperExecutable = platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 03102dd62b44..a93198a1e639 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -340,6 +340,7 @@ export class DeviceHostUnavailableError extends Schema.TaggedError { ), ); + it.effect("rejects a hard-linked node alias pointing back at the standalone app", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const platform = yield* HostProcessPlatform; + const executable = path.join(directory, platform === "win32" ? "t3.exe" : "t3"); + const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); + yield* fs.writeFileString(executable, "standalone executable fixture"); + yield* fs.chmod(executable, 0o755); + yield* fs.link(executable, node); + const error = yield* resolveNodeExecutable("Device support", { PATH: directory }).pipe( + Effect.provideService(HostProcessExecutablePath, executable), + Effect.flip, + ); + expect(error._tag).toBe("NodeRuntimeUnavailableError"); + expect(error.message).toContain("Install Node.js"); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessIsExecutable, true), + Effect.provide(NodeServices.layer), + ), + ); + it.effect.skipIf(!symlinksSupported)("preserves the node alias used by runtime launchers", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts index 8cb869b9d205..198afc14a973 100644 --- a/packages/shared/src/nodeRuntime.ts +++ b/packages/shared/src/nodeRuntime.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { @@ -10,12 +11,15 @@ import { } from "./hostProcess.ts"; import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; +export const nodeRuntimeUnavailableMessage = (feature: string): string => + `${feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`; + export class NodeRuntimeUnavailableError extends Schema.TaggedError()( "NodeRuntimeUnavailableError", { feature: Schema.String, cause: Schema.optional(Schema.Defect()) }, ) { override get message(): string { - return `${this.feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`; + return nodeRuntimeUnavailableMessage(this.feature); } } @@ -42,6 +46,22 @@ export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutabl .realPath(nodePath) .pipe(Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause }))); if (resolvedPath === executablePath) return yield* new NodeRuntimeUnavailableError({ feature }); + const [hostInfo, nodeInfo] = yield* Effect.all([ + fs.stat(executablePath).pipe(Effect.option), + fs.stat(nodePath).pipe(Effect.option), + ]); + if ( + Option.isSome(hostInfo) && + Option.isSome(nodeInfo) && + hostInfo.value.dev === nodeInfo.value.dev && + Option.isSome(hostInfo.value.ino) && + Option.isSome(nodeInfo.value.ino) && + Number.isSafeInteger(hostInfo.value.ino.value) && + hostInfo.value.ino.value > 0 && + hostInfo.value.ino.value === nodeInfo.value.ino.value + ) { + return yield* new NodeRuntimeUnavailableError({ feature }); + } // Launchers such as Vite+ dispatch by argv[0]; keep the node name intact. return nodePath; }); From d24233d7fbbffa88d60e27f6f96a9e47f6705043 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 23:27:50 -0700 Subject: [PATCH 3/4] fix(server): select native Node executables on Windows --- packages/shared/src/nodeRuntime.test.ts | 55 +++++++++++++++++++++++-- packages/shared/src/nodeRuntime.ts | 8 +++- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/nodeRuntime.test.ts b/packages/shared/src/nodeRuntime.test.ts index 3e45dc321d3d..48cbf0d3f838 100644 --- a/packages/shared/src/nodeRuntime.test.ts +++ b/packages/shared/src/nodeRuntime.test.ts @@ -4,8 +4,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; -// @effect-diagnostics-next-line nodeBuiltinImport:off - FileSystem has no symbolic-link API. -import * as NodeFSP from "node:fs/promises"; import { HostProcessExecutablePath, @@ -78,6 +76,55 @@ describe("Node runtime selection", () => { ), ); + it.effect("uses node.exe even when Windows batch wrappers appear first on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const wrappers = path.join(directory, "wrappers"); + const runtime = path.join(directory, "runtime"); + yield* fs.makeDirectory(wrappers); + yield* fs.makeDirectory(runtime); + yield* fs.writeFileString(path.join(wrappers, "node.cmd"), "@echo off"); + yield* fs.writeFileString(path.join(wrappers, "node.bat"), "@echo off"); + const node = path.join(runtime, "node.exe"); + yield* fs.copyFile(process.execPath, node); + expect( + yield* resolveNodeExecutable("Device support", { + PATH: `${wrappers};${runtime}`, + PATHEXT: ".CMD;.BAT", + }), + ).toBe(node); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("reports install guidance when Windows only has batch runtime wrappers", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(path.join(directory, "node.cmd"), "@echo off"); + yield* fs.writeFileString(path.join(directory, "node.bat"), "@echo off"); + const error = yield* resolveNodeExecutable("Device support", { + PATH: directory, + PATHEXT: ".CMD;.BAT;.EXE", + }).pipe(Effect.flip); + expect(error._tag).toBe("NodeRuntimeUnavailableError"); + expect(error.message).toContain("Install Node.js"); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); + it.effect("rejects a hard-linked node alias pointing back at the standalone app", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -109,7 +156,7 @@ describe("Node runtime selection", () => { const directory = yield* fs.makeTempDirectoryScoped(); const platform = yield* HostProcessPlatform; const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); - yield* Effect.tryPromise(() => NodeFSP.symlink(process.execPath, node)); + yield* fs.symlink(process.execPath, node); expect(yield* resolveNodeExecutable("Device support", { PATH: directory })).toBe(node); }).pipe( Effect.scoped, @@ -128,7 +175,7 @@ describe("Node runtime selection", () => { const directory = yield* fs.makeTempDirectoryScoped(); const platform = yield* HostProcessPlatform; const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); - yield* Effect.tryPromise(() => NodeFSP.symlink(process.execPath, node)); + yield* fs.symlink(process.execPath, node); const error = yield* resolveNodeExecutable("Device support", { PATH: directory }).pipe( Effect.flip, ); diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts index 198afc14a973..49c987267ac3 100644 --- a/packages/shared/src/nodeRuntime.ts +++ b/packages/shared/src/nodeRuntime.ts @@ -8,6 +8,7 @@ import { HostProcessEnvironment, HostProcessExecutablePath, HostProcessIsExecutable, + HostProcessPlatform, } from "./hostProcess.ts"; import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; @@ -33,8 +34,11 @@ export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutabl const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const nodePath = yield* resolveCommandPath("node", { - env: environment ?? (yield* HostProcessEnvironment), + const platform = yield* HostProcessPlatform; + const env = environment ?? (yield* HostProcessEnvironment); + const nodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", { + // Batch wrappers require a shell; helper callers launch the runtime directly. + env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env, }).pipe( // Refresh immediately after the user installs Node and retries setup. Effect.provideService(CommandResolutionCache, new Map()), From 49fb7a0f9d51414f1cb55b2d5497b4a7bc3d3f26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 23:32:45 -0700 Subject: [PATCH 4/4] fix(shared): bound Node runtime feature labels --- packages/shared/src/nodeRuntime.test.ts | 26 ++++++++++++++----------- packages/shared/src/nodeRuntime.ts | 13 ++++++++++--- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/nodeRuntime.test.ts b/packages/shared/src/nodeRuntime.test.ts index 48cbf0d3f838..f8c0883a1b22 100644 --- a/packages/shared/src/nodeRuntime.test.ts +++ b/packages/shared/src/nodeRuntime.test.ts @@ -18,7 +18,7 @@ describe("Node runtime selection", () => { Effect.gen(function* () { for (const executable of ["/runtime/node", "/Applications/T3 Code.app/Electron"]) { expect( - yield* resolveNodeExecutable("Device support", { PATH: "" }).pipe( + yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe( Effect.provideService(HostProcessExecutablePath, executable), Effect.provideService(HostProcessIsExecutable, false), ), @@ -31,7 +31,9 @@ describe("Node runtime selection", () => { Effect.gen(function* () { const path = yield* Path.Path; expect( - yield* resolveNodeExecutable("Device support", { PATH: path.dirname(process.execPath) }), + yield* resolveNodeExecutable("Local device support", { + PATH: path.dirname(process.execPath), + }), ).toBe(process.execPath); }).pipe( Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), @@ -63,11 +65,13 @@ describe("Node runtime selection", () => { const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); const env = { PATH: directory }; expect( - Result.isFailure(yield* resolveNodeExecutable("Device support", env).pipe(Effect.result)), + Result.isFailure( + yield* resolveNodeExecutable("Local device support", env).pipe(Effect.result), + ), ).toBe(true); yield* fs.copyFile(process.execPath, node); yield* fs.chmod(node, 0o755); - expect(yield* resolveNodeExecutable("Device support", env)).toBe(node); + expect(yield* resolveNodeExecutable("Local device support", env)).toBe(node); }).pipe( Effect.scoped, Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), @@ -90,7 +94,7 @@ describe("Node runtime selection", () => { const node = path.join(runtime, "node.exe"); yield* fs.copyFile(process.execPath, node); expect( - yield* resolveNodeExecutable("Device support", { + yield* resolveNodeExecutable("Local device support", { PATH: `${wrappers};${runtime}`, PATHEXT: ".CMD;.BAT", }), @@ -111,7 +115,7 @@ describe("Node runtime selection", () => { const directory = yield* fs.makeTempDirectoryScoped(); yield* fs.writeFileString(path.join(directory, "node.cmd"), "@echo off"); yield* fs.writeFileString(path.join(directory, "node.bat"), "@echo off"); - const error = yield* resolveNodeExecutable("Device support", { + const error = yield* resolveNodeExecutable("Local device support", { PATH: directory, PATHEXT: ".CMD;.BAT;.EXE", }).pipe(Effect.flip); @@ -136,7 +140,7 @@ describe("Node runtime selection", () => { yield* fs.writeFileString(executable, "standalone executable fixture"); yield* fs.chmod(executable, 0o755); yield* fs.link(executable, node); - const error = yield* resolveNodeExecutable("Device support", { PATH: directory }).pipe( + const error = yield* resolveNodeExecutable("Local device support", { PATH: directory }).pipe( Effect.provideService(HostProcessExecutablePath, executable), Effect.flip, ); @@ -157,7 +161,7 @@ describe("Node runtime selection", () => { const platform = yield* HostProcessPlatform; const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); yield* fs.symlink(process.execPath, node); - expect(yield* resolveNodeExecutable("Device support", { PATH: directory })).toBe(node); + expect(yield* resolveNodeExecutable("Local device support", { PATH: directory })).toBe(node); }).pipe( Effect.scoped, Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), @@ -176,9 +180,9 @@ describe("Node runtime selection", () => { const platform = yield* HostProcessPlatform; const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); yield* fs.symlink(process.execPath, node); - const error = yield* resolveNodeExecutable("Device support", { PATH: directory }).pipe( - Effect.flip, - ); + const error = yield* resolveNodeExecutable("Local device support", { + PATH: directory, + }).pipe(Effect.flip); expect(error.message).toContain("Install Node.js"); }).pipe( Effect.scoped, diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts index 49c987267ac3..017436fb9856 100644 --- a/packages/shared/src/nodeRuntime.ts +++ b/packages/shared/src/nodeRuntime.ts @@ -12,12 +12,19 @@ import { } from "./hostProcess.ts"; import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; -export const nodeRuntimeUnavailableMessage = (feature: string): string => +const NodeRuntimeFeature = Schema.Literals([ + "Local device support", + "Device automation", + "Antigravity", + "Antigravity sign-in", +]); + +export const nodeRuntimeUnavailableMessage = (feature: typeof NodeRuntimeFeature.Type): string => `${feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`; export class NodeRuntimeUnavailableError extends Schema.TaggedError()( "NodeRuntimeUnavailableError", - { feature: Schema.String, cause: Schema.optional(Schema.Defect()) }, + { feature: NodeRuntimeFeature, cause: Schema.optional(Schema.Defect()) }, ) { override get message(): string { return nodeRuntimeUnavailableMessage(this.feature); @@ -26,7 +33,7 @@ export class NodeRuntimeUnavailableError extends Schema.TaggedError