diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index e7fcdcd68719e..417ab6b68ef79 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,6 +1,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { listLoginShellCandidates, + LoginShellEnvironment, mergePathEntries, readEnvironmentFromLoginShell, readPathFromLaunchctl, @@ -140,6 +141,27 @@ export const fixPath = Effect.fn("fixPath")(function* (options?: { ); }); +/** Re-reads the login shell PATH after startup; true when it added entries to PATH. */ +export const rereadLoginShellPath = Effect.fn("rereadLoginShellPath")(function* () { + const platform = yield* HostProcess.Platform; + if (platform !== "darwin" && platform !== "linux") return false; + const env = yield* HostProcess.Environment; + const readEnvironment = yield* LoginShellEnvironment; + for (const shell of listLoginShellCandidates(platform, env.SHELL)) { + const shellEnvironment = yield* readEnvironment(shell, ["PATH", "__MISE_DIFF"]); + const shellPath = shellEnvironment.PATH; + if (!shellPath) continue; + const current = new Set((env.PATH ?? "").split(":")); + if (shellPath.split(":").every((entry) => !entry.trim() || current.has(entry.trim()))) { + return false; + } + env.PATH = mergePathEntries(shellPath, env.PATH, platform); + if (shellEnvironment.__MISE_DIFF) env.__MISE_DIFF = shellEnvironment.__MISE_DIFF; + return true; + } + return false; +}); + export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 3f4df0eb35d18..b00c5b41e296d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -542,6 +542,22 @@ export const makeProviderInstanceRegistry = Effect.fn( Effect.provideContext(driverContext), ); + const rebuild: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["rebuild"] = + Effect.fn("ProviderInstanceRegistry.rebuild")( + function* (instances) { + const live = yield* Ref.get(entries); + const ids = new Set( + instances + .filter((instance) => live.get(instance.instanceId)?.instance === instance) + .map((instance) => instance.instanceId), + ); + if (ids.size === 0) return; + yield* reconcileWithR(yield* Ref.get(configMapRef), ids); + }, + (effect) => semaphore.withPermits(1)(effect), + Effect.provideContext(driverContext), + ); + // Reading secrets can wait on a vault unlock, so the server does not wait for it to start. yield* semaphore.withPermits(1)( Ref.set(configMapRef, input.configMap).pipe( @@ -592,6 +608,7 @@ export const makeProviderInstanceRegistry = Effect.fn( const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { reconcile, reloadSecrets, + rebuild, }; return { registry, mutator }; diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index dc44f0324a6b0..0ba6245e98290 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -165,6 +165,7 @@ describe("layerSettingsWatcher", () => { const mutator = Layer.succeed(ProviderInstanceRegistryMutator, { reconcile: () => Queue.offer(reconciled, undefined), reloadSecrets: () => Effect.sync(() => void reloads++), + rebuild: () => Effect.void, }); yield* Layer.build( layerSettingsWatcher(Stream.fromQueue(changes), initial).pipe(Layer.provide(mutator)), diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index 35eb52d3f2906..2a5659cf6a3d6 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,5 +1,6 @@ /** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ import type { ProviderInstanceConfigMap, ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -7,6 +8,8 @@ export interface ProviderInstanceRegistryMutatorShape { readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; /** Re-reads the secrets of every instance, or one, and rebuilds those whose secrets changed. */ readonly reloadSecrets: (instanceId?: ProviderInstanceId) => Effect.Effect; + /** Rebuilds these instances from their current config, so their drivers read the environment again. */ + readonly rebuild: (instances: ReadonlyArray) => Effect.Effect; } export class ProviderInstanceRegistryMutator extends Context.Service< diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5c53f76237b6a..78a7ea492fe61 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -43,6 +44,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { LoginShellEnvironment } from "@t3tools/shared/shell"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG } from "./ClaudeModelCatalog.ts"; @@ -345,6 +347,8 @@ const layerTestNodeServices = Layer.mergeAll( Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), }), + // Refreshes of uninstalled providers re-read the login shell; never spawn the developer's. + Layer.succeed(LoginShellEnvironment, () => Effect.succeed({})), // An empty home, so the developer's own Claude settings never reach provider probes. Layer.effect( HostProcess.HomeDirectory, @@ -1296,6 +1300,108 @@ it.layer( ); }); + it.effect("rebuilds every uninstalled instance when the login shell PATH gained entries", () => + Effect.gen(function* () { + const makeInstance = (id: string, installed: boolean) => { + const provider = { + instanceId: ProviderInstanceId.make(id), + driver: ProviderDriverKind.make("claudeAgent"), + status: installed ? "ready" : "error", + enabled: true, + installed, + auth: { status: "unknown" }, + checkedAt: "2026-11-11T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + return { + provider, + instance: { + instanceId: provider.instanceId, + driverKind: provider.driver, + continuationIdentity: { driverKind: provider.driver, continuationKey: id }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: provider.driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance, + }; + }; + const missing = makeInstance("claude-missing", false); + const otherMissing = makeInstance("codex-missing", false); + const installed = makeInstance("claude-installed", true); + const instances = [missing.instance, otherMissing.instance, installed.instance]; + const missingIds = [missing.instance.instanceId, otherMissing.instance.instanceId]; + const rebuilds = yield* Queue.unbounded>(); + const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", PATH: "/usr/bin" }; + const shellPath = yield* Ref.make("/usr/bin"); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + yield* registry.refresh(); + assert.equal(yield* Queue.size(rebuilds), 0); + + // Refreshing one instance merges PATH globally, so it rebuilds every missing one. + yield* Ref.set(shellPath, "/home/test/.local/bin:/usr/bin"); + yield* registry.refreshInstance(missing.instance.instanceId); + assert.deepStrictEqual(yield* Queue.take(rebuilds), missingIds); + assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); + yield* registry.refresh(); + assert.equal(yield* Queue.size(rebuilds), 0); + }).pipe( + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide( + Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, { + getInstance: (id) => Effect.succeed(instances.find((i) => i.instanceId === id)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }), + ), + Layer.provide( + Layer.mock(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator)({ + rebuild: (rebuilt) => + Queue.offer( + rebuilds, + rebuilt.map((instance) => instance.instanceId), + ).pipe(Effect.asVoid), + }), + ), + ), + ), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, env), + Effect.provideService(LoginShellEnvironment, () => + Ref.get(shellPath).pipe(Effect.map((PATH) => ({ PATH }))), + ), + Effect.scoped, + ); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-login-shell-path-", + }).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ), + ), + ); + describe("Antigravity model inventories", () => { const previousProvider = { instanceId: ProviderInstanceId.make("antigravity-personal"), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index a0ea5c78fbc61..49fae77a1c1f8 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -27,6 +27,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ServerConfig from "../config.ts"; +import { rereadLoginShellPath } from "../os-jank.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import { @@ -627,6 +628,21 @@ export const layer = Layer.effect( ); }); + // PATH is read from the login shell at startup and drivers copy it when built, so a CLI + // installed later needs a fresh read and a rebuild. Installed instances keep their sessions. + // The PATH merge is global, so every missing instance is rebuilt, not just a refreshed one. + const rebuildMissingAfterPathChange = Effect.fn("rebuildMissingAfterPathChange")(function* () { + const instances = yield* instanceRegistry.listInstances; + const missing = yield* Effect.filter(instances, (instance) => + // ACP also reports installed:false for setup states unrelated to executable discovery. + !instance.enabled || instance.driverKind === "acpRegistry" + ? Effect.succeed(false) + : instance.snapshot.getSnapshot.pipe(Effect.map((snapshot) => !snapshot.installed)), + ); + if (missing.length === 0 || !(yield* rereadLoginShellPath())) return; + yield* instanceRegistryMutator.rebuild(missing); + }); + type RefreshAllCompletion = Deferred.Deferred>; const refreshAllInFlightRef = yield* Ref.make>( Option.none(), @@ -656,7 +672,8 @@ export const layer = Layer.effect( // Syncing first keeps a refresh right after a rebuild from probing the replaced instance. yield* manifestService.current.pipe( Effect.flatMap(({ updatedAt }) => - syncLiveSourcesAndContinue.pipe( + rebuildMissingAfterPathChange().pipe( + Effect.andThen(syncLiveSourcesAndContinue), Effect.andThen(Ref.get(liveSubsRef)), Effect.flatMap((instances) => Effect.forEach(instances.values(), (instance) => refreshOneSource(instance), { @@ -693,6 +710,7 @@ export const layer = Layer.effect( const refreshInstance = Effect.fn("refreshInstance")(function* ( instanceId: ProviderInstanceId, ) { + yield* rebuildMissingAfterPathChange(); yield* syncLiveSourcesAndContinue; const instance = (yield* Ref.get(liveSubsRef)).get(instanceId); if (!instance) { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 2b4dd9b96493b..a7134273e50a4 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -21,6 +21,8 @@ const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const WINDOWS_SHELL_PROBE_TIMEOUT_MS = 5000; // A shell stuck in the kernel survives the kill and holds stdout, so execFile's callback never fires. const WINDOWS_SHELL_PROBE_DEADLINE = Duration.millis(WINDOWS_SHELL_PROBE_TIMEOUT_MS + 1000); +const LOGIN_SHELL_PROBE_TIMEOUT_MS = 5000; +const LOGIN_SHELL_PROBE_DEADLINE = Duration.millis(LOGIN_SHELL_PROBE_TIMEOUT_MS + 1000); type ExecFileSyncLike = ( file: string, @@ -331,6 +333,67 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( return environment; }; +const execLoginShellProbe = (shell: string, args: ReadonlyArray) => + Effect.callback>((resume) => { + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn(shell, args, { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + timeout: LOGIN_SHELL_PROBE_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + } catch { + resume(Effect.succeedNone); + return; + } + let output = ""; + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + output += chunk; + }); + child.once("error", () => resume(Effect.succeedNone)); + child.once("close", (code) => + resume(Effect.succeed(code === 0 ? Option.some(output) : Option.none())), + ); + return Effect.sync(() => { + child.kill("SIGKILL"); + child.stdout?.destroy(); + child.unref(); + }); + }); + +export type LoginShellEnvironmentReader = ( + shell: string, + names: ReadonlyArray, +) => Effect.Effect>>; + +/** `readEnvironmentFromLoginShell` without blocking the event loop, for reads after startup. */ +const readEnvironmentFromLoginShellAsync: LoginShellEnvironmentReader = Effect.fn( + "shell.readEnvironmentFromLoginShellAsync", +)(function* (shell, names) { + if (names.length === 0) return {}; + const output = yield* execLoginShellProbe(shell, [ + "-ilc", + buildEnvironmentCaptureCommand(shell, names), + ]).pipe(Effect.timeoutOption(LOGIN_SHELL_PROBE_DEADLINE), Effect.map(Option.flatten)); + if (Option.isNone(output)) return {}; + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output.value, name); + if (value !== undefined) environment[name] = value; + } + return environment; +}); + +export const LoginShellEnvironment = Context.Reference( + "@t3tools/shared/shell/LoginShellEnvironment", + { + defaultValue: () => readEnvironmentFromLoginShellAsync, + }, +); + export type WindowsShellEnvironmentReader = ( names: ReadonlyArray, options?: WindowsEnvironmentProbeOptions,