authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 23:42:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 23:42:27-07:00
logbf55408a5cddbb0f3f7f50d538398e315b5407d0
tree23f53d74394ba054ee66d5b2ab0399d8167a15f9
parent6ee03240bccd9287a8c2246e9547697aa5857998
signature Signed by PGP key B5690EEEBB952194

fix(server): resolve Node for standalone helper scripts (#12033)


19 files changed, 540 insertions(+), 38 deletions(-)

apps/server/src/device/AgentDeviceShim.ts+3-2
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.1// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.
2/**2/**
3 * A directory holding an `agent-device` launcher that runs the pinned install3 * A directory holding an `agent-device` launcher that runs the pinned install
4 * with the server's Node. Prepended to provider subprocess PATHs so the agent4 * with a Node runtime. Prepended to provider subprocess PATHs so the agent
5 * types `agent-device …` and gets the version the injected instructions were5 * types `agent-device …` and gets the version the injected instructions were
6 * written for, regardless of what is or is not globally installed.6 * written for, regardless of what is or is not globally installed.
7 */7 */
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";12import * as Path from "effect/Path";
...@@ -20,9 +21,9 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio...@@ -20,9 +21,9 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio
20 const fs = yield* FileSystem.FileSystem;21 const fs = yield* FileSystem.FileSystem;
21 const path = yield* Path.Path;22 const path = yield* Path.Path;
22 const platform = yield* HostProcessPlatform;23 const platform = yield* HostProcessPlatform;
24 const node = yield* resolveNodeExecutable("Device automation");
23 const shimDir = path.join(input.stateDir, SHIM_DIR);25 const shimDir = path.join(input.stateDir, SHIM_DIR);
24 yield* fs.makeDirectory(shimDir, { recursive: true });26 yield* fs.makeDirectory(shimDir, { recursive: true });
25 const node = process.execPath;
26 const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");27 const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");
27 yield* fs.writeFileString(28 yield* fs.writeFileString(
28 launcherPath,29 launcherPath,
apps/server/src/device/AgentDeviceTarget.test.ts+11-2
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.1// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";2import {
3 HostProcessExecutablePath,
4 HostProcessIsExecutable,
5 HostProcessPlatform,
6} from "@t3tools/shared/hostProcess";
3import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
4import * as NodeChildProcess from "node:child_process";8import * as NodeChildProcess from "node:child_process";
5import * as NodeUtil from "node:util";9import * as NodeUtil from "node:util";
...@@ -84,6 +88,11 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,...@@ -84,6 +88,11 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
84 ).rejects.toThrow("Call device_open first"),88 ).rejects.toThrow("Call device_open first"),
85 );89 );
86 }90 }
87 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),91 }).pipe(
92 Effect.scoped,
93 Effect.provideService(HostProcessIsExecutable, true),
94 Effect.provideService(HostProcessExecutablePath, "/packaged/t3"),
95 Effect.provide(NodeServices.layer),
96 ),
88 );97 );
89});98});
apps/server/src/device/DeviceHost.ts+6-2
...@@ -18,6 +18,7 @@ import type {...@@ -18,6 +18,7 @@ import type {
18import * as Context from "effect/Context";18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
20import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
21import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
2122
22export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", {23export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", {
23 hostId: Schema.String,24 hostId: Schema.String,
...@@ -88,11 +89,14 @@ export class DeviceHost extends Context.Service<...@@ -88,11 +89,14 @@ export class DeviceHost extends Context.Service<
88 */89 */
89 readonly ensureReady: (90 readonly ensureReady: (
90 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,91 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
91 ) => Effect.Effect<DeviceHostReady, DeviceHostError>;92 ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;
92 /** Installs and starts agent-device after the user grants agent access. */93 /** Installs and starts agent-device after the user grants agent access. */
93 readonly ensureAgentReady: (94 readonly ensureAgentReady: (
94 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,95 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
95 ) => Effect.Effect<DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError>;96 ) => Effect.Effect<
97 DeviceHostAgentReady,
98 DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError
99 >;
96 /** Current endpoints when already running, without starting anything. */100 /** Current endpoints when already running, without starting anything. */
97 readonly current: Effect.Effect<DeviceHostReady | null>;101 readonly current: Effect.Effect<DeviceHostReady | null>;
98 /** Stops only agent-device. Manual viewing through the hub stays available. */102 /** Stops only agent-device. Manual viewing through the hub stays available. */
apps/server/src/device/DeviceService.test.ts+41
...@@ -15,6 +15,7 @@ import * as Stream from "effect/Stream";...@@ -15,6 +15,7 @@ import * as Stream from "effect/Stream";
15import { HttpClient, HttpClientResponse } from "effect/unstable/http";15import { HttpClient, HttpClientResponse } from "effect/unstable/http";
16import { ServerSettingsService } from "../serverSettings.ts";16import { ServerSettingsService } from "../serverSettings.ts";
17import * as DeviceHost from "./DeviceHost.ts";17import * as DeviceHost from "./DeviceHost.ts";
18import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
1819
19import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts";20import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts";
2021
...@@ -61,6 +62,7 @@ const fixture = Effect.fn("fixture")(function* (...@@ -61,6 +62,7 @@ const fixture = Effect.fn("fixture")(function* (
61 onBoot: Effect.Effect<void> = Effect.void,62 onBoot: Effect.Effect<void> = Effect.void,
62 bootError?: string,63 bootError?: string,
63 failListAfterShutdown = false,64 failListAfterShutdown = false,
65 runtimeFailure?: NodeRuntimeUnavailableError,
64) {66) {
65 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);67 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
66 const starts: string[] = [];68 const starts: string[] = [];
...@@ -88,12 +90,14 @@ const fixture = Effect.fn("fixture")(function* (...@@ -88,12 +90,14 @@ const fixture = Effect.fn("fixture")(function* (
88 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),90 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
89 ensureReady: (onPhase) =>91 ensureReady: (onPhase) =>
90 Effect.gen(function* () {92 Effect.gen(function* () {
93 if (runtimeFailure) return yield* runtimeFailure;
91 starts.push("start");94 starts.push("start");
92 yield* onPhase("starting");95 yield* onPhase("starting");
93 return ready;96 return ready;
94 }),97 }),
95 ensureAgentReady: (onPhase) =>98 ensureAgentReady: (onPhase) =>
96 Effect.gen(function* () {99 Effect.gen(function* () {
100 if (runtimeFailure) return yield* runtimeFailure;
97 agentStarts.push("start");101 agentStarts.push("start");
98 yield* onPhase("starting");102 yield* onPhase("starting");
99 return {103 return {
...@@ -188,6 +192,43 @@ const fixture = Effect.fn("fixture")(function* (...@@ -188,6 +192,43 @@ const fixture = Effect.fn("fixture")(function* (
188});192});
189193
190describe("device setup consent", () => {194describe("device setup consent", () => {
195 it.effect(
196 "preserves missing-runtime guidance and causes through manual and agent readiness",
197 () =>
198 Effect.gen(function* () {
199 const underlying = new Error("private lookup diagnostics");
200 const runtimeFailure = new NodeRuntimeUnavailableError({
201 feature: "Local device support",
202 cause: underlying,
203 });
204 const { service, settings, requests } = yield* fixture(
205 Effect.void,
206 undefined,
207 false,
208 runtimeFailure,
209 );
210 yield* Ref.update(settings, (current) => ({
211 ...current,
212 enableDeviceSupport: true,
213 enableAgentDeviceAccess: true,
214 }));
215 for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) {
216 const error = yield* readiness.pipe(Effect.flip);
217 expect(error).toMatchObject({
218 _tag: "DeviceHostUnavailableError",
219 reason: expect.stringContaining("Install Node.js"),
220 cause: runtimeFailure,
221 });
222 expect(error.message).not.toContain(underlying.message);
223 }
224 expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({
225 status: "failed",
226 detail: expect.stringContaining("Install Node.js"),
227 });
228 expect(requests).toEqual([]);
229 }).pipe(Effect.scoped),
230 );
231
191 it.effect("listing and provider startup do not start helpers before consent", () =>232 it.effect("listing and provider startup do not start helpers before consent", () =>
192 Effect.gen(function* () {233 Effect.gen(function* () {
193 const { service, starts, requests } = yield* fixture();234 const { service, starts, requests } = yield* fixture();
apps/server/src/device/DeviceService.ts+35-10
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 type ThreadId,36 type ThreadId,
37} from "@t3tools/contracts";37} from "@t3tools/contracts";
38import * as FileSystem from "effect/FileSystem";38import * as FileSystem from "effect/FileSystem";
39import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
39import * as Path from "effect/Path";40import * as Path from "effect/Path";
40import { ensureAgentDevice } from "./DeviceToolchain.ts";41import { ensureAgentDevice } from "./DeviceToolchain.ts";
41import * as ServerConfig from "../config.ts";42import * as ServerConfig from "../config.ts";
...@@ -262,7 +263,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -262,7 +263,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
262 setHostStatus(host.id, { status: "failed", detail: error.message }),263 setHostStatus(host.id, { status: "failed", detail: error.message }),
263 ),264 ),
264 Effect.mapError(265 Effect.mapError(
265 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),266 (error) =>
267 new DeviceHostUnavailableError({
268 hostId: host.id,
269 reason:
270 error._tag === "NodeRuntimeUnavailableError"
271 ? nodeRuntimeUnavailableMessage("Local device support")
272 : `Device host ${error.hostId} failed while ${error.step}.`,
273 cause: error,
274 }),
266 ),275 ),
267 );276 );
268 if (hosts.get(host.id) !== host)277 if (hosts.get(host.id) !== host)
...@@ -305,7 +314,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -305,7 +314,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
305 setHostStatus(host.id, { status: "failed", detail: error.message }),314 setHostStatus(host.id, { status: "failed", detail: error.message }),
306 ),315 ),
307 Effect.mapError(316 Effect.mapError(
308 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),317 (error) =>
318 new DeviceHostUnavailableError({
319 hostId: host.id,
320 reason:
321 error._tag === "NodeRuntimeUnavailableError"
322 ? nodeRuntimeUnavailableMessage("Local device support")
323 : error._tag === "DeviceHostTimeoutError"
324 ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.`
325 : `Device host ${error.hostId} failed while ${error.step}.`,
326 cause: error,
327 }),
309 ),328 ),
310 );329 );
311 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);330 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
...@@ -1003,18 +1022,24 @@ export const make = Effect.gen(function* () {...@@ -1003,18 +1022,24 @@ export const make = Effect.gen(function* () {
1003 );1022 );
1004 return {1023 return {
1005 ...service,1024 ...service,
1006 agentCli: ensureAgentDevice(config.baseDir).pipe(1025 agentCli: resolveNodeExecutable("Device automation").pipe(
1026 Effect.flatMap(() => ensureAgentDevice(config.baseDir)),
1007 Effect.provideService(FileSystem.FileSystem, fs),1027 Effect.provideService(FileSystem.FileSystem, fs),
1008 Effect.provideService(Path.Path, path),1028 Effect.provideService(Path.Path, path),
1009 Effect.provideService(ProcessRunner.ProcessRunner, runner),1029 Effect.provideService(ProcessRunner.ProcessRunner, runner),
1010 Effect.map((tool) => tool.entryPath),1030 Effect.map((tool) => tool.entryPath),
1011 Effect.mapError(1031 Effect.mapError((error) =>
1012 (error) =>1032 error._tag === "NodeRuntimeUnavailableError"
1013 new DeviceOperationError({1033 ? new DeviceHostUnavailableError({
1014 operation: "install agent CLI",1034 hostId: LOCAL_DEVICE_HOST_ID,
1015 reason: "command_failed",1035 reason: nodeRuntimeUnavailableMessage("Device automation"),
1016 cause: error,1036 cause: error,
1017 }),1037 })
1038 : new DeviceOperationError({
1039 operation: "install agent CLI",
1040 reason: "command_failed",
1041 cause: error,
1042 }),
1018 ),1043 ),
1019 ),1044 ),
1020 };1045 };
apps/server/src/device/DeviceToolchain.ts+2-2
...@@ -4,7 +4,7 @@...@@ -4,7 +4,7 @@
4 * `expo-device-hub` streams simulator and emulator screens and `agent-device`4 * `expo-device-hub` streams simulator and emulator screens and `agent-device`
5 * drives them. Each is npm-installed separately after its matching consent5 * drives them. Each is npm-installed separately after its matching consent
6 * step into `<baseDir>/tools/<name>/<version>` and executed from there with the6 * step into `<baseDir>/tools/<name>/<version>` and executed from there with the
7 * server's own Node, never `npx`: an ephemeral7 * resolved Node runtime, never `npx`: an ephemeral
8 * npx cache would make every first `device_open` after a reboot depend on the8 * npx cache would make every first `device_open` after a reboot depend on the
9 * registry, and the pinned versions are part of the contract the injected9 * registry, and the pinned versions are part of the contract the injected
10 * agent instructions describe.10 * agent instructions describe.
...@@ -34,7 +34,7 @@ const installLock = Semaphore.makeUnsafe(1);...@@ -34,7 +34,7 @@ const installLock = Semaphore.makeUnsafe(1);
3434
35export interface DeviceToolPaths {35export interface DeviceToolPaths {
36 readonly installDir: string;36 readonly installDir: string;
37 /** Absolute path of the tool's entry script, run with the server's Node. */37 /** Absolute path of the tool's entry script, run with a resolved Node runtime. */
38 readonly entryPath: string;38 readonly entryPath: string;
39 readonly sentinelPath: string;39 readonly sentinelPath: string;
40}40}
apps/server/src/device/LocalDeviceHost.test.ts+10-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as NodePath from "@effect/platform-node/NodePath";2import * as NodePath from "@effect/platform-node/NodePath";
3import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";3import {
4 HostProcessEnvironment,
5 HostProcessPlatform,
6 HostProcessIsExecutable,
7} from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";10import * as Path from "effect/Path";
...@@ -157,6 +161,11 @@ it.effect(...@@ -157,6 +161,11 @@ it.effect(
157 ),161 ),
158 );162 );
159 expect(yield* host.current).toBeNull();163 expect(yield* host.current).toBeNull();
164 const error = yield* host
165 .ensureReady(() => Effect.die("Must not install without Node"))
166 .pipe(Effect.flip, Effect.provideService(HostProcessIsExecutable, true));
167 expect(error.message).toContain("Local device support requires Node.js");
168 expect(error.message).toContain("Install Node.js");
160 yield* host.stop;169 yield* host.stop;
161 expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false);170 expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false);
162 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),171 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
apps/server/src/device/LocalDeviceHost.ts+32-13
...@@ -18,6 +18,10 @@ import {...@@ -18,6 +18,10 @@ import {
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { waitForHttpReady } from "@t3tools/shared/httpReadiness";19import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import {
22 resolveNodeExecutable,
23 type NodeRuntimeUnavailableError,
24} from "@t3tools/shared/nodeRuntime";
21import * as NetService from "@t3tools/shared/Net";25import * as NetService from "@t3tools/shared/Net";
22import { isCommandAvailable } from "@t3tools/shared/shell";26import { isCommandAvailable } from "@t3tools/shared/shell";
23import * as Clock from "effect/Clock";27import * as Clock from "effect/Clock";
...@@ -76,6 +80,7 @@ const AgentDeviceDaemonFile = Schema.Struct({...@@ -76,6 +80,7 @@ const AgentDeviceDaemonFile = Schema.Struct({
76const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));80const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
7781
78interface HubProcess {82interface HubProcess {
83 readonly nodePath: string;
79 readonly child: ChildProcessSpawner.ChildProcessHandle;84 readonly child: ChildProcessSpawner.ChildProcessHandle;
80 readonly scope: Scope.Closeable;85 readonly scope: Scope.Closeable;
81 readonly origin: string;86 readonly origin: string;
...@@ -310,6 +315,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -310,6 +315,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
310315
311 const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (316 const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (
312 hubTool: DeviceToolPaths,317 hubTool: DeviceToolPaths,
318 nodePath: string,
313 ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {319 ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {
314 yield* reapStaleHub;320 yield* reapStaleHub;
315 yield* fs321 yield* fs
...@@ -330,7 +336,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -330,7 +336,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
330 const child = yield* spawner336 const child = yield* spawner
331 .spawn(337 .spawn(
332 ChildProcess.make(338 ChildProcess.make(
333 process.execPath,339 nodePath,
334 [340 [
335 hubTool.entryPath,341 hubTool.entryPath,
336 "--port",342 "--port",
...@@ -361,7 +367,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -361,7 +367,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
361 ),367 ),
362 );368 );
363 const startedAtMillis = yield* Clock.currentTimeMillis;369 const startedAtMillis = yield* Clock.currentTimeMillis;
364 const hub: HubProcess = { child, scope, origin, startedAtMillis };370 const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath };
365 yield* Effect.forkIn(observeHubOutput(hub), scope);371 yield* Effect.forkIn(observeHubOutput(hub), scope);
366 yield* waitForHttpReady({372 yield* waitForHttpReady({
367 baseUrl: origin,373 baseUrl: origin,
...@@ -418,7 +424,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -418,7 +424,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
418 Effect.gen(function* () {424 Effect.gen(function* () {
419 const current = yield* Ref.get(runningRef);425 const current = yield* Ref.get(runningRef);
420 if (current?.hub.child.pid !== hub.child.pid) return;426 if (current?.hub.child.pid !== hub.child.pid) return;
421 const replacement = yield* spawnHub(hubTool);427 const replacement = yield* spawnHub(hubTool, hub.nodePath);
422 yield* Ref.set(runningRef, { ...current, hub: replacement });428 yield* Ref.set(runningRef, { ...current, hub: replacement });
423 yield* Effect.forkDetach(superviseHub(replacement, hubTool));429 yield* Effect.forkDetach(superviseHub(replacement, hubTool));
424 }),430 }),
...@@ -441,6 +447,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -441,6 +447,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
441 */447 */
442 const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (448 const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (
443 agentTool: DeviceToolPaths,449 agentTool: DeviceToolPaths,
450 nodePath: string,
444 ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> {451 ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> {
445 const stateDir = agentDeviceStateDir(path, config.stateDir);452 const stateDir = agentDeviceStateDir(path, config.stateDir);
446 yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore);453 yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore);
...@@ -481,7 +488,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -481,7 +488,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
481 // daemon and blocks until it answers. `devices` is the cheapest one.488 // daemon and blocks until it answers. `devices` is the cheapest one.
482 yield* runner489 yield* runner
483 .run({490 .run({
484 command: process.execPath,491 command: nodePath,
485 args: [agentTool.entryPath, "devices", "--json"],492 args: [agentTool.entryPath, "devices", "--json"],
486 env: daemonEnvironment,493 env: daemonEnvironment,
487 timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),494 timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),
...@@ -502,11 +509,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -502,11 +509,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
502 }509 }
503 });510 });
504511
505 const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) =>512 const stopAgentDeviceDaemon = (
513 agentTool: { readonly entryPath: string; readonly nodePath: string } | null,
514 ) =>
506 agentTool515 agentTool
507 ? runner516 ? runner
508 .run({517 .run({
509 command: process.execPath,518 command: agentTool.nodePath,
510 args: [519 args: [
511 agentTool.entryPath,520 agentTool.entryPath,
512 "daemon",521 "daemon",
...@@ -521,17 +530,22 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -521,17 +530,22 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
521 .pipe(Effect.ignore)530 .pipe(Effect.ignore)
522 : Effect.void;531 : Effect.void;
523532
524 let agentToolRef: DeviceToolPaths | null = null;533 let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null;
525534
526 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (535 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (
527 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,536 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
528 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError> {537 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
529 const running = yield* Ref.get(runningRef);538 const running = yield* Ref.get(runningRef);
530 if (running) {539 if (running) {
531 const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));540 const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));
532 if (alive) return running;541 if (alive) return running;
533 yield* Ref.set(runningRef, null);542 yield* Ref.set(runningRef, null);
534 }543 }
544 const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe(
545 Effect.provideService(FileSystem.FileSystem, fs),
546 Effect.provideService(Path.Path, path),
547 Effect.provideService(HostProcessPlatform, hostPlatform),
548 );
535 const installed = yield* isDeviceHubInstalled(config.baseDir).pipe(549 const installed = yield* isDeviceHubInstalled(config.baseDir).pipe(
536 Effect.provideService(FileSystem.FileSystem, fs),550 Effect.provideService(FileSystem.FileSystem, fs),
537 Effect.provideService(Path.Path, path),551 Effect.provideService(Path.Path, path),
...@@ -551,7 +565,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -551,7 +565,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
551 ),565 ),
552 );566 );
553 yield* onPhase("starting");567 yield* onPhase("starting");
554 const hub = yield* spawnHub(hubTool);568 const hub = yield* spawnHub(hubTool, nodePath);
555 const candidate = helperPaths(hubTool);569 const candidate = helperPaths(hubTool);
556 const [axExists, cliExists] = yield* Effect.all([570 const [axExists, cliExists] = yield* Effect.all([
557 fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),571 fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
...@@ -577,7 +591,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -577,7 +591,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
577 const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) =>591 const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) =>
578 startLock.withPermits(1)(592 startLock.withPermits(1)(
579 Effect.gen(function* (): Generator<593 Effect.gen(function* (): Generator<
580 Effect.Effect<unknown, DeviceHost.DeviceHostError | DeviceHost.DeviceHostTimeoutError>,594 Effect.Effect<
595 unknown,
596 | DeviceHost.DeviceHostError
597 | DeviceHost.DeviceHostTimeoutError
598 | NodeRuntimeUnavailableError
599 >,
581 DeviceHost.DeviceHostAgentReady600 DeviceHost.DeviceHostAgentReady
582 > {601 > {
583 const running = yield* ensureHubReady(onPhase);602 const running = yield* ensureHubReady(onPhase);
...@@ -600,9 +619,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -600,9 +619,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
600 }),619 }),
601 ),620 ),
602 );621 );
603 agentToolRef = agentTool;622 agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath };
604 yield* onPhase("starting");623 yield* onPhase("starting");
605 const agentDevice = yield* startAgentDeviceDaemon(agentTool);624 const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath);
606 const next = { ...running, agentDevice };625 const next = { ...running, agentDevice };
607 yield* Ref.set(runningRef, next);626 yield* Ref.set(runningRef, next);
608 return { ...toReady(next), agentDevice };627 return { ...toReady(next), agentDevice };
...@@ -652,7 +671,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -652,7 +671,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
652671
653 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({672 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
654 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,673 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
655 nodePath: process.execPath,674 nodePath: running.hub.nodePath,
656 run,675 run,
657 helpers: running.helpers,676 helpers: running.helpers,
658 });677 });
apps/server/src/mcp/toolkits/device/handlers.ts+7-2
...@@ -12,6 +12,7 @@ import * as Path from "effect/Path";...@@ -12,6 +12,7 @@ import * as Path from "effect/Path";
12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
13import { ServerConfig } from "../../../config.ts";13import { ServerConfig } from "../../../config.ts";
14import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts";14import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts";
15import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
1516
16import * as DeviceService from "../../../device/DeviceService.ts";17import * as DeviceService from "../../../device/DeviceService.ts";
17import * as McpInvocationContext from "../../McpInvocationContext.ts";18import * as McpInvocationContext from "../../McpInvocationContext.ts";
...@@ -182,9 +183,13 @@ const handlers = {...@@ -182,9 +183,13 @@ const handlers = {
182 stateDir: config.stateDir,183 stateDir: config.stateDir,
183 }).pipe(184 }).pipe(
184 Effect.mapError(185 Effect.mapError(
185 () =>186 (error) =>
186 new DeviceToolUnavailableError({187 new DeviceToolUnavailableError({
187 reason: "Could not prepare the agent-device launcher.",188 reason:
189 error._tag === "NodeRuntimeUnavailableError"
190 ? nodeRuntimeUnavailableMessage("Device automation")
191 : "Could not prepare the agent-device launcher.",
192 cause: error,
188 }),193 }),
189 ),194 ),
190 );195 );
apps/server/src/provider/AntigravityInstallation.test.ts+14
...@@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest";...@@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest";
3import {3import {
4 HostProcessArchitecture,4 HostProcessArchitecture,
5 HostProcessEnvironment,5 HostProcessEnvironment,
6 HostProcessIsExecutable,
6 HostProcessPlatform,7 HostProcessPlatform,
7} from "@t3tools/shared/hostProcess";8} from "@t3tools/shared/hostProcess";
8import * as Deferred from "effect/Deferred";9import * as Deferred from "effect/Deferred";
...@@ -242,6 +243,19 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")...@@ -242,6 +243,19 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")
242});243});
243244
244it.layer(NodeServices.layer)("Antigravity installation", (it) => {245it.layer(NodeServices.layer)("Antigravity installation", (it) => {
246 it.effect("reports missing Node before downloading the standalone provider runtime", () =>
247 Effect.gen(function* () {
248 const { installation, requests, validations } = yield* makeHarness();
249 yield* installation.start;
250 expect(yield* terminalState(installation)).toMatchObject({
251 phase: "failed",
252 message: expect.stringContaining("Install Node.js"),
253 });
254 expect(requests).toEqual([]);
255 expect(validations).toEqual([]);
256 }).pipe(Effect.provideService(HostProcessIsExecutable, true)),
257 );
258
245 it.effect("verifies both files before activating a streamed download", () =>259 it.effect("verifies both files before activating a streamed download", () =>
246 Effect.gen(function* () {260 Effect.gen(function* () {
247 const enteredValidation = yield* Deferred.make<void>();261 const enteredValidation = yield* Deferred.make<void>();
apps/server/src/provider/AntigravityInstallation.ts+9
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 HostProcessEnvironment,6 HostProcessEnvironment,
7 HostProcessPlatform,7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";8} from "@t3tools/shared/hostProcess";
9import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
9import * as Clock from "effect/Clock";10import * as Clock from "effect/Clock";
10import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
11import * as Context from "effect/Context";12import * as Context from "effect/Context";
...@@ -517,6 +518,14 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma...@@ -517,6 +518,14 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
517518
518 const install = Effect.fn("AntigravityInstallation.install")(519 const install = Effect.fn("AntigravityInstallation.install")(
519 function* (asset: AntigravityReleaseAsset) {520 function* (asset: AntigravityReleaseAsset) {
521 yield* resolveNodeExecutable("Antigravity", environment).pipe(
522 Effect.provideService(FileSystem.FileSystem, fs),
523 Effect.provideService(Path.Path, path),
524 Effect.provideService(HostProcessPlatform, platform),
525 Effect.mapError((cause) =>
526 installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause),
527 ),
528 );
520 const report = (phase: ProviderInstallState["phase"], message: string | null) =>529 const report = (phase: ProviderInstallState["phase"], message: string | null) =>
521 SubscriptionRef.update(state, (current) => ({ ...current, phase, message }));530 SubscriptionRef.update(state, (current) => ({ ...current, phase, message }));
522 yield* fs.makeDirectory(versionsDirectory, { recursive: true });531 yield* fs.makeDirectory(versionsDirectory, { recursive: true });
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+16
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8import {8import {
9 HostProcessEnvironment,9 HostProcessEnvironment,
10 HostProcessExecutablePath,10 HostProcessExecutablePath,
11 HostProcessIsExecutable,
11 HostProcessPlatform,12 HostProcessPlatform,
12} from "@t3tools/shared/hostProcess";13} from "@t3tools/shared/hostProcess";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
...@@ -260,6 +261,21 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {...@@ -260,6 +261,21 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {
260);261);
261262
262it.layer(testLayer)("AntigravityDriver", (it) => {263it.layer(testLayer)("AntigravityDriver", (it) => {
264 it.effect.skipIf(windowsHost)(
265 "preserves the Node install message when starting a standalone provider",
266 () =>
267 Effect.gen(function* () {
268 const h = yield* makeHarness();
269 const error = yield* h.refresh().pipe(Effect.flip);
270 expect(error.detail).toContain("Install Node.js");
271 expect(h.launches).toEqual([]);
272 }).pipe(
273 Effect.scoped,
274 Effect.provideService(HostProcessIsExecutable, true),
275 Effect.provideService(HostProcessEnvironment, { PATH: "" }),
276 ),
277 );
278
263 it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () =>279 it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () =>
264 Effect.gen(function* () {280 Effect.gen(function* () {
265 const h = yield* makeHarness();281 const h = yield* makeHarness();
apps/server/src/provider/Drivers/AntigravityDriver.ts+17-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts";1import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts";
2import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";2import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import {
5 NodeRuntimeUnavailableError,
6 nodeRuntimeUnavailableMessage,
7} from "@t3tools/shared/nodeRuntime";
4import * as Crypto from "effect/Crypto";8import * as Crypto from "effect/Crypto";
5import * as Deferred from "effect/Deferred";9import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
...@@ -59,6 +63,7 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra...@@ -59,6 +63,7 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra
5963
60const DRIVER = ProviderDriverKind.make("antigravity");64const DRIVER = ProviderDriverKind.make("antigravity");
61const decodeSettings = Schema.decodeSync(AntigravitySettings);65const decodeSettings = Schema.decodeSync(AntigravitySettings);
66const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError);
6267
63export type AntigravityDriverEnv =68export type AntigravityDriverEnv =
64 | AntigravityInstallation69 | AntigravityInstallation
...@@ -164,6 +169,16 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD...@@ -164,6 +169,16 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
164 Effect.provideService(FileSystem.FileSystem, fileSystem),169 Effect.provideService(FileSystem.FileSystem, fileSystem),
165 Effect.provideService(Path.Path, path),170 Effect.provideService(Path.Path, path),
166 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),171 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
172 Effect.mapError((cause) =>
173 isNodeRuntimeUnavailableError(cause.cause)
174 ? new ProviderSetupError({
175 instanceId,
176 operation: "start",
177 detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"),
178 cause,
179 })
180 : cause,
181 ),
167 );182 );
168 // Each process unpacks into its own directory that dies with the183 // Each process unpacks into its own directory that dies with the
169 // runtime scope, after the child is killed. A shared directory would184 // runtime scope, after the child is killed. A shared directory would
...@@ -422,7 +437,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD...@@ -422,7 +437,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
422 instanceId,437 instanceId,
423 detail: isAntigravitySignInRequiredError(cause)438 detail: isAntigravitySignInRequiredError(cause)
424 ? "Sign in to Antigravity in provider settings before refreshing models."439 ? "Sign in to Antigravity in provider settings before refreshing models."
425 : cause._tag === "ProviderSetupError" && cause.operation === "configure"440 : cause._tag === "ProviderSetupError" &&
441 (cause.operation === "configure" || cause.operation === "start")
426 ? cause.detail442 ? cause.detail
427 : "Could not refresh Antigravity models. The previous model list is unchanged.",443 : "Could not refresh Antigravity models. The previous model list is unchanged.",
428 cause,444 cause,
apps/server/src/provider/antigravityAuthSupport.test.ts+47-1
...@@ -3,7 +3,11 @@ import * as NodeChildProcess from "node:child_process";...@@ -3,7 +3,11 @@ import * as NodeChildProcess from "node:child_process";
33
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { ProviderInstanceId } from "@t3tools/contracts";5import { ProviderInstanceId } from "@t3tools/contracts";
6import { HostProcessPlatform } from "@t3tools/shared/hostProcess";6import {
7 HostProcessExecutablePath,
8 HostProcessIsExecutable,
9 HostProcessPlatform,
10} from "@t3tools/shared/hostProcess";
7import { describe, expect, it } from "@effect/vitest";11import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";13import * as FileSystem from "effect/FileSystem";
...@@ -531,6 +535,48 @@ describe("Antigravity stderr compatibility", () => {...@@ -531,6 +535,48 @@ describe("Antigravity stderr compatibility", () => {
531});535});
532536
533it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {537it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {
538 it.effect("runs the browser helper with installed Node in standalone builds", () =>
539 Effect.gen(function* () {
540 const fs = yield* FileSystem.FileSystem;
541 const path = yield* Path.Path;
542 const directory = yield* fs.makeTempDirectoryScoped();
543 const profile = yield* prepareAntigravityProfile({
544 profileDirectory: path.join(directory, "profile"),
545 baseEnv: { PATH: path.dirname(process.execPath) },
546 });
547 expect(profile.browserCommand).not.toContain("/packaged/t3");
548 expect(yield* fs.exists(profile.acpDirectory)).toBe(true);
549 }).pipe(
550 Effect.provideService(HostProcessIsExecutable, true),
551 Effect.provideService(HostProcessExecutablePath, "/packaged/t3"),
552 ),
553 );
554
555 it.effect("reports missing Node before creating the standalone sign-in profile", () =>
556 Effect.gen(function* () {
557 const fs = yield* FileSystem.FileSystem;
558 const path = yield* Path.Path;
559 const directory = yield* fs.makeTempDirectoryScoped();
560 const profileDirectory = path.join(directory, "profile");
561 const result = yield* prepareAntigravityProfile({
562 profileDirectory,
563 baseEnv: { PATH: "" },
564 }).pipe(Effect.result);
565 expect(Result.isFailure(result)).toBe(true);
566 if (Result.isFailure(result)) {
567 expect(result.failure).toMatchObject({
568 _tag: "AcpTransportError",
569 detail: expect.stringContaining("Install Node.js"),
570 cause: {
571 _tag: "NodeRuntimeUnavailableError",
572 cause: { _tag: "CommandResolutionError" },
573 },
574 });
575 }
576 expect(yield* fs.exists(profileDirectory)).toBe(false);
577 }).pipe(Effect.provideService(HostProcessIsExecutable, true)),
578 );
579
534 it.effect("preflights the no-browser helper and creates private directories only", () =>580 it.effect("preflights the no-browser helper and creates private directories only", () =>
535 Effect.gen(function* () {581 Effect.gen(function* () {
536 const fs = yield* FileSystem.FileSystem;582 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/antigravityAuthSupport.ts+13-2
...@@ -5,7 +5,8 @@ import * as NodeFSP from "node:fs/promises";...@@ -5,7 +5,8 @@ import * as NodeFSP from "node:fs/promises";
5import * as NodePath from "node:path";5import * as NodePath from "node:path";
66
7import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts";7import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts";
8import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";12import * as Path from "effect/Path";
...@@ -307,7 +308,17 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(...@@ -307,7 +308,17 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(
307 const platform = input.platform ?? (yield* HostProcessPlatform);308 const platform = input.platform ?? (yield* HostProcessPlatform);
308 const userHome =309 const userHome =
309 input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env);310 input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env);
310 const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath);311 const runtimeExecutablePath =
312 input.runtimeExecutablePath ??
313 (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe(
314 Effect.mapError(
315 (cause) =>
316 new AcpErrors.AcpTransportError({
317 detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"),
318 cause,
319 }),
320 ),
321 ));
311 const helperExecutable =322 const helperExecutable =
312 platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath;323 platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath;
313 const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"];324 const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"];
packages/contracts/src/device.ts+2
...@@ -340,6 +340,7 @@ export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUna...@@ -340,6 +340,7 @@ export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUna
340 {340 {
341 hostId: DeviceHostId,341 hostId: DeviceHostId,
342 reason: Schema.String,342 reason: Schema.String,
343 cause: Schema.optional(Schema.Defect()),
343 },344 },
344) {345) {
345 override get message(): string {346 override get message(): string {
...@@ -527,6 +528,7 @@ export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUna...@@ -527,6 +528,7 @@ export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUna
527 "DeviceToolUnavailableError",528 "DeviceToolUnavailableError",
528 {529 {
529 reason: Schema.String,530 reason: Schema.String,
531 cause: Schema.optional(Schema.Defect()),
530 },532 },
531) {533) {
532 override get message(): string {534 override get message(): string {
packages/shared/package.json+4
...@@ -267,6 +267,10 @@...@@ -267,6 +267,10 @@
267 "types": "./src/chatList.ts",267 "types": "./src/chatList.ts",
268 "import": "./src/chatList.ts"268 "import": "./src/chatList.ts"
269 },269 },
270 "./nodeRuntime": {
271 "types": "./src/nodeRuntime.ts",
272 "import": "./src/nodeRuntime.ts"
273 },
270 "./hostProcess": {274 "./hostProcess": {
271 "types": "./src/hostProcess.ts",275 "types": "./src/hostProcess.ts",
272 "import": "./src/hostProcess.ts"276 "import": "./src/hostProcess.ts"
packages/shared/src/nodeRuntime.test.ts created+193
...@@ -0,0 +1,193 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Result from "effect/Result";
7
8import {
9 HostProcessExecutablePath,
10 HostProcessIsExecutable,
11 HostProcessPlatform,
12} from "./hostProcess.ts";
13import { resolveNodeExecutable } from "./nodeRuntime.ts";
14import { symlinksSupported } from "./testing/symlinks.ts";
15
16describe("Node runtime selection", () => {
17 it.effect("keeps the current Node or Electron runtime without requiring Node on PATH", () =>
18 Effect.gen(function* () {
19 for (const executable of ["/runtime/node", "/Applications/T3 Code.app/Electron"]) {
20 expect(
21 yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe(
22 Effect.provideService(HostProcessExecutablePath, executable),
23 Effect.provideService(HostProcessIsExecutable, false),
24 ),
25 ).toBe(executable);
26 }
27 }).pipe(Effect.provide(NodeServices.layer)),
28 );
29
30 it.effect("uses installed Node instead of the standalone T3 executable", () =>
31 Effect.gen(function* () {
32 const path = yield* Path.Path;
33 expect(
34 yield* resolveNodeExecutable("Local device support", {
35 PATH: path.dirname(process.execPath),
36 }),
37 ).toBe(process.execPath);
38 }).pipe(
39 Effect.provideService(HostProcessExecutablePath, "/packaged/t3"),
40 Effect.provideService(HostProcessIsExecutable, true),
41 Effect.provide(NodeServices.layer),
42 ),
43 );
44
45 it.effect("explains how to install Node when a standalone helper has no runtime", () =>
46 Effect.gen(function* () {
47 const error = yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe(
48 Effect.flip,
49 );
50 expect(error._tag).toBe("NodeRuntimeUnavailableError");
51 expect(error.message).toContain("Local device support requires Node.js");
52 expect(error.message).toContain("Install Node.js");
53 }).pipe(
54 Effect.provideService(HostProcessIsExecutable, true),
55 Effect.provide(NodeServices.layer),
56 ),
57 );
58
59 it.effect("finds a newly installed runtime immediately after a failed lookup", () =>
60 Effect.gen(function* () {
61 const fs = yield* FileSystem.FileSystem;
62 const path = yield* Path.Path;
63 const directory = yield* fs.makeTempDirectoryScoped();
64 const platform = yield* HostProcessPlatform;
65 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
66 const env = { PATH: directory };
67 expect(
68 Result.isFailure(
69 yield* resolveNodeExecutable("Local device support", env).pipe(Effect.result),
70 ),
71 ).toBe(true);
72 yield* fs.copyFile(process.execPath, node);
73 yield* fs.chmod(node, 0o755);
74 expect(yield* resolveNodeExecutable("Local device support", env)).toBe(node);
75 }).pipe(
76 Effect.scoped,
77 Effect.provideService(HostProcessExecutablePath, "/packaged/t3"),
78 Effect.provideService(HostProcessIsExecutable, true),
79 Effect.provide(NodeServices.layer),
80 ),
81 );
82
83 it.effect("uses node.exe even when Windows batch wrappers appear first on PATH", () =>
84 Effect.gen(function* () {
85 const fs = yield* FileSystem.FileSystem;
86 const path = yield* Path.Path;
87 const directory = yield* fs.makeTempDirectoryScoped();
88 const wrappers = path.join(directory, "wrappers");
89 const runtime = path.join(directory, "runtime");
90 yield* fs.makeDirectory(wrappers);
91 yield* fs.makeDirectory(runtime);
92 yield* fs.writeFileString(path.join(wrappers, "node.cmd"), "@echo off");
93 yield* fs.writeFileString(path.join(wrappers, "node.bat"), "@echo off");
94 const node = path.join(runtime, "node.exe");
95 yield* fs.copyFile(process.execPath, node);
96 expect(
97 yield* resolveNodeExecutable("Local device support", {
98 PATH: `${wrappers};${runtime}`,
99 PATHEXT: ".CMD;.BAT",
100 }),
101 ).toBe(node);
102 }).pipe(
103 Effect.scoped,
104 Effect.provideService(HostProcessExecutablePath, "/packaged/t3"),
105 Effect.provideService(HostProcessIsExecutable, true),
106 Effect.provideService(HostProcessPlatform, "win32"),
107 Effect.provide(NodeServices.layer),
108 ),
109 );
110
111 it.effect("reports install guidance when Windows only has batch runtime wrappers", () =>
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115 const directory = yield* fs.makeTempDirectoryScoped();
116 yield* fs.writeFileString(path.join(directory, "node.cmd"), "@echo off");
117 yield* fs.writeFileString(path.join(directory, "node.bat"), "@echo off");
118 const error = yield* resolveNodeExecutable("Local device support", {
119 PATH: directory,
120 PATHEXT: ".CMD;.BAT;.EXE",
121 }).pipe(Effect.flip);
122 expect(error._tag).toBe("NodeRuntimeUnavailableError");
123 expect(error.message).toContain("Install Node.js");
124 }).pipe(
125 Effect.scoped,
126 Effect.provideService(HostProcessIsExecutable, true),
127 Effect.provideService(HostProcessPlatform, "win32"),
128 Effect.provide(NodeServices.layer),
129 ),
130 );
131
132 it.effect("rejects a hard-linked node alias pointing back at the standalone app", () =>
133 Effect.gen(function* () {
134 const fs = yield* FileSystem.FileSystem;
135 const path = yield* Path.Path;
136 const directory = yield* fs.makeTempDirectoryScoped();
137 const platform = yield* HostProcessPlatform;
138 const executable = path.join(directory, platform === "win32" ? "t3.exe" : "t3");
139 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
140 yield* fs.writeFileString(executable, "standalone executable fixture");
141 yield* fs.chmod(executable, 0o755);
142 yield* fs.link(executable, node);
143 const error = yield* resolveNodeExecutable("Local device support", { PATH: directory }).pipe(
144 Effect.provideService(HostProcessExecutablePath, executable),
145 Effect.flip,
146 );
147 expect(error._tag).toBe("NodeRuntimeUnavailableError");
148 expect(error.message).toContain("Install Node.js");
149 }).pipe(
150 Effect.scoped,
151 Effect.provideService(HostProcessIsExecutable, true),
152 Effect.provide(NodeServices.layer),
153 ),
154 );
155
156 it.effect.skipIf(!symlinksSupported)("preserves the node alias used by runtime launchers", () =>
157 Effect.gen(function* () {
158 const fs = yield* FileSystem.FileSystem;
159 const path = yield* Path.Path;
160 const directory = yield* fs.makeTempDirectoryScoped();
161 const platform = yield* HostProcessPlatform;
162 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
163 yield* fs.symlink(process.execPath, node);
164 expect(yield* resolveNodeExecutable("Local device support", { PATH: directory })).toBe(node);
165 }).pipe(
166 Effect.scoped,
167 Effect.provideService(HostProcessExecutablePath, "/packaged/t3"),
168 Effect.provideService(HostProcessIsExecutable, true),
169 Effect.provide(NodeServices.layer),
170 ),
171 );
172
173 it.effect.skipIf(!symlinksSupported)(
174 "rejects a node alias pointing back at the standalone app",
175 () =>
176 Effect.gen(function* () {
177 const fs = yield* FileSystem.FileSystem;
178 const path = yield* Path.Path;
179 const directory = yield* fs.makeTempDirectoryScoped();
180 const platform = yield* HostProcessPlatform;
181 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
182 yield* fs.symlink(process.execPath, node);
183 const error = yield* resolveNodeExecutable("Local device support", {
184 PATH: directory,
185 }).pipe(Effect.flip);
186 expect(error.message).toContain("Install Node.js");
187 }).pipe(
188 Effect.scoped,
189 Effect.provideService(HostProcessIsExecutable, true),
190 Effect.provide(NodeServices.layer),
191 ),
192 );
193});
packages/shared/src/nodeRuntime.ts created+78
...@@ -0,0 +1,78 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import {
8 HostProcessEnvironment,
9 HostProcessExecutablePath,
10 HostProcessIsExecutable,
11 HostProcessPlatform,
12} from "./hostProcess.ts";
13import { CommandResolutionCache, resolveCommandPath } from "./shell.ts";
14
15const NodeRuntimeFeature = Schema.Literals([
16 "Local device support",
17 "Device automation",
18 "Antigravity",
19 "Antigravity sign-in",
20]);
21
22export const nodeRuntimeUnavailableMessage = (feature: typeof NodeRuntimeFeature.Type): string =>
23 `${feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`;
24
25export class NodeRuntimeUnavailableError extends Schema.TaggedError<NodeRuntimeUnavailableError>()(
26 "NodeRuntimeUnavailableError",
27 { feature: NodeRuntimeFeature, cause: Schema.optional(Schema.Defect()) },
28) {
29 override get message(): string {
30 return nodeRuntimeUnavailableMessage(this.feature);
31 }
32}
33
34/** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */
35export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutable")(function* (
36 feature: typeof NodeRuntimeFeature.Type,
37 environment?: NodeJS.ProcessEnv,
38) {
39 const executablePath = yield* HostProcessExecutablePath;
40 if (!(yield* HostProcessIsExecutable)) return executablePath;
41
42 const fs = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const platform = yield* HostProcessPlatform;
45 const env = environment ?? (yield* HostProcessEnvironment);
46 const nodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", {
47 // Batch wrappers require a shell; helper callers launch the runtime directly.
48 env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env,
49 }).pipe(
50 // Refresh immediately after the user installs Node and retries setup.
51 Effect.provideService(CommandResolutionCache, new Map()),
52 Effect.map((commandPath) => path.resolve(commandPath)),
53 Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })),
54 );
55 // A launcher or symlink named node must not point back at the standalone app.
56 const resolvedPath = yield* fs
57 .realPath(nodePath)
58 .pipe(Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })));
59 if (resolvedPath === executablePath) return yield* new NodeRuntimeUnavailableError({ feature });
60 const [hostInfo, nodeInfo] = yield* Effect.all([
61 fs.stat(executablePath).pipe(Effect.option),
62 fs.stat(nodePath).pipe(Effect.option),
63 ]);
64 if (
65 Option.isSome(hostInfo) &&
66 Option.isSome(nodeInfo) &&
67 hostInfo.value.dev === nodeInfo.value.dev &&
68 Option.isSome(hostInfo.value.ino) &&
69 Option.isSome(nodeInfo.value.ino) &&
70 Number.isSafeInteger(hostInfo.value.ino.value) &&
71 hostInfo.value.ino.value > 0 &&
72 hostInfo.value.ino.value === nodeInfo.value.ino.value
73 ) {
74 return yield* new NodeRuntimeUnavailableError({ feature });
75 }
76 // Launchers such as Vite+ dispatch by argv[0]; keep the node name intact.
77 return nodePath;
78});