diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index 511ed6446fd3292e127e855ccdd6c8cdc2c7fa18..c1c470b3be540a654684e8bbb930f0bec5435c8b 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -106,7 +106,6 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(HostProcess.Platform, platform), - Effect.option, ); const run = (command: string, args: ReadonlyArray) => @@ -120,9 +119,9 @@ export const make = Effect.gen(function* () { if (runtimeDir === undefined || runtimeDir.length === 0) return undefined; const systemdRun = yield* resolve("systemd-run", hostEnvironment); const systemctl = yield* resolve("systemctl", hostEnvironment); - if (systemdRun._tag === "None" || systemctl._tag === "None") return undefined; + if (systemdRun === null || systemctl === null) return undefined; const probe = (options: ReadonlyArray) => - run(systemdRun.value, [ + run(systemdRun, [ "--user", "--scope", "--quiet", @@ -146,7 +145,7 @@ export const make = Effect.gen(function* () { return undefined; } const limits = agentSliceMemoryLimits(NodeOS.totalmem()); - const limited = yield* run(systemctl.value, [ + const limited = yield* run(systemctl, [ "--user", "set-property", "--runtime", @@ -165,8 +164,8 @@ export const make = Effect.gen(function* () { oomPolicy: scopeOptions.length > 0, }); return { - systemdRun: systemdRun.value, - systemctl: systemctl.value, + systemdRun, + systemctl, runtimeDir, scopeOptions, }; @@ -294,9 +293,9 @@ export const make = Effect.gen(function* () { wrap: Effect.fn("AgentScope.wrap")(function* ({ command, args, name, threadId, env }) { const resolved = yield* resolve(command, env ?? hostEnvironment); // Leave unresolved commands alone so the spawn reports the missing binary. - if (resolved._tag === "None") return { command, args }; + if (resolved === null) return { command, args }; const scope = yield* systemd; - if (scope === undefined) return agentScopeCommand({ command: resolved.value, args }); + if (scope === undefined) return agentScopeCommand({ command: resolved, args }); const unit = `t3code-${name}-${NodeCrypto.randomUUID().slice(0, 8)}.scope`; if (threadId !== undefined) yield* track(scope.systemctl, threadId, unit); const systemdScope: AgentSystemdScope = { @@ -308,7 +307,7 @@ export const make = Effect.gen(function* () { // the unit even when it fails. options: threadId === undefined ? [...scope.scopeOptions, "--collect"] : scope.scopeOptions, }; - return agentScopeCommand({ command: resolved.value, args, scope: systemdScope }); + return agentScopeCommand({ command: resolved, args, scope: systemdScope }); }), oomKilled, clear, diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index fa7876b1e92a5f441d5e78ccbb0ab520c865c33e..ecc06e6796ecca73ba11a157561b9f2c28e2a5fb 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -335,6 +335,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); + if (executablePath === null) return null; // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name. const realExecutablePath = yield* fs.realPath(executablePath); // A PATH entry pointing into T3's download remains a managed installation. diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index a1be101b1436459f462241f8a19ecdcf0221001e..66d7baf7049ad05821b7593a507f52fc6c7c1ac0 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -592,7 +592,6 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { detail: expect.stringContaining("Install Node.js"), cause: { _tag: "NodeRuntimeUnavailableError", - cause: { _tag: "CommandResolutionError" }, }, }); } diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index f71f7c1ecfd8720fc0df750810e2fbd4492267cf..af72c11a19f005331ee1d6a14939522f664f8e47 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -238,4 +238,25 @@ describe("provider installation routing", () => { assert.deepEqual(harness.calls, ["remove", "refresh"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("keeps only the configured path when another instance's binary is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-install-route-" }); + const binaryPath = path.join(directory, "missing"); + const harness = yield* makeHarness({ + settings: { + providerInstances: { + [ProviderInstanceId.make("antigravity-work")]: { + driver, + config: { binaryPath }, + }, + }, + }, + }); + yield* harness.router.remove({ instanceId }); + assert.deepEqual(harness.protectedPaths(), [binaryPath]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index ab72575533390c176eb15c56ce808b9856ba0d23..b2ac6cf5ca87c61bb7d73e8a98f167546c10f5d6 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -141,12 +141,10 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu return []; } const binaryPath = entry.config.binaryPath.trim(); - return yield* resolveCommandPath(binaryPath, { + const resolved = yield* resolveCommandPath(binaryPath, { env: yield* mergeProviderInstanceEnvironment(entry.environment), - }).pipe( - Effect.map((resolved) => [binaryPath, resolved]), - Effect.orElseSucceed(() => [binaryPath]), - ); + }); + return resolved === null ? [binaryPath] : [binaryPath, resolved]; }), ); yield* installation diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index c625f067bd459842260b589a275b7ee399d320db..0f084d47b92bb5d0f669f9d461b022823d337350 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -498,9 +498,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const kegBrewPath = path.join(homebrew.prefix, "bin", "brew"); const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false))) ? kegBrewPath - : yield* resolveCommandPath("brew", { env: context.env }).pipe( - Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), - ); + : yield* resolveCommandPath("brew", { env: context.env }); if (!brewPath) { return fallback; } @@ -648,9 +646,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( // resolveCommandPath checks explicit paths for existence too, so a missing // binary always lands in the no-context branch and never gets an update // command it cannot run. - const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }).pipe( - Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), - ); + const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }); if (!resolvedCommandPath) { return yield* resolver.resolve(null); } diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts index 4ad752f43d0d9192b5caf94200aa6045d96a15d5..ef7872e38cade1b003f6ff11b7f46f1e3f80cd3f 100644 --- a/packages/shared/src/nodeRuntime.ts +++ b/packages/shared/src/nodeRuntime.ts @@ -76,15 +76,15 @@ export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutabl const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const env = environment ?? (yield* HostProcess.Environment); - const nodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", { + const resolvedNodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", { // Batch wrappers require a shell; helper callers launch the runtime directly. env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env, }).pipe( // Refresh immediately after the user installs Node and retries setup. Effect.provideService(CommandResolutionCache, new Map()), - Effect.map((commandPath) => path.resolve(commandPath)), - Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })), ); + if (resolvedNodePath === null) return yield* new NodeRuntimeUnavailableError({ feature }); + const nodePath = path.resolve(resolvedNodePath); // A launcher or symlink named node must not point back at the standalone app. const resolvedPath = yield* fs .realPath(nodePath) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 5f493c8044d33c32edddf12f4e167d5a91e91103..56c1b4fa4caf0febf829995234f355d3c36823d9 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,9 +2,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { describe, expect, it, vi } from "vite-plus/test"; import { @@ -41,6 +44,19 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); +const recordCommandTrace = Effect.fnUntraced(function* (effect: Effect.Effect) { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const result = yield* effect.pipe(Effect.withTracer(tracer), Effect.result); + return { result, spans }; +}); + describe("readPathFromLoginShell", () => { it("uses a shell-agnostic printenv PATH probe", () => { const execFile = vi.fn< @@ -338,6 +354,65 @@ describe("resolveKnownWindowsCliDirs", () => { }); effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { + it.effect.each(["win32", "linux"] as const)( + "records missing optional commands as successful lookups on %s", + (platform) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-command-trace-" }); + const env = { PATH: cwd, PATHEXT: ".CMD" }; + const checks = [ + isCommandAvailable("missing", { env }), + isCommandAvailable("missing", { env }), + isCommandAvailable("missing", { env: { ...env, PATH: "" } }), + isCommandAvailable(path.join(cwd, "missing.cmd"), { env }), + ]; + for (const check of checks) { + const { result, spans } = yield* recordCommandTrace(check); + expect(Result.getOrThrow(result)).toBe(false); + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.status._tag).toBe("Ended"); + if (span.status._tag === "Ended") { + expect(Exit.isSuccess(span.status.exit), span.name).toBe(true); + } + } + } + }).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(CommandResolutionCache, new Map()), + ), + ); + + it.effect.each(["win32", "linux"] as const)( + "records available commands as successful lookups on %s", + (platform) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-command-trace-" }); + const executable = path.join(cwd, platform === "win32" ? "available.cmd" : "available"); + yield* fs.writeFileString(executable, ""); + yield* fs.chmod(executable, 0o700); + const env = { PATH: cwd, PATHEXT: ".CMD" }; + for (const command of ["available", executable]) { + const { result, spans } = yield* recordCommandTrace(isCommandAvailable(command, { env })); + expect(Result.getOrThrow(result)).toBe(true); + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.status._tag).toBe("Ended"); + if (span.status._tag === "Ended") { + expect(Exit.isSuccess(span.status.exit), span.name).toBe(true); + } + } + } + }).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(CommandResolutionCache, new Map()), + ), + ); + it.effect("returns false when PATH is empty", () => Effect.gen(function* () { expect( @@ -350,13 +425,29 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { }); effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { - it.effect("fails when PATH is empty", () => + it.effect("records a successful lookup when a command is missing", () => + Effect.gen(function* () { + const { result, spans } = yield* recordCommandTrace( + resolveCommandPath("missing", { env: { PATH: "" } }), + ); + expect(Result.getOrThrow(result)).toBeNull(); + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.status._tag).toBe("Ended"); + if (span.status._tag === "Ended") { + expect(Exit.isSuccess(span.status.exit), span.name).toBe(true); + } + } + }), + ); + + it.effect("returns null when PATH is empty", () => Effect.gen(function* () { const result = yield* resolveCommandPath("definitely-not-installed", { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }).pipe(Effect.provideService(HostProcess.Platform, "win32"), Effect.result); + }).pipe(Effect.provideService(HostProcess.Platform, "win32")); - expect(result._tag).toBe("Failure"); + expect(result).toBeNull(); }), ); @@ -379,10 +470,9 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { }), }), ), - Effect.result, ); - expect(result._tag).toBe("Failure"); + expect(result).toBeNull(); return probed; }); @@ -448,13 +538,9 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { const executable = path.join(cwd, "appeared.CMD"); const options = { env: { PATH: `${cwd};${cwd}`, PATHEXT: ".CMD" } }; - expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe( - "Failure", - ); + expect(yield* resolveCommandPath("appeared", options)).toBeNull(); yield* fs.writeFileString(executable, "@echo off\n"); - expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe( - "Failure", - ); + expect(yield* resolveCommandPath("appeared", options)).toBeNull(); expect(yield* resolveCommandPath(executable, options)).toBe(executable); yield* TestClock.adjust("30 seconds"); expect(yield* resolveCommandPath("appeared", options)).toBe(executable); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 4d29841bfa8b1bd23277a8dea15d1d732d6ee7b2..d97fcd15fda64685f5cf28a3c5b232c6470ef5b9 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -5,7 +5,6 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -43,11 +42,6 @@ export type CommandAvailabilityChecker = ( options?: CommandAvailabilityOptions, ) => Effect.Effect; -export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ - readonly command: string; - readonly reason: "not-found"; -}> {} - const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; /** @@ -612,7 +606,7 @@ const isExecutableFile = Effect.fnUntraced(function* ( const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( command: string, options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, -): Effect.fn.Return { +): Effect.fn.Return { const path = yield* Path.Path; const platform = options.platform; const env = options.env ?? process.env; @@ -630,12 +624,12 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat return candidate; } } - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return null; } const pathValue = resolvePathEnvironmentVariable(env); if (pathValue.length === 0) { - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return null; } const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( @@ -645,9 +639,6 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat const nowNanos = yield* Clock.monotonicTimeNanos; const cached = cache.get(cacheKey); if (cached !== undefined && cached.expiresAtNanos > nowNanos) { - if (cached.resolvedPath === null) { - return yield* new CommandResolutionError({ command, reason: "not-found" }); - } return cached.resolvedPath; } @@ -680,7 +671,7 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat } } cacheCommandResolution(cache, cacheKey, null, nowNanos); - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return null; }); export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( @@ -800,10 +791,7 @@ export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function command: string, options: CommandAvailabilityOptions = {}, ) { - return yield* resolveCommandPath(command, options).pipe( - Effect.as(true), - Effect.catchTags({ CommandResolutionError: () => Effect.succeed(false) }), - ); + return (yield* resolveCommandPath(command, options)) !== null; }); export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray {