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 @@
11// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.
22/**
33 * 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 agent
4 * with a Node runtime. Prepended to provider subprocess PATHs so the agent
55 * types `agent-device …` and gets the version the injected instructions were
66 * written for, regardless of what is or is not globally installed.
77 */
88import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime";
910import * as Effect from "effect/Effect";
1011import * as FileSystem from "effect/FileSystem";
1112import * as Path from "effect/Path";
......@@ -20,9 +21,9 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio
2021 const fs = yield* FileSystem.FileSystem;
2122 const path = yield* Path.Path;
2223 const platform = yield* HostProcessPlatform;
24 const node = yield* resolveNodeExecutable("Device automation");
2325 const shimDir = path.join(input.stateDir, SHIM_DIR);
2426 yield* fs.makeDirectory(shimDir, { recursive: true });
25 const node = process.execPath;
2627 const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");
2728 yield* fs.writeFileString(
2829 launcherPath,
apps/server/src/device/AgentDeviceTarget.test.ts+11-2
......@@ -1,5 +1,9 @@
11// @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";
37import { describe, expect, it } from "@effect/vitest";
48import * as NodeChildProcess from "node:child_process";
59import * as NodeUtil from "node:util";
......@@ -84,6 +88,11 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
8488 ).rejects.toThrow("Call device_open first"),
8589 );
8690 }
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 ),
8897 );
8998});
apps/server/src/device/DeviceHost.ts+6-2
......@@ -18,6 +18,7 @@ import type {
1818import * as Context from "effect/Context";
1919import * as Effect from "effect/Effect";
2020import * as Schema from "effect/Schema";
21import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
2122
2223export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", {
2324 hostId: Schema.String,
......@@ -88,11 +89,14 @@ export class DeviceHost extends Context.Service<
8889 */
8990 readonly ensureReady: (
9091 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
91 ) => Effect.Effect<DeviceHostReady, DeviceHostError>;
92 ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;
9293 /** Installs and starts agent-device after the user grants agent access. */
9394 readonly ensureAgentReady: (
9495 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
95 ) => Effect.Effect<DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError>;
96 ) => Effect.Effect<
97 DeviceHostAgentReady,
98 DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError
99 >;
96100 /** Current endpoints when already running, without starting anything. */
97101 readonly current: Effect.Effect<DeviceHostReady | null>;
98102 /** 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";
1515import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1616import { ServerSettingsService } from "../serverSettings.ts";
1717import * as DeviceHost from "./DeviceHost.ts";
18import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
1819
1920import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts";
2021
......@@ -61,6 +62,7 @@ const fixture = Effect.fn("fixture")(function* (
6162 onBoot: Effect.Effect<void> = Effect.void,
6263 bootError?: string,
6364 failListAfterShutdown = false,
65 runtimeFailure?: NodeRuntimeUnavailableError,
6466) {
6567 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
6668 const starts: string[] = [];
......@@ -88,12 +90,14 @@ const fixture = Effect.fn("fixture")(function* (
8890 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
8991 ensureReady: (onPhase) =>
9092 Effect.gen(function* () {
93 if (runtimeFailure) return yield* runtimeFailure;
9194 starts.push("start");
9295 yield* onPhase("starting");
9396 return ready;
9497 }),
9598 ensureAgentReady: (onPhase) =>
9699 Effect.gen(function* () {
100 if (runtimeFailure) return yield* runtimeFailure;
97101 agentStarts.push("start");
98102 yield* onPhase("starting");
99103 return {
......@@ -188,6 +192,43 @@ const fixture = Effect.fn("fixture")(function* (
188192});
189193
190194describe("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
191232 it.effect("listing and provider startup do not start helpers before consent", () =>
192233 Effect.gen(function* () {
193234 const { service, starts, requests } = yield* fixture();
apps/server/src/device/DeviceService.ts+35-10
......@@ -36,6 +36,7 @@ import {
3636 type ThreadId,
3737} from "@t3tools/contracts";
3838import * as FileSystem from "effect/FileSystem";
39import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
3940import * as Path from "effect/Path";
4041import { ensureAgentDevice } from "./DeviceToolchain.ts";
4142import * as ServerConfig from "../config.ts";
......@@ -262,7 +263,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
262263 setHostStatus(host.id, { status: "failed", detail: error.message }),
263264 ),
264265 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 }),
266275 ),
267276 );
268277 if (hosts.get(host.id) !== host)
......@@ -305,7 +314,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
305314 setHostStatus(host.id, { status: "failed", detail: error.message }),
306315 ),
307316 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 }),
309328 ),
310329 );
311330 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
......@@ -1003,18 +1022,24 @@ export const make = Effect.gen(function* () {
10031022 );
10041023 return {
10051024 ...service,
1006 agentCli: ensureAgentDevice(config.baseDir).pipe(
1025 agentCli: resolveNodeExecutable("Device automation").pipe(
1026 Effect.flatMap(() => ensureAgentDevice(config.baseDir)),
10071027 Effect.provideService(FileSystem.FileSystem, fs),
10081028 Effect.provideService(Path.Path, path),
10091029 Effect.provideService(ProcessRunner.ProcessRunner, runner),
10101030 Effect.map((tool) => tool.entryPath),
1011 Effect.mapError(
1012 (error) =>
1013 new DeviceOperationError({
1014 operation: "install agent CLI",
1015 reason: "command_failed",
1016 cause: error,
1017 }),
1031 Effect.mapError((error) =>
1032 error._tag === "NodeRuntimeUnavailableError"
1033 ? new DeviceHostUnavailableError({
1034 hostId: LOCAL_DEVICE_HOST_ID,
1035 reason: nodeRuntimeUnavailableMessage("Device automation"),
1036 cause: error,
1037 })
1038 : new DeviceOperationError({
1039 operation: "install agent CLI",
1040 reason: "command_failed",
1041 cause: error,
1042 }),
10181043 ),
10191044 ),
10201045 };
apps/server/src/device/DeviceToolchain.ts+2-2
......@@ -4,7 +4,7 @@
44 * `expo-device-hub` streams simulator and emulator screens and `agent-device`
55 * drives them. Each is npm-installed separately after its matching consent
66 * step into `<baseDir>/tools/<name>/<version>` and executed from there with the
7 * server's own Node, never `npx`: an ephemeral
7 * resolved Node runtime, never `npx`: an ephemeral
88 * npx cache would make every first `device_open` after a reboot depend on the
99 * registry, and the pinned versions are part of the contract the injected
1010 * agent instructions describe.
......@@ -34,7 +34,7 @@ const installLock = Semaphore.makeUnsafe(1);
3434
3535export interface DeviceToolPaths {
3636 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. */
3838 readonly entryPath: string;
3939 readonly sentinelPath: string;
4040}
apps/server/src/device/LocalDeviceHost.test.ts+10-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "@effect/vitest";
22import * 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";
48import * as Effect from "effect/Effect";
59import * as Layer from "effect/Layer";
610import * as Path from "effect/Path";
......@@ -157,6 +161,11 @@ it.effect(
157161 ),
158162 );
159163 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");
160169 yield* host.stop;
161170 expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false);
162171 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
apps/server/src/device/LocalDeviceHost.ts+32-13
......@@ -18,6 +18,10 @@ import {
1818} from "@t3tools/contracts";
1919import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
2020import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import {
22 resolveNodeExecutable,
23 type NodeRuntimeUnavailableError,
24} from "@t3tools/shared/nodeRuntime";
2125import * as NetService from "@t3tools/shared/Net";
2226import { isCommandAvailable } from "@t3tools/shared/shell";
2327import * as Clock from "effect/Clock";
......@@ -76,6 +80,7 @@ const AgentDeviceDaemonFile = Schema.Struct({
7680const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
7781
7882interface HubProcess {
83 readonly nodePath: string;
7984 readonly child: ChildProcessSpawner.ChildProcessHandle;
8085 readonly scope: Scope.Closeable;
8186 readonly origin: string;
......@@ -310,6 +315,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
310315
311316 const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (
312317 hubTool: DeviceToolPaths,
318 nodePath: string,
313319 ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {
314320 yield* reapStaleHub;
315321 yield* fs
......@@ -330,7 +336,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
330336 const child = yield* spawner
331337 .spawn(
332338 ChildProcess.make(
333 process.execPath,
339 nodePath,
334340 [
335341 hubTool.entryPath,
336342 "--port",
......@@ -361,7 +367,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
361367 ),
362368 );
363369 const startedAtMillis = yield* Clock.currentTimeMillis;
364 const hub: HubProcess = { child, scope, origin, startedAtMillis };
370 const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath };
365371 yield* Effect.forkIn(observeHubOutput(hub), scope);
366372 yield* waitForHttpReady({
367373 baseUrl: origin,
......@@ -418,7 +424,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
418424 Effect.gen(function* () {
419425 const current = yield* Ref.get(runningRef);
420426 if (current?.hub.child.pid !== hub.child.pid) return;
421 const replacement = yield* spawnHub(hubTool);
427 const replacement = yield* spawnHub(hubTool, hub.nodePath);
422428 yield* Ref.set(runningRef, { ...current, hub: replacement });
423429 yield* Effect.forkDetach(superviseHub(replacement, hubTool));
424430 }),
......@@ -441,6 +447,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
441447 */
442448 const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (
443449 agentTool: DeviceToolPaths,
450 nodePath: string,
444451 ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> {
445452 const stateDir = agentDeviceStateDir(path, config.stateDir);
446453 yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore);
......@@ -481,7 +488,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
481488 // daemon and blocks until it answers. `devices` is the cheapest one.
482489 yield* runner
483490 .run({
484 command: process.execPath,
491 command: nodePath,
485492 args: [agentTool.entryPath, "devices", "--json"],
486493 env: daemonEnvironment,
487494 timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),
......@@ -502,11 +509,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
502509 }
503510 });
504511
505 const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) =>
512 const stopAgentDeviceDaemon = (
513 agentTool: { readonly entryPath: string; readonly nodePath: string } | null,
514 ) =>
506515 agentTool
507516 ? runner
508517 .run({
509 command: process.execPath,
518 command: agentTool.nodePath,
510519 args: [
511520 agentTool.entryPath,
512521 "daemon",
......@@ -521,17 +530,22 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
521530 .pipe(Effect.ignore)
522531 : Effect.void;
523532
524 let agentToolRef: DeviceToolPaths | null = null;
533 let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null;
525534
526535 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (
527536 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
528 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError> {
537 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
529538 const running = yield* Ref.get(runningRef);
530539 if (running) {
531540 const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));
532541 if (alive) return running;
533542 yield* Ref.set(runningRef, null);
534543 }
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 );
535549 const installed = yield* isDeviceHubInstalled(config.baseDir).pipe(
536550 Effect.provideService(FileSystem.FileSystem, fs),
537551 Effect.provideService(Path.Path, path),
......@@ -551,7 +565,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
551565 ),
552566 );
553567 yield* onPhase("starting");
554 const hub = yield* spawnHub(hubTool);
568 const hub = yield* spawnHub(hubTool, nodePath);
555569 const candidate = helperPaths(hubTool);
556570 const [axExists, cliExists] = yield* Effect.all([
557571 fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
......@@ -577,7 +591,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
577591 const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) =>
578592 startLock.withPermits(1)(
579593 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 >,
581600 DeviceHost.DeviceHostAgentReady
582601 > {
583602 const running = yield* ensureHubReady(onPhase);
......@@ -600,9 +619,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
600619 }),
601620 ),
602621 );
603 agentToolRef = agentTool;
622 agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath };
604623 yield* onPhase("starting");
605 const agentDevice = yield* startAgentDeviceDaemon(agentTool);
624 const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath);
606625 const next = { ...running, agentDevice };
607626 yield* Ref.set(runningRef, next);
608627 return { ...toReady(next), agentDevice };
......@@ -652,7 +671,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
652671
653672 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
654673 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
655 nodePath: process.execPath,
674 nodePath: running.hub.nodePath,
656675 run,
657676 helpers: running.helpers,
658677 });
apps/server/src/mcp/toolkits/device/handlers.ts+7-2
......@@ -12,6 +12,7 @@ import * as Path from "effect/Path";
1212import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1313import { ServerConfig } from "../../../config.ts";
1414import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts";
15import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
1516
1617import * as DeviceService from "../../../device/DeviceService.ts";
1718import * as McpInvocationContext from "../../McpInvocationContext.ts";
......@@ -182,9 +183,13 @@ const handlers = {
182183 stateDir: config.stateDir,
183184 }).pipe(
184185 Effect.mapError(
185 () =>
186 (error) =>
186187 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,
188193 }),
189194 ),
190195 );
apps/server/src/provider/AntigravityInstallation.test.ts+14
......@@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest";
33import {
44 HostProcessArchitecture,
55 HostProcessEnvironment,
6 HostProcessIsExecutable,
67 HostProcessPlatform,
78} from "@t3tools/shared/hostProcess";
89import * as Deferred from "effect/Deferred";
......@@ -242,6 +243,19 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")
242243});
243244
244245it.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
245259 it.effect("verifies both files before activating a streamed download", () =>
246260 Effect.gen(function* () {
247261 const enteredValidation = yield* Deferred.make<void>();
apps/server/src/provider/AntigravityInstallation.ts+9
......@@ -6,6 +6,7 @@ import {
66 HostProcessEnvironment,
77 HostProcessPlatform,
88} from "@t3tools/shared/hostProcess";
9import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
910import * as Clock from "effect/Clock";
1011import * as Cause from "effect/Cause";
1112import * as Context from "effect/Context";
......@@ -517,6 +518,14 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
517518
518519 const install = Effect.fn("AntigravityInstallation.install")(
519520 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 );
520529 const report = (phase: ProviderInstallState["phase"], message: string | null) =>
521530 SubscriptionRef.update(state, (current) => ({ ...current, phase, message }));
522531 yield* fs.makeDirectory(versionsDirectory, { recursive: true });
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+16
......@@ -8,6 +8,7 @@ import {
88import {
99 HostProcessEnvironment,
1010 HostProcessExecutablePath,
11 HostProcessIsExecutable,
1112 HostProcessPlatform,
1213} from "@t3tools/shared/hostProcess";
1314import * as Effect from "effect/Effect";
......@@ -260,6 +261,21 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {
260261);
261262
262263it.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
263279 it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () =>
264280 Effect.gen(function* () {
265281 const h = yield* makeHarness();
apps/server/src/provider/Drivers/AntigravityDriver.ts+17-1
......@@ -1,6 +1,10 @@
11import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts";
22import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
33import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import {
5 NodeRuntimeUnavailableError,
6 nodeRuntimeUnavailableMessage,
7} from "@t3tools/shared/nodeRuntime";
48import * as Crypto from "effect/Crypto";
59import * as Deferred from "effect/Deferred";
610import * as Effect from "effect/Effect";
......@@ -59,6 +63,7 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra
5963
6064const DRIVER = ProviderDriverKind.make("antigravity");
6165const decodeSettings = Schema.decodeSync(AntigravitySettings);
66const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError);
6267
6368export type AntigravityDriverEnv =
6469 | AntigravityInstallation
......@@ -164,6 +169,16 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
164169 Effect.provideService(FileSystem.FileSystem, fileSystem),
165170 Effect.provideService(Path.Path, path),
166171 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 ),
167182 );
168183 // Each process unpacks into its own directory that dies with the
169184 // runtime scope, after the child is killed. A shared directory would
......@@ -422,7 +437,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
422437 instanceId,
423438 detail: isAntigravitySignInRequiredError(cause)
424439 ? "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")
426442 ? cause.detail
427443 : "Could not refresh Antigravity models. The previous model list is unchanged.",
428444 cause,
apps/server/src/provider/antigravityAuthSupport.test.ts+47-1
......@@ -3,7 +3,11 @@ import * as NodeChildProcess from "node:child_process";
33
44import * as NodeServices from "@effect/platform-node/NodeServices";
55import { ProviderInstanceId } from "@t3tools/contracts";
6import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6import {
7 HostProcessExecutablePath,
8 HostProcessIsExecutable,
9 HostProcessPlatform,
10} from "@t3tools/shared/hostProcess";
711import { describe, expect, it } from "@effect/vitest";
812import * as Effect from "effect/Effect";
913import * as FileSystem from "effect/FileSystem";
......@@ -531,6 +535,48 @@ describe("Antigravity stderr compatibility", () => {
531535});
532536
533537it.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
534580 it.effect("preflights the no-browser helper and creates private directories only", () =>
535581 Effect.gen(function* () {
536582 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/antigravityAuthSupport.ts+13-2
......@@ -5,7 +5,8 @@ import * as NodeFSP from "node:fs/promises";
55import * as NodePath from "node:path";
66
77import 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";
910import * as Effect from "effect/Effect";
1011import * as FileSystem from "effect/FileSystem";
1112import * as Path from "effect/Path";
......@@ -307,7 +308,17 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(
307308 const platform = input.platform ?? (yield* HostProcessPlatform);
308309 const userHome =
309310 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 ));
311322 const helperExecutable =
312323 platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath;
313324 const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"];
packages/contracts/src/device.ts+2
......@@ -340,6 +340,7 @@ export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUna
340340 {
341341 hostId: DeviceHostId,
342342 reason: Schema.String,
343 cause: Schema.optional(Schema.Defect()),
343344 },
344345) {
345346 override get message(): string {
......@@ -527,6 +528,7 @@ export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUna
527528 "DeviceToolUnavailableError",
528529 {
529530 reason: Schema.String,
531 cause: Schema.optional(Schema.Defect()),
530532 },
531533) {
532534 override get message(): string {
packages/shared/package.json+4
......@@ -267,6 +267,10 @@
267267 "types": "./src/chatList.ts",
268268 "import": "./src/chatList.ts"
269269 },
270 "./nodeRuntime": {
271 "types": "./src/nodeRuntime.ts",
272 "import": "./src/nodeRuntime.ts"
273 },
270274 "./hostProcess": {
271275 "types": "./src/hostProcess.ts",
272276 "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});