From 0a5131a50c9b63a977a1078e0f462be1efae31de Mon Sep 17 00:00:00 2001 From: ettoc00 <41289053+ettoc00@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:56:36 +0200 Subject: [PATCH 1/6] feat(server): support Scoop and WinGet provider updates --- .../src/provider/Drivers/ClaudeDriver.ts | 18 +- .../src/provider/Drivers/CodexDriver.ts | 21 +- .../src/provider/Drivers/CursorDriver.ts | 17 +- .../server/src/provider/Drivers/GrokDriver.ts | 35 +- .../src/provider/Drivers/OpenCodeDriver.ts | 21 +- .../src/provider/providerMaintenance.test.ts | 733 +++++++++++++++++- .../src/provider/providerMaintenance.ts | 479 +++++++++++- .../providerMaintenanceRunner.test.ts | 390 +++++++++- .../src/provider/providerMaintenanceRunner.ts | 121 ++- .../provider/windowsUpdateElevation.test.ts | 102 +++ .../src/provider/windowsUpdateElevation.ts | 149 ++++ ...iderUpdateLaunchNotification.logic.test.ts | 32 +- .../ProviderUpdateLaunchNotification.logic.ts | 27 +- .../settings/ProviderInstanceCard.tsx | 24 +- .../settings/providerStatus.test.ts | 23 +- .../src/components/settings/providerStatus.ts | 18 +- docs/user/install.md | 14 +- 17 files changed, 2059 insertions(+), 165 deletions(-) create mode 100644 apps/server/src/provider/windowsUpdateElevation.test.ts create mode 100644 apps/server/src/provider/windowsUpdateElevation.ts diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 324284a3a4c7..9b3c750ad62d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -49,10 +49,9 @@ import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, + makeProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -78,6 +77,7 @@ function isClaudeNativeCommandPath(commandPath: string): boolean { const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@anthropic-ai/claude-code", + wingetPackageId: "Anthropic.ClaudeCode", nativeUpdate: { args: ["update"], isCommandPath: isClaudeNativeCommandPath, @@ -125,16 +125,10 @@ export const ClaudeDriver: ProviderDriver = { enabled, binaryPath: expandHomePath(config.binaryPath), } satisfies ClaudeSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ); + const resolveMaintenance = yield* makeProviderMaintenanceResolution(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); const stampIdentity = withInstanceIdentity({ instanceId, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 071fb20674a8..2d26be65a287 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -56,10 +56,9 @@ import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, + makeProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -90,6 +89,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", + wingetPackageId: "OpenAI.Codex", nativeUpdate: { args: ["update"], isCommandPath: isCodexStandaloneCommandPath, @@ -128,8 +128,6 @@ export const CodexDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const resetCreditCoordinator = yield* CodexResetCreditCoordinator; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -161,18 +159,9 @@ export const CodexDriver: ProviderDriver = { binaryPath: expandHomePath(config.binaryPath), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect( - makeCodexMaintenanceResolver(homeLayout.sharedHomePath), - { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }, - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ), + const resolveMaintenance = yield* makeProviderMaintenanceResolution( + makeCodexMaintenanceResolver(homeLayout.sharedHomePath), + { binaryPath: effectiveConfig.binaryPath, env: processEnv }, ); // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 5466af802e50..6f8d41aa23dc 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -42,11 +42,10 @@ import { import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { - makeCachedProviderMaintenanceResolution, + makeProviderMaintenanceResolution, makeManualOnlyProviderMaintenanceCapabilities, makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, - resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -119,16 +118,10 @@ export const CursorDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies CursorSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ); + const resolveMaintenance = yield* makeProviderMaintenanceResolution(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 066d0a6b63a8..f2da4703fd54 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -28,7 +28,10 @@ import { import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { discoverGrokSkills } from "./GrokSkills.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + makeProviderMaintenanceResolution, + makePackageManagedProviderMaintenanceResolver, +} from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -37,9 +40,11 @@ import { const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); -const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ +const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, - packageName: null, + npmPackageName: "@xai-official/grok", + wingetPackageId: "xAI.GrokBuild", + nativeUpdate: null, }); export type GrokDriverEnv = @@ -82,6 +87,10 @@ export const GrokDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies GrokSettings; + const resolveMaintenance = yield* makeProviderMaintenanceResolution(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -97,7 +106,7 @@ export const GrokDriver: ProviderDriver = { const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -105,13 +114,17 @@ export const GrokDriver: ProviderDriver = { buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - enrichGrokSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities: MAINTENANCE_CAPABILITIES, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - httpClient, - }), + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichGrokSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + ), + ), }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 72c1c0683de5..e465a62ff437 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -45,10 +45,9 @@ import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, - makeCachedProviderMaintenanceResolution, + makeProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -70,6 +69,7 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean { const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "opencode-ai", + wingetPackageId: "SST.opencode", nativeUpdate: { args: ["upgrade"], isCommandPath: isOpenCodeNativeCommandPath, @@ -98,9 +98,6 @@ export const OpenCodeDriver: ProviderDriver defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; @@ -119,16 +116,10 @@ export const OpenCodeDriver: ProviderDriver continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ), - ); + const resolveMaintenance = yield* makeProviderMaintenanceResolution(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { instanceId, diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 2ceaf21996bf..fe4297ae2d21 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -5,13 +5,19 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/unstable/http"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createProviderVersionAdvisory, @@ -19,6 +25,7 @@ import { homebrewOwnershipFromCommandPath, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, + makeProviderMaintenanceResolution, makeProviderMaintenanceCapabilities, normalizeCommandPath, npmGlobalPrefixFromCommandPath, @@ -26,11 +33,19 @@ import { ProviderVersionCache, resolveLatestProviderVersion, resolveProviderMaintenanceCapabilitiesEffect, + type PackageManagedProviderMaintenanceDefinition, type ProviderMaintenanceCapabilities, } from "./providerMaintenance.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { enrichGrokSnapshot } from "./Layers/GrokProvider.ts"; const driver = (value: string) => ProviderDriverKind.make(value); +const grokDefinition: PackageManagedProviderMaintenanceDefinition = { + provider: driver("grok"), + npmPackageName: "@xai-official/grok", + wingetPackageId: "xAI.GrokBuild", + nativeUpdate: null, +}; // These write `#!/bin/sh` stubs and evaluate them with darwin/linux path // semantics; a Windows temp path cannot be split on `:`. const windowsHost = HostProcessPlatform.defaultValue() === "win32"; @@ -94,23 +109,37 @@ function linkIntoPackage(tempDir: string, name: string, packageSegments: Readonl const noSpawn = ChildProcessSpawner.make(() => Effect.die("maintenance resolution should not spawn a process here"), ); +const noNpmRequest = HttpClient.make(() => Effect.die("installer versions must not query npm")); -function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray) => string) { +function stdoutSpawner( + onSpawn: ( + command: string, + args: ReadonlyArray, + env?: NodeJS.ProcessEnv, + ) => string | { stdout: string; code: number; stderr?: string }, +) { return ChildProcessSpawner.make((command) => { - const { command: executable, args } = command as unknown as { + const { + command: executable, + args, + options, + } = command as unknown as { readonly command: string; readonly args: ReadonlyArray; + readonly options: { readonly env?: NodeJS.ProcessEnv }; }; + const output = onSpawn(executable, args, options.env); + const result = typeof output === "string" ? { stdout: output, code: 0 } : output; return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.encodeText(Stream.make(onSpawn(executable, args))), - stderr: Stream.empty, + stdout: Stream.encodeText(Stream.make(result.stdout)), + stderr: Stream.encodeText(Stream.make(result.stderr ?? "")), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -120,6 +149,634 @@ function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray) = } it.layer(NodeServices.layer)("providerMaintenance", (it) => { + const windowsFixture = Effect.fn("windowsFixture")(function* ( + failure = "", + scope = "user", + definition: PackageManagedProviderMaintenanceDefinition = { + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + wingetPackageId: "Example.PackageTool", + nativeUpdate: { args: ["update"], isCommandPath: isNativeTestCommandPath("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/.local/bin/") }, + }, + scoopPackage = "package-tool", + ) { + const fs = yield* FileSystem.FileSystem; + const probeStarted = yield* Deferred.make(); + const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3 installer caffè 日本 O'Brien " }); + const managerRoot = NodePath.join(temp, "Scoop Root"); + const root = scope === "machine" ? NodePath.join(temp, "Global Scoop") : managerRoot; + const current = NodePath.join(root, "apps", scoopPackage, "current"); + const target = NodePath.join(current, "tool.exe"); + const shim = NodePath.join(root, "shims", "tool.exe"); + const wingetName = + failure === "renamed-install" + ? "custom tool.exe" + : definition.wingetPackageId === "OpenAI.Codex" + ? "codex-x86_64-pc-windows-msvc.exe" + : definition.wingetPackageId === "SST.opencode" + ? "opencode.exe" + : "tool.exe"; + const wingetTarget = NodePath.join(temp, "Microsoft", "WinGet", "Packages", wingetName); + const indexPath = NodePath.join( + NodePath.dirname(wingetTarget), + `${definition.wingetPackageId}.db`, + ); + const archive = failure.startsWith("archive") || failure === "directory-install"; + const wingetLink = NodePath.join(temp, "Microsoft", "WinGet", "Links", "tool.exe"); + const manager = NodePath.join(managerRoot, "shims", "scoop.EXE"); + const winget = NodePath.join(temp, "WindowsApps", "winget.EXE"); + const write = (file: string, text = "fixture") => { + NodeFS.mkdirSync(NodePath.dirname(file), { recursive: true }); + NodeFS.writeFileSync(file, text); + }; + for (const file of [target, shim, wingetTarget, manager, winget]) write(file); + if (archive) { + NodeFS.mkdirSync(NodePath.dirname(wingetLink), { recursive: true }); + if (failure !== "directory-install") NodeFS.symlinkSync(wingetTarget, wingetLink); + const db = new NodeSqlite.DatabaseSync(indexPath); + try { + // WinGet Portable_1_0/PortableTable.cpp; archives store file and alias entries here. + db.exec( + "CREATE TABLE portable (filepath TEXT NOT NULL UNIQUE COLLATE NOCASE, filetype INT NOT NULL, sha256 BLOB, symlinktarget TEXT)", + ); + const insert = db.prepare("INSERT INTO portable VALUES (?, ?, '', ?)"); + insert.run(wingetTarget, 1, ""); + insert.run(wingetLink, 3, wingetTarget); + for (const name of ["codex-command-runner.exe", "codex-windows-sandbox-setup.exe"]) { + const file = NodePath.join(NodePath.dirname(wingetTarget), name); + insert.run(file, 1, ""); + insert.run(NodePath.join(NodePath.dirname(wingetLink), name), 3, file); + } + } finally { + db.close(); + } + } + write( + shim.replace(/\.exe$/, ".shim"), + `path = "${failure === "foreign-target" ? wingetTarget : target}"\n${failure === "duplicate-target" ? `path = "${target}"` : ""}`, + ); + write( + NodePath.join(current, "install.json"), + failure === "bad-metadata" ? "{}" : '{"bucket":"main"}', + ); + write( + NodePath.join(managerRoot, "buckets", "main", "bucket", `${scoopPackage}.json`), + '{"version":"1.2.0"}', + ); + if (failure === "missing-shim") NodeFS.unlinkSync(shim.replace(/\.exe$/, ".shim")); + if (failure === "missing-manager") { + NodeFS.unlinkSync(manager); + NodeFS.unlinkSync(winget); + } + const env = { + PATH: [NodePath.dirname(manager), NodePath.dirname(winget)].join(";"), + PATHEXT: ".EXE;.CMD", + ...(failure === "global-config" + ? {} + : { SCOOP_GLOBAL: failure === "wrong-global" ? temp : root }), + TEST_INSTALLER: "instance", + }; + const resolver = makePackageManagedProviderMaintenanceResolver(definition); + let versionProbes = 0; + const spawner = stdoutSpawner((_command, commandArgs, probeEnv) => { + const script = commandArgs.includes("-EncodedCommand") + ? Buffer.from(commandArgs.at(-1)!, "base64").toString("utf16le") + : null; + const args = script + ? [...script.matchAll(/ '((?:[^']|'')*)'/g)] + .slice(1) + .map((match) => match[1]!.replaceAll("''", "'")) + : commandArgs; + expect(probeEnv).toEqual(env); + if (args[0] === "config") return root; + if (args[0] === "query") { + if (failure.startsWith("missing-uninstall")) { + if (args.includes("/s")) + return { stdout: "", stderr: "Missing or inaccessible", code: 1 }; + return failure === "missing-uninstall-unreadable" + ? { stdout: "", stderr: "Access denied", code: 1 } + : failure === "missing-uninstall-empty" + ? "" + : `${args[1]}\\${failure === "missing-uninstall-present" ? "Uninstall" : "Explorer"}\n`; + } + if (failure === "registry-failure") return { stdout: "", code: 2 }; + const key = `HKEY_CURRENT_USER\\Software\\${definition.wingetPackageId}`; + if (failure === "registry-partial") + return { stdout: key, code: 0, stderr: "Access denied" }; + const matchesScope = + args[1]!.startsWith(scope === "user" ? "HKCU" : "HKLM") && args.at(-1) === "/reg:64"; + if (args.includes("/s")) + return matchesScope && failure !== "no-ownership" + ? `${key}\n${failure === "duplicate-install" ? `${key}-other\n` : ""}` + : { stdout: "End of search: 0 match(es) found.", code: 1 }; + const recordTarget = + failure === "wrong-target" || args[1]!.endsWith("-other") ? target : wingetTarget; + return `${key}\n WinGetPackageIdentifier REG_SZ ${definition.wingetPackageId}\n WinGetSourceIdentifier REG_SZ source-id\n WinGetInstallerType REG_SZ portable\n InstallLocation REG_SZ ${NodePath.dirname(wingetTarget)}${failure === "directory-install" ? NodePath.sep : ""}\n${archive ? (failure === "directory-install" ? " InstallDirectoryAddedToPath REG_DWORD 0x1" : "") : ` TargetFullPath REG_SZ ${recordTarget}\n SymlinkFullPath REG_SZ ${wingetTarget}`}`; + } + if (args[0] === "source") { + const source = JSON.stringify({ + Identifier: failure === "wrong-source" ? "another-source" : "source-id", + Name: "private source", + }); + return failure === "duplicate-source" ? `${source}\n${source}` : source; + } + versionProbes++; + expect(args).toEqual([ + "show", + "--id", + definition.wingetPackageId, + "--exact", + "--source", + "private source", + "--versions", + "--accept-source-agreements", + "--disable-interactivity", + ]); + return failure === "show-failure" + ? { stdout: "error 9.9.9", code: 1 } + : "Versions\n--------\n1.0.0\n1.2.0\nError 9.9.9"; + }); + const resolve = (binaryPath: string) => + resolveProviderMaintenanceCapabilitiesEffect(resolver, { binaryPath, env }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + failure === "slow-probes" + ? ChildProcessSpawner.make((command) => + Deferred.succeed(probeStarted, undefined).pipe( + Effect.andThen(spawner.spawn(command).pipe(Effect.delay("6 seconds"))), + ), + ) + : spawner, + ), + ); + return { + resolve, + resolver, + spawner, + shim, + target, + wingetTarget, + wingetLink, + indexPath, + manager, + winget, + env, + root, + current, + probeStarted, + versionProbes: () => versionProbes, + }; + }); + + it.effect("defers WinGet version lookups until update checks are enabled", () => + Effect.gen(function* () { + const f = yield* windowsFixture(); + const capabilities = yield* f.resolve(f.wingetTarget); + const disabled = yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + { enableProviderUpdateChecks: false }, + ); + expect(disabled.versionAdvisory?.canUpdate).toBe(true); + expect(f.versionProbes()).toBe(0); + for (let index = 0; index < 2; index++) { + const enabled = yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + { enableProviderUpdateChecks: true }, + ); + expect(enabled.versionAdvisory?.latestVersion).toBe("1.2.0"); + } + expect(f.versionProbes()).toBe(1); + }).pipe(Effect.provideService(HttpClient.HttpClient, noNpmRequest), Effect.scoped), + ); + + it.effect("binds each instance's services and keeps fresh ownership checks independent", () => + Effect.gen(function* () { + const f = yield* windowsFixture(); + const makeResolution = (binaryPath: string) => + makeProviderMaintenanceResolution(f.resolver, { binaryPath, env: f.env }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, f.spawner), + ); + const scoop = yield* makeResolution(f.shim); + const winget = yield* makeResolution(f.wingetTarget); + // Execution must use the services captured at construction, not its caller's. + yield* Effect.gen(function* () { + expect((yield* scoop()).update?.executable).toBe(f.manager); + expect((yield* winget()).update?.executable).toBe(f.winget); + NodeFS.unlinkSync(f.wingetTarget); + expect((yield* winget()).update?.executable).toBe(f.winget); + expect((yield* winget({ fresh: true })).update).toBeNull(); + expect((yield* scoop({ fresh: true })).update?.executable).toBe(f.manager); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.scoped), + ); + + it.effect("publishes Grok installer advisories even when the available version is unknown", () => + Effect.gen(function* () { + for (const [installer, failure] of [ + ["scoop", ""], + ["winget", ""], + ["winget", "show-failure"], + ]) { + const f = yield* windowsFixture(failure, "user", grokDefinition, "grok-cli"); + const unknown = installer === "scoop" || failure === "show-failure"; + expect((yield* f.resolve(f.shim)).update).toMatchObject({ + executable: f.manager, + args: ["update", "main/grok-cli"], + }); + const published: ServerProvider[] = []; + yield* enrichGrokSnapshot({ + snapshot: { + ...installedPackageToolProvider, + driver: driver("grok"), + instanceId: ProviderInstanceId.make("grok_test"), + }, + maintenanceCapabilities: yield* f.resolve( + installer === "scoop" ? f.shim : f.wingetTarget, + ), + httpClient: HttpClient.make(() => Effect.die("Grok must not query npm")), + publishSnapshot: (snapshot) => + Effect.sync(() => { + published.push(snapshot); + }), + }); + expect(published).toHaveLength(1); + expect(published[0]?.versionAdvisory).toMatchObject({ + canUpdate: true, + latestVersion: unknown ? null : "1.2.0", + status: unknown ? "unknown" : "behind_latest", + }); + } + }).pipe(Effect.scoped), + ); + + it.effect("pins Grok npm updates to the selected prefix and requires its official package", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const f = yield* windowsFixture("no-ownership", "user", grokDefinition, "grok-cli"); + const locks = []; + for (let index = 0; index < 2; index++) { + const prefix = yield* fs.makeTempDirectoryScoped({ prefix: "t3 grok npm " }); + const shim = NodePath.join(prefix, "grok.cmd"); + NodeFS.writeFileSync(shim, "fixture"); + const unrelated = NodePath.join(prefix, "node_modules", "grok"); + NodeFS.mkdirSync(unrelated, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(unrelated, "package.json"), "{}"); + expect((yield* f.resolve(shim)).update).toBeNull(); + const official = NodePath.join(prefix, "node_modules", "@xai-official", "grok"); + NodeFS.mkdirSync(official, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(official, "package.json"), "{}"); + const capabilities = yield* f.resolve(shim); + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: [ + "install", + "-g", + "--prefix", + prefix, + "--allow-scripts=@xai-official/grok", + "@xai-official/grok@latest", + ], + }); + expect(capabilities.packageName).toBe("@xai-official/grok"); + expect(capabilities.latestVersion).toBeUndefined(); + locks.push(capabilities.update?.lockKey); + } + expect(locks[0]).not.toBe(locks[1]); + }).pipe(Effect.scoped), + ); + + it.effect("fetches Grok npm versions and leaves native installs manual", () => + Effect.gen(function* () { + const resolver = makePackageManagedProviderMaintenanceResolver(grokDefinition); + const resolve = (commandPath: string) => + resolver.resolve({ + binaryPath: commandPath, + resolvedCommandPath: commandPath, + realCommandPath: commandPath, + env: {}, + platform: "linux", + }); + const capabilities = yield* resolve( + "/opt/Node Tools/lib/node_modules/@xai-official/grok/bin/grok.js", + ); + expect(capabilities.update?.executable).toBe("npm"); + const latest = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.url).toBe("https://registry.npmjs.org/%40xai-official%2Fgrok/latest"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ version: "1.3.0" })), + ), + ); + }), + ), + ); + expect(latest).toBe("1.3.0"); + expect((yield* resolve("/home/user/.local/bin/grok")).update).toBeNull(); + }).pipe(Effect.provideService(HostProcessPlatform, "linux")), + ); + it.effect("bounds the whole Windows resolution across sequential probes", () => + Effect.gen(function* () { + const f = yield* windowsFixture("slow-probes"); + const fiber = yield* Effect.forkChild(f.resolve(f.wingetTarget)); + yield* Deferred.await(f.probeStarted); + yield* TestClock.adjust("10 seconds"); + const result = fiber.pollUnsafe(); + expect(result && Exit.isSuccess(result)).toBe(true); + if (result && Exit.isSuccess(result)) + expect(result.value).toMatchObject({ update: null, latestVersion: null }); + }).pipe(Effect.scoped), + ); + + for (const invalid of ["oversized", "invalid-utf8"]) { + it.effect(`rejects ${invalid} Scoop metadata`, () => + Effect.gen(function* () { + const f = yield* windowsFixture(); + NodeFS.writeFileSync( + NodePath.join(f.current, "install.json"), + invalid === "oversized" + ? `{"bucket":"main","padding":"${"x".repeat(256 * 1024)}"}` + : Buffer.concat([ + Buffer.from('{"bucket":"main","padding":"'), + Buffer.from([0xff]), + Buffer.from('"}'), + ]), + ); + expect((yield* f.resolve(f.shim)).update).toBeNull(); + }).pipe(Effect.scoped), + ); + } + + for (const scope of ["user", "machine"]) { + it.effect(`updates only the explicitly selected Scoop/WinGet installation (${scope})`, () => + Effect.gen(function* () { + const f = yield* windowsFixture(scope === "machine" ? "global-config" : "", scope); + const scoop = yield* f.resolve(f.shim); + const winget = yield* f.resolve(f.wingetTarget); + expect(scoop.update).toMatchObject({ + executable: f.manager, + args: ["update", "main/package-tool", ...(scope === "machine" ? ["--global"] : [])], + env: { + ...f.env, + ...(scope === "machine" ? { SCOOP_GLOBAL: f.root.replaceAll("\\", "/") } : {}), + SCOOP: NodePath.dirname(NodePath.dirname(f.manager)).replaceAll("\\", "/"), + }, + }); + expect(winget.update).toMatchObject({ + executable: f.winget, + args: [ + "upgrade", + "--id", + "Example.PackageTool", + "--exact", + "--source", + "private source", + "--scope", + scope, + "--location", + NodePath.dirname(f.wingetTarget), + "--rename", + "tool.exe", + "--accept-source-agreements", + "--disable-interactivity", + ], + env: f.env, + }); + expect(scoop.update?.command).toContain("& '"); + expect(winget.update?.command).toContain("'private source'"); + expect(winget.update?.windowsInstaller).toEqual({ manager: "winget", scope }); + expect(scoop.update?.windowsInstaller).toEqual({ manager: "scoop", scope }); + expect(scoop.latestVersion).toBeNull(); + expect(yield* resolveLatestProviderVersion(winget)).toBe("1.2.0"); + expect(scoop.update?.lockKey).not.toBe(winget.update?.lockKey); + }).pipe(Effect.provideService(HttpClient.HttpClient, noNpmRequest), Effect.scoped), + ); + } + + it.effect("shares Scoop's lock across packages and user/global scope in the same manager", () => + Effect.gen(function* () { + const f = yield* windowsFixture("", "machine"); + const userCurrent = NodePath.join( + NodePath.dirname(NodePath.dirname(f.manager)), + "apps", + "other", + "current", + ); + NodeFS.mkdirSync(userCurrent, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(userCurrent, "install.json"), '{"bucket":"extras"}'); + const userTarget = NodePath.join(userCurrent, "other.exe"); + writeExecutable(userTarget); + const global = yield* f.resolve(f.target); + const user = yield* f.resolve(userTarget); + expect(user.update).not.toBeNull(); + expect(user.update?.lockKey).toBe(global.update?.lockKey); + expect(user.update?.args).toEqual(["update", "extras/other"]); + const independent = yield* windowsFixture(); + expect((yield* independent.resolve(independent.target)).update?.lockKey).not.toBe( + user.update?.lockKey, + ); + }).pipe(Effect.scoped), + ); + + it.effect("preserves a registered custom WinGet executable name and location", () => + Effect.gen(function* () { + const f = yield* windowsFixture("renamed-install"); + const { update } = yield* f.resolve(f.wingetTarget); + expect(update?.args).toEqual( + expect.arrayContaining([ + "--rename", + "custom tool.exe", + "--location", + NodePath.dirname(f.wingetTarget), + ]), + ); + expect(update?.command).toContain("'custom tool.exe'"); + }).pipe(Effect.scoped), + ); + + for (const [packageId, scope] of [ + ["OpenAI.Codex", "user"], + ["SST.opencode", "machine"], + ]) { + it.effect.skipIf(!symlinksSupported)( + `recognizes indexed ${packageId} aliases and direct selection (${scope})`, + () => + Effect.gen(function* () { + const f = yield* windowsFixture("archive", scope, { + ...grokDefinition, + wingetPackageId: packageId!, + }); + const before = NodeFS.readFileSync(f.indexPath); + for (const selected of [f.wingetLink, f.wingetTarget]) { + const { update } = yield* f.resolve(selected); + expect(update?.executable).toBe(f.winget); + expect(update?.args).toEqual( + expect.arrayContaining([ + "--id", + packageId, + "--scope", + scope, + "--location", + NodePath.dirname(f.wingetTarget), + ]), + ); + expect(update?.args).not.toContain("--rename"); + } + expect(NodeFS.readFileSync(f.indexPath)).toEqual(before); + NodeFS.unlinkSync(f.wingetLink); + NodeFS.symlinkSync(f.target, f.wingetLink); + expect((yield* f.resolve(f.wingetTarget)).update).toBeNull(); + expect((yield* f.resolve(f.wingetLink)).update).toBeNull(); + }).pipe(Effect.scoped), + ); + } + + for (const failure of ["missing", "corrupt", "wrong-target", "ambiguous"]) { + it.effect(`refuses a WinGet archive with ${failure} index ownership`, () => + Effect.gen(function* () { + const f = yield* windowsFixture("directory-install"); + if (failure === "missing") NodeFS.unlinkSync(f.indexPath); + else if (failure === "corrupt") NodeFS.writeFileSync(f.indexPath, "not sqlite"); + else { + const db = new NodeSqlite.DatabaseSync(f.indexPath); + try { + if (failure === "wrong-target") + db.prepare("UPDATE portable SET symlinktarget = ? WHERE filetype = 3").run(f.target); + else + db.prepare("INSERT INTO portable VALUES (?, 3, '', ?)").run( + f.wingetLink + "-other", + f.wingetTarget, + ); + } finally { + db.close(); + } + } + expect((yield* f.resolve(f.wingetTarget)).update).toBeNull(); + if (failure === "missing") expect(NodeFS.existsSync(f.indexPath)).toBe(false); + }).pipe(Effect.scoped), + ); + } + + const scoopFailures = [ + "missing-shim", + "duplicate-target", + "foreign-target", + "bad-metadata", + "missing-manager", + "wrong-global", + ]; + for (const failure of [ + ...scoopFailures, + "registry-failure", + "registry-partial", + "no-ownership", + "wrong-target", + "duplicate-install", + "wrong-source", + "duplicate-source", + ]) { + it.effect(`refuses unproven Windows ownership: ${failure}`, () => + Effect.gen(function* () { + const f = yield* windowsFixture(failure, "machine"); + const scoopFailure = scoopFailures.includes(failure); + expect((yield* f.resolve(scoopFailure ? f.shim : f.wingetTarget)).update).toBeNull(); + }).pipe(Effect.scoped), + ); + } + + for (const mode of ["directory-install", "show-failure"]) { + it.effect(`resolves WinGet ${mode} without falling back to npm versions`, () => + Effect.gen(function* () { + const f = yield* windowsFixture(mode); + const result = yield* f.resolve(f.wingetTarget); + expect(result.update?.executable).toBe(f.winget); + expect(yield* resolveLatestProviderVersion(result)).toBe( + mode === "show-failure" ? null : "1.2.0", + ); + }).pipe(Effect.provideService(HttpClient.HttpClient, noNpmRequest), Effect.scoped), + ); + } + + it.effect.skipIf(!windowsHost && !symlinksSupported)( + "follows Scoop current junctions and accepts direct selection", + () => + Effect.gen(function* () { + const f = yield* windowsFixture(); + const version = NodePath.join(NodePath.dirname(f.current), "1.0.0"); + NodeFS.renameSync(f.current, version); + NodeFS.symlinkSync(version, f.current, windowsHost ? "junction" : "dir"); + expect((yield* f.resolve(f.shim)).update?.executable).toBe(f.manager); + expect((yield* f.resolve(f.target)).update?.executable).toBe(f.manager); + const pinned = NodePath.join(version, "tool.exe"); + expect((yield* f.resolve(pinned)).update).toBeNull(); + NodeFS.writeFileSync(f.shim.replace(/\.exe$/, ".shim"), `path = "${pinned}"`); + expect((yield* f.resolve(f.shim)).update).toBeNull(); + const stale = NodePath.join(NodePath.dirname(f.current), "0.9.0", "tool.exe"); + NodeFS.mkdirSync(NodePath.dirname(stale)); + NodeFS.writeFileSync(stale, "old binary"); + expect((yield* f.resolve(stale)).update).toBeNull(); + }).pipe(Effect.scoped), + ); + + for (const failure of [ + "missing-uninstall", + "missing-uninstall-empty", + "missing-uninstall-present", + "missing-uninstall-unreadable", + ]) { + it.effect(`keeps native/npm updates only for provably absent registry roots: ${failure}`, () => + Effect.gen(function* () { + const f = yield* windowsFixture(failure); + for (const segments of [ + [".local", "bin", "tool.exe"], + ["npm", "tool.cmd"], + ["shims", "tool.cmd"], + ["apps", "node", "global", "tool.cmd"], + ]) { + const native = segments[0] === ".local"; + const binary = NodePath.join(NodePath.dirname(f.root), ...segments); + writeExecutable(binary); + if (!native) { + const manifest = NodePath.join( + NodePath.dirname(binary), + "node_modules", + "@example", + "package-tool", + "package.json", + ); + NodeFS.mkdirSync(NodePath.dirname(manifest), { recursive: true }); + NodeFS.writeFileSync(manifest, '{"name":"@example/package-tool"}'); + } + const result = yield* f.resolve(binary); + if (failure === "missing-uninstall" || failure === "missing-uninstall-empty") + expect(result.update?.executable).toBe(native ? binary : "npm"); + else expect(result.update).toBeNull(); + } + }).pipe(Effect.scoped), + ); + } + + it.effect.skipIf(!symlinksSupported)( + "rejects a WinGet link retargeted to another installer", + () => + Effect.gen(function* () { + const f = yield* windowsFixture(); + const link = NodePath.join(NodePath.dirname(f.wingetTarget), "link.exe"); + NodeFS.symlinkSync(f.wingetTarget, link); + expect((yield* f.resolve(link)).update?.executable).toBe(f.winget); + NodeFS.unlinkSync(link); + NodeFS.symlinkSync(f.target, link); + expect((yield* f.resolve(link)).update).toBeNull(); + }).pipe(Effect.scoped), + ); + it.effect("reads cached versions through the injectable cache reference", () => resolveLatestProviderVersion(manualPackageTool).pipe( Effect.provideService( @@ -312,7 +969,13 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("proves Windows npm ownership from the package manifest beside the shim", () => Effect.gen(function* () { - const tempDir = yield* makeTempDir("t3-npm-windows-capabilities"); + const tempDir = NodePath.join( + yield* makeTempDir("t3-npm-windows-capabilities"), + "scoop", + "apps", + "nodejs-lts", + "current", + ); const shim = NodePath.join(tempDir, "package-tool.cmd"); NodeFS.mkdirSync(tempDir, { recursive: true }); NodeFS.writeFileSync(shim, "@echo off\r\n"); @@ -748,4 +1411,60 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(resolutions).toBe(2); }), ); + + it.effect("retries an interrupted resolution without poisoning the cache", () => + Effect.gen(function* () { + let calls = 0; + const started = yield* Deferred.make(); + const resolve = yield* makeCachedProviderMaintenanceResolution( + Effect.suspend(() => + ++calls === 1 + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(manualPackageTool), + ), + ); + const first = yield* Effect.forkChild(resolve()); + yield* Deferred.await(started); + yield* Fiber.interrupt(first); + expect(Exit.isSuccess(yield* Effect.exit(resolve()))).toBe(true); + expect(calls).toBe(2); + }), + ); + + it.effect("shares advisory work and re-resolves fresh requests queued behind it", () => + Effect.gen(function* () { + let calls = 0; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const sharedRequested = yield* Deferred.make(); + const freshRequested = yield* Deferred.make(); + const resolve = yield* makeCachedProviderMaintenanceResolution( + Effect.gen(function* () { + const version = String(++calls); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { ...manualPackageTool, latestVersion: version }; + }), + ); + const first = yield* Effect.forkChild(resolve()); + yield* Deferred.await(started); + const shared = yield* Effect.forkChild( + Deferred.succeed(sharedRequested, undefined).pipe(Effect.andThen(resolve())), + ); + yield* Deferred.await(sharedRequested); + const fresh = yield* Effect.forkChild( + Deferred.succeed(freshRequested, undefined).pipe(Effect.andThen(resolve({ fresh: true }))), + ); + yield* Deferred.await(freshRequested); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(first)).latestVersion).toBe("1"); + // Advisory readers may acquire the permit before or after the fresh reader. + expect(["1", "2"]).toContain((yield* Fiber.join(shared)).latestVersion); + expect((yield* Fiber.join(fresh)).latestVersion).toBe("2"); + expect(calls).toBe(2); + expect((yield* resolve()).latestVersion).toBe("2"); + yield* TestClock.adjust("1 hour"); + expect((yield* resolve()).latestVersion).toBe("3"); + }), + ); }); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index e8ff090a4ec9..5ea60be7d357 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -6,8 +6,10 @@ import { import { compareSemverVersions } from "@t3tools/shared/semver"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { causeErrorTag } from "@t3tools/shared/observability"; -import { resolveCommandPath } from "@t3tools/shared/shell"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -16,16 +18,17 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { SqlClient } from "effect/unstable/sql"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; -const HOMEBREW_INFO_TIMEOUT_MS = 10_000; -const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; +const INSTALLER_PROBE_TIMEOUT_MS = 10_000; +const INSTALLER_PROBE_MAX_BYTES = 256 * 1_024; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; /** @@ -62,9 +65,10 @@ export interface ProviderMaintenanceCapabilities { * Latest version reported by the installer that owns the executable. * `undefined` means the installer has no channel of its own and the npm * registry entry for `packageName` is authoritative; `null` means the - * installer was asked and did not know. + * installer was asked and did not know. An effect defers a separate lookup + * until update checks are enabled and binds the owning instance's services. */ - readonly latestVersion?: string | null; + readonly latestVersion?: string | null | Effect.Effect; } export interface ProviderMaintenanceCommandAction { @@ -72,6 +76,11 @@ export interface ProviderMaintenanceCommandAction { readonly executable: string; readonly args: ReadonlyArray; readonly lockKey: string; + /** Proven Windows installer and scope; machine installs may request a UAC retry. */ + readonly windowsInstaller?: { + readonly manager: "scoop" | "winget"; + readonly scope: "user" | "machine"; + }; /** * Extra environment for the spawned updater, on top of the server's own. * A native updater finds its install through the same variables the @@ -105,6 +114,7 @@ export interface ProviderMaintenanceCapabilitiesResolver { export interface PackageManagedProviderMaintenanceDefinition { readonly provider: ProviderDriverKind; readonly npmPackageName: string; + readonly wingetPackageId?: string; readonly nativeUpdate: { readonly args: ReadonlyArray; readonly isCommandPath: (commandPath: string) => boolean; @@ -160,7 +170,8 @@ export function makeProviderMaintenanceCapabilities(input: { readonly updateCommand?: string; readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; - readonly latestVersion?: string | null; + readonly latestVersion?: Exclude; + readonly windowsInstaller?: ProviderMaintenanceCommandAction["windowsInstaller"]; }): ProviderMaintenanceCapabilities { const platform = input.platform ?? HostProcessPlatform.defaultValue(); const update = @@ -177,6 +188,7 @@ export function makeProviderMaintenanceCapabilities(input: { args: input.updateArgs, lockKey: input.updateLockKey, ...(input.env ? { env: input.env } : {}), + ...(input.windowsInstaller ? { windowsInstaller: input.windowsInstaller } : {}), }; return { provider: input.provider, @@ -308,37 +320,401 @@ export function parseHomebrewLatestVersion( return nonEmptyString(raw); } -/** Run `brew ` and return stdout, or null on failure, timeout, or oversized output. */ -const runHomebrew = Effect.fn("runHomebrew")(function* ( - brewPath: string, +/** Return stdout, or null on failure, timeout, or oversized output. */ +const runInstallerProbe = Effect.fn("runInstallerProbe")(function* ( + executable: string, args: ReadonlyArray, env: NodeJS.ProcessEnv, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const collect = Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(brewPath, args, { env, extendEnv: true })); + // reg.exe follows the console code page, including when stdout is redirected. + // Set it before invoking reg so paths survive the UTF-8 stream decoder. + const registryQuery = /(?:^|[\\/])reg\.exe$/i.test(executable); + const registryScript = registryQuery + ? `[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); & ${[executable, ...args] + .map((value) => `'${value.replaceAll("'", "''")}'`) + .join(" ")}; exit $LASTEXITCODE` + : null; + const path = yield* Path.Path; + const resolved = yield* resolveSpawnCommand( + registryScript + ? path.join( + env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ) + : executable, + registryScript + ? [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(registryScript, "utf16le").toString("base64"), + ] + : args, + { env, extendEnv: true }, + ); + const child = yield* spawner.spawn( + ChildProcess.make(resolved.command, resolved.args, { + env, + extendEnv: true, + shell: resolved.shell, + }), + ); yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); - // stderr is drained so a chatty brew cannot block on a full pipe. - const [stdout, exitCode] = yield* Effect.all( + const [stdout, exitCode, stderr] = yield* Effect.all( [ - collectUint8StreamText({ stream: child.stdout, maxBytes: HOMEBREW_INFO_MAX_BYTES }), + collectUint8StreamText({ stream: child.stdout, maxBytes: INSTALLER_PROBE_MAX_BYTES }), child.exitCode, - Stream.runDrain(child.stderr), + collectUint8StreamText({ stream: child.stderr, maxBytes: INSTALLER_PROBE_MAX_BYTES }), ], { concurrency: "unbounded" }, ); - return Number(exitCode) !== 0 || stdout.truncated ? null : stdout.text; + // `reg query /s /f` exits 1 with a summary on stdout when there are no matches. + const noMatches = + args.includes("/s") && + Number(exitCode) === 1 && + stdout.text.trim() !== "" && + stderr.text.trim() === ""; + if (args[0] === "query" && stderr.text.trim() !== "") return null; + return (Number(exitCode) !== 0 && !noMatches) || + stdout.truncated || + stderr.truncated || + stdout.invalidUtf8 + ? null + : stdout.text; }); return yield* collect.pipe( Effect.scoped, - Effect.timeoutOption(Duration.millis(HOMEBREW_INFO_TIMEOUT_MS)), + Effect.timeoutOption(Duration.millis(INSTALLER_PROBE_TIMEOUT_MS)), Effect.map(Option.getOrNull), - Effect.catchCause((cause) => - Effect.logWarning("Homebrew probe failed", { + Effect.catchCause((cause) => { + const interrupts = cause.reasons.filter(Cause.isInterruptReason); + if (interrupts.length > 0) return Effect.failCause(Cause.fromReasons(interrupts)); + return Effect.logWarning("Installer probe failed", { subcommand: args[0], errorTag: causeErrorTag(cause), - }).pipe(Effect.as(null)), + }).pipe(Effect.as(null)); + }), + ); +}); + +const decodeScoopMetadata = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + bucket: Schema.optional(Schema.String), + }), + ), +); +const decodeWingetSource = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + Identifier: Schema.String, + Name: Schema.String, + }), + ), +); + +const readWingetPortableIndex = Effect.fn("readWingetPortableIndex")( + function* (filename: string) { + const fs = yield* FileSystem.FileSystem; + if (Number((yield* fs.stat(filename)).size) > INSTALLER_PROBE_MAX_BYTES) return null; + const sqlite = yield* Effect.promise(async () => + process.versions.bun !== undefined + ? await import("@effect/sql-sqlite-bun/SqliteClient") + : await import("@t3tools/shared/nodeSqliteClient"), + ); + return yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql`SELECT filepath, filetype, symlinktarget FROM portable`; + }).pipe( + Effect.provide(sqlite.layer({ filename, readonly: true })), + Effect.flatMap( + Schema.decodeUnknownEffect( + Schema.Array( + Schema.Struct({ + filepath: Schema.String, + filetype: Schema.Number, + symlinktarget: Schema.NullOr(Schema.String), + }), + ), + ), + ), + ); + }, + Effect.orElseSucceed(() => null), +); + +/** Windows ownership must survive explicit selection, including a shim retargeted off PATH. */ +const resolveWindowsInstaller = Effect.fn("resolveWindowsInstaller")(function* ( + definition: PackageManagedProviderMaintenanceDefinition, + context: ProviderMaintenanceResolutionContext, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const manual = { + ...makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }), + latestVersion: null, + }; + const canonical = (value: string) => normalizeCommandPath(path.normalize(value)); + const realPath = (value: string) => fs.realPath(value).pipe(Effect.orElseSucceed(() => null)); + const read = Effect.fn("readInstallerMetadata")(function* (file: string) { + return yield* collectUint8StreamText({ + stream: fs.stream(file, { bytesToRead: INSTALLER_PROBE_MAX_BYTES + 1 }), + maxBytes: INSTALLER_PROBE_MAX_BYTES, + }).pipe( + Effect.map((result) => (result.truncated || result.invalidUtf8 ? null : result.text)), + Effect.orElseSucceed(() => null), + ); + }); + const resolve = (command: string) => + resolveCommandPath(command, { env: context.env }).pipe( + Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), + ); + const action = ( + windowsInstaller: NonNullable, + executable: string, + args: string[], + lockKey: string, + latestVersion: Exclude, + env = context.env, + ) => + makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + windowsInstaller, + updateExecutable: executable, + updateArgs: args, + updateLockKey: lockKey, + platform: context.platform, + env, + latestVersion, + }); + + const observed = context.resolvedCommandPath.replaceAll("\\", "/"); + // npm prefixes can contain "shims" or "apps" without belonging to Scoop. + const npmShim = + /\.cmd$/i.test(observed) && + (yield* resolveNpmGlobalPrefix(context, definition.npmPackageName)) !== null; + const shim = npmShim ? null : /^(.*)\/shims\/[^/]+\.(?:exe|cmd|ps1)$/i.exec(observed); + if (shim && !/\.exe$/i.test(observed)) return manual; + const shimText = shim ? yield* read(observed.replace(/\.exe$/i, ".shim")) : null; + const targets = [...(shimText ?? "").matchAll(/^\s*path\s*=\s*"([^"\r\n]+)"\s*$/gim)]; + const target = shim ? (targets.length === 1 ? targets[0]![1]! : null) : context.realCommandPath; + const scoop = + !npmShim && + target && + /^(.*)\/apps\/([\w.-]+)\/[^/]+\/(.+)$/i.exec(target.replaceAll("\\", "/")); + if (shim || scoop) { + if (!scoop || !target || !path.isAbsolute(target)) return manual; + const [root, app, relative] = [scoop[1]!, scoop[2]!, scoop[3]!]; + if (/\/microsoft\/winget\//i.test(observed)) return manual; + // Globals beside a Scoop-installed Node belong to npm, not to Node's Scoop package. + if (/^nodejs(?:-lts|\d+)?$/i.test(app)) return null; + if (relative.split(/[\\/]/).includes("..")) return manual; + if (shim && canonical(shim[1]!) !== canonical(root)) return manual; + const current = path.join(root, "apps", app, "current"); + const realCurrent = yield* realPath(current); + const realTarget = yield* realPath(target); + if ( + canonical(shim ? target : observed) !== canonical(path.join(current, relative)) || + !realCurrent || + !realTarget || + canonical(path.join(realCurrent, relative)) !== canonical(realTarget) + ) + return manual; + const install = decodeScoopMetadata((yield* read(path.join(current, "install.json"))) ?? ""); + const bucket = Option.isSome(install) ? install.value.bucket : null; + if (!bucket || !/^[\w][\w.-]*$/.test(bucket)) return manual; + const executable = yield* resolve("scoop"); + const manager = + executable && /^(.*)\/shims\/scoop\.(?:cmd|exe)$/i.exec(executable.replaceAll("\\", "/")); + if (!executable || !manager) return manual; + const managerRoot = manager[1]!; + const global = canonical(root) !== canonical(managerRoot); + if (global) { + const configured = + context.env.SCOOP_GLOBAL ?? + (yield* runInstallerProbe(executable, ["config", "global_path"], context.env)); + if (configured === null) return manual; + const globalRoot = + !configured.trim() || /^'global_path' is not set\s*$/i.test(configured) + ? path.join(context.env.ProgramData ?? "C:\\ProgramData", "scoop") + : configured.trim(); + if (canonical(globalRoot) !== canonical(root)) return manual; + } + return action( + { manager: "scoop", scope: global ? "machine" : "user" }, + executable, + ["update", `${bucket}/${app}`, ...(global ? ["--global"] : [])], + // Updating any app may first refresh this manager and its shared buckets. + `scoop:${canonical((yield* realPath(managerRoot)) ?? managerRoot)}`, + // Local bucket manifests can be stale; keep an explicit update check available. + null, + { ...context.env, SCOOP: managerRoot, ...(global ? { SCOOP_GLOBAL: root } : {}) }, + ); + } + + const packageId = definition.wingetPackageId; + if (!packageId) return null; + const registry = path.join(context.env.SystemRoot ?? "C:\\Windows", "System32", "reg.exe"); + const currentVersion = "Software\\Microsoft\\Windows\\CurrentVersion"; + const uninstall = `${currentVersion}\\Uninstall`; + const entries: Array<{ + productCode: string; + values: Map; + scope: "user" | "machine"; + }> = []; + for (const [hive, scope, view] of [ + ["HKCU", "user", "/reg:64"], + ["HKLM", "machine", "/reg:64"], + ["HKLM", "machine", "/reg:32"], + ] as const) { + const found = yield* runInstallerProbe( + registry, + ["query", `${hive}\\${uninstall}`, "/s", "/f", packageId, "/d", "/e", view], + context.env, + ); + if (found === null) { + // A missing uninstall root is normal on fresh profiles; an unreadable one is not proof. + const parent = `${hive === "HKCU" ? "HKEY_CURRENT_USER" : "HKEY_LOCAL_MACHINE"}\\${currentVersion}`; + const listing = yield* runInstallerProbe(registry, ["query", parent, view], context.env); + const keys = listing?.split(/\r?\n/).map((line) => line.trim().toLowerCase()); + if (keys !== undefined && !keys.includes(`${parent}\\Uninstall`.toLowerCase())) continue; + return manual; + } + for (const key of found.match(/^HKEY_[^\r\n]+/gim) ?? []) { + const record = yield* runInstallerProbe(registry, ["query", key.trim(), view], context.env); + if (record === null) return manual; + const values = new Map( + [...record.matchAll(/^\s*(\w+)\s+REG_(?:SZ|DWORD)\s+(.+?)\s*$/gm)].map((match) => [ + match[1]!, + match[2]!, + ]), + ); + if (values.get("WinGetPackageIdentifier")?.toLowerCase() === packageId.toLowerCase()) + entries.push({ productCode: key.trim().split("\\").at(-1)!, values, scope }); + } + } + const matches = []; + for (const entry of entries) { + const { values, productCode } = entry; + const targetPath = values.get("TargetFullPath"); + // A matching link name alone is insufficient: the link may now point elsewhere. + if (targetPath) { + if (canonical(targetPath) === canonical(context.realCommandPath)) matches.push(entry); + continue; + } + const location = values.get("InstallLocation"); + if ( + !location || + !path.isAbsolute(location) || + !/^[\w.-]+$/.test(productCode) || + !canonical(context.realCommandPath).startsWith(`${canonical(location).replace(/\/+$/, "")}/`) + ) + continue; + // Archive portables record ownership in .db, not TargetFullPath. + const files = yield* readWingetPortableIndex(path.join(location, `${productCode}.db`)); + if (!files) return manual; + const owned = files.filter( + (file) => + file.filetype === 1 && canonical(file.filepath) === canonical(context.realCommandPath), + ); + const links = files.filter( + (file) => + file.filetype === 3 && + file.symlinktarget && + canonical(file.symlinktarget) === canonical(context.realCommandPath), + ); + if (owned.length !== 1 || links.length !== 1) return manual; + const linkTarget = yield* realPath(links[0]!.filepath); + const directoryOnPath = /^(?:0x0*1|1)$/i.test(values.get("InstallDirectoryAddedToPath") ?? ""); + if ( + linkTarget ? canonical(linkTarget) !== canonical(context.realCommandPath) : !directoryOnPath + ) + return manual; + matches.push(entry); + } + if (matches.length === 0) + return /\/microsoft\/winget\//i.test( + `${observed}/${context.realCommandPath.replaceAll("\\", "/")}`, + ) + ? manual + : null; + if (matches.length !== 1) return manual; + const { values, scope } = matches[0]!; + if (values.get("WinGetInstallerType") !== "portable") return manual; + const sourceId = values.get("WinGetSourceIdentifier"); + // --id/--source/--scope cannot distinguish two records with the same identity. + if ( + !sourceId || + entries.filter( + (entry) => entry.scope === scope && entry.values.get("WinGetSourceIdentifier") === sourceId, + ).length !== 1 + ) + return manual; + let executable = yield* resolve("winget"); + if (!executable && context.env.LOCALAPPDATA) { + const alias = path.join(context.env.LOCALAPPDATA, "Microsoft", "WindowsApps", "winget.exe"); + // App Installer's execution alias is a reparse point, not a regular executable. + if (yield* fs.readLink(alias).pipe(Effect.orElseSucceed(() => null))) executable = alias; + } + if (!executable) return manual; + const sources = yield* runInstallerProbe( + executable, + ["source", "export", "--disable-interactivity"], + context.env, + ); + const sourceMatches = (sources ?? "").split(/\r?\n/).flatMap((line) => { + const source = decodeWingetSource(line); + return Option.isSome(source) && source.value.Identifier === sourceId ? [source.value.Name] : []; + }); + if (sourceMatches.length !== 1 || !sourceMatches[0]) return manual; + const selection = ["--id", packageId, "--exact", "--source", sourceMatches[0]]; + const unattended = ["--accept-source-agreements", "--disable-interactivity"]; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const latest = yield* runInstallerProbe( + executable, + ["show", ...selection, "--versions", ...unattended], + context.env, + ).pipe( + Effect.map((versions) => + (versions?.match(/^\s*\d+\.\d+\.\d+(?:-[\w.-]+)?\s*$/gm) ?? []) + .map((version) => version.trim()) + .reduce( + (latest, version) => + latest === null || compareSemverVersions(version, latest) > 0 ? version : latest, + null, + ), ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.cached, + ); + return action( + { manager: "winget", scope }, + executable, + [ + "upgrade", + ...selection, + "--scope", + scope, + // Portable upgrades otherwise relocate custom installs to WinGet's default directory. + "--location", + values.get("InstallLocation") ?? path.dirname(context.realCommandPath), + // Single-file portable upgrades otherwise revert a custom executable name. + ...(values.has("TargetFullPath") ? ["--rename", path.basename(context.realCommandPath)] : []), + ...unattended, + ], + `winget:${sourceId}:${packageId}:${scope}:${canonical(context.realCommandPath)}`, + latest, ); }); @@ -361,6 +737,15 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( if (!context) { return manual; } + if (context.platform === "win32") { + const windows = yield* resolveWindowsInstaller(definition, context).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(INSTALLER_PROBE_TIMEOUT_MS), + orElse: () => Effect.succeed({ ...manual, latestVersion: null }), + }), + ); + if (windows) return windows; + } const commandPaths = [context.resolvedCommandPath, context.realCommandPath]; const packageName = definition.npmPackageName; @@ -445,7 +830,9 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( // A keg-shaped path is only Homebrew's if it sits under the prefix of the // `brew` that would upgrade it; `brew --prefix` is a cheap shell script. const fileSystem = yield* FileSystem.FileSystem; - const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env)); + const brewPrefix = nonEmptyString( + yield* runInstallerProbe(brewPath, ["--prefix"], context.env), + ); const realBrewPrefix = brewPrefix ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix)) : null; @@ -459,7 +846,11 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name]; // Homebrew lags npm by hours on every release, so compare against what // `brew upgrade` can actually deliver. - const info = yield* runHomebrew(brewPath, ["info", "--json=v2", homebrew.name], context.env); + const info = yield* runInstallerProbe( + brewPath, + ["info", "--json=v2", homebrew.name], + context.env, + ); return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, @@ -579,14 +970,44 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( export const makeCachedProviderMaintenanceResolution = Effect.fn( "makeCachedProviderMaintenanceResolution", )(function* (resolve: Effect.Effect) { - const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL( - resolve, - MAINTENANCE_CAPABILITIES_CACHE_TTL, - ); + const semaphore = yield* Semaphore.make(1); + let cached: { value: ProviderMaintenanceCapabilities; expiresAt: number } | undefined; return (options?: { readonly fresh?: boolean }) => - options?.fresh ? invalidate.pipe(Effect.andThen(cached)) : cached; + semaphore.withPermit( + Effect.gen(function* () { + if (options?.fresh) cached = undefined; + if (cached && cached.expiresAt > (yield* Clock.currentTimeMillis)) return cached.value; + const value = yield* resolve; + cached = { + value, + expiresAt: + (yield* Clock.currentTimeMillis) + + Duration.toMillis(MAINTENANCE_CAPABILITIES_CACHE_TTL), + }; + return value; + }), + ); }); +/** Bind the instance's lookup services once; callers need no resolver services. */ +export const makeProviderMaintenanceResolution = Effect.fn("makeProviderMaintenanceResolution")( + function* ( + resolver: ProviderMaintenanceCapabilitiesResolver, + options: { readonly binaryPath: string; readonly env: NodeJS.ProcessEnv }, + ) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(resolver, options).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + }, +); + function deriveVersionAdvisory(input: { readonly currentVersion: string | null; readonly latestVersion: string | null; @@ -659,7 +1080,9 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers maintenanceCapabilities: ProviderMaintenanceCapabilities, ) { if (maintenanceCapabilities.latestVersion !== undefined) { - return maintenanceCapabilities.latestVersion; + return Effect.isEffect(maintenanceCapabilities.latestVersion) + ? yield* maintenanceCapabilities.latestVersion + : maintenanceCapabilities.latestVersion; } const packageName = maintenanceCapabilities.packageName; if (!packageName) { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..42269bf83b25 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1,4 +1,6 @@ import { describe, it, assert } from "@effect/vitest"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodePath from "@effect/platform-node/NodePath"; import { ProviderDriverKind, ProviderInstanceId, @@ -7,9 +9,11 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -154,6 +158,7 @@ function mockSpawnerLayer( function makeRegistry( initialProviders: ServerProvider | ReadonlyArray = baseProvider, + refreshedVersion?: string, ) { return Effect.gen(function* () { const providersRef = yield* Ref.make>( @@ -192,7 +197,16 @@ function makeRegistry( const registry: ProviderRegistryShape = { getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), - refreshInstance: () => Ref.get(providersRef), + refreshInstance: (instanceId) => + refreshedVersion === undefined + ? Ref.get(providersRef) + : Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => + provider.instanceId === instanceId + ? { ...provider, version: refreshedVersion } + : provider, + ), + ), refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), @@ -208,12 +222,14 @@ function makeRegistry( }); } -const makeTestRunner = (registry: ProviderRegistryShape) => +const makeTestRunner = (registry: ProviderRegistryShape, fileSystem = NodeFileSystem.layer) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( Layer.provide( Layer.mergeAll( + fileSystem, + NodePath.layer, Layer.succeed(ProviderRegistry, registry), // Fresh per runner so a version cached by one test cannot leak into another. Layer.sync(ProviderVersionCache, () => new Map()), @@ -224,6 +240,275 @@ const makeTestRunner = (registry: ProviderRegistryShape) => ); describe("providerMaintenanceRunner", () => { + it.effect.each([ + { scope: "user", code: 2316632107 }, + { scope: "machine", code: -1978335189 }, + { scope: undefined, code: 2316632107 }, + ] as const)("verifies WinGet's no-update result: $scope/$code", ({ scope, code }) => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + let refreshed = false; + const runner = yield* makeTestRunner({ + ...registry, + refreshInstance: (instanceId) => + Effect.sync(() => { + refreshed = true; + }).pipe(Effect.andThen(registry.refreshInstance(instanceId))), + getProviderMaintenanceCapabilitiesForInstance: () => { + const capabilities = lifecycleFor(CODEX_DRIVER); + return Effect.succeed({ + ...capabilities, + latestVersion: null, + update: { + ...capabilities.update!, + ...(scope ? { windowsInstaller: { manager: "winget", scope } as const } : {}), + }, + }); + }, + }); + const result = yield* runner.updateProvider(CODEX_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, scope ? "unchanged" : "failed"); + assert.strictEqual(refreshed, scope !== undefined); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => ({ code, stdout: "No available upgrade found." })), + ), + ), + ), + ); + it.effect.each([false, true])( + "keeps the update lock during cancellation: worker started=%s", + (workerStarted) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const secondQueued = yield* Deferred.make(); + const waitingForExit = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + const secondInstanceId = ProviderInstanceId.make("codex_second"); + const events: string[] = []; + let firstStopped = false; + let installStarts = 0; + let exitReads = 0; + const fs = yield* FileSystem.FileSystem; + const { registry } = yield* makeRegistry( + [baseProvider, { ...baseProvider, instanceId: secondInstanceId }], + "0.0.1", + ); + const runner = yield* makeTestRunner( + { + ...registry, + setProviderMaintenanceActionState: (input) => + registry + .setProviderMaintenanceActionState(input) + .pipe( + Effect.tap(() => + input.instanceId === secondInstanceId && input.state?.status === "queued" + ? Deferred.succeed(secondQueued, undefined) + : Effect.void, + ), + ), + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + provider: CODEX_DRIVER, + packageName: null, + update: { + command: "winget upgrade", + executable: "C:\\Tools\\winget.exe", + args: ["upgrade"], + lockKey: "selected-winget", + windowsInstaller: { manager: "winget", scope: "machine" }, + }, + }), + }, + Layer.succeed(FileSystem.FileSystem, { + ...fs, + exists: (file) => + /[\\/]started$/.test(file) ? Effect.succeed(workerStarted) : fs.exists(file), + writeFileString: (file, content, options) => + fs.writeFileString(file, content, options).pipe( + Effect.tap(() => + Effect.sync(() => { + if (content === "cancel") events.push("cancel"); + }), + ), + ), + }), + ).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const executable = (command as unknown as { command: string }).command; + if (/powershell/i.test(executable)) { + return Deferred.succeed(started, undefined).pipe( + Effect.as({ + ...mockHandle({ + exitCode: Effect.gen(function* () { + // The first read collects command completion; cancellation + // must independently await exit before releasing the lock. + if (++exitReads > 1) yield* Deferred.succeed(waitingForExit, undefined); + return yield* Deferred.await(stopped); + }), + }), + kill: () => + Effect.sync(() => { + events.push("kill"); + }), + }), + ); + } + return Effect.sync(() => { + if (++installStarts === 1) return mockHandle({ code: 2316632089 }); + assert.strictEqual( + firstStopped, + true, + "second installer started before the first stopped", + ); + events.push("second spawn"); + return mockHandle({ code: 0 }); + }); + }), + ), + ); + const first = yield* runner.updateProvider(CODEX_DRIVER).pipe(Effect.forkChild); + yield* Deferred.await(started); + const second = yield* runner + .updateProvider({ provider: CODEX_DRIVER, instanceId: secondInstanceId }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondQueued); + const interrupt = yield* Fiber.interrupt(first).pipe(Effect.forkChild); + yield* Deferred.await(waitingForExit); + assert.deepStrictEqual(events, workerStarted ? ["cancel"] : ["cancel", "kill"]); + assert.strictEqual(installStarts, 1); + firstStopped = true; + events.push("stopped"); + yield* Deferred.succeed(stopped, ChildProcessSpawner.ExitCode(1460)); + yield* Fiber.join(interrupt); + const result = yield* Fiber.join(second); + assert.strictEqual( + result.providers.find((provider) => provider.instanceId === secondInstanceId)?.updateState + ?.status, + "succeeded", + ); + assert.strictEqual(installStarts, 2); + assert.deepStrictEqual( + events, + workerStarted + ? ["cancel", "stopped", "kill", "second spawn"] + : ["cancel", "kill", "stopped", "kill", "second spawn"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeFileSystem.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(SpawnExecutableResolution, (command) => command), + latestVersionHttpClient("0.0.1"), + ), + ), + ), + ); + for (const manager of ["winget", "scoop"] as const) { + it.effect.each( + ( + [ + { status: "succeeded", calls: 2 }, + { + code: manager === "winget" ? -1978335207 : 1, + version: "", + status: "unchanged", + calls: 2, + }, + { elevatedCode: 1223, status: "failed", calls: 2 }, + { elevatedCode: 1460, status: "failed", calls: 2 }, + { eligible: false, status: "failed", calls: 1 }, + { verified: false, status: "failed", calls: 1 }, + { platform: "linux", status: "failed", calls: 1 }, + { code: 1, stdout: "Some other failure", status: "failed", calls: 1 }, + { code: 2, status: "failed", calls: 1 }, + { code: 0, status: "succeeded", calls: manager === "scoop" ? 2 : 1 }, + { code: 0, stdout: "Updated successfully", status: "succeeded", calls: 1 }, + ] as const + ).map((scenario) => ({ + platform: "win32" as const, + eligible: true, + code: manager === "winget" ? 2316632089 : 1, + stdout: + manager === "scoop" ? "ERROR: You need admin rights to update global apps.\r\n" : "", + verified: true, + elevatedCode: 0, + version: "0.0.1", + ...scenario, + })), + )( + `gates ${manager} UAC retry: $platform/$eligible/$verified/$code/$elevatedCode/$version`, + (scenario) => { + const commands: string[] = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider, scenario.version); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + provider: CODEX_DRIVER, + packageName: null, + latestVersion: "0.0.1", + update: { + command: manager, + executable: `C:/Tools/${manager}.${manager === "scoop" ? "cmd" : "exe"}`, + args: + manager === "scoop" + ? ["update", "main/tool", ...(scenario.eligible ? ["--global"] : [])] + : [ + "upgrade", + "--id", + "Example.Tool", + "--scope", + scenario.eligible ? "machine" : "user", + ], + lockKey: "selected-installer", + ...(scenario.verified + ? { + windowsInstaller: { + manager, + scope: scenario.eligible ? ("machine" as const) : ("user" as const), + }, + } + : {}), + }, + }), + }); + const result = yield* runner.updateProvider(CODEX_DRIVER); + assert.strictEqual(commands.length, scenario.calls); + assert.strictEqual(result.providers[0]?.updateState?.status, scenario.status); + if (scenario.elevatedCode === 1223) { + assert.match(result.providers[0]?.updateState?.message ?? "", /cancelled.*declined/); + } + if (scenario.elevatedCode === 1460) { + assert.strictEqual(result.providers[0]?.updateState?.message, "Update timed out."); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, scenario.platform), + Layer.succeed(SpawnExecutableResolution, (command) => command), + latestVersionHttpClient("0.0.1"), + mockSpawnerLayer((command) => { + commands.push(command); + return { + code: commands.length === 1 ? scenario.code : scenario.elevatedCode, + stdout: commands.length === 1 ? scenario.stdout : "", + }; + }), + ), + ), + ); + }, + ); + } + it.effect("runs the allowlisted provider update command and records success", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { @@ -256,15 +541,57 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("reports unchanged when the updater exits 0 but the provider is gone", () => { + it.effect.each([ + { before: "1.0.0", after: "1.0.0", status: "unchanged" }, + { before: "1.0.0", after: "0.9.0", status: "unchanged" }, + { before: "1.0.0", after: "1.2.0", status: "succeeded" }, + { before: null, after: "1.2.0", status: "succeeded" }, + ])( + "verifies version advancement with unknown latest: $before -> $after", + ({ before, after, status }) => + Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ + ...baseProvider, + version: before, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + ...lifecycleFor(CODEX_DRIVER), + latestVersion: null, + }), + refreshInstance: () => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, version: after })), + ), + }); + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, status); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("1.2.0"), + mockSpawnerLayer(() => ({ stdout: "completed" })), + ), + ), + ), + ); + + it.effect.each([ + { installed: false, version: null }, + { installed: true, version: null }, + { installed: true, version: "" }, + ])("reports unchanged after exit 0: $installed/$version", ({ installed, version }) => { return Effect.gen(function* () { const { registry, providersRef } = yield* makeRegistry(baseProvider); - // After the update, the refreshed snapshot no longer sees an install. + // After the update, the refreshed snapshot cannot verify the install/version. const updater = yield* makeTestRunner({ ...registry, refreshInstance: () => Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, installed: false, version: null })), + providers.map((provider) => ({ ...provider, installed, version })), ), }); @@ -487,7 +814,7 @@ describe("providerMaintenanceRunner", () => { () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); + const { registry } = yield* makeRegistry(baseProvider, "0.0.1"); const runner = yield* makeTestRunner(registry); const result = yield* runner.updateProvider(CODEX_DRIVER); @@ -520,18 +847,21 @@ describe("providerMaintenanceRunner", () => { const personalInstanceId = ProviderInstanceId.make("codex_personal"); const workInstanceId = ProviderInstanceId.make("codex_work"); const refreshedInstanceIds: Array = []; - const { registry } = yield* makeRegistry([ - { - ...baseProvider, - instanceId: personalInstanceId, - version: "0.124.0-alpha.3", - }, - { - ...baseProvider, - instanceId: workInstanceId, - version: "0.124.0-alpha.3", - }, - ]); + const { registry } = yield* makeRegistry( + [ + { + ...baseProvider, + instanceId: personalInstanceId, + version: "0.124.0-alpha.3", + }, + { + ...baseProvider, + instanceId: workInstanceId, + version: "0.124.0-alpha.3", + }, + ], + "0.124.0-alpha.4", + ); const updater = yield* makeTestRunner({ ...registry, getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) => @@ -572,6 +902,8 @@ describe("providerMaintenanceRunner", () => { assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); assert.strictEqual(result.providers[1]?.instanceId, workInstanceId); assert.strictEqual(result.providers[1]?.updateState, undefined); + assert.strictEqual(result.providers[0]?.version, "0.124.0-alpha.4"); + assert.strictEqual(result.providers[1]?.version, "0.124.0-alpha.3"); }).pipe( Effect.provide( Layer.mergeAll( @@ -765,7 +1097,7 @@ describe("providerMaintenanceRunner", () => { it.effect("accepts arbitrary driver-provided update lock keys", () => { const calls: Array = []; return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); + const { registry } = yield* makeRegistry(baseProvider, "0.0.1"); const updater = yield* makeTestRunner({ ...registry, getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => @@ -801,7 +1133,7 @@ describe("providerMaintenanceRunner", () => { "releases the running-provider marker when interrupted after queuing but before the lock run starts", () => Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); + const { registry } = yield* makeRegistry(baseProvider, "0.0.1"); let blockQueuedState = true; const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} }; const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} }; @@ -850,14 +1182,21 @@ describe("providerMaintenanceRunner", () => { ); it.effect("resolves npm to a .cmd shim and routes through the shell on win32", () => { + const env = { PATH: "C:\\selected tools", SCOOP: "C:\\Scoop Root" }; const captured: Array<{ readonly command: string; readonly args: ReadonlyArray; readonly shell: boolean | string | undefined; }> = []; return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const runner = yield* makeTestRunner(registry); + const { registry } = yield* makeRegistry(baseProvider, "0.0.1"); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => { + const capabilities = lifecycleFor(provider); + return Effect.succeed({ ...capabilities, update: { ...capabilities.update!, env } }); + }, + }); const result = yield* runner.updateProvider(CODEX_DRIVER); @@ -886,9 +1225,10 @@ describe("providerMaintenanceRunner", () => { PATH: "C:\\fake\\npm", PATHEXT: ".COM;.EXE;.BAT;.CMD", }), - Layer.succeed(SpawnExecutableResolution, (command) => - command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, - ), + Layer.succeed(SpawnExecutableResolution, (command, _platform, resolvedEnv) => { + assert.strictEqual(resolvedEnv?.PATH, env.PATH); + return command === "npm" ? `${env.PATH}\\npm.cmd` : undefined; + }), latestVersionHttpClient("0.0.0"), Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index e91560312566..fee34d6bd90e 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -8,14 +8,18 @@ import { type ServerProviderUpdateState, } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; @@ -30,6 +34,7 @@ import { } from "./providerMaintenance.ts"; import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { prepareWindowsUpdateElevation } from "./windowsUpdateElevation.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; @@ -78,6 +83,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly command: string; readonly args: ReadonlyArray; readonly env?: NodeJS.ProcessEnv; + readonly cancel?: Effect.Effect; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -86,7 +92,10 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR // which a bare ChildProcess.spawn cannot launch (spawn npm ENOENT); // resolveSpawnCommand finds the real `.cmd` and routes it through the // shell. On Linux/macOS (incl. the WSL backend) this is a no-op. - const resolved = yield* resolveSpawnCommand(input.command, input.args); + const resolved = yield* resolveSpawnCommand(input.command, input.args, { + ...(input.env ? { env: input.env } : {}), + extendEnv: true, + }); const child = yield* input.spawner .spawn( ChildProcess.make(resolved.command, resolved.args, { @@ -103,7 +112,19 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }), ), ); - yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (input.cancel) { + const workerStarted = yield* input.cancel; + // Keep the installation lock until the elevated worker has stopped. + // If UAC is still pending, stopping the launcher is safe: a late + // worker sees cancellation/a dead parent before starting WinGet. + if (!workerStarted) yield* child.kill().pipe(Effect.ignore); + yield* child.exitCode.pipe(Effect.ignore); + } + yield* child.kill().pipe(Effect.ignore); + }), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ @@ -187,12 +208,24 @@ function failureMessage(result: ProviderMaintenanceCommandResult): string { return "Update command failed."; } +// Scoop's PowerShell entry point can swallow the update script's exit 1. +const requiresWindowsAdministrator = ( + action: ProviderMaintenanceCommandAction, + result: ProviderMaintenanceCommandResult, +) => + action.windowsInstaller?.scope === "machine" && + !result.timedOut && + (action.windowsInstaller.manager === "winget" + ? result.exitCode !== null && result.exitCode >>> 0 === 0x8a150019 + : (result.exitCode === 0 || result.exitCode === 1) && + result.stdout.trim() === "ERROR: You need admin rights to update global apps."); + function isOutdatedProvider(provider: ServerProvider | undefined): boolean { return provider?.versionAdvisory?.status === "behind_latest"; } function isStillInstalled(provider: ServerProvider): boolean { - return provider.installed; + return provider.installed && (provider.driver === "cursor" || Boolean(provider.version?.trim())); } function makeUpdateState(input: { @@ -216,6 +249,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; + const platform = yield* HostProcessPlatform; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) => runProviderMaintenanceCommandWithSpawner({ spawner, @@ -375,7 +411,60 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } - const result = yield* runMaintenanceCommand(fresh.update); + const versionBeforeUpdate = (yield* providerRegistry.getProviders) + .find( + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, + ) + ?.version?.trim(); + let result: ProviderMaintenanceCommandResult = yield* runMaintenanceCommand( + fresh.update, + ); + const needsElevation = + platform === "win32" && requiresWindowsAdministrator(fresh.update, result); + if (needsElevation) { + yield* setUpdateState( + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message: "Approve the Windows administrator prompt to update this provider.", + }), + ); + const elevatedUpdate = fresh.update; + result = yield* Effect.gen(function* () { + const elevated = yield* prepareWindowsUpdateElevation( + elevatedUpdate, + UPDATE_TIMEOUT_MS, + UPDATE_OUTPUT_MAX_BYTES, + ); + const result = yield* runProviderMaintenanceCommandWithSpawner({ + spawner, + ...elevated, + }); + const [stdout, stderr] = yield* elevated.readOutput; + return { + ...result, + stdout: stdout?.text || result.stdout, + stderr: stderr?.text || result.stderr, + stdoutTruncated: stdout?.truncated ?? result.stdoutTruncated, + stderrTruncated: stderr?.truncated ?? result.stderrTruncated, + timedOut: result.timedOut || result.exitCode === 1460, + }; + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.scoped, + ); + } + // WinGet reports "no applicable update" as a nonzero exit. Still + // verify the selected provider instead of presenting this as failure. + if ( + fresh.update.windowsInstaller?.manager === "winget" && + result.exitCode !== null && + result.exitCode >>> 0 === 0x8a15002b + ) { + result = { ...result, exitCode: 0 }; + } const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -383,7 +472,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "failed", startedAt, finishedAt, - message: failureMessage(result), + message: + needsElevation && result.exitCode === 1223 + ? "Update cancelled: the Windows administrator prompt was declined." + : failureMessage(result), output: commandOutput(result), }), ); @@ -402,24 +494,35 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); // "Succeeded" needs the provider to still be installed: an // installer that exits 0 and leaves the binary missing is not a - // success. A missing version alone is not held against it, since - // Cursor's `about` probe can fail transiently on a healthy binary. + // success. Only Cursor tolerates a missing version, since its + // `about` probe can fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); const stillOutdated = verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider), ); + const versionUnchanged = + provider !== "cursor" && + !verifiedProviders.some( + (verifiedProvider) => + verifiedProvider.version?.trim() && + (!versionBeforeUpdate || + compareSemverVersions(versionBeforeUpdate, verifiedProvider.version) < 0), + ); return yield* finish( makeUpdateState({ - status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", + status: + couldNotVerify || stillOutdated || versionUnchanged ? "unchanged" : "succeeded", startedAt, finishedAt, message: couldNotVerify ? "Update command completed, but T3 Code could not verify the provider version." : stillOutdated ? "Update command completed, but T3 Code still detects an outdated provider version." - : "Provider updated.", + : versionUnchanged + ? "Update command completed, but the provider version did not advance." + : "Provider updated.", output: commandOutput(result), }), ); diff --git a/apps/server/src/provider/windowsUpdateElevation.test.ts b/apps/server/src/provider/windowsUpdateElevation.test.ts new file mode 100644 index 000000000000..24e3b4ca763c --- /dev/null +++ b/apps/server/src/provider/windowsUpdateElevation.test.ts @@ -0,0 +1,102 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { prepareWindowsUpdateElevation } from "./windowsUpdateElevation.ts"; +const decodeOutput = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +// Exercise the real PowerShell/Win32 argv boundary without requesting UAC or +// launching a provider. Only the launcher's RunAs verb is removed in the test. +for (const { mode, expectedCode } of [ + { mode: "complete", expectedCode: 7 }, + { mode: "cmd-complete", expectedCode: 7 }, + { mode: "cancel", expectedCode: 1460 }, + { mode: "expired", expectedCode: 1460 }, + { mode: "timeout", expectedCode: 1460 }, + { mode: "cmd-timeout", expectedCode: 1460 }, + { mode: "declined", expectedCode: 1223 }, +]) { + it.effect.skipIf(HostProcessPlatform.defaultValue() !== "win32")( + `runs the Windows elevation worker without UAC: ${mode}`, + () => + Effect.gen(function* () { + const timeout = mode.endsWith("timeout"); + const args = [ + "C:\\Program Files\\tool\\", + "private source", + "O'Brien & $literal", + 'a"b', + "caffè", + ]; + const program = timeout + ? "console.log('started'); setTimeout(() => process.exit(99), 30000)" + : "process.stdout.write(JSON.stringify({ args: process.argv.slice(2), env: process.env.T3_UPDATE_TEST, scoop: process.env.SCOOP, global: process.env.SCOOP_GLOBAL })); process.exit(7)"; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3 update O'Brien & " }); + const script = path.join(directory, "fixture.js"); + yield* fs.writeFileString(script, program); + const shim = path.join(directory, "scoop.cmd"); + yield* fs.writeFileString(shim, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + const cmd = mode.startsWith("cmd-"); + const prepared = yield* prepareWindowsUpdateElevation( + { + command: "fixture", + executable: cmd ? shim : process.execPath, + args: cmd ? args : [script, ...args], + lockKey: "fixture", + env: { + T3_UPDATE_TEST: "value ' with spaces & $data", + t3_update_test: "shadowed", + SCOOP: directory, + SCOOP_GLOBAL: path.join(directory, "global apps"), + }, + }, + mode === "expired" ? 0 : timeout ? 3_000 : 15_000, + 10_000, + ); + if (mode === "cancel") yield* prepared.cancel; + const launcher = Buffer.from(prepared.args[3]!, "base64").toString("utf16le"); + const testLauncher = + mode === "declined" + ? launcher.replace( + "$child = [Diagnostics.Process]::Start($start)", + "throw [InvalidOperationException]::new('fixture', [ComponentModel.Win32Exception]::new(1223))", + ) + : launcher.replace("$start.Verb = 'runas'", "$start.Verb = ''"); + const encoded = Buffer.from(testLauncher, "utf16le").toString("base64"); + const exitCode = yield* Effect.promise( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile( + prepared.command, + [...prepared.args.slice(0, 3), encoded], + { windowsHide: true }, + (error) => { + if (error && typeof error.code !== "number") reject(error); + else resolve(typeof error?.code === "number" ? error.code : 0); + }, + ); + }), + ); + const [stdout, stderr] = yield* prepared.readOutput; + assert.strictEqual(exitCode, expectedCode, stderr?.text); + if (mode.endsWith("complete")) { + assert.deepStrictEqual(yield* decodeOutput(stdout?.text), { + args, + env: "value ' with spaces & $data", + scoop: directory, + global: path.join(directory, "global apps"), + }); + } else if (!timeout) { + assert.strictEqual(stdout?.text, ""); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} diff --git a/apps/server/src/provider/windowsUpdateElevation.ts b/apps/server/src/provider/windowsUpdateElevation.ts new file mode 100644 index 000000000000..9d9f0efe69ef --- /dev/null +++ b/apps/server/src/provider/windowsUpdateElevation.ts @@ -0,0 +1,149 @@ +import * as NodeCrypto from "node:crypto"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import type { ProviderMaintenanceCommandAction } from "./providerMaintenance.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; + +const quotePowerShell = (value: string) => `'${value.replaceAll("'", "''")}'`; +const encodePowerShell = (value: string) => Buffer.from(value, "utf16le").toString("base64"); +const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +// Start-Process joins ArgumentList with spaces, so quote for the native argv parser. +const quoteWindowsArgument = (value: string) => + `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`; + +export const prepareWindowsUpdateElevation = Effect.fn("prepareWindowsUpdateElevation")(function* ( + update: ProviderMaintenanceCommandAction, + timeoutMs: number, + maxOutputBytes: number, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-update-" }); + const payloadPath = path.join(directory, "command.json"); + const cancelPath = path.join(directory, "cancel"); + const startedPath = path.join(directory, "started"); + const stdoutPath = path.join(directory, "stdout"); + const stderrPath = path.join(directory, "stderr"); + const env = { ...environment, ...update.env }; + const envKeys = new Set(); + // Match Node's Windows spawn: the first sorted spelling wins. An array + // also avoids PowerShell rejecting JSON properties such as Path and PATH. + const variables = Object.keys(env) + .sort() + .flatMap((key) => { + const canonical = key.toUpperCase(); + if (envKeys.has(canonical) || env[key] === undefined) return []; + envKeys.add(canonical); + return [[key, env[key]]]; + }); + const resolved = yield* resolveSpawnCommand(update.executable, update.args, { + env, + extendEnv: true, + }); + const payload = yield* encodePayload({ + executable: resolved.shell + ? path.join(environment.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe") + : resolved.command, + arguments: resolved.shell + ? `/d /s /c "${[resolved.command, ...resolved.args].join(" ")}"` + : resolved.args.map(quoteWindowsArgument).join(" "), + env: variables, + stdout: stdoutPath, + stderr: stderrPath, + }); + yield* fs.writeFileString(payloadPath, payload, { mode: 0o600 }); + const digest = NodeCrypto.createHash("sha256").update(payload).digest("hex"); + const powershell = path.join( + environment.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + // The elevated worker owns the updater and its cancellation. Killing only + // the unelevated launcher cannot stop an administrator process. + const worker = ` +$ErrorActionPreference = 'Stop' +[IO.File]::WriteAllText(${quotePowerShell(startedPath)}, 'started') +$parent = Get-Process -Id __T3_PARENT_PID__ -ErrorAction Stop +$deadline = [DateTime]::Parse('__T3_DEADLINE__').ToUniversalTime() +$payloadPath = ${quotePowerShell(payloadPath)} +$cancelPath = ${quotePowerShell(cancelPath)} +$child = $null +try { + if ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -ne '__T3_USER_SID__') { + throw 'The update must be approved by the same Windows user.' + } + $bytes = [IO.File]::ReadAllBytes($payloadPath) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + if ($hash -ne '${digest}') { throw 'Update command changed before elevation.' } + $config = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json + foreach ($variable in $config.env) { + [Environment]::SetEnvironmentVariable($variable[0], $variable[1], 'Process') + } + if ((Test-Path -LiteralPath $cancelPath) -or [DateTime]::UtcNow -ge $deadline -or $parent.HasExited) { exit 1460 } + $child = Start-Process -FilePath $config.executable -ArgumentList $config.arguments -WindowStyle Hidden -RedirectStandardOutput $config.stdout -RedirectStandardError $config.stderr -PassThru + $null = $child.Handle + while (-not $child.WaitForExit(200)) { + if ($parent.HasExited -or (Test-Path -LiteralPath $cancelPath) -or [DateTime]::UtcNow -ge $deadline) { exit 1460 } + } + exit $child.ExitCode +} catch { + [IO.File]::WriteAllText(${quotePowerShell(stderrPath)}, $_.Exception.Message) + exit 1 +} finally { + if ($null -ne $child -and -not $child.HasExited) { + & "$PSHOME\\..\\..\\taskkill.exe" /PID $child.Id /T /F | Out-Null + if (-not $child.HasExited) { $child.Kill() } + $child.WaitForExit() + } +} +`; + const launcher = ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$worker = ${quotePowerShell(worker)} +$worker = $worker.Replace('__T3_PARENT_PID__', [string]$PID).Replace('__T3_USER_SID__', [Security.Principal.WindowsIdentity]::GetCurrent().User.Value).Replace('__T3_DEADLINE__', [DateTime]::UtcNow.AddMilliseconds(${timeoutMs}).ToString('o')) +$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($worker)) +try { + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = ${quotePowerShell(powershell)} + $start.Arguments = "-NoProfile -NonInteractive -EncodedCommand $encoded" + $start.UseShellExecute = $true + $start.Verb = 'runas' + $start.WindowStyle = 'Hidden' + $child = [Diagnostics.Process]::Start($start) + $child.WaitForExit() + exit $child.ExitCode +} catch { + $failure = $_.Exception + while ($null -ne $failure) { + if ($failure -is [ComponentModel.Win32Exception] -and $failure.NativeErrorCode -eq 1223) { exit 1223 } + $failure = $failure.InnerException + } + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} +`; + return { + command: powershell, + args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(launcher)], + cancel: fs + .writeFileString(cancelPath, "cancel") + .pipe(Effect.andThen(fs.exists(startedPath)), Effect.orDie), + readOutput: Effect.all( + [stdoutPath, stderrPath].map((file) => + collectUint8StreamText({ stream: fs.stream(file), maxBytes: maxOutputBytes }).pipe( + Effect.orElseSucceed(() => ({ text: "", truncated: false })), + ), + ), + ), + }; +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 12f63f3e16fd..84e55d5e4fd5 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -24,6 +24,7 @@ import { getProviderUpdateSidebarPillView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, + isProviderSettingsUpdateCandidate, isTerminalProviderUpdatePhase, localEnvironmentUpdateNotificationKey, providerUpdateNotificationKey, @@ -90,6 +91,26 @@ function updateCandidate(input: Parameters[0]): ProviderUpdateC } describe("provider update launch notification logic", () => { + it("allows verified unknown-version updates only from Settings", () => { + const candidate = provider({ + driver: driver("codex"), + advisoryStatus: "unknown", + latestVersion: null, + }); + expect(isProviderSettingsUpdateCandidate(candidate)).toBe(true); + expect(isProviderUpdateCandidate(candidate)).toBe(false); + for (const override of [ + { enabled: false }, + { advisoryStatus: "current" as const }, + { canUpdate: false }, + { updateCommand: null }, + ]) + expect( + isProviderSettingsUpdateCandidate( + provider({ driver: driver("codex"), advisoryStatus: "unknown", ...override }), + ), + ).toBe(false); + }); it("detects enabled providers with a latest-version advisory", () => { expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true); expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe( @@ -365,11 +386,12 @@ describe("provider update launch notification logic", () => { }); }); - it("keeps unchanged providers actionable from settings", () => { + it("reports the reason for an unchanged provider without claiming it is outdated", () => { const view = getProviderUpdateProgressToastView({ providers: [ provider({ driver: driver("cursor"), + advisoryStatus: "unknown", updateState: { status: "unchanged", startedAt: checkedAt, @@ -385,8 +407,8 @@ describe("provider update launch notification logic", () => { expect(view).toMatchObject({ phase: "unchanged", type: "warning", - title: "Provider still needs an update", - description: "Cursor still appears outdated. Check provider settings for details.", + title: "Provider unchanged", + description: "still old", }); }); @@ -556,6 +578,7 @@ describe("provider update launch notification logic", () => { [ provider({ driver: driver("cursor"), + advisoryStatus: "current", updateState: { status: "unchanged", startedAt: checkedAt, @@ -571,7 +594,8 @@ describe("provider update launch notification logic", () => { expect(view).toMatchObject({ key: "unchanged:cursor:2026-04-23T10:00:00.000Z:still old", tone: "warning", - title: "Cursor still needs an update", + title: "Cursor unchanged", + description: "still old", dismissible: true, }); }); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 6da4eaac6dde..a5e0831e55f0 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -161,7 +161,8 @@ export function isProviderSettingsUpdateCandidate( ): provider is ProviderSettingsUpdateCandidate { return ( provider.enabled && - provider.versionAdvisory?.status === "behind_latest" && + (provider.versionAdvisory?.status === "behind_latest" || + provider.versionAdvisory?.status === "unknown") && provider.versionAdvisory.canUpdate === true && provider.versionAdvisory.updateCommand !== null ); @@ -270,6 +271,13 @@ export function getProviderUpdateRejectedToastView( }; } +function getUnchangedProviderUpdateDescription(providers: ReadonlyArray): string { + return ( + (providers.length === 1 ? providers[0]?.updateState?.message : null) ?? + "No provider update was verified. Review provider settings for details." + ); +} + export function getProviderUpdateProgressToastView(input: { readonly providers: ReadonlyArray; readonly providerCount: number; @@ -292,13 +300,8 @@ export function getProviderUpdateProgressToastView(input: { return { phase: "unchanged", type: "warning", - title: - unchangedProviders.length === 1 - ? "Provider still needs an update" - : "Providers still need updates", - description: `${formatProviderList(unchangedProviders)} ${ - unchangedProviders.length === 1 ? "still appears" : "still appear" - } outdated. Check provider settings for details.`, + title: unchangedProviders.length === 1 ? "Provider unchanged" : "Providers unchanged", + description: getUnchangedProviderUpdateDescription(unchangedProviders), }; } @@ -461,11 +464,9 @@ export function getProviderUpdateSidebarPillView( tone: "warning", title: unchangedProviders.length === 1 - ? `${unchangedProviderName} still needs an update` - : `${unchangedProviders.length} providers still need updates`, - description: `${formatProviderList(unchangedProviders)} ${ - unchangedProviders.length === 1 ? "still appears" : "still appear" - } outdated. Review provider settings for details.`, + ? `${unchangedProviderName} unchanged` + : `${unchangedProviders.length} providers unchanged`, + description: getUnchangedProviderUpdateDescription(unchangedProviders), dismissible: true, }); } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 327b48c2d44a..b478b18f7c35 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -9,6 +9,7 @@ import { LockIcon, LockOpenIcon, PlusIcon, + RefreshCwIcon, Trash2Icon, XIcon, } from "lucide-react"; @@ -437,6 +438,7 @@ export function ProviderInstanceCard({ : null; const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); + const VersionAdvisoryIcon = versionAdvisory?.isCheck ? RefreshCwIcon : ArrowUpCircleIcon; const updateCommand = versionAdvisory?.updateCommand ?? null; const FallbackIconComponent = driverOption?.icon; const displayName = @@ -641,15 +643,19 @@ export function ProviderInstanceCard({ copyToClipboard(updateCommand, { providerName: displayName }) } > - + } /> Copy update command ) : ( - - + + ) ) : null} @@ -704,9 +710,9 @@ export function ProviderInstanceCard({ ? "text-warning hover:text-warning" : "text-muted-foreground hover:text-foreground", )} - aria-label="Update available — view details" + aria-label={`${versionAdvisory.title} — view details`} > - + } /> @@ -718,7 +724,7 @@ export function ProviderInstanceCard({

- Update available + {versionAdvisory.title}

{isUpdating ? : } - {isUpdating ? "Updating" : "Update now"} + {isUpdating + ? "Updating" + : versionAdvisory.isCheck + ? versionAdvisory.title + : "Update now"} ) : null} {onRunUpdate && updateCommand ? ( diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts index 46dc7e262512..2b80c63a72d0 100644 --- a/apps/web/src/components/settings/providerStatus.test.ts +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -1,7 +1,28 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { getProviderSummary } from "./providerStatus"; +import { getProviderSummary, getProviderVersionAdvisoryPresentation } from "./providerStatus"; + +it("presents an unknown version only when the installer can check for updates", () => { + const advisory = { + status: "unknown" as const, + currentVersion: "1.0.0", + latestVersion: null, + updateCommand: "scoop update main/claude-code", + canUpdate: true, + checkedAt: null, + message: null, + }; + expect(getProviderVersionAdvisoryPresentation(advisory)).toMatchObject({ + title: "Check for updates", + isCheck: true, + }); + expect(getProviderVersionAdvisoryPresentation({ ...advisory, canUpdate: false })).toBeNull(); + expect(getProviderVersionAdvisoryPresentation({ ...advisory, updateCommand: null })).toBeNull(); + expect( + getProviderVersionAdvisoryPresentation({ ...advisory, status: "behind_latest" }), + ).toMatchObject({ title: "Update available", isCheck: false }); +}); const provider: ServerProvider = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 90c618f5daa7..f33be4f2767d 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -102,19 +102,35 @@ export function getProviderVersionLabel(version: string | null | undefined) { export function getProviderVersionAdvisoryPresentation( advisory: ServerProviderVersionAdvisory | undefined, ): { + readonly title: string; + readonly isCheck: boolean; readonly detail: string; readonly updateCommand: string | null; readonly emphasis: "normal" | "strong"; } | null { - if (!advisory || advisory.status === "current" || advisory.status === "unknown") { + if (!advisory || advisory.status === "current") { return null; } + if (advisory.status === "unknown") { + return advisory.canUpdate && advisory.updateCommand + ? { + title: "Check for updates", + isCheck: true, + detail: + "The latest version is unknown. Run the installer to check for and install updates.", + updateCommand: advisory.updateCommand, + emphasis: "normal", + } + : null; + } const label = "Update available"; const version = advisory.latestVersion; const versionLabel = getProviderVersionLabel(version); return { + title: label, + isCheck: false, detail: advisory.message ?? (versionLabel diff --git a/docs/user/install.md b/docs/user/install.md index 17e9291bf1a0..04c4d8b77759 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -83,10 +83,16 @@ Cursor's executable is `cursor-agent`, although its login command is When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** appears only when T3 Code can tell which -installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, -bun, or Vite+ install) and runs that installer. Otherwise update the CLI the same -way you installed it. Homebrew installs compare against the version Homebrew -offers, which can trail the npm release by a few hours. +installer owns the CLI (its own update command, Homebrew, Scoop, portable WinGet, +or a global npm, pnpm, bun, or Vite+ install) and runs that installer. Otherwise +update the CLI the same way you installed it. Homebrew and WinGet installs +compare against their installer's available version, which can trail the npm release. +For Scoop, or when that version is unknown but the installer is verified, **Check for updates** +runs the installer to check for and install an update. + +On Windows, a global Scoop or machine-wide WinGet update requests administrator approval when +needed; T3 Code itself can run normally. Approve the UAC prompt on the server's +Windows desktop using the same account. Declining the prompt cancels the update. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom From d66842cab60d31c0428af4e9526339b432972ccb Mon Sep 17 00:00:00 2001 From: ettoc00 <41289053+ettoc00@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:00:40 +0200 Subject: [PATCH 2/6] fix(server): verify selected provider installations --- .../provider/AntigravityInstallation.test.ts | 46 ++++ .../src/provider/AntigravityInstallation.ts | 29 ++- .../Drivers/AntigravityDriver.test.ts | 86 ++++++- .../src/provider/Drivers/AntigravityDriver.ts | 38 ++- .../src/provider/Drivers/CursorDriver.test.ts | 60 +++++ .../src/provider/Drivers/CursorDriver.ts | 32 +-- .../src/provider/Drivers/OpenCodeDriver.ts | 2 + .../Layers/AntigravityProvider.test.ts | 35 +++ .../provider/Layers/AntigravityProvider.ts | 33 ++- .../makeManagedServerProvider.test.ts | 2 + .../src/provider/providerMaintenance.test.ts | 2 + .../src/provider/providerMaintenance.ts | 59 +++-- .../providerMaintenanceRunner.test.ts | 231 +++++++++++++++--- .../src/provider/providerMaintenanceRunner.ts | 84 +++++-- .../provider/windowsUpdateElevation.test.ts | 1 + 15 files changed, 637 insertions(+), 103 deletions(-) diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index e2bab831712a..0c35200639c9 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -737,6 +737,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }); expect(yield* installation.resolve(externalExecutable)).toMatchObject({ executablePath: externalExecutable, + resolvedCommandPath: externalExecutable, source: "override", managedVersionDirectory: null, }); @@ -768,6 +769,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { ).toMatchObject({ source: "path", executablePath: externalExecutable, + resolvedCommandPath: externalExecutable, }); expect( yield* isolated.installation.resolve(executableName, { PATH: externalDirectory }), @@ -775,6 +777,50 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }), ); + it.effect.skipIf(hostPlatform !== "win32")( + "preserves a selected Scoop alias while resolving its paired runtime", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-scoop-test-" }); + const scoopRoot = path.join(baseDir, "Scoop Root"); + const current = path.join(scoopRoot, "apps", "fixture-antigravity-runtime", "current"); + const shims = path.join(scoopRoot, "shims"); + const executable = path.join(current, executableName); + const harness = path.join(current, harnessName); + const alias = path.join(shims, executableName); + yield* fs.makeDirectory(current, { recursive: true }); + yield* fs.makeDirectory(shims, { recursive: true }); + yield* fs.writeFileString(executable, serverContents); + yield* fs.writeFileString(harness, harnessContents); + yield* fs.writeFileString(alias, "scoop shim"); + yield* fs.writeFileString(alias.replace(/\.exe$/iu, ".shim"), `path = "${executable}"\r\n`); + const { installation } = yield* makeHarness({ baseDir }); + + expect(yield* installation.resolve(alias)).toMatchObject({ + executablePath: executable, + resolvedCommandPath: alias, + harnessPath: harness, + source: "override", + managedVersionDirectory: null, + }); + }), + ); + + it.effect("recognizes an explicitly selected managed release by directory identity", () => + Effect.gen(function* () { + const { installation } = yield* makeHarness({ previous: true }); + const managed = yield* installation.resolve(); + expect(yield* installation.resolve(managed.executablePath)).toMatchObject({ + executablePath: managed.executablePath, + resolvedCommandPath: managed.executablePath, + source: "override", + managedVersionDirectory: managed.managedVersionDirectory, + }); + }), + ); + it.effect("keeps leased releases available while new sessions resolve the new release", () => Effect.gen(function* () { const { installation, fs, stagingReleased } = yield* makeHarness({ previous: true }); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 24eb4e3d6df8..9f15506f90de 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -76,6 +76,8 @@ const isInstallationError = Schema.is(AntigravityInstallationError); export interface AntigravityExecutable { readonly executablePath: string; + /** Selected alias before realpath resolution, when an external install supplied one. */ + readonly resolvedCommandPath?: string; readonly harnessPath: string; readonly source: "override" | "managed" | "path"; readonly version: string | null; @@ -367,7 +369,23 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma source: "override" | "path", ) { if (!(yield* executableFile(candidate))) return null; - const executablePath = yield* fs.realPath(candidate); + let executablePath = yield* fs.realPath(candidate); + if (platform === "win32" && /(?:^|[\\/])shims[\\/][^\\/]+\.exe$/iu.test(candidate)) { + const shimPath = candidate.replace(/\.exe$/iu, ".shim"); + const shimInfo = yield* fs.stat(shimPath).pipe(Effect.option); + if ( + Option.isSome(shimInfo) && + shimInfo.value.type === "File" && + Number(shimInfo.value.size) <= RECORD_MAX_BYTES + ) { + const contents = yield* fs.readFileString(shimPath); + const targets = [...contents.matchAll(/^\s*path\s*=\s*"([^"\r\n]+)"\s*$/gimu)]; + const target = targets.length === 1 ? targets[0]![1]! : null; + if (target && path.isAbsolute(target) && (yield* executableFile(target))) { + executablePath = yield* fs.realPath(target); + } + } + } const directory = path.dirname(executablePath); const harnessPath = path.join(directory, names.harness); if (!(yield* executableFile(harnessPath))) return null; @@ -378,10 +396,17 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma /^[a-f0-9]{64}$/u.test(path.basename(directory)) ) { const installed = yield* completedRelease(path.basename(directory)); - return { ...installed, executablePath, harnessPath, source } satisfies AntigravityExecutable; + return { + ...installed, + executablePath, + resolvedCommandPath: candidate, + harnessPath, + source, + } satisfies AntigravityExecutable; } return { executablePath, + resolvedCommandPath: candidate, harnessPath, source, version: null, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index cc9ccb074b9e..028037da5a1a 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -60,7 +60,11 @@ function shellQuote(value: string): string { } const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( - options: { readonly config?: Partial; readonly enabled?: boolean } = {}, + options: { + readonly config?: Partial; + readonly enabled?: boolean; + readonly externalScoop?: boolean; + } = {}, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -75,7 +79,9 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( ); const requestLog = path.join(root, "requests.jsonl"); const profileDirectory = resolveAntigravityProfileDirectory(config.stateDir, instanceId); - const instancePath = `${path.join(root, "instance-bin")}:${baseEnv.PATH ?? ""}`; + let instancePath = [path.join(root, "instance-bin"), baseEnv.PATH ?? ""] + .filter(Boolean) + .join(windowsHost ? ";" : ":"); const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* ( name: string, @@ -113,7 +119,37 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const first = yield* makeExecutable("runtime 'one"); const second = yield* makeExecutable("runtime two"); const signedOut = yield* makeExecutable("runtime signed-out", true); - const controls = { selected: first, failResolution: false, beforeAcquire: Effect.void }; + let selected: AntigravityExecutable = first; + if (options.externalScoop) { + const scoopRoot = path.join(root, "Scoop Root"); + const app = "fixture-antigravity-runtime"; + const current = path.join(scoopRoot, "apps", app, "current"); + const executablePath = path.join(current, "agy_acp_server.exe"); + const harnessPath = path.join(current, "localharness_external.exe"); + const manager = path.join(scoopRoot, "shims", "scoop.cmd"); + const selectedAlias = path.join(scoopRoot, "shims", "agy_acp_server.exe"); + yield* fs.makeDirectory(current, { recursive: true }); + yield* fs.makeDirectory(path.dirname(manager), { recursive: true }); + yield* fs.writeFileString(executablePath, "fixture"); + yield* fs.writeFileString(harnessPath, "fixture"); + yield* fs.writeFileString(path.join(current, "install.json"), '{"bucket":"fixture"}'); + yield* fs.writeFileString(manager, "@echo off\r\n"); + yield* fs.writeFileString(selectedAlias, "fixture shim"); + yield* fs.writeFileString( + `${selectedAlias.slice(0, -4)}.shim`, + `path = "${executablePath}"\r\n`, + ); + instancePath = [path.dirname(manager), instancePath].join(windowsHost ? ";" : ":"); + selected = { + executablePath, + resolvedCommandPath: selectedAlias, + harnessPath, + source: "override", + version: null, + managedVersionDirectory: null, + }; + } + const controls = { selected, failResolution: false, beforeAcquire: Effect.void }; const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = []; const releases: Array = []; const launches: Array<{ @@ -533,4 +569,48 @@ it.layer(testLayer)("AntigravityDriver", (it) => { expect(h.acquisitions).toEqual([]); }).pipe(Effect.scoped), ); + + it.effect("keeps managed runtimes out of command-based maintenance", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.controls.selected = { + ...h.first, + source: "override", + resolvedCommandPath: h.first.executablePath, + }; + expect((yield* h.instance.snapshot.resolveMaintenance({ fresh: true })).update).toBeNull(); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(!windowsHost)( + "updates an external Antigravity ACP pair through its owning Scoop app", + () => + Effect.gen(function* () { + const h = yield* makeHarness({ externalScoop: true }); + const update = (yield* h.instance.snapshot.resolveMaintenance({ fresh: true })).update; + expect(update).toMatchObject({ + args: ["update", "fixture/fixture-antigravity-runtime"], + windowsInstaller: { manager: "scoop", scope: "user" }, + }); + expect(update?.installationKey).toContain("fixture-antigravity-runtime"); + expect(h.launches).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(!windowsHost)("keeps unproven WinGet ACP paths manual-only", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const directory = h.path.join("C:\\Users\\fixture", "Microsoft", "WinGet", "Packages"); + h.controls.selected = { + executablePath: h.path.join(directory, "agy_acp_server.exe"), + resolvedCommandPath: h.path.join(directory, "agy_acp_server.exe"), + harnessPath: h.path.join(directory, "localharness_external.exe"), + source: "override", + version: null, + managedVersionDirectory: null, + }; + expect((yield* h.instance.snapshot.resolveMaintenance({ fresh: true })).update).toBeNull(); + expect(h.launches).toEqual([]); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1141ac5856fc..4f671fdf9932 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -58,12 +58,26 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + makePackageManagedProviderMaintenanceResolver, +} from "../providerMaintenance.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); +const MANUAL_MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER, + packageName: null, +}); +const EXTERNAL_UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER, + npmPackageName: null, + nativeUpdate: null, +}); export type AntigravityDriverEnv = | AntigravityInstallation @@ -102,7 +116,28 @@ export const AntigravityDriver: ProviderDriver + executable.managedVersionDirectory !== null + ? Effect.succeed(MANUAL_MAINTENANCE) + : EXTERNAL_UPDATE.resolve({ + binaryPath: settings.binaryPath || executable.executablePath, + resolvedCommandPath: executable.resolvedCommandPath ?? executable.executablePath, + realCommandPath: executable.executablePath, + env: processEnvironment, + platform, + }), + ), + Effect.orElseSucceed(() => MANUAL_MAINTENANCE), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + ), + ); const profileDirectory = resolveAntigravityProfileDirectory( serverConfig.stateDir, instanceId, @@ -357,6 +392,7 @@ export const AntigravityDriver: ProviderDriver { Effect.scoped, ), ); + + it.effect.skipIf(!windowsHost)("uses Scoop for an owned Windows Cursor CLI", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-scoop-" }); + const current = NodePath.join(root, "apps", "cursor-agent", "current"); + const target = NodePath.join(current, "cursor-agent.exe"); + const shim = NodePath.join(root, "shims", "cursor-agent.exe"); + const manager = NodePath.join(root, "shims", "scoop.cmd"); + yield* fs.makeDirectory(current, { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(shim), { recursive: true }); + yield* fs.writeFileString(target, "fixture"); + yield* fs.writeFileString(shim, "fixture"); + yield* fs.writeFileString(shim.replace(/\.exe$/i, ".shim"), `path = "${target}"`); + yield* fs.writeFileString(NodePath.join(current, "install.json"), '{"bucket":"extras"}'); + yield* fs.writeFileString(manager, "@echo off\r\n"); + + const instance = yield* CursorDriver.create({ + instanceId: ProviderInstanceId.make("cursor-scoop"), + displayName: "Cursor Scoop", + enabled: false, + environment: [{ name: "PATH", value: NodePath.dirname(manager), sensitive: false }], + config: { ...CursorDriver.defaultConfig(), binaryPath: shim }, + }); + expect((yield* instance.snapshot.resolveMaintenance({ fresh: true })).update).toMatchObject({ + args: ["update", "extras/cursor-agent"], + windowsInstaller: { manager: "scoop", scope: "user" }, + }); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("Scoop ownership must not spawn a probe")), + ), + Effect.scoped, + ), + ); + + it.effect.skipIf(!windowsHost)("keeps unproven WinGet Cursor CLI paths manual-only", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-winget-" }); + const binaryPath = NodePath.join(root, "Microsoft", "WinGet", "Packages", "cursor-agent.exe"); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "fixture"); + const instance = yield* CursorDriver.create({ + instanceId: ProviderInstanceId.make("cursor-winget"), + displayName: "Cursor WinGet", + enabled: false, + environment: [], + config: { ...CursorDriver.defaultConfig(), binaryPath }, + }); + expect((yield* instance.snapshot.resolveMaintenance({ fresh: true })).update).toBeNull(); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("Unknown WinGet ownership must not spawn")), + ), + Effect.scoped, + ), + ); }); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2a5482ce4ec1..3612cf518959 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -45,9 +45,7 @@ import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeProviderMaintenanceResolution, - makeManualOnlyProviderMaintenanceCapabilities, - makeProviderMaintenanceCapabilities, - type ProviderMaintenanceCapabilitiesResolver, + makePackageManagedProviderMaintenanceResolver, } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -58,26 +56,14 @@ import { discoverCursorSkills, probeCursorSkills } from "./CursorSkills.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); -// cursor-agent updates itself, so the resolved executable is its own updater. -// No executable means nothing to update, not "whatever is on PATH". -const UPDATE: ProviderMaintenanceCapabilitiesResolver = { - resolve: (context) => - Effect.succeed( - context - ? makeProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, - updateExecutable: context.resolvedCommandPath, - updateArgs: ["update"], - updateLockKey: "cursor-agent", - platform: context.platform, - }) - : makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, - }), - ), -}; +// Official Cursor CLI installs update themselves. Package-manager ownership +// still wins on Windows so a Scoop-managed copy is not allowed to mutate its +// files behind Scoop; WinGet stays manual without a proven CLI package id. +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: null, + nativeUpdate: { args: ["update"], isCommandPath: () => true }, +}); export type CursorDriverEnv = | BackgroundPolicy.BackgroundPolicy diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 0db7ae8b71e6..f8a80106e379 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -100,6 +100,8 @@ export const OpenCodeDriver: ProviderDriver defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 34fd81a424ca..65c53f8004ab 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -20,6 +20,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import { makeProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import { buildAntigravityModelsFromSession, makeAntigravityProvider, @@ -234,6 +235,40 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { ), ); + it.effect("publishes an explicit update action when external ownership is proven", () => + Effect.scoped( + Effect.gen(function* () { + const capabilities = makeProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + updateExecutable: "scoop", + updateArgs: ["update", "fixture/fixture-antigravity-runtime"], + updateLockKey: "scoop:c:/scoop", + updateInstallationKey: "scoop:c:/scoop:fixture-antigravity-runtime", + latestVersion: null, + }); + const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: false }), { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Effect.die("Disabled Antigravity must not probe"), + supportsTextGeneration: Effect.succeed(false), + resolveMaintenance: () => Effect.succeed(capabilities), + }); + const advisory = yield* Stream.toPull( + provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.versionAdvisory?.canUpdate === true), + ), + ); + yield* provider.snapshot.refresh; + const [snapshot] = yield* advisory; + expect(snapshot.versionAdvisory).toMatchObject({ + status: "unknown", + canUpdate: true, + updateCommand: "scoop update fixture/fixture-antigravity-runtime", + }); + }), + ), + ); + it.effect("records explicit sign-in while disabled without starting a health probe", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index 956a5d81d3f4..f44be49b5de5 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -21,6 +21,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { + createProviderVersionAdvisory, makeManualOnlyProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; @@ -31,6 +32,7 @@ import { } from "../providerSnapshot.ts"; const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const DRIVER = ProviderDriverKind.make("antigravity"); const MAX_WORKSPACE_SNAPSHOTS = 32; const HEALTH_CHECK_TIMEOUT = "90 seconds"; const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; @@ -124,6 +126,7 @@ interface AntigravityProviderOptions { >; readonly supportsTextGeneration: Effect.Effect; readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; + readonly resolveMaintenance?: () => Effect.Effect; /** Auth type and label published once a session authenticates. */ readonly auth?: { readonly type: string; readonly label: string }; } @@ -240,19 +243,35 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func provider: ProviderDriverKind.make("antigravity"), packageName: null, }); + const resolveMaintenance = + options.resolveMaintenance ?? (() => Effect.succeed(maintenanceCapabilities)); const managed = yield* makeManagedServerProvider({ - resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + resolveMaintenance, getSettings: Effect.succeed(settings), streamSettings: Stream.empty, haveSettingsChanged: () => false, initialSnapshot: () => getSnapshot, checkProvider: checkProvider(), - enrichSnapshot: ({ publishSnapshot }) => - SubscriptionRef.changes(metadata).pipe( - Stream.runForEach((state) => - options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)), - ), - ), + enrichSnapshot: ({ snapshot, publishSnapshot }) => + Effect.gen(function* () { + const capabilities = yield* resolveMaintenance(); + const publishWithMaintenance = (nextSnapshot: ServerProvider) => + publishSnapshot({ + ...nextSnapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: DRIVER, + currentVersion: nextSnapshot.version, + checkedAt: nextSnapshot.checkedAt, + maintenanceCapabilities: capabilities, + }), + }); + yield* publishWithMaintenance(snapshot); + yield* SubscriptionRef.changes(metadata).pipe( + Stream.runForEach((state) => + options.stampIdentity(state.draft).pipe(Effect.flatMap(publishWithMaintenance)), + ), + ); + }), }); const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 292ef66ff2ee..ffc10a3afc3d 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -47,6 +47,8 @@ const maintenanceCapabilities = { args: ["install", "-g", "@openai/codex@latest"], + installationKey: "npm-global", + lockKey: "npm-global", }, } as const; diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index b34d08d1747c..a5b6d5fac4ca 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -741,6 +741,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ["npm", "tool.cmd"], ["shims", "tool.cmd"], ["apps", "node", "global", "tool.cmd"], + ["apps", "node", "global", "tool"], + ["apps", "node", "global", "tool.exe"], ]) { const native = segments[0] === ".local"; const binary = NodePath.join(NodePath.dirname(f.root), ...segments); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 1234936f7e85..4a394206440b 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; @@ -75,6 +76,8 @@ export interface ProviderMaintenanceCommandAction { readonly command: string; readonly executable: string; readonly args: ReadonlyArray; + /** Stable identity of the owned installation selected by this action. */ + readonly installationKey: string; readonly lockKey: string; /** Proven Windows installer and scope; machine installs may request a UAC retry. */ readonly windowsInstaller?: { @@ -113,7 +116,7 @@ export interface ProviderMaintenanceCapabilitiesResolver { export interface PackageManagedProviderMaintenanceDefinition { readonly provider: ProviderDriverKind; - readonly npmPackageName: string; + readonly npmPackageName: string | null; readonly wingetPackageId?: string; readonly nativeUpdate: { readonly args: ReadonlyArray; @@ -166,6 +169,7 @@ export function makeProviderMaintenanceCapabilities(input: { readonly updateExecutable: string | null; readonly updateArgs: ReadonlyArray; readonly updateLockKey: string | null; + readonly updateInstallationKey?: string | null; /** Shown to the user instead of ` `; use for a bare tool name like `brew`. */ readonly updateCommand?: string; readonly platform?: NodeJS.Platform; @@ -186,6 +190,7 @@ export function makeProviderMaintenanceCapabilities(input: { ].join(" "), executable: input.updateExecutable, args: input.updateArgs, + installationKey: input.updateInstallationKey ?? input.updateLockKey, lockKey: input.updateLockKey, ...(input.env ? { env: input.env } : {}), ...(input.windowsInstaller ? { windowsInstaller: input.windowsInstaller } : {}), @@ -422,16 +427,11 @@ const readWingetPortableIndex = Effect.fn("readWingetPortableIndex")( function* (filename: string) { const fs = yield* FileSystem.FileSystem; if (Number((yield* fs.stat(filename)).size) > INSTALLER_PROBE_MAX_BYTES) return null; - const sqlite = yield* Effect.promise(async () => - process.versions.bun !== undefined - ? await import("@effect/sql-sqlite-bun/SqliteClient") - : await import("@t3tools/shared/nodeSqliteClient"), - ); return yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; return yield* sql`SELECT filepath, filetype, symlinktarget FROM portable`; }).pipe( - Effect.provide(sqlite.layer({ filename, readonly: true })), + Effect.provide(NodeSqliteClient.layer({ filename, readonly: true })), Effect.flatMap( Schema.decodeUnknownEffect( Schema.Array( @@ -482,6 +482,7 @@ const resolveWindowsInstaller = Effect.fn("resolveWindowsInstaller")(function* ( executable: string, args: string[], lockKey: string, + installationKey: string, latestVersion: Exclude, env = context.env, ) => @@ -492,6 +493,7 @@ const resolveWindowsInstaller = Effect.fn("resolveWindowsInstaller")(function* ( updateExecutable: executable, updateArgs: args, updateLockKey: lockKey, + updateInstallationKey: installationKey, platform: context.platform, env, latestVersion, @@ -499,16 +501,16 @@ const resolveWindowsInstaller = Effect.fn("resolveWindowsInstaller")(function* ( const observed = context.resolvedCommandPath.replaceAll("\\", "/"); // npm prefixes can contain "shims" or "apps" without belonging to Scoop. - const npmShim = - /\.cmd$/i.test(observed) && + const npmOwned = + definition.npmPackageName !== null && (yield* resolveNpmGlobalPrefix(context, definition.npmPackageName)) !== null; - const shim = npmShim ? null : /^(.*)\/shims\/[^/]+\.(?:exe|cmd|ps1)$/i.exec(observed); + const shim = npmOwned ? null : /^(.*)\/shims\/[^/]+\.(?:exe|cmd|ps1)$/i.exec(observed); if (shim && !/\.exe$/i.test(observed)) return manual; const shimText = shim ? yield* read(observed.replace(/\.exe$/i, ".shim")) : null; const targets = [...(shimText ?? "").matchAll(/^\s*path\s*=\s*"([^"\r\n]+)"\s*$/gim)]; const target = shim ? (targets.length === 1 ? targets[0]![1]! : null) : context.realCommandPath; const scoop = - !npmShim && + !npmOwned && target && /^(.*)\/apps\/([\w.-]+)\/[^/]+\/(.+)$/i.exec(target.replaceAll("\\", "/")); if (shim || scoop) { @@ -555,6 +557,9 @@ const resolveWindowsInstaller = Effect.fn("resolveWindowsInstaller")(function* ( ["update", `${bucket}/${app}`, ...(global ? ["--global"] : [])], // Updating any app may first refresh this manager and its shared buckets. `scoop:${canonical((yield* realPath(managerRoot)) ?? managerRoot)}`, + `scoop:${canonical(root)}:${bucket.toLowerCase()}:${app.toLowerCase()}:${canonical( + path.join(current, relative), + )}`, // Local bucket manifests can be stale; keep an explicit update check available. null, { ...context.env, SCOOP: managerRoot, ...(global ? { SCOOP_GLOBAL: root } : {}) }, @@ -563,7 +568,14 @@ const resolveWindowsInstaller = Effect.fn("resolveWindowsInstaller")(function* ( const packageId = definition.wingetPackageId; // Proven npm ownership must survive an inconclusive Windows registry probe. - if (!packageId || npmShim) return null; + if (npmOwned) return null; + if (!packageId) { + return /\/microsoft\/winget\//i.test( + `${observed}/${context.realCommandPath.replaceAll("\\", "/")}`, + ) + ? manual + : null; + } const registry = path.join(context.env.SystemRoot ?? "C:\\Windows", "System32", "reg.exe"); const currentVersion = "Software\\Microsoft\\Windows\\CurrentVersion"; const uninstall = `${currentVersion}\\Uninstall`; @@ -715,6 +727,9 @@ const resolveWindowsInstaller = Effect.fn("resolveWindowsInstaller")(function* ( ...unattended, ], `winget:${sourceId}:${packageId}:${scope}:${canonical(context.realCommandPath)}`, + `winget:${sourceId}:${packageId}:${scope}:${canonical( + values.get("InstallLocation") ?? path.dirname(context.realCommandPath), + )}:${canonical(path.basename(context.realCommandPath))}`, latest, ); }); @@ -758,42 +773,50 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateExecutable: context.resolvedCommandPath, updateArgs: nativeUpdate.args, updateLockKey: `${definition.provider}-native`, + updateInstallationKey: `${definition.provider}-native:${normalizeCommandPath( + context.resolvedCommandPath, + )}`, platform: context.platform, ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), }); } - if (commandPaths.some(isVitePlusGlobalCommandPath)) { + if (packageName && commandPaths.some(isVitePlusGlobalCommandPath)) { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, updateExecutable: "vp", updateArgs: ["i", "-g", packageName], updateLockKey: "vite-plus-global", + updateInstallationKey: `vite-plus-global:${normalizeCommandPath( + context.resolvedCommandPath, + )}`, }); } - if (commandPaths.some(isBunGlobalCommandPath)) { + if (packageName && commandPaths.some(isBunGlobalCommandPath)) { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, updateExecutable: "bun", updateArgs: ["i", "-g", `${packageName}@latest`], updateLockKey: "bun-global", + updateInstallationKey: `bun-global:${normalizeCommandPath(context.resolvedCommandPath)}`, }); } - if (commandPaths.some(isPnpmGlobalCommandPath)) { + if (packageName && commandPaths.some(isPnpmGlobalCommandPath)) { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, updateExecutable: "pnpm", updateArgs: ["add", "-g", `${packageName}@latest`], updateLockKey: "pnpm-global", + updateInstallationKey: `pnpm-global:${normalizeCommandPath(context.resolvedCommandPath)}`, }); } // npm proof names the package, so it outranks a keg the path merely passes // through: a Homebrew-installed Node keeps its globals under // `Cellar/node//lib/node_modules/`, and that is npm's install, not brew's. - const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName); + const npmPrefix = packageName ? yield* resolveNpmGlobalPrefix(context, packageName) : null; if (npmPrefix) { // npm 12 blocks install scripts by default (empty allow-scripts allowlist) // and still exits 0, so a package whose postinstall finishes the install @@ -813,6 +836,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( `${packageName}@latest`, ], updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`, + updateInstallationKey: `npm-global:${normalizeCommandPath(npmPrefix)}:${packageName}`, }); } @@ -858,6 +882,9 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateExecutable: brewPath, updateArgs: args, updateLockKey: "homebrew", + updateInstallationKey: `homebrew:${normalizeCommandPath( + realBrewPrefix, + )}:${homebrew.kind}:${homebrew.name.toLowerCase()}`, updateCommand: ["brew", ...args].join(" "), latestVersion: info ? parseHomebrewLatestVersion(info, homebrew) : null, }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 42269bf83b25..828535c081ca 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -318,6 +318,7 @@ describe("providerMaintenanceRunner", () => { command: "winget upgrade", executable: "C:\\Tools\\winget.exe", args: ["upgrade"], + installationKey: "selected-winget", lockKey: "selected-winget", windowsInstaller: { manager: "winget", scope: "machine" }, }, @@ -468,6 +469,7 @@ describe("providerMaintenanceRunner", () => { "--scope", scenario.eligible ? "machine" : "user", ], + installationKey: "selected-installer", lockKey: "selected-installer", ...(scenario.verified ? { @@ -512,7 +514,7 @@ describe("providerMaintenanceRunner", () => { it.effect("runs the allowlisted provider update command and records success", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { - const { registry, updateStatesRef } = yield* makeRegistry(baseCursorProvider); + const { registry, updateStatesRef } = yield* makeRegistry(baseCursorProvider, "0.0.1"); const updater = yield* makeTestRunner(registry); const result = yield* updater.updateProvider(CURSOR_DRIVER); @@ -609,33 +611,46 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect( - "keeps a successful update when the binary is present but its version is unreadable", - () => { - return Effect.gen(function* () { - const { registry, providersRef } = yield* makeRegistry(baseCursorProvider); - // Cursor's `agent about` probe can fail right after an update while the - // new binary is perfectly fine. - const updater = yield* makeTestRunner({ - ...registry, - refreshInstance: () => - Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, installed: true, version: null })), - ), - }); - - const result = yield* updater.updateProvider(CURSOR_DRIVER); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - }).pipe( - Effect.provide( - Layer.mergeAll( - NonWindowsPlatform, - latestVersionHttpClient("0.0.0"), - mockSpawnerLayer(() => ({ stdout: "updated" })), + it.effect("does not claim success when a known Cursor version becomes unreadable", () => { + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(baseCursorProvider); + // A missing post-update version cannot prove that the known install advanced. + const updater = yield* makeTestRunner({ + ...registry, + refreshInstance: () => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, installed: true, version: null })), ), + }); + + const result = yield* updater.updateProvider(CURSOR_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => ({ stdout: "updated" })), ), - ); - }, + ), + ); + }); + + it.effect("allows Cursor's updater when both versions are unknown but the binary remains", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry({ ...baseCursorProvider, version: null }); + const updater = yield* makeTestRunner(registry); + const result = yield* updater.updateProvider(CURSOR_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => ({ stdout: "updated" })), + ), + ), + ), ); it.effect("spawns the updater with the environment its capabilities declare", () => { @@ -651,6 +666,7 @@ describe("providerMaintenanceRunner", () => { command: "codex update", executable: "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/work/codex-home/packages/standalone/bin/codex", args: ["update"], + installationKey: "codex-native", lockKey: "codex-native", env: { CODEX_HOME: "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/work/codex-home" }, }, @@ -673,7 +689,7 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("re-resolves ownership before running and executes the fresh command", () => { + it.effect("re-resolves stable ownership before and after running the command", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; const fresh: Array = []; return Effect.gen(function* () { @@ -686,7 +702,7 @@ describe("providerMaintenanceRunner", () => { makeProviderMaintenanceCapabilities({ provider, packageName: "@openai/codex", - updateExecutable: options?.fresh ? "/opt/homebrew/bin/brew" : "brew", + updateExecutable: "/opt/homebrew/bin/brew", updateArgs: ["upgrade", "--cask", "codex"], updateLockKey: "homebrew", }), @@ -759,6 +775,165 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("rejects a different Scoop app under the same manager lock", () => { + const calls: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const capabilities = (installationKey: string, env = { SCOOP: "C:/Scoop" }) => + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "C:/Scoop/shims/scoop.cmd", + updateArgs: ["update", installationKey], + updateLockKey: "scoop:c:/scoop", + updateInstallationKey: `scoop:c:/scoop:${installationKey}`, + env, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, _provider, options) => + Effect.succeed(capabilities(options?.fresh ? "extras/codex" : "main/codex")), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, []); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + assert.strictEqual( + result.providers[0]?.updateState?.message, + "Provider installation changed. Refresh and try again.", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.1"), + mockSpawnerLayer((command) => { + calls.push(command); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("rejects a changed selected-instance environment before execution", () => { + const calls: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const capabilities = (home: string) => + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "codex", + updateArgs: ["update"], + updateLockKey: "codex-native", + updateInstallationKey: "codex-native:c:/tools/codex.exe", + env: { CODEX_HOME: home }, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, _provider, options) => + Effect.succeed(capabilities(options?.fresh ? "C:/new home" : "C:/selected home")), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, []); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.1"), + mockSpawnerLayer((command) => { + calls.push(command); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("rejects a different updater executable before execution", () => { + const calls: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const capabilities = (executable: string) => + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: executable, + updateArgs: ["update", "main/codex"], + updateLockKey: "scoop:c:/scoop", + updateInstallationKey: "scoop:c:/scoop:main:codex", + env: { SCOOP: "C:/Scoop" }, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, _provider, options) => + Effect.succeed( + capabilities(options?.fresh ? "C:/Scoop/shims/scoop.exe" : "C:/Scoop/shims/scoop.cmd"), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, []); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.1"), + mockSpawnerLayer((command) => { + calls.push(command); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("does not accept a fallback installation after an update", () => { + let freshReads = 0; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider, "0.0.1"); + const capabilities = (installationKey: string) => + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "C:/Scoop/shims/scoop.cmd", + updateArgs: ["update", "main/codex"], + updateLockKey: "scoop:c:/scoop", + updateInstallationKey: installationKey, + env: { SCOOP: "C:/Scoop" }, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, _provider, options) => { + if (!options?.fresh) return Effect.succeed(capabilities("scoop:selected")); + freshReads += 1; + return Effect.succeed( + capabilities(freshReads === 1 ? "scoop:selected" : "scoop:fallback"), + ); + }, + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.strictEqual( + result.providers[0]?.updateState?.message, + "Update command completed, but the selected provider installation changed before it could be verified.", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.1"), + mockSpawnerLayer(() => ({ stdout: "updated" })), + ), + ), + ); + }); + it.effect("uses the resolved provider capabilities when choosing the update executable", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 86849091877f..d30e939b8403 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -228,6 +228,35 @@ function isStillInstalled(provider: ServerProvider): boolean { return provider.installed && (provider.driver === "cursor" || Boolean(provider.version?.trim())); } +function hasSameEntries( + left: Readonly> | undefined, + right: Readonly> | undefined, +): boolean { + const leftEntries = Object.entries(left ?? {}); + const rightEntries = Object.entries(right ?? {}); + return ( + leftEntries.length === rightEntries.length && + leftEntries.every(([key, value]) => right?.[key] === value) + ); +} + +function isSameMaintenanceAction( + selected: ProviderMaintenanceCommandAction, + current: ProviderMaintenanceCommandAction | null, +): boolean { + return ( + current !== null && + current.installationKey === selected.installationKey && + current.lockKey === selected.lockKey && + current.executable === selected.executable && + current.args.length === selected.args.length && + current.args.every((arg, index) => arg === selected.args[index]) && + current.windowsInstaller?.manager === selected.windowsInstaller?.manager && + current.windowsInstaller?.scope === selected.windowsInstaller?.scope && + hasSameEntries(current.env, selected.env) + ); +} + function makeUpdateState(input: { readonly status: ServerProviderUpdateState["status"]; readonly startedAt: string | null; @@ -401,7 +430,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { provider, { fresh: true }, ); - if (!fresh.update || fresh.update.lockKey !== update.lockKey) { + const freshUpdate = fresh.update; + if (!freshUpdate || !isSameMaintenanceAction(update, freshUpdate)) { return yield* finish( makeUpdateState({ status: "failed", @@ -417,11 +447,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, ) ?.version?.trim(); - let result: ProviderMaintenanceCommandResult = yield* runMaintenanceCommand( - fresh.update, - ); + let result: ProviderMaintenanceCommandResult = + yield* runMaintenanceCommand(freshUpdate); const needsElevation = - platform === "win32" && requiresWindowsAdministrator(fresh.update, result); + platform === "win32" && requiresWindowsAdministrator(freshUpdate, result); if (needsElevation) { yield* setUpdateState( makeUpdateState({ @@ -431,7 +460,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { message: "Approve the Windows administrator prompt to update this provider.", }), ); - const elevatedUpdate = fresh.update; + const elevatedUpdate = freshUpdate; result = yield* Effect.gen(function* () { const elevated = yield* prepareWindowsUpdateElevation( elevatedUpdate, @@ -460,7 +489,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // WinGet reports "no applicable update" as a nonzero exit. Still // verify the selected provider instead of presenting this as failure. if ( - fresh.update.windowsInstaller?.manager === "winget" && + freshUpdate.windowsInstaller?.manager === "winget" && result.exitCode !== null && result.exitCode >>> 0 === 0x8a15002b ) { @@ -493,6 +522,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { verified, instanceId, ); + const installationChanged = !isSameMaintenanceAction(freshUpdate, verified.update); // "Succeeded" needs the provider to still be installed: an // installer that exits 0 and leaves the binary missing is not a // success. Only Cursor tolerates a missing version, since its @@ -503,27 +533,35 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const stillOutdated = verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider), ); - const versionUnchanged = - provider !== "cursor" && - !verifiedProviders.some( - (verifiedProvider) => - verifiedProvider.version?.trim() && - (!versionBeforeUpdate || - compareSemverVersions(versionBeforeUpdate, verifiedProvider.version) < 0), - ); + const versionAdvanced = verifiedProviders.some( + (verifiedProvider) => + verifiedProvider.version?.trim() && + (!versionBeforeUpdate || + compareSemverVersions(versionBeforeUpdate, verifiedProvider.version) < 0), + ); + // Cursor can be healthy without a readable version. That unknown + // baseline remains compatible, but a known version must advance + // just like every other provider before the update is successful. + const versionUnchanged = versionBeforeUpdate + ? !versionAdvanced + : provider !== "cursor" && !versionAdvanced; return yield* finish( makeUpdateState({ status: - couldNotVerify || stillOutdated || versionUnchanged ? "unchanged" : "succeeded", + installationChanged || couldNotVerify || stillOutdated || versionUnchanged + ? "unchanged" + : "succeeded", startedAt, finishedAt, - message: couldNotVerify - ? "Update command completed, but T3 Code could not verify the provider version." - : stillOutdated - ? "Update command completed, but T3 Code still detects an outdated provider version." - : versionUnchanged - ? "Update command completed, but the provider version did not advance." - : "Provider updated.", + message: installationChanged + ? "Update command completed, but the selected provider installation changed before it could be verified." + : couldNotVerify + ? "Update command completed, but T3 Code could not verify the provider version." + : stillOutdated + ? "Update command completed, but T3 Code still detects an outdated provider version." + : versionUnchanged + ? "Update command completed, but the provider version did not advance." + : "Provider updated.", output: commandOutput(result), }), ); diff --git a/apps/server/src/provider/windowsUpdateElevation.test.ts b/apps/server/src/provider/windowsUpdateElevation.test.ts index 28fbff593a5a..ec813fb2abb9 100644 --- a/apps/server/src/provider/windowsUpdateElevation.test.ts +++ b/apps/server/src/provider/windowsUpdateElevation.test.ts @@ -50,6 +50,7 @@ for (const { mode, expectedCode } of [ command: "fixture", executable: cmd ? shim : process.execPath, args: cmd ? args : [script, ...args], + installationKey: "fixture", lockKey: "fixture", env: { T3_UPDATE_TEST: "value ' with spaces & $data", From ffa7d7eac379ac709dd64a6003868854c225f2ee Mon Sep 17 00:00:00 2001 From: ettoc00 <41289053+ettoc00@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:29:19 +0200 Subject: [PATCH 3/6] fix(web): label provider version checks accurately --- .../settings/ProviderInstanceCard.test.ts | 24 ++++++++++++++++++- .../settings/ProviderInstanceCard.tsx | 19 +++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index a5085774f0c6..555e11d832ce 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -8,7 +8,11 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; -import { deriveProviderModelsForDisplay, ProviderInstanceCard } from "./ProviderInstanceCard"; +import { + deriveProviderModelsForDisplay, + getProviderUpdateActionLabel, + ProviderInstanceCard, +} from "./ProviderInstanceCard"; describe("deriveProviderModelsForDisplay", () => { it("uses current config custom models instead of stale live custom rows", () => { @@ -121,6 +125,24 @@ describe("deriveProviderModelsForDisplay", () => { expect(markup).toContain("blur-[2px]"); expect(markup).not.toContain("developer@example.com"); }); + + it("describes an unknown-version action as checking while it runs", () => { + expect( + getProviderUpdateActionLabel({ + isCheck: true, + isUpdating: true, + title: "Check for updates", + }), + ).toBe("Checking for updates"); + expect( + getProviderUpdateActionLabel({ + isCheck: false, + isUpdating: true, + title: "Update available", + }), + ).toBe("Updating"); + }); + it("surfaces a failed probe message in both the list row and the editor", () => { const instanceId = ProviderInstanceId.make("codex_work"); const driver = ProviderDriverKind.make("codex"); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 4132f7a4840a..eb30dc716cb1 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -384,6 +384,15 @@ interface ProviderInstanceCardProps { readonly isUpdating?: boolean | undefined; } +export function getProviderUpdateActionLabel(input: { + readonly isCheck: boolean; + readonly isUpdating: boolean; + readonly title: string; +}) { + if (input.isUpdating) return input.isCheck ? "Checking for updates" : "Updating"; + return input.isCheck ? input.title : "Update now"; +} + /** * Renders one provider instance as either a compact selectable list row or * the full editor shown beside that list. Both modes use the same enabled @@ -752,11 +761,11 @@ export function ProviderInstanceCard({ onClick={onRunUpdate} > {isUpdating ? : } - {isUpdating - ? "Updating" - : versionAdvisory.isCheck - ? versionAdvisory.title - : "Update now"} + {getProviderUpdateActionLabel({ + isCheck: versionAdvisory.isCheck, + isUpdating, + title: versionAdvisory.title, + })} ) : null} {onRunUpdate && updateCommand ? ( From 906d383d3da86c2f25f9f6e4369bd2858e1a8c33 Mon Sep 17 00:00:00 2001 From: ettoc00 <41289053+ettoc00@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:05:45 +0200 Subject: [PATCH 4/6] fix(server): revalidate provider updates before execution --- .../src/provider/providerMaintenance.test.ts | 110 +++++++--- .../src/provider/providerMaintenance.ts | 78 +++++-- .../providerMaintenanceRunner.test.ts | 198 ++++++++++++++++-- .../src/provider/providerMaintenanceRunner.ts | 104 +++++++-- 4 files changed, 408 insertions(+), 82 deletions(-) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index a5b6d5fac4ca..01624207d4b2 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -97,6 +97,22 @@ function writeExecutable(path: string) { NodeFS.chmodSync(path, 0o755); } +function writeWindowsNpmShim(shim: string, packageName: string, binPath = "bin/package-tool.js") { + const prefix = NodePath.dirname(shim); + const commandName = NodePath.basename(shim).replace(/\.(?:cmd|ps1)$/i, ""); + const packageDirectory = NodePath.join(prefix, "node_modules", ...packageName.split("/")); + NodeFS.mkdirSync(packageDirectory, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(packageDirectory, "package.json"), + JSON.stringify({ name: packageName, bin: { [commandName]: binPath } }), + ); + const target = NodePath.join("node_modules", ...packageName.split("/"), binPath); + const contents = /\.cmd$/i.test(shim) + ? `@ECHO off\r\nSET dp0=%~dp0\r\n"%dp0%\\node.exe" "%dp0%\\${target}" %*\r\n` + : `#!/bin/sh\nbasedir=$(dirname "$0")\nexec "$basedir/node" "$basedir/${target}" "$@"\n`; + NodeFS.writeFileSync(shim, contents); +} + /** Symlink `/bin/` into a package entry point, like npm/pnpm do. */ function linkIntoPackage(tempDir: string, name: string, packageSegments: ReadonlyArray) { const target = NodePath.join(tempDir, ...packageSegments, "bin", `${name}.js`); @@ -421,14 +437,12 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { for (let index = 0; index < 2; index++) { const prefix = yield* fs.makeTempDirectoryScoped({ prefix: "t3 grok npm " }); const shim = NodePath.join(prefix, "grok.cmd"); - NodeFS.writeFileSync(shim, "fixture"); + NodeFS.writeFileSync(shim, "unrelated shim"); const unrelated = NodePath.join(prefix, "node_modules", "grok"); NodeFS.mkdirSync(unrelated, { recursive: true }); NodeFS.writeFileSync(NodePath.join(unrelated, "package.json"), "{}"); expect((yield* f.resolve(shim)).update).toBeNull(); - const official = NodePath.join(prefix, "node_modules", "@xai-official", "grok"); - NodeFS.mkdirSync(official, { recursive: true }); - NodeFS.writeFileSync(NodePath.join(official, "package.json"), "{}"); + writeWindowsNpmShim(shim, "@xai-official/grok", "bin/grok.js"); const capabilities = yield* f.resolve(shim); expect(capabilities.update).toMatchObject({ executable: "npm", @@ -742,22 +756,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ["shims", "tool.cmd"], ["apps", "node", "global", "tool.cmd"], ["apps", "node", "global", "tool"], - ["apps", "node", "global", "tool.exe"], ]) { const native = segments[0] === ".local"; const binary = NodePath.join(NodePath.dirname(f.root), ...segments); - writeExecutable(binary); - if (!native) { - const manifest = NodePath.join( - NodePath.dirname(binary), - "node_modules", - "@example", - "package-tool", - "package.json", - ); - NodeFS.mkdirSync(NodePath.dirname(manifest), { recursive: true }); - NodeFS.writeFileSync(manifest, '{"name":"@example/package-tool"}'); - } + if (native) writeExecutable(binary); + else writeWindowsNpmShim(binary, "@example/package-tool"); if (native) { const result = yield* f.resolve(binary); if (failure === "missing-uninstall" || failure === "missing-uninstall-empty") @@ -785,6 +788,31 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(result.latestVersion).toBeUndefined(); } } + + const executable = NodePath.join( + NodePath.dirname(f.root), + "apps", + "node", + "global", + "tool.exe", + ); + writeExecutable(executable); + const manifest = NodePath.join( + NodePath.dirname(executable), + "node_modules", + "@example", + "package-tool", + "package.json", + ); + NodeFS.mkdirSync(NodePath.dirname(manifest), { recursive: true }); + NodeFS.writeFileSync( + manifest, + JSON.stringify({ + name: "@example/package-tool", + bin: { tool: "bin/package-tool.js" }, + }), + ); + expect((yield* f.resolve(executable)).update?.executable).not.toBe("npm"); }).pipe(Effect.scoped), ); } @@ -1040,14 +1068,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ); const shim = NodePath.join(tempDir, "package-tool.cmd"); NodeFS.mkdirSync(tempDir, { recursive: true }); - NodeFS.writeFileSync(shim, "@echo off\r\n"); - NodeFS.mkdirSync(NodePath.join(tempDir, "node_modules", "@example", "package-tool"), { - recursive: true, - }); - NodeFS.writeFileSync( - NodePath.join(tempDir, "node_modules", "@example", "package-tool", "package.json"), - "{}", - ); + writeWindowsNpmShim(shim, "@example/package-tool"); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: shim, @@ -1062,6 +1083,16 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { args: ["install", "-g", "--prefix", tempDir, expect.any(String), expect.any(String)], }); + NodeFS.writeFileSync(shim, "@echo off\r\n"); + const unrelatedShim = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: shim, + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + expect(unrelatedShim.update).toBeNull(); + // The same layout on POSIX is a project checkout, not a global install. const script = NodePath.join(tempDir, "package-tool"); writeExecutable(script); @@ -1073,6 +1104,35 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), ); expect(posix.update).toBeNull(); + + const executable = NodePath.join(tempDir, "package-tool.exe"); + writeExecutable(executable); + const notNpm = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: executable, + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + expect(notNpm.update).toBeNull(); + + const packageExecutable = NodePath.join( + tempDir, + "node_modules", + "@example", + "package-tool", + "bin", + "package-tool.exe", + ); + writeExecutable(packageExecutable); + const packageBinary = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: packageExecutable, + env: { PATH: "" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + expect(packageBinary.update).toBeNull(); }), ); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 4a394206440b..e2e6898b018e 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -422,6 +422,13 @@ const decodeWingetSource = Schema.decodeUnknownOption( }), ), ); +const decodeNpmPackageManifest = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + bin: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)]), + }), + ), +); const readWingetPortableIndex = Effect.fn("readWingetPortableIndex")( function* (filename: string) { @@ -895,36 +902,67 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( /** * POSIX npm links `/bin/` into the package, so the real path is - * proof. Windows npm writes `.cmd` shims beside `node_modules`, so the proof - * is the package manifest next to the shim. + * proof. On Windows, both the package's declared bin entry and an npm-shaped + * shim targeting that entry must match the selected command. */ const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* ( context: ProviderMaintenanceResolutionContext, packageName: string, ) { - const fromRealPath = npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName); - if (fromRealPath) { - return fromRealPath; - } if ((yield* HostProcessPlatform) !== "win32") { - return null; + return npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName); } const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const selectedFilename = path.basename(context.resolvedCommandPath); + if (/\.(?:exe|com|bat)$/i.test(selectedFilename)) return null; + const shimKind = /\.cmd$/i.test(selectedFilename) + ? "cmd" + : /\.ps1$/i.test(selectedFilename) + ? "powershell" + : "shell"; + const commandName = selectedFilename.replace(/\.(?:cmd|ps1)$/i, ""); const shimDir = path.dirname(context.resolvedCommandPath); - const manifestPath = path.join( - shimDir, - "node_modules", - ...packageName.split("/"), - "package.json", - ); - // npm writes both `.cmd` and an extensionless sh script into the - // Windows prefix; either one sits directly beside `node_modules`. A POSIX - // project checkout has the same shape, which is why this is Windows-only. - const hasManifest = yield* fileSystem - .exists(manifestPath) - .pipe(Effect.orElseSucceed(() => false)); - return hasManifest ? shimDir : null; + const packageSegments = packageName.split("/"); + const manifestPath = path.join(shimDir, "node_modules", ...packageSegments, "package.json"); + const read = (filename: string) => + collectUint8StreamText({ + stream: fileSystem.stream(filename, { bytesToRead: INSTALLER_PROBE_MAX_BYTES + 1 }), + maxBytes: INSTALLER_PROBE_MAX_BYTES, + }).pipe( + Effect.map((result) => (result.truncated || result.invalidUtf8 ? null : result.text)), + Effect.orElseSucceed(() => null), + ); + const manifest = decodeNpmPackageManifest((yield* read(manifestPath)) ?? ""); + if (Option.isNone(manifest)) return null; + const unscopedPackageName = packageSegments.at(-1)?.toLowerCase(); + const binPath = + typeof manifest.value.bin === "string" + ? commandName.toLowerCase() === unscopedPackageName + ? manifest.value.bin + : null + : (Object.entries(manifest.value.bin).find( + ([name]) => name.toLowerCase() === commandName.toLowerCase(), + )?.[1] ?? null); + if (!binPath || path.isAbsolute(binPath) || binPath.split(/[\\/]/).includes("..")) return null; + const shimText = yield* read(context.resolvedCommandPath); + if (!shimText) return null; + const recognizedShim = + shimKind === "cmd" + ? /%(?:~)?dp0%?/i.test(shimText) && /%\*/.test(shimText) + : shimKind === "powershell" + ? /\$basedir/i.test(shimText) && /\$args/i.test(shimText) + : /^#!.*\bsh\b/m.test(shimText) && /\bbasedir=/i.test(shimText) && /"\$@"/.test(shimText); + const normalizedBinPath = binPath + .split(/[\\/]/) + .filter((segment) => segment !== "." && segment !== "") + .join("/"); + const expectedTarget = ["node_modules", ...packageSegments, normalizedBinPath] + .join("/") + .toLowerCase(); + return recognizedShim && shimText.replaceAll("\\", "/").toLowerCase().includes(expectedTarget) + ? shimDir + : null; }); export function makePackageManagedProviderMaintenanceResolver( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 828535c081ca..969cda0df732 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -165,6 +165,7 @@ function makeRegistry( Array.isArray(initialProviders) ? initialProviders : [initialProviders], ); const updateStatesRef = yield* Ref.make>([]); + const refreshCounts = new Map(); const setProviderMaintenanceActionState = Effect.fn( "providerMaintenanceRunner.test.setProviderMaintenanceActionState", @@ -197,8 +198,10 @@ function makeRegistry( const registry: ProviderRegistryShape = { getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), - refreshInstance: (instanceId) => - refreshedVersion === undefined + refreshInstance: (instanceId) => { + const refreshCount = (refreshCounts.get(instanceId) ?? 0) + 1; + refreshCounts.set(instanceId, refreshCount); + return refreshedVersion === undefined || refreshCount === 1 ? Ref.get(providersRef) : Ref.updateAndGet(providersRef, (providers) => providers.map((provider) => @@ -206,7 +209,8 @@ function makeRegistry( ? { ...provider, version: refreshedVersion } : provider, ), - ), + ); + }, refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), @@ -268,7 +272,7 @@ describe("providerMaintenanceRunner", () => { }); const result = yield* runner.updateProvider(CODEX_DRIVER); assert.strictEqual(result.providers[0]?.updateState?.status, scope ? "unchanged" : "failed"); - assert.strictEqual(refreshed, scope !== undefined); + assert.strictEqual(refreshed, true); }).pipe( Effect.provide( Layer.mergeAll( @@ -511,6 +515,59 @@ describe("providerMaintenanceRunner", () => { ); } + it.effect("re-checks ownership before an elevated retry", () => { + const commands: string[] = []; + const freshReads: boolean[] = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const capabilities = (installationKey: string) => + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "C:/Tools/winget.exe", + updateArgs: ["upgrade", "--id", "OpenAI.Codex", "--scope", "machine"], + updateLockKey: "winget:source:OpenAI.Codex:machine", + updateInstallationKey: installationKey, + latestVersion: null, + windowsInstaller: { manager: "winget", scope: "machine" }, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, _provider, options) => { + freshReads.push(options?.fresh === true); + return Effect.succeed( + capabilities( + options?.fresh && freshReads.filter(Boolean).length > 2 + ? "winget:replacement" + : "winget:selected", + ), + ); + }, + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(freshReads, [false, true, true, true]); + assert.deepStrictEqual(commands, ["C:/Tools/winget.exe"]); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + assert.strictEqual( + result.providers[0]?.updateState?.message, + "Provider installation changed. Refresh and try again.", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(SpawnExecutableResolution, (command) => command), + latestVersionHttpClient("0.0.1"), + mockSpawnerLayer((command) => { + commands.push(command); + return { code: -1978335207 }; + }), + ), + ), + ); + }); + it.effect("runs the allowlisted provider update command and records success", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { @@ -556,6 +613,7 @@ describe("providerMaintenanceRunner", () => { ...baseProvider, version: before, }); + let refreshes = 0; const updater = yield* makeTestRunner({ ...registry, getProviderMaintenanceCapabilitiesForInstance: () => @@ -563,10 +621,14 @@ describe("providerMaintenanceRunner", () => { ...lifecycleFor(CODEX_DRIVER), latestVersion: null, }), - refreshInstance: () => - Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, version: after })), - ), + refreshInstance: () => { + refreshes += 1; + return refreshes === 1 + ? Ref.get(providersRef) + : Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, version: after })), + ); + }, }); const result = yield* updater.updateProvider(CODEX_DRIVER); assert.strictEqual(result.providers[0]?.updateState?.status, status); @@ -581,6 +643,98 @@ describe("providerMaintenanceRunner", () => { ), ); + it.effect("uses a refreshed version baseline when the provider changed outside T3", () => { + const commands: string[] = []; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ ...baseProvider, version: "1.0.0" }); + const updater = yield* makeTestRunner({ + ...registry, + refreshInstance: () => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, version: "2.0.0" })), + ), + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "winget", + updateArgs: ["upgrade", "--id", "OpenAI.Codex"], + updateLockKey: "winget:source:OpenAI.Codex:user", + updateInstallationKey: "winget:selected", + latestVersion: null, + windowsInstaller: { manager: "winget", scope: "user" }, + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(commands, ["winget"]); + assert.strictEqual(result.providers[0]?.version, "2.0.0"); + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.match(result.providers[0]?.updateState?.message ?? "", /version did not advance/); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("2.0.0"), + mockSpawnerLayer((command) => { + commands.push(command); + return { code: 2316632107, stdout: "No available upgrade found." }; + }), + ), + ), + ); + }); + + it.effect.each([ + { latest: "1.9.0", calls: 0, status: "unchanged" }, + { latest: "2.0.0", calls: 0, status: "unchanged" }, + { latest: null, calls: 1, status: "succeeded" }, + ] as const)( + "does not downgrade a newer WinGet install: latest=$latest", + ({ latest, calls, status }) => { + const commands: string[] = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ ...baseProvider, version: "2.0.0" }, "2.1.0"); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "winget", + updateArgs: ["upgrade", "--id", "OpenAI.Codex"], + updateLockKey: "winget:source:OpenAI.Codex:user", + updateInstallationKey: "winget:selected", + latestVersion: latest, + windowsInstaller: { manager: "winget", scope: "user" }, + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(commands.length, calls); + assert.strictEqual(result.providers[0]?.updateState?.status, status); + if (latest !== null) { + assert.match(result.providers[0]?.updateState?.message ?? "", /already at or newer/); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("2.1.0"), + mockSpawnerLayer((command) => { + commands.push(command); + return { stdout: "completed" }; + }), + ), + ), + ); + }, + ); + it.effect.each([ { installed: false, version: null }, { installed: true, version: null }, @@ -589,12 +743,17 @@ describe("providerMaintenanceRunner", () => { return Effect.gen(function* () { const { registry, providersRef } = yield* makeRegistry(baseProvider); // After the update, the refreshed snapshot cannot verify the install/version. + let refreshes = 0; const updater = yield* makeTestRunner({ ...registry, - refreshInstance: () => - Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, installed, version })), - ), + refreshInstance: () => { + refreshes += 1; + return refreshes === 1 + ? Ref.get(providersRef) + : Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, installed, version })), + ); + }, }); const result = yield* updater.updateProvider(CODEX_DRIVER); @@ -615,12 +774,17 @@ describe("providerMaintenanceRunner", () => { return Effect.gen(function* () { const { registry, providersRef } = yield* makeRegistry(baseCursorProvider); // A missing post-update version cannot prove that the known install advanced. + let refreshes = 0; const updater = yield* makeTestRunner({ ...registry, - refreshInstance: () => - Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, installed: true, version: null })), - ), + refreshInstance: () => { + refreshes += 1; + return refreshes === 1 + ? Ref.get(providersRef) + : Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, installed: true, version: null })), + ); + }, }); const result = yield* updater.updateProvider(CURSOR_DRIVER); @@ -1072,7 +1236,7 @@ describe("providerMaintenanceRunner", () => { args: ["i", "-g", "@openai/codex"], }, ]); - assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]); + assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId, personalInstanceId]); assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); assert.strictEqual(result.providers[1]?.instanceId, workInstanceId); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index d30e939b8403..c4ffa95aa7d3 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -31,6 +31,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCommandAction, ProviderVersionCache, + resolveLatestProviderVersion, } from "./providerMaintenance.ts"; import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -421,37 +422,101 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { message: "Updating provider.", }), ); + const finishInstallationChanged = (output?: string | null) => + nowIso.pipe( + Effect.flatMap((finishedAt) => + finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt, + message: "Provider installation changed. Refresh and try again.", + ...(output !== undefined ? { output } : {}), + }), + ), + ), + ); - // The cached capabilities chose the lock; re-derive ownership - // now so the command that runs matches the executable as it is - // at click time, not as it was at the last health refresh. - const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( - instanceId, - provider, - { fresh: true }, + // Refresh the exact selected instance before taking the version + // baseline. The advisory snapshot may be stale when another + // process already updated this installation. + const providerBeforeUpdate = (yield* providerRegistry.refreshInstance(instanceId)).find( + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, ); - const freshUpdate = fresh.update; - if (!freshUpdate || !isSameMaintenanceAction(update, freshUpdate)) { + if (!providerBeforeUpdate?.installed) { return yield* finish( makeUpdateState({ status: "failed", startedAt, finishedAt: yield* nowIso, - message: "Provider installation changed. Refresh and try again.", + message: "T3 Code could not verify the selected provider before updating it.", }), ); } + const versionBeforeUpdate = providerBeforeUpdate.version?.trim(); - const versionBeforeUpdate = (yield* providerRegistry.getProviders) - .find( - (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, - ) - ?.version?.trim(); + // The cached capabilities chose the lock; re-derive ownership + // after refreshing the provider so the command matches the + // executable that is about to run. + const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( + instanceId, + provider, + { fresh: true }, + ); + const freshUpdate = fresh.update; + if (!freshUpdate || !isSameMaintenanceAction(update, freshUpdate)) { + return yield* finishInstallationChanged(); + } + let executionUpdate = freshUpdate; + if (freshUpdate.windowsInstaller?.manager === "winget") { + const latestInstallerVersion = yield* resolveLatestProviderVersion(fresh).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, versionCache), + ); + if ( + versionBeforeUpdate && + latestInstallerVersion && + compareSemverVersions(versionBeforeUpdate, latestInstallerVersion) >= 0 + ) { + return yield* finish( + makeUpdateState({ + status: "unchanged", + startedAt, + finishedAt: yield* nowIso, + message: + "The selected provider is already at or newer than the latest version available from WinGet.", + }), + ); + } + const executionCapabilities = + yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( + instanceId, + provider, + { fresh: true }, + ); + if ( + !executionCapabilities.update || + !isSameMaintenanceAction(freshUpdate, executionCapabilities.update) + ) { + return yield* finishInstallationChanged(); + } + executionUpdate = executionCapabilities.update; + } let result: ProviderMaintenanceCommandResult = - yield* runMaintenanceCommand(freshUpdate); + yield* runMaintenanceCommand(executionUpdate); const needsElevation = - platform === "win32" && requiresWindowsAdministrator(freshUpdate, result); + platform === "win32" && requiresWindowsAdministrator(executionUpdate, result); if (needsElevation) { + const elevatedCapabilities = + yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( + instanceId, + provider, + { fresh: true }, + ); + const elevatedUpdate = elevatedCapabilities.update; + if (!elevatedUpdate || !isSameMaintenanceAction(executionUpdate, elevatedUpdate)) { + return yield* finishInstallationChanged(commandOutput(result)); + } yield* setUpdateState( makeUpdateState({ status: "running", @@ -460,7 +525,6 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { message: "Approve the Windows administrator prompt to update this provider.", }), ); - const elevatedUpdate = freshUpdate; result = yield* Effect.gen(function* () { const elevated = yield* prepareWindowsUpdateElevation( elevatedUpdate, @@ -489,7 +553,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // WinGet reports "no applicable update" as a nonzero exit. Still // verify the selected provider instead of presenting this as failure. if ( - freshUpdate.windowsInstaller?.manager === "winget" && + executionUpdate.windowsInstaller?.manager === "winget" && result.exitCode !== null && result.exitCode >>> 0 === 0x8a15002b ) { @@ -522,7 +586,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { verified, instanceId, ); - const installationChanged = !isSameMaintenanceAction(freshUpdate, verified.update); + const installationChanged = !isSameMaintenanceAction(executionUpdate, verified.update); // "Succeeded" needs the provider to still be installed: an // installer that exits 0 and leaves the binary missing is not a // success. Only Cursor tolerates a missing version, since its From 44093021c00fbb1607df8c4db77c6631df2b8729 Mon Sep 17 00:00:00 2001 From: ettoc00 <41289053+ettoc00@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:58:23 +0200 Subject: [PATCH 5/6] fix(server): strengthen provider update verification --- .../src/provider/providerMaintenance.test.ts | 41 ++++- .../src/provider/providerMaintenance.ts | 51 ++++-- .../providerMaintenanceRunner.test.ts | 146 +++++++++++++----- .../src/provider/providerMaintenanceRunner.ts | 76 ++++++--- .../provider/windowsUpdateElevation.test.ts | 21 ++- .../src/provider/windowsUpdateElevation.ts | 33 +++- 6 files changed, 296 insertions(+), 72 deletions(-) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 01624207d4b2..f28bb3f86806 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -109,7 +109,9 @@ function writeWindowsNpmShim(shim: string, packageName: string, binPath = "bin/p const target = NodePath.join("node_modules", ...packageName.split("/"), binPath); const contents = /\.cmd$/i.test(shim) ? `@ECHO off\r\nSET dp0=%~dp0\r\n"%dp0%\\node.exe" "%dp0%\\${target}" %*\r\n` - : `#!/bin/sh\nbasedir=$(dirname "$0")\nexec "$basedir/node" "$basedir/${target}" "$@"\n`; + : /\.ps1$/i.test(shim) + ? `#!/usr/bin/env pwsh\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n& "$basedir/node.exe" "$basedir/${target}" $args\n` + : `#!/bin/sh\nbasedir=$(dirname "$0")\nexec "$basedir/node" "$basedir/${target}" "$@"\n`; NodeFS.writeFileSync(shim, contents); } @@ -1093,6 +1095,43 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ); expect(unrelatedShim.update).toBeNull(); + for (const [filename, comment] of [ + ["package-tool.cmd", "REM"], + ["package-tool.ps1", "#"], + ["package-tool.sh", "#"], + ] as const) { + const wrapper = NodePath.join(tempDir, filename); + for (const candidate of ["package-tool.sh", "package-tool.cmd", "package-tool.ps1"]) { + NodeFS.rmSync(NodePath.join(tempDir, candidate), { force: true }); + } + writeWindowsNpmShim(wrapper, "@example/package-tool"); + const owned = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: wrapper, + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD;.PS1;.SH" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + expect(owned.update, filename).not.toBeNull(); + + const target = "node_modules/@example/package-tool/bin/package-tool.js"; + const decoy = + filename === "package-tool.cmd" + ? `${comment} "%_prog%" "%dp0%/${target}" %*\r\n` + : filename === "package-tool.ps1" + ? `<#\n& "$basedir/node.exe" "$basedir/${target}" $args\n#>\n${comment} decoy\n` + : `#!/bin/sh\n${comment} exec "$basedir/node" "$basedir/${target}" "$@"\n`; + NodeFS.writeFileSync(wrapper, decoy); + const commentOnly = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: wrapper, + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD;.PS1;.SH" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + expect(commentOnly.update).toBeNull(); + } + // The same layout on POSIX is a project checkout, not a global install. const script = NodePath.join(tempDir, "package-tool"); writeExecutable(script); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index e2e6898b018e..80244eb7bfa8 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -947,12 +947,6 @@ const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* ( if (!binPath || path.isAbsolute(binPath) || binPath.split(/[\\/]/).includes("..")) return null; const shimText = yield* read(context.resolvedCommandPath); if (!shimText) return null; - const recognizedShim = - shimKind === "cmd" - ? /%(?:~)?dp0%?/i.test(shimText) && /%\*/.test(shimText) - : shimKind === "powershell" - ? /\$basedir/i.test(shimText) && /\$args/i.test(shimText) - : /^#!.*\bsh\b/m.test(shimText) && /\bbasedir=/i.test(shimText) && /"\$@"/.test(shimText); const normalizedBinPath = binPath .split(/[\\/]/) .filter((segment) => segment !== "." && segment !== "") @@ -960,9 +954,48 @@ const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* ( const expectedTarget = ["node_modules", ...packageSegments, normalizedBinPath] .join("/") .toLowerCase(); - return recognizedShim && shimText.replaceAll("\\", "/").toLowerCase().includes(expectedTarget) - ? shimDir - : null; + const lines = shimText + .replaceAll("\\", "/") + .split(/\r?\n/) + .map((line) => line.trim()); + let inPowerShellBlockComment = false; + const invokesDeclaredBin = lines.some((line) => { + if (shimKind === "powershell") { + if (inPowerShellBlockComment) { + if (line.includes("#>")) inPowerShellBlockComment = false; + return false; + } + if (line.startsWith("<#")) { + if (!line.includes("#>")) inPowerShellBlockComment = true; + return false; + } + } + const normalized = line.toLowerCase(); + const targetIndex = normalized.indexOf(expectedTarget); + if (targetIndex < 0) return false; + if (shimKind === "cmd") { + if (/^(?:::|@?rem\b|@?echo\b)/i.test(line)) return false; + const argumentsIndex = normalized.indexOf("%*", targetIndex + expectedTarget.length); + const command = normalized.slice(0, targetIndex); + return ( + argumentsIndex >= 0 && + (/%_prog%/.test(command) || /(?:^|[&|]\s*)[^&|]*\bnode(?:\.exe)?["']?\s/.test(command)) + ); + } + if (shimKind === "powershell") { + if (line.startsWith("#")) return false; + return ( + normalized.startsWith("&") && + normalized.indexOf("$args", targetIndex + expectedTarget.length) >= 0 + ); + } + if (line.startsWith("#")) return false; + return ( + normalized.startsWith("exec ") && + normalized.indexOf('"$@"', targetIndex + expectedTarget.length) >= 0 + ); + }); + return invokesDeclaredBin ? shimDir : null; }); export function makePackageManagedProviderMaintenanceResolver( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 969cda0df732..97a796fc6cdc 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -295,7 +295,6 @@ describe("providerMaintenanceRunner", () => { const events: string[] = []; let firstStopped = false; let installStarts = 0; - let exitReads = 0; const fs = yield* FileSystem.FileSystem; const { registry } = yield* makeRegistry( [baseProvider, { ...baseProvider, instanceId: secondInstanceId }], @@ -331,15 +330,25 @@ describe("providerMaintenanceRunner", () => { Layer.succeed(FileSystem.FileSystem, { ...fs, exists: (file) => - /[\\/]started$/.test(file) ? Effect.succeed(workerStarted) : fs.exists(file), + /[\\/](?:ready|started)$/.test(file) + ? Effect.succeed(workerStarted) + : fs.exists(file), writeFileString: (file, content, options) => - fs.writeFileString(file, content, options).pipe( - Effect.tap(() => - Effect.sync(() => { - if (content === "cancel") events.push("cancel"); - }), + fs + .writeFileString(file, content, options) + .pipe( + Effect.tap(() => + content === "cancel" + ? Effect.sync(() => events.push("cancel")).pipe( + Effect.andThen( + workerStarted + ? Deferred.succeed(waitingForExit, undefined) + : Effect.void, + ), + ) + : Effect.void, + ), ), - ), }), ).pipe( Effect.provideService( @@ -350,17 +359,14 @@ describe("providerMaintenanceRunner", () => { return Deferred.succeed(started, undefined).pipe( Effect.as({ ...mockHandle({ - exitCode: Effect.gen(function* () { - // The first read collects command completion; cancellation - // must independently await exit before releasing the lock. - if (++exitReads > 1) yield* Deferred.succeed(waitingForExit, undefined); - return yield* Deferred.await(stopped); - }), + exitCode: Deferred.await(stopped), }), kill: () => - Effect.sync(() => { - events.push("kill"); - }), + Effect.sync(() => events.push("kill")).pipe( + Effect.andThen( + workerStarted ? Effect.void : Deferred.succeed(waitingForExit, undefined), + ), + ), }), ); } @@ -515,10 +521,12 @@ describe("providerMaintenanceRunner", () => { ); } - it.effect("re-checks ownership before an elevated retry", () => { + it.effect("re-checks ownership after UAC approval before an elevated retry", () => { const commands: string[] = []; const freshReads: boolean[] = []; + const handshakeWrites: string[] = []; return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; const { registry } = yield* makeRegistry(baseProvider); const capabilities = (installationKey: string) => makeProviderMaintenanceCapabilities({ @@ -531,23 +539,46 @@ describe("providerMaintenanceRunner", () => { latestVersion: null, windowsInstaller: { manager: "winget", scope: "machine" }, }); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, _provider, options) => { - freshReads.push(options?.fresh === true); - return Effect.succeed( - capabilities( - options?.fresh && freshReads.filter(Boolean).length > 2 - ? "winget:replacement" - : "winget:selected", - ), - ); + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, _provider, options) => { + freshReads.push(options?.fresh === true); + return Effect.succeed( + capabilities( + options?.fresh && freshReads.filter(Boolean).length > 2 + ? "winget:replacement" + : "winget:selected", + ), + ); + }, }, - }); + Layer.succeed(FileSystem.FileSystem, { + ...fs, + exists: (file) => (/[\\/]ready$/.test(file) ? Effect.succeed(true) : fs.exists(file)), + writeFileString: (file, content, options) => + fs.writeFileString(file, content, options).pipe( + Effect.tap(() => + Effect.sync(() => { + if (content === "cancel" || content === "authorize") { + handshakeWrites.push(content); + } + }), + ), + ), + }), + ); const result = yield* updater.updateProvider(CODEX_DRIVER); assert.deepStrictEqual(freshReads, [false, true, true, true]); - assert.deepStrictEqual(commands, ["C:/Tools/winget.exe"]); + assert.strictEqual(commands[0], "C:/Tools/winget.exe"); + assert.match(commands[1] ?? "", /powershell\.exe$/i); + assert.strictEqual(commands.length, 2); + assert.isAbove(handshakeWrites.length, 0); + assert.strictEqual( + handshakeWrites.every((write) => write === "cancel"), + true, + ); assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); assert.strictEqual( result.providers[0]?.updateState?.message, @@ -556,6 +587,7 @@ describe("providerMaintenanceRunner", () => { }).pipe( Effect.provide( Layer.mergeAll( + NodeFileSystem.layer, Layer.succeed(HostProcessPlatform, "win32"), Layer.succeed(SpawnExecutableResolution, (command) => command), latestVersionHttpClient("0.0.1"), @@ -604,7 +636,7 @@ describe("providerMaintenanceRunner", () => { { before: "1.0.0", after: "1.0.0", status: "unchanged" }, { before: "1.0.0", after: "0.9.0", status: "unchanged" }, { before: "1.0.0", after: "1.2.0", status: "succeeded" }, - { before: null, after: "1.2.0", status: "succeeded" }, + { before: null, after: "1.2.0", status: "unchanged" }, ])( "verifies version advancement with unknown latest: $before -> $after", ({ before, after, status }) => @@ -672,7 +704,7 @@ describe("providerMaintenanceRunner", () => { assert.deepStrictEqual(commands, ["winget"]); assert.strictEqual(result.providers[0]?.version, "2.0.0"); assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); - assert.match(result.providers[0]?.updateState?.message ?? "", /version did not advance/); + assert.match(result.providers[0]?.updateState?.message ?? "", /no applicable update/i); }).pipe( Effect.provide( Layer.mergeAll( @@ -687,6 +719,50 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("preserves WinGet's no-update result when the version changes concurrently", () => { + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ ...baseProvider, version: "1.0.0" }); + let refreshes = 0; + const updater = yield* makeTestRunner({ + ...registry, + refreshInstance: () => { + refreshes += 1; + return refreshes === 1 + ? Ref.get(providersRef) + : Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, version: "2.0.0" })), + ); + }, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "winget", + updateArgs: ["upgrade", "--id", "OpenAI.Codex"], + updateLockKey: "winget:source:OpenAI.Codex:user", + updateInstallationKey: "winget:selected", + latestVersion: null, + windowsInstaller: { manager: "winget", scope: "user" }, + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(result.providers[0]?.version, "2.0.0"); + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.match(result.providers[0]?.updateState?.message ?? "", /no applicable update/i); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("2.0.0"), + mockSpawnerLayer(() => ({ code: 2316632107, stdout: "No available upgrade found." })), + ), + ), + ); + }); + it.effect.each([ { latest: "1.9.0", calls: 0, status: "unchanged" }, { latest: "2.0.0", calls: 0, status: "unchanged" }, @@ -800,12 +876,12 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("allows Cursor's updater when both versions are unknown but the binary remains", () => + it.effect("does not claim Cursor advanced when both versions are unknown", () => Effect.gen(function* () { const { registry } = yield* makeRegistry({ ...baseCursorProvider, version: null }); const updater = yield* makeTestRunner(registry); const result = yield* updater.updateProvider(CURSOR_DRIVER); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index c4ffa95aa7d3..9f0b7c8d3c8f 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -85,6 +85,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly args: ReadonlyArray; readonly env?: NodeJS.ProcessEnv; readonly cancel?: Effect.Effect; + readonly afterSpawn?: (launcherExited: Effect.Effect) => Effect.Effect; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -126,6 +127,9 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR yield* child.kill().pipe(Effect.ignore); }), ); + if (input.afterSpawn) { + yield* input.afterSpawn(child.exitCode.pipe(Effect.asVoid, Effect.orDie)); + } const [stdout, stderr, exitCode] = yield* Effect.all( [ @@ -504,19 +508,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { } let result: ProviderMaintenanceCommandResult = yield* runMaintenanceCommand(executionUpdate); + let elevationOwnershipChanged = false; const needsElevation = platform === "win32" && requiresWindowsAdministrator(executionUpdate, result); if (needsElevation) { - const elevatedCapabilities = - yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( - instanceId, - provider, - { fresh: true }, - ); - const elevatedUpdate = elevatedCapabilities.update; - if (!elevatedUpdate || !isSameMaintenanceAction(executionUpdate, elevatedUpdate)) { - return yield* finishInstallationChanged(commandOutput(result)); - } yield* setUpdateState( makeUpdateState({ status: "running", @@ -527,13 +522,42 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); result = yield* Effect.gen(function* () { const elevated = yield* prepareWindowsUpdateElevation( - elevatedUpdate, + executionUpdate, UPDATE_TIMEOUT_MS, UPDATE_OUTPUT_MAX_BYTES, ); const result = yield* runProviderMaintenanceCommandWithSpawner({ spawner, ...elevated, + afterSpawn: (launcherExited) => + Effect.race( + elevated.waitUntilReady.pipe(Effect.as("ready" as const)), + launcherExited.pipe(Effect.as("exited" as const)), + ).pipe( + Effect.flatMap((state) => + state === "ready" ? Effect.succeed(true) : elevated.isReady, + ), + Effect.flatMap((ready) => { + if (!ready) return Effect.void; + return Effect.gen(function* () { + const approvedCapabilities = + yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( + instanceId, + provider, + { fresh: true }, + ); + if ( + !approvedCapabilities.update || + !isSameMaintenanceAction(executionUpdate, approvedCapabilities.update) + ) { + elevationOwnershipChanged = true; + yield* elevated.cancel; + return; + } + yield* elevated.authorize; + }); + }), + ), }); const [stdout, stderr] = yield* elevated.readOutput; return { @@ -550,13 +574,18 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { Effect.scoped, ); } + if (elevationOwnershipChanged) { + return yield* finishInstallationChanged(commandOutput(result)); + } // WinGet reports "no applicable update" as a nonzero exit. Still // verify the selected provider instead of presenting this as failure. + let noApplicableUpdate = false; if ( executionUpdate.windowsInstaller?.manager === "winget" && result.exitCode !== null && result.exitCode >>> 0 === 0x8a15002b ) { + noApplicableUpdate = true; result = { ...result, exitCode: 0 }; } const finishedAt = yield* nowIso; @@ -599,20 +628,19 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); const versionAdvanced = verifiedProviders.some( (verifiedProvider) => + versionBeforeUpdate && verifiedProvider.version?.trim() && - (!versionBeforeUpdate || - compareSemverVersions(versionBeforeUpdate, verifiedProvider.version) < 0), + compareSemverVersions(versionBeforeUpdate, verifiedProvider.version) < 0, ); - // Cursor can be healthy without a readable version. That unknown - // baseline remains compatible, but a known version must advance - // just like every other provider before the update is successful. - const versionUnchanged = versionBeforeUpdate - ? !versionAdvanced - : provider !== "cursor" && !versionAdvanced; + const versionUnchanged = !versionAdvanced; return yield* finish( makeUpdateState({ status: - installationChanged || couldNotVerify || stillOutdated || versionUnchanged + installationChanged || + couldNotVerify || + stillOutdated || + noApplicableUpdate || + versionUnchanged ? "unchanged" : "succeeded", startedAt, @@ -623,9 +651,11 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ? "Update command completed, but T3 Code could not verify the provider version." : stillOutdated ? "Update command completed, but T3 Code still detects an outdated provider version." - : versionUnchanged - ? "Update command completed, but the provider version did not advance." - : "Provider updated.", + : noApplicableUpdate + ? "WinGet reported that no applicable update is available." + : versionUnchanged + ? "Update command completed, but the provider version did not advance." + : "Provider updated.", output: commandOutput(result), }), ); diff --git a/apps/server/src/provider/windowsUpdateElevation.test.ts b/apps/server/src/provider/windowsUpdateElevation.test.ts index ec813fb2abb9..086119966726 100644 --- a/apps/server/src/provider/windowsUpdateElevation.test.ts +++ b/apps/server/src/provider/windowsUpdateElevation.test.ts @@ -4,6 +4,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -73,7 +74,7 @@ for (const { mode, expectedCode } of [ : launcher.replace("$start.Verb = 'runas'", "$start.Verb = ''"); assert.notStrictEqual(testLauncher, launcher, "launcher patch did not apply"); const encoded = Buffer.from(testLauncher, "utf16le").toString("base64"); - const exitCode = yield* Effect.promise( + const exitFiber = yield* Effect.promise( () => new Promise((resolve, reject) => { NodeChildProcess.execFile( @@ -86,7 +87,23 @@ for (const { mode, expectedCode } of [ }, ); }), - ); + ).pipe(Effect.forkScoped); + if (!["cancel", "expired", "declined"].includes(mode)) { + const readiness = yield* Effect.race( + prepared.waitUntilReady.pipe(Effect.as({ ready: true as const })), + Fiber.join(exitFiber).pipe( + Effect.map((exitCode) => ({ ready: false as const, exitCode })), + ), + ); + if (!readiness.ready) { + const [, earlyStderr] = yield* prepared.readOutput; + assert.fail( + `elevated worker exited ${readiness.exitCode} before becoming ready: ${earlyStderr?.text ?? ""}`, + ); + } + yield* prepared.authorize; + } + const exitCode = yield* Fiber.join(exitFiber); const [stdout, stderr] = yield* prepared.readOutput; assert.strictEqual(exitCode, expectedCode, stderr?.text); if (mode.endsWith("complete")) { diff --git a/apps/server/src/provider/windowsUpdateElevation.ts b/apps/server/src/provider/windowsUpdateElevation.ts index 9d9f0efe69ef..c3c70b21d3ea 100644 --- a/apps/server/src/provider/windowsUpdateElevation.ts +++ b/apps/server/src/provider/windowsUpdateElevation.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import type { ProviderMaintenanceCommandAction } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -28,6 +29,8 @@ export const prepareWindowsUpdateElevation = Effect.fn("prepareWindowsUpdateElev const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-update-" }); const payloadPath = path.join(directory, "command.json"); const cancelPath = path.join(directory, "cancel"); + const readyPath = path.join(directory, "ready"); + const authorizePath = path.join(directory, "authorize"); const startedPath = path.join(directory, "started"); const stdoutPath = path.join(directory, "stdout"); const stderrPath = path.join(directory, "stderr"); @@ -71,11 +74,11 @@ export const prepareWindowsUpdateElevation = Effect.fn("prepareWindowsUpdateElev // the unelevated launcher cannot stop an administrator process. const worker = ` $ErrorActionPreference = 'Stop' -[IO.File]::WriteAllText(${quotePowerShell(startedPath)}, 'started') $parent = Get-Process -Id __T3_PARENT_PID__ -ErrorAction Stop $deadline = [DateTime]::Parse('__T3_DEADLINE__').ToUniversalTime() $payloadPath = ${quotePowerShell(payloadPath)} $cancelPath = ${quotePowerShell(cancelPath)} +$authorizePath = ${quotePowerShell(authorizePath)} $child = $null try { if ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -ne '__T3_USER_SID__') { @@ -89,6 +92,12 @@ try { [Environment]::SetEnvironmentVariable($variable[0], $variable[1], 'Process') } if ((Test-Path -LiteralPath $cancelPath) -or [DateTime]::UtcNow -ge $deadline -or $parent.HasExited) { exit 1460 } + while (-not (Test-Path -LiteralPath $authorizePath)) { + if ((Test-Path -LiteralPath $cancelPath) -or [DateTime]::UtcNow -ge $deadline -or $parent.HasExited) { exit 1460 } + Start-Sleep -Milliseconds 25 + } + if ((Test-Path -LiteralPath $cancelPath) -or [DateTime]::UtcNow -ge $deadline -or $parent.HasExited) { exit 1460 } + [IO.File]::WriteAllText(${quotePowerShell(startedPath)}, 'started') $child = Start-Process -FilePath $config.executable -ArgumentList $config.arguments -WindowStyle Hidden -RedirectStandardOutput $config.stdout -RedirectStandardError $config.stderr -PassThru $null = $child.Handle while (-not $child.WaitForExit(200)) { @@ -120,6 +129,7 @@ try { $start.Verb = 'runas' $start.WindowStyle = 'Hidden' $child = [Diagnostics.Process]::Start($start) + [IO.File]::WriteAllText(${quotePowerShell(readyPath)}, 'ready') $child.WaitForExit() exit $child.ExitCode } catch { @@ -132,12 +142,31 @@ try { exit 1 } `; + const waitUntilReady = Effect.race( + fs.watch(directory).pipe( + Stream.filter((event) => path.basename(event.path) === "ready"), + Stream.runHead, + Effect.asVoid, + ), + Effect.yieldNow.pipe( + Effect.andThen(fs.exists(readyPath)), + Effect.flatMap((ready) => (ready ? Effect.void : Effect.never)), + ), + ).pipe(Effect.orDie); return { command: powershell, args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(launcher)], + waitUntilReady, + isReady: fs.exists(readyPath).pipe(Effect.orDie), + authorize: fs.writeFileString(authorizePath, "authorize").pipe(Effect.orDie), cancel: fs .writeFileString(cancelPath, "cancel") - .pipe(Effect.andThen(fs.exists(startedPath)), Effect.orDie), + .pipe( + Effect.andThen( + Effect.zipWith(fs.exists(readyPath), fs.exists(startedPath), (a, b) => a || b), + ), + Effect.orDie, + ), readOutput: Effect.all( [stdoutPath, stderrPath].map((file) => collectUint8StreamText({ stream: fs.stream(file), maxBytes: maxOutputBytes }).pipe( From 22e8cd0c5eee5e973f90f3cbd734b1ee65c3111f Mon Sep 17 00:00:00 2001 From: ettoc00 <41289053+ettoc00@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:30:13 +0200 Subject: [PATCH 6/6] fix(server): tighten provider update ownership proof --- .../src/provider/providerMaintenance.test.ts | 98 +++++++++----- .../src/provider/providerMaintenance.ts | 120 ++++++++++++------ .../providerMaintenanceRunner.test.ts | 53 ++++++++ .../src/provider/providerMaintenanceRunner.ts | 99 ++++++++------- 4 files changed, 251 insertions(+), 119 deletions(-) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index f28bb3f86806..6d5236d75bea 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -107,11 +107,12 @@ function writeWindowsNpmShim(shim: string, packageName: string, binPath = "bin/p JSON.stringify({ name: packageName, bin: { [commandName]: binPath } }), ); const target = NodePath.join("node_modules", ...packageName.split("/"), binPath); + const portableTarget = target.replaceAll("\\", "/"); const contents = /\.cmd$/i.test(shim) - ? `@ECHO off\r\nSET dp0=%~dp0\r\n"%dp0%\\node.exe" "%dp0%\\${target}" %*\r\n` + ? `@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n\r\nIF EXIST "%dp0%\\node.exe" (\r\n SET "_prog=%dp0%\\node.exe"\r\n) ELSE (\r\n SET "_prog=node"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${target}" %*\r\n` : /\.ps1$/i.test(shim) - ? `#!/usr/bin/env pwsh\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n& "$basedir/node.exe" "$basedir/${target}" $args\n` - : `#!/bin/sh\nbasedir=$(dirname "$0")\nexec "$basedir/node" "$basedir/${target}" "$@"\n`; + ? `#!/usr/bin/env pwsh\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\n$exe=""\nif ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {\n # Fix case when both the Windows and Linux builds of Node\n # are installed in the same directory\n $exe=".exe"\n}\n$ret=0\nif (Test-Path "$basedir/node$exe") {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & "$basedir/node$exe" "$basedir/${portableTarget}" $args\n } else {\n & "$basedir/node$exe" "$basedir/${portableTarget}" $args\n }\n $ret=$LASTEXITCODE\n} else {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & "node$exe" "$basedir/${portableTarget}" $args\n } else {\n & "node$exe" "$basedir/${portableTarget}" $args\n }\n $ret=$LASTEXITCODE\n}\nexit $ret\n` + : `#!/bin/sh\nbasedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")\n\ncase \`uname\` in\n *CYGWIN*|*MINGW*|*MSYS*)\n if command -v cygpath > /dev/null 2>&1; then\n basedir=\`cygpath -w "$basedir"\`\n fi\n ;;\nesac\n\nif [ -x "$basedir/node" ]; then\n exec "$basedir/node" "$basedir/${portableTarget}" "$@"\nelse\u0020\n exec node "$basedir/${portableTarget}" "$@"\nfi\n`; NodeFS.writeFileSync(shim, contents); } @@ -1095,41 +1096,80 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ); expect(unrelatedShim.update).toBeNull(); - for (const [filename, comment] of [ - ["package-tool.cmd", "REM"], - ["package-tool.ps1", "#"], - ["package-tool.sh", "#"], - ] as const) { + for (const filename of ["package-tool.cmd", "package-tool.ps1", "package-tool"] as const) { const wrapper = NodePath.join(tempDir, filename); - for (const candidate of ["package-tool.sh", "package-tool.cmd", "package-tool.ps1"]) { + for (const candidate of ["package-tool", "package-tool.cmd", "package-tool.ps1"]) { NodeFS.rmSync(NodePath.join(tempDir, candidate), { force: true }); } writeWindowsNpmShim(wrapper, "@example/package-tool"); - const owned = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { - binaryPath: wrapper, - env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD;.PS1;.SH" }, - }).pipe( - Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), - ); + const resolveWrapper = () => + (filename === "package-tool" + ? packageToolUpdate.resolve({ + binaryPath: wrapper, + resolvedCommandPath: wrapper, + realCommandPath: wrapper, + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD;.PS1" }, + platform: "win32", + }) + : resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: wrapper, + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD;.PS1" }, + }) + ).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + const owned = yield* resolveWrapper(); expect(owned.update, filename).not.toBeNull(); - const target = "node_modules/@example/package-tool/bin/package-tool.js"; - const decoy = + const target = NodePath.join( + "node_modules", + "@example", + "package-tool", + "bin", + "package-tool.js", + ); + const wrapperTarget = + filename === "package-tool.cmd" ? target : target.replaceAll("\\", "/"); + const canonical = NodeFS.readFileSync(wrapper, "utf8"); + const invocation = filename === "package-tool.cmd" - ? `${comment} "%_prog%" "%dp0%/${target}" %*\r\n` + ? `"%_prog%" "%dp0%\\${target}" %*` : filename === "package-tool.ps1" - ? `<#\n& "$basedir/node.exe" "$basedir/${target}" $args\n#>\n${comment} decoy\n` - : `#!/bin/sh\n${comment} exec "$basedir/node" "$basedir/${target}" "$@"\n`; - NodeFS.writeFileSync(wrapper, decoy); - const commentOnly = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { - binaryPath: wrapper, - env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD;.PS1;.SH" }, - }).pipe( - Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ? `& "$basedir/node$exe" "$basedir/${wrapperTarget}" $args` + : `exec "$basedir/node" "$basedir/${wrapperTarget}" "$@"`; + const attacks = + filename === "package-tool.cmd" + ? [ + `@ECHO off\r\nIF 1==0 ${invocation}\r\n`, + `@ECHO off\r\nGOTO safe\r\n:skipped\r\n${invocation}\r\n:safe\r\nEXIT /b 0\r\n`, + `@ECHO off\r\nnode evil.js "%dp0%\\${target}" %*\r\n`, + `@ECHO off\r\nREM ${invocation}\r\n`, + ] + : filename === "package-tool.ps1" + ? [ + `if ($false) { ${invocation} }\n`, + `& "$basedir/node$exe" "evil.js" "$basedir/${wrapperTarget}" $args\n`, + `<#\n${invocation}\n#>\n`, + `# ${invocation}\n`, + ] + : [ + `#!/bin/sh\nif false; then ${invocation}; fi\n`, + `#!/bin/sh\nexec node evil.js "$basedir/${wrapperTarget}" "$@"\n`, + `#!/bin/sh\n# ${invocation}\n`, + ]; + attacks.push( + canonical.replaceAll( + wrapperTarget, + filename === "package-tool.cmd" + ? NodePath.join("node_modules", "evil", "evil.js") + : "node_modules/evil/evil.js", + ), ); - expect(commentOnly.update).toBeNull(); + for (const attack of attacks) { + NodeFS.writeFileSync(wrapper, attack); + expect((yield* resolveWrapper()).update, `${filename}: ${attack}`).toBeNull(); + } } // The same layout on POSIX is a project checkout, not a global install. diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 80244eb7bfa8..1b13b6ef3ef2 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -905,6 +905,83 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( * proof. On Windows, both the package's declared bin entry and an npm-shaped * shim targeting that entry must match the selected command. */ +function isCanonicalWindowsNpmShim( + shimKind: "cmd" | "powershell" | "shell", + shimText: string, + expectedTarget: string, +): boolean { + // Match cmd-shim's generated bodies as a unit. Accepting an invocation line + // in isolation cannot prove that control flow actually reaches that line. + const target = expectedTarget.replaceAll("\\", "/"); + const cmdTarget = target.replaceAll("/", "\\"); + const normalized = shimText.replaceAll("\r\n", "\n").replaceAll("\r", "\n").trimEnd(); + const expected = + shimKind === "cmd" + ? `@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\\node.exe" ( + SET "_prog=%dp0%\\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${cmdTarget}" %*` + : shimKind === "powershell" + ? `#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/${target}" $args + } else { + & "$basedir/node$exe" "$basedir/${target}" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/${target}" $args + } else { + & "node$exe" "$basedir/${target}" $args + } + $ret=$LASTEXITCODE +} +exit $ret` + : `#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") + +case \`uname\` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=\`cygpath -w "$basedir"\` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/${target}" "$@" +else\u0020 + exec node "$basedir/${target}" "$@" +fi`; + return normalized === expected; +} + const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* ( context: ProviderMaintenanceResolutionContext, packageName: string, @@ -954,48 +1031,7 @@ const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* ( const expectedTarget = ["node_modules", ...packageSegments, normalizedBinPath] .join("/") .toLowerCase(); - const lines = shimText - .replaceAll("\\", "/") - .split(/\r?\n/) - .map((line) => line.trim()); - let inPowerShellBlockComment = false; - const invokesDeclaredBin = lines.some((line) => { - if (shimKind === "powershell") { - if (inPowerShellBlockComment) { - if (line.includes("#>")) inPowerShellBlockComment = false; - return false; - } - if (line.startsWith("<#")) { - if (!line.includes("#>")) inPowerShellBlockComment = true; - return false; - } - } - const normalized = line.toLowerCase(); - const targetIndex = normalized.indexOf(expectedTarget); - if (targetIndex < 0) return false; - if (shimKind === "cmd") { - if (/^(?:::|@?rem\b|@?echo\b)/i.test(line)) return false; - const argumentsIndex = normalized.indexOf("%*", targetIndex + expectedTarget.length); - const command = normalized.slice(0, targetIndex); - return ( - argumentsIndex >= 0 && - (/%_prog%/.test(command) || /(?:^|[&|]\s*)[^&|]*\bnode(?:\.exe)?["']?\s/.test(command)) - ); - } - if (shimKind === "powershell") { - if (line.startsWith("#")) return false; - return ( - normalized.startsWith("&") && - normalized.indexOf("$args", targetIndex + expectedTarget.length) >= 0 - ); - } - if (line.startsWith("#")) return false; - return ( - normalized.startsWith("exec ") && - normalized.indexOf('"$@"', targetIndex + expectedTarget.length) >= 0 - ); - }); - return invokesDeclaredBin ? shimDir : null; + return isCanonicalWindowsNpmShim(shimKind, shimText, expectedTarget) ? shimDir : null; }); export function makePackageManagedProviderMaintenanceResolver( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 97a796fc6cdc..47c6efc39372 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1174,6 +1174,59 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("re-resolves ownership after refresh changes the selected executable", () => { + const capabilityPaths: string[] = []; + let selectedExecutable = "C:/Scoop/apps/codex/current/codex.exe"; + let refreshes = 0; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(baseProvider); + const capabilities = (executable: string) => + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "C:/Scoop/shims/scoop.cmd", + updateArgs: ["update", "main/codex"], + updateLockKey: "scoop:c:/scoop", + updateInstallationKey: `scoop:c:/scoop:main:codex:${executable.toLowerCase()}`, + env: { SCOOP: "C:/Scoop", T3_SELECTED_EXECUTABLE: executable }, + }); + const updater = yield* makeTestRunner({ + ...registry, + refreshInstance: () => { + refreshes += 1; + if (refreshes === 1) return Ref.get(providersRef); + selectedExecutable = "D:/Portable/codex/codex.exe"; + return Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, version: "0.0.1" })), + ); + }, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.sync(() => { + capabilityPaths.push(selectedExecutable); + return capabilities(selectedExecutable); + }), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(capabilityPaths, [ + "C:/Scoop/apps/codex/current/codex.exe", + "C:/Scoop/apps/codex/current/codex.exe", + "D:/Portable/codex/codex.exe", + ]); + assert.strictEqual(result.providers[0]?.version, "0.0.1"); + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.match(result.providers[0]?.updateState?.message ?? "", /installation changed/i); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.1"), + mockSpawnerLayer(() => ({ stdout: "updated" })), + ), + ), + ); + }); + it.effect("uses the resolved provider capabilities when choosing the update executable", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 9f0b7c8d3c8f..9ffd55e99422 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -74,6 +74,7 @@ class ProviderMaintenanceCommandError extends Data.TaggedError("ProviderMaintena interface VerifiedProviderRefresh { readonly providers: ReadonlyArray; readonly verifiedProviders: ReadonlyArray; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; } const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -304,7 +305,6 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const verifyRefreshedProvider = ( provider: ProviderDriverKind, - maintenanceCapabilities: ProviderMaintenanceCapabilities, instanceId: ProviderInstanceId, ): Effect.Effect => providerRegistry.getProviders.pipe( @@ -333,42 +333,51 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const refreshedProviders = providers.filter( (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, ); - if (refreshedProviders.length === 0) { - return Effect.succeed({ - providers, - verifiedProviders: [], - }); - } - return Effect.forEach( - refreshedProviders, - (refreshedProvider) => - enrichProviderSnapshotWithVersionAdvisory( - refreshedProvider, - maintenanceCapabilities, - ).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderVersionCache, versionCache), - ), - { - concurrency: "unbounded", - }, - ).pipe( - Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ - providers, - verifiedProviders, - })), - Effect.catchCause((cause) => - Effect.logWarning("Provider post-update version verification failed", { - provider, - cause: Cause.pretty(cause), - }).pipe( - Effect.as({ - providers, - verifiedProviders: refreshedProviders, - }), - ), - ), - ); + return providerRegistry + .getProviderMaintenanceCapabilitiesForInstance(instanceId, provider, { fresh: true }) + .pipe( + Effect.flatMap((maintenanceCapabilities) => { + if (refreshedProviders.length === 0) { + return Effect.succeed({ + providers, + verifiedProviders: [], + maintenanceCapabilities, + }); + } + return Effect.forEach( + refreshedProviders, + (refreshedProvider) => + enrichProviderSnapshotWithVersionAdvisory( + refreshedProvider, + maintenanceCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, versionCache), + ), + { + concurrency: "unbounded", + }, + ).pipe( + Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ + providers, + verifiedProviders, + maintenanceCapabilities, + })), + Effect.catchCause((cause) => + Effect.logWarning("Provider post-update version verification failed", { + provider, + cause: Cause.pretty(cause), + }).pipe( + Effect.as({ + providers, + verifiedProviders: refreshedProviders, + maintenanceCapabilities, + }), + ), + ), + ); + }), + ); }), ); @@ -604,17 +613,11 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } - // Homebrew's "latest" moves once the upgrade lands; read it again. - const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( - instanceId, - provider, - { fresh: true }, - ); - const { verifiedProviders } = yield* verifyRefreshedProvider( - provider, - verified, - instanceId, - ); + // Refresh the selected executable before re-resolving ownership. + // A refresh can discover a different path/shim, whose version must + // never be used as evidence that the selected installation updated. + const { verifiedProviders, maintenanceCapabilities: verified } = + yield* verifyRefreshedProvider(provider, instanceId); const installationChanged = !isSameMaintenanceAction(executionUpdate, verified.update); // "Succeeded" needs the provider to still be installed: an // installer that exits 0 and leaves the binary missing is not a