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..a947f8dc9e68 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -18,6 +18,7 @@ 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, @@ -88,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 32de0b69a33f..30e1f18c0497 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, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; import { ensureAgentDevice } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; @@ -262,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) @@ -305,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); @@ -1003,18 +1022,24 @@ 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: nodeRuntimeUnavailableMessage("Device automation"), + cause: error, + }) + : 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..b7341e04f8ad 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -18,6 +18,10 @@ import { } from "@t3tools/contracts"; import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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"; @@ -76,6 +80,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 +315,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 +336,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const child = yield* spawner .spawn( ChildProcess.make( - process.execPath, + nodePath, [ hubTool.entryPath, "--port", @@ -361,7 +367,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 +424,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 +447,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 +488,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 +509,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,17 +530,22 @@ 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, - ): 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)); 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), + ); const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -551,7 +565,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)), @@ -577,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); @@ -600,9 +619,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 +671,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..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"; @@ -182,9 +183,13 @@ const handlers = { stateDir: config.stateDir, }).pipe( Effect.mapError( - () => + (error) => new DeviceToolUnavailableError({ - reason: "Could not prepare the agent-device launcher.", + reason: + error._tag === "NodeRuntimeUnavailableError" + ? nodeRuntimeUnavailableMessage("Device automation") + : "Could not prepare the agent-device launcher.", + cause: error, }), ), ); 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..24eb4e3d6df8 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, nodeRuntimeUnavailableMessage } 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,14 @@ 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", nodeRuntimeUnavailableMessage("Antigravity"), 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..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,6 +169,16 @@ export const AntigravityDriver: ProviderDriver + 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 // runtime scope, after the child is killed. A shared directory would @@ -422,7 +437,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"), + cause: { + _tag: "NodeRuntimeUnavailableError", + cause: { _tag: "CommandResolutionError" }, + }, + }); + } + 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..b48368ad2a1d 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, nodeRuntimeUnavailableMessage } 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,17 @@ 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( + (cause) => + new AcpErrors.AcpTransportError({ + detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), + cause, + }), + ), + )); const helperExecutable = platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; 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("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("Local 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("Local 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("Local device support", env).pipe(Effect.result), + ), + ).toBe(true); + yield* fs.copyFile(process.execPath, node); + yield* fs.chmod(node, 0o755); + expect(yield* resolveNodeExecutable("Local device support", env)).toBe(node); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcessIsExecutable, true), + Effect.provide(NodeServices.layer), + ), + ); + + 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("Local 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("Local 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; + 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("Local 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; + 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* fs.symlink(process.execPath, node); + expect(yield* resolveNodeExecutable("Local 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* fs.symlink(process.execPath, node); + const error = yield* resolveNodeExecutable("Local 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..017436fb9856 --- /dev/null +++ b/packages/shared/src/nodeRuntime.ts @@ -0,0 +1,78 @@ +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 { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "./hostProcess.ts"; +import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; + +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: NodeRuntimeFeature, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return nodeRuntimeUnavailableMessage(this.feature); + } +} + +/** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */ +export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutable")(function* ( + feature: typeof NodeRuntimeFeature.Type, + environment?: NodeJS.ProcessEnv, +) { + const executablePath = yield* HostProcessExecutablePath; + if (!(yield* HostProcessIsExecutable)) return executablePath; + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + 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()), + 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 }); + 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; +});