From 9be877dc09f997d705b641244bf54e6077cd467c Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 14:22:12 +0300 Subject: [PATCH 1/4] Fix trusted Antigravity install and update routing --- .../provider/Layers/ProviderHealth.test.ts | 4 +- .../src/provider/Layers/ProviderHealth.ts | 32 ++++- .../Layers/ProviderRuntimeManager.test.ts | 20 ++- .../provider/Layers/ProviderRuntimeManager.ts | 26 ++++ .../src/provider/antigravityReleaseChannel.ts | 47 +++++++ .../src/provider/providerMaintenance.test.ts | 56 +++++++- .../src/provider/providerMaintenance.ts | 123 +++--------------- .../provider/providerRuntimeRecipes.test.ts | 101 ++++++++++++++ .../src/provider/providerRuntimeRecipes.ts | 52 +++++--- .../providerUpdateRuntimePolicy.test.ts | 46 +++++++ .../provider/providerUpdateRuntimePolicy.ts | 20 +++ apps/server/src/wsRpc.ts | 13 +- .../ProviderConnectionDialog.browser.tsx | 104 +++++++++++++-- .../components/ProviderConnectionDialog.tsx | 44 +++++-- .../providerConnectionPresentation.test.ts | 66 ++++++++++ .../src/lib/providerConnectionPresentation.ts | 68 ++++++++++ apps/web/src/providerConnectionDialogStore.ts | 3 +- apps/web/src/providerUpdates.test.ts | 76 +++++++++++ apps/web/src/providerUpdates.ts | 5 + apps/web/src/routes/_chat.settings.tsx | 17 ++- docs/plans/managed-provider-installation.md | 18 +-- packages/shared/package.json | 4 + packages/shared/src/providerVersions.test.ts | 23 ++++ packages/shared/src/providerVersions.ts | 92 +++++++++++++ 24 files changed, 894 insertions(+), 166 deletions(-) create mode 100644 apps/server/src/provider/antigravityReleaseChannel.ts create mode 100644 apps/server/src/provider/providerRuntimeRecipes.test.ts create mode 100644 apps/server/src/provider/providerUpdateRuntimePolicy.test.ts create mode 100644 apps/server/src/provider/providerUpdateRuntimePolicy.ts create mode 100644 packages/shared/src/providerVersions.test.ts create mode 100644 packages/shared/src/providerVersions.ts diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 3e635c3fb..7dd54331e 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -261,8 +261,8 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }); assert.deepStrictEqual(capabilities.update, { - command: "agy update", - executable: "agy", + command: "/Users/test/.local/bin/agy update", + executable: "/Users/test/.local/bin/agy", args: ["update"], lockKey: "antigravity-native", pathPrepend: "/Users/test/.local/bin", diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 6c016273b..b73c9ad28 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -73,6 +73,7 @@ import { type ClaudeAccountCapabilities, } from "../claudeCapabilities"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; +import { MINIMUM_ANTIGRAVITY_CLI_VERSION } from "../antigravityReleaseChannel"; import { detailFromResult, extractAuthBoolean, @@ -92,6 +93,7 @@ import { writeProviderStatusCache, } from "../providerStatusCache"; import { makeProviderMaintenanceCommandCoordinator } from "../providerMaintenanceCommandCoordinator"; +import { providerExternalUpdateBlockReason } from "../providerUpdateRuntimePolicy"; import { enrichProviderStatusWithVersionAdvisory, compareSemverVersions, @@ -123,8 +125,6 @@ const OPENCODE_PROVIDER = "opencode" as const; const PI_PROVIDER = "pi" as const; type ProviderStatuses = ReadonlyArray; const DISABLED_PROVIDER_STATUS_MESSAGE = "Provider is disabled in Scient settings."; -const MINIMUM_ANTIGRAVITY_CLI_VERSION = "1.1.4"; - const PROVIDERS = [ CODEX_PROVIDER, CLAUDE_AGENT_PROVIDER, @@ -2231,8 +2231,22 @@ export function makeProviderHealthLive(options?: { updateLockKey: null, }); } + const configuredExecutable = getProviderBinaryPath(provider, settings); + const runtime = options?.resolveProviderRuntime + ? yield* options.resolveProviderRuntime(provider, configuredExecutable) + : null; + if (runtime && !runtime.executable) { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: definition.npmPackageName, + latestVersionSource: definition.latestVersionSource ?? null, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); + } return yield* resolveProviderMaintenanceCapabilitiesEffect(definition, { - binaryPath: getProviderBinaryPath(provider, settings), + binaryPath: runtime?.executable ?? configuredExecutable, env: process.env, platform: process.platform, }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); @@ -2648,6 +2662,18 @@ export function makeProviderHealthLive(options?: { reason: "Provider is disabled in Scient settings.", }); } + if (options?.resolveProviderRuntime) { + const runtime = yield* options + .resolveProviderRuntime(provider, getProviderBinaryPath(provider, settings)) + .pipe(Effect.mapError(toUpdateError)); + const blockReason = providerExternalUpdateBlockReason(provider, runtime); + if (blockReason) { + return yield* new ServerProviderUpdateError({ + provider, + reason: blockReason, + }); + } + } const capabilities = yield* getProviderMaintenanceCapabilities(provider).pipe( Effect.mapError(toUpdateError), ); diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index 08751e3b8..412ce1e29 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -10,7 +10,10 @@ import { describe, expect, it } from "vitest"; import { ServerConfig } from "../../config"; import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import type { ProviderRuntimeCurrentRecord } from "../providerRuntimeTypes"; -import { ProviderRuntimeManagerLive } from "./ProviderRuntimeManager"; +import { + canActivateManagedRuntimeVersion, + ProviderRuntimeManagerLive, +} from "./ProviderRuntimeManager"; function sha256(filePath: string): string { return createHash("sha256").update(readFileSync(filePath)).digest("hex"); @@ -31,6 +34,21 @@ function resolveAntigravity(baseDir: string, configuredExecutable?: string) { } describe("ProviderRuntimeManager managed integrity", () => { + it("allows install or repair at the current version but never downgrades", () => { + expect( + canActivateManagedRuntimeVersion({ currentVersion: null, candidateVersion: "1.1.5" }), + ).toBe(true); + expect( + canActivateManagedRuntimeVersion({ currentVersion: "1.1.4", candidateVersion: "1.1.5" }), + ).toBe(true); + expect( + canActivateManagedRuntimeVersion({ currentVersion: "1.1.5", candidateVersion: "1.1.5" }), + ).toBe(true); + expect( + canActivateManagedRuntimeVersion({ currentVersion: "1.1.5", candidateVersion: "1.1.4" }), + ).toBe(false); + }); + it("preserves an invalid custom executable as an explicit configuration error", async () => { const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-custom-")); try { diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts index eb6585b7d..bb2271d58 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -10,6 +10,7 @@ import { type ServerProviderInstallationState, type ServerProviderRuntimeSource, } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; import { Effect, Layer, PubSub, Stream } from "effect"; import { ServerConfig } from "../../config"; @@ -39,6 +40,16 @@ import { providerRuntimeTargetId, } from "../providerRuntimeTypes"; +export function canActivateManagedRuntimeVersion(input: { + readonly currentVersion: string | null; + readonly candidateVersion: string; +}): boolean { + return ( + input.currentVersion === null || + compareSemverVersions(input.candidateVersion, input.currentVersion) >= 0 + ); +} + const PROVIDERS: ReadonlyArray = [ "codex", "claudeAgent", @@ -721,6 +732,21 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: "The installation plan expired. Review the provider download again.", }); } + const current = records.get(input.provider); + if ( + current && + !canActivateManagedRuntimeVersion({ + currentVersion: current.runtimeVersion, + candidateVersion: plan.artifact.version, + }) + ) { + plans.delete(input.planToken); + return yield* installationError({ + provider: input.provider, + reason: "managed_runtime_unavailable", + message: `Scient will not replace ${input.provider} ${current.runtimeVersion} with older version ${plan.artifact.version}.`, + }); + } plans.delete(input.planToken); yield* startOperation({ provider: input.provider, diff --git a/apps/server/src/provider/antigravityReleaseChannel.ts b/apps/server/src/provider/antigravityReleaseChannel.ts new file mode 100644 index 000000000..5789c3bac --- /dev/null +++ b/apps/server/src/provider/antigravityReleaseChannel.ts @@ -0,0 +1,47 @@ +// FILE: antigravityReleaseChannel.ts +// Purpose: Defines Antigravity's trusted stable manifest and artifact boundaries. +// Layer: Provider runtime infrastructure + +import type { ProviderRuntimeTarget } from "./providerRuntimeTypes"; + +export const MINIMUM_ANTIGRAVITY_CLI_VERSION = "1.1.4"; +export const ANTIGRAVITY_MANIFEST_HOST = + "antigravity-cli-auto-updater-974169037036.us-central1.run.app"; +export const ANTIGRAVITY_ARTIFACT_HOSTS = ["storage.googleapis.com"] as const; +const ANTIGRAVITY_ARTIFACT_PATH_PREFIX = "/antigravity-public/antigravity-cli/"; + +export function antigravityManifestPlatform(target: ProviderRuntimeTarget): string { + const arch = target.arch === "arm64" ? "arm64" : "amd64"; + if (target.platform === "linux" && target.libc === "musl") return `linux_${arch}_musl`; + const os = target.platform === "win32" ? "windows" : target.platform; + return `${os}_${arch}`; +} + +export function antigravityManifestUrl(target: ProviderRuntimeTarget): string { + return `https://${ANTIGRAVITY_MANIFEST_HOST}/manifests/${antigravityManifestPlatform(target)}.json`; +} + +export function validateAntigravityArtifactUrl(input: { + readonly url: string; + readonly version: string; +}): string { + let parsed: URL; + try { + parsed = new URL(input.url); + } catch { + throw new Error("Antigravity release manifest contains an invalid artifact URL."); + } + if ( + parsed.protocol !== "https:" || + !ANTIGRAVITY_ARTIFACT_HOSTS.includes( + parsed.hostname as (typeof ANTIGRAVITY_ARTIFACT_HOSTS)[number], + ) + ) { + throw new Error("Antigravity release manifest uses an untrusted artifact host."); + } + const expectedVersionPrefix = `${ANTIGRAVITY_ARTIFACT_PATH_PREFIX}${input.version}-`; + if (!parsed.pathname.startsWith(expectedVersionPrefix)) { + throw new Error("Antigravity release manifest artifact URL does not match its version."); + } + return parsed.toString(); +} diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 25d7b3c2c..a250161c0 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,9 +1,12 @@ import { describe, it, assert } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect } from "effect"; import { createProviderVersionAdvisory, deriveNpmGlobalPrefix, parseGenericCliVersion, + resolveProviderMaintenanceCapabilitiesEffect, resolvePackageManagedProviderMaintenance, type PackageManagedProviderMaintenanceDefinition, } from "./providerMaintenance"; @@ -113,8 +116,8 @@ describe("providerMaintenance", () => { }); assert.deepStrictEqual(capabilities.update, { - command: "opencode upgrade --method pnpm", - executable: "opencode", + command: "/Users/test/.local/share/pnpm/opencode upgrade --method pnpm", + executable: "/Users/test/.local/share/pnpm/opencode", args: ["upgrade", "--method", "pnpm"], lockKey: "opencode-native", }); @@ -124,6 +127,55 @@ describe("providerMaintenance", () => { }); }); + it.effect("does not invent an update command for a missing bare executable", () => + Effect.gen(function* () { + const definition = { + provider: "antigravity", + binaryName: "agy", + npmPackageName: null, + homebrew: null, + nativeUpdate: { + executable: "agy", + args: () => ["update"], + lockKey: "antigravity-native", + strategy: "always", + }, + } as const satisfies PackageManagedProviderMaintenanceDefinition; + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(definition, { + binaryPath: "agy", + env: { PATH: "" }, + platform: "darwin", + }); + + assert.strictEqual(capabilities.update, null); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("uses the resolved absolute executable for provider-native updates", () => { + const definition = { + provider: "antigravity", + binaryName: "agy", + npmPackageName: null, + homebrew: null, + nativeUpdate: { + executable: "agy", + args: () => ["update"], + lockKey: "antigravity-native", + strategy: "always", + }, + } as const satisfies PackageManagedProviderMaintenanceDefinition; + const capabilities = resolvePackageManagedProviderMaintenance(definition, { + binaryPath: "/Users/test/.local/bin/agy", + }); + + assert.deepStrictEqual(capabilities.update, { + command: "/Users/test/.local/bin/agy update", + executable: "/Users/test/.local/bin/agy", + args: ["update"], + lockKey: "antigravity-native", + }); + }); + it("uses Homebrew updates but keeps npm latest metadata for tapped OpenCode installs", () => { const capabilities = resolvePackageManagedProviderMaintenance(OPENCODE_DEFINITION, { binaryPath: "opencode", diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 3540d1e99..51db4b9b5 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,6 +3,7 @@ import type { ServerProviderStatus, ServerProviderVersionAdvisory, } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -14,13 +15,6 @@ const WINDOWS_EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat"] as const; type ProviderInstallSource = "npm" | "bun" | "pnpm" | "homebrew" | "native" | "unknown"; -interface ParsedSemver { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray; -} - export interface ProviderLatestVersionSource { readonly kind: "npm" | "homebrew"; readonly name: string; @@ -74,8 +68,6 @@ const latestVersionCache = new Map< string, { readonly expiresAt: number; readonly version: string | null } >(); -const SEMVER_NUMBER_SEGMENT = /^\d+$/; - function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -94,96 +86,7 @@ function normalizeSemverVersion(version: string): string { return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); } -function parseSemver(value: string): ParsedSemver | null { - const [main = "", prerelease] = normalizeSemverVersion(value).split("-", 2); - const segments = main.split("."); - if (segments.length !== 3) { - return null; - } - - const [majorSegment, minorSegment, patchSegment] = segments; - if ( - majorSegment === undefined || - minorSegment === undefined || - patchSegment === undefined || - !SEMVER_NUMBER_SEGMENT.test(majorSegment) || - !SEMVER_NUMBER_SEGMENT.test(minorSegment) || - !SEMVER_NUMBER_SEGMENT.test(patchSegment) - ) { - return null; - } - - return { - major: Number.parseInt(majorSegment, 10), - minor: Number.parseInt(minorSegment, 10), - patch: Number.parseInt(patchSegment, 10), - prerelease: - prerelease - ?.split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) ?? [], - }; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftNumeric = SEMVER_NUMBER_SEGMENT.test(left); - const rightNumeric = SEMVER_NUMBER_SEGMENT.test(right); - - if (leftNumeric && rightNumeric) { - return Number.parseInt(left, 10) - Number.parseInt(right, 10); - } - if (leftNumeric) { - return -1; - } - if (rightNumeric) { - return 1; - } - return left.localeCompare(right); -} - -export function compareSemverVersions(left: string, right: string): number { - const parsedLeft = parseSemver(left); - const parsedRight = parseSemver(right); - if (!parsedLeft || !parsedRight) { - return left.localeCompare(right); - } - - if (parsedLeft.major !== parsedRight.major) { - return parsedLeft.major - parsedRight.major; - } - if (parsedLeft.minor !== parsedRight.minor) { - return parsedLeft.minor - parsedRight.minor; - } - if (parsedLeft.patch !== parsedRight.patch) { - return parsedLeft.patch - parsedRight.patch; - } - if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { - return 0; - } - if (parsedLeft.prerelease.length === 0) { - return 1; - } - if (parsedRight.prerelease.length === 0) { - return -1; - } - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (comparison !== 0) { - return comparison; - } - } - return 0; -} +export { compareSemverVersions } from "@synara/shared/providerVersions"; export function parseGenericCliVersion(output: string): string | null { const match = output.match(/\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/); @@ -427,6 +330,7 @@ function makeProviderMaintenanceForInstallSource(input: { readonly commandPath?: string | null; }): ProviderMaintenanceCapabilities { const { definition, installSource, executable, pathPrepend, commandPath } = input; + const resolvedNativeExecutable = commandPath ?? executable; if ( definition.nativeUpdate?.strategy === "always" && !definition.nativeUpdate.excludedInstallSources?.includes(installSource) @@ -435,7 +339,7 @@ function makeProviderMaintenanceForInstallSource(input: { makeNativeProviderMaintenanceCapabilities( definition, installSource, - executable, + resolvedNativeExecutable, pathPrepend, ) ?? makeManualOnlyProviderMaintenanceCapabilities({ @@ -449,7 +353,7 @@ function makeProviderMaintenanceForInstallSource(input: { makeNativeProviderMaintenanceCapabilities( definition, installSource, - executable, + resolvedNativeExecutable, pathPrepend, ) ?? makeManualOnlyProviderMaintenanceCapabilities({ @@ -544,6 +448,17 @@ export function resolvePackageManagedProviderMaintenance( } } + const resolvedCommandPath = nonEmptyString(options?.realCommandPath) ?? binaryPath; + if (definition.nativeUpdate?.strategy === "always" && hasPathSeparator(resolvedCommandPath)) { + return makeProviderMaintenanceForInstallSource({ + definition, + installSource: "unknown", + executable: resolvedCommandPath, + commandPath: resolvedCommandPath, + ...(options?.commandDirectory === undefined ? {} : { pathPrepend: options.commandDirectory }), + }); + } + if (!hasPathSeparator(binaryPath)) { return makeProviderMaintenanceForInstallSource({ definition, @@ -597,9 +512,9 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( } } - return resolvePackageManagedProviderMaintenance(definition, { - ...options, - binaryPath, + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, }); }); diff --git a/apps/server/src/provider/providerRuntimeRecipes.test.ts b/apps/server/src/provider/providerRuntimeRecipes.test.ts new file mode 100644 index 000000000..6eba8e66e --- /dev/null +++ b/apps/server/src/provider/providerRuntimeRecipes.test.ts @@ -0,0 +1,101 @@ +// FILE: providerRuntimeRecipes.test.ts +// Purpose: Verifies trusted moving provider manifests produce safe managed-runtime artifacts. +// Layer: Provider runtime recipe tests + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ProviderRuntimeTarget } from "./providerRuntimeTypes"; +import { getProviderRuntimeRecipe, ProviderRuntimeRecipeError } from "./providerRuntimeRecipes"; + +const TARGET: ProviderRuntimeTarget = { + platform: "darwin", + arch: "arm64", + cpu: "standard", +}; +const SHA512 = "a".repeat(128); + +function mockManifest(input: { + readonly version: string; + readonly url?: string; + readonly sha512?: string; +}) { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + version: input.version, + url: + input.url ?? + `https://storage.googleapis.com/antigravity-public/antigravity-cli/${input.version}-build/darwin-arm/cli_mac_arm64.tar.gz`, + sha512: input.sha512 ?? SHA512, + }), + ), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Antigravity managed runtime recipe", () => { + it("follows a newer stable release on the trusted official manifest", async () => { + mockManifest({ version: "1.1.5" }); + + const artifact = await getProviderRuntimeRecipe("antigravity").resolve( + TARGET, + new AbortController().signal, + ); + + expect(artifact).toMatchObject({ + provider: "antigravity", + version: "1.1.5", + digestAlgorithm: "sha512", + digest: SHA512, + allowedHosts: ["storage.googleapis.com"], + archiveFormat: "tar.gz", + executablePath: "antigravity", + }); + expect(artifact.catalogRevision).toBe(`antigravity:1.1.5:${SHA512}`); + }); + + it.each([ + ["prerelease version", { version: "1.1.6-beta.1" }, "invalid version"], + ["older incompatible version", { version: "1.1.3" }, "minimum compatible version"], + ["invalid digest", { version: "1.1.5", sha512: "not-a-digest" }, "valid SHA-512"], + [ + "untrusted artifact host", + { version: "1.1.5", url: "https://example.com/antigravity-cli/1.1.5-build/agy" }, + "untrusted artifact host", + ], + [ + "mismatched artifact version", + { + version: "1.1.5", + url: "https://storage.googleapis.com/antigravity-public/antigravity-cli/1.1.4-build/darwin-arm/cli_mac_arm64.tar.gz", + }, + "does not match its version", + ], + [ + "different Google Cloud Storage bucket", + { + version: "1.1.5", + url: "https://storage.googleapis.com/untrusted/antigravity-cli/1.1.5-build/darwin-arm/cli_mac_arm64.tar.gz", + }, + "does not match its version", + ], + ] as const)("rejects a %s", async (_label, manifest, expectedMessage) => { + mockManifest(manifest); + + await expect( + getProviderRuntimeRecipe("antigravity").resolve(TARGET, new AbortController().signal), + ).rejects.toThrow(expectedMessage); + }); + + it("preserves recipe error classification for invalid official metadata", async () => { + mockManifest({ version: "1.1.5", sha512: "broken" }); + + await expect( + getProviderRuntimeRecipe("antigravity").resolve(TARGET, new AbortController().signal), + ).rejects.toBeInstanceOf(ProviderRuntimeRecipeError); + }); +}); diff --git a/apps/server/src/provider/providerRuntimeRecipes.ts b/apps/server/src/provider/providerRuntimeRecipes.ts index 57166ae47..9667f344d 100644 --- a/apps/server/src/provider/providerRuntimeRecipes.ts +++ b/apps/server/src/provider/providerRuntimeRecipes.ts @@ -1,5 +1,13 @@ import type { ProviderKind } from "@synara/contracts"; +import { compareSemverVersions, isStableSemver } from "@synara/shared/providerVersions"; +import { + ANTIGRAVITY_ARTIFACT_HOSTS, + ANTIGRAVITY_MANIFEST_HOST, + antigravityManifestUrl, + MINIMUM_ANTIGRAVITY_CLI_VERSION, + validateAntigravityArtifactUrl, +} from "./antigravityReleaseChannel"; import type { ProviderRuntimeArtifact, ProviderRuntimeRecipe, @@ -255,41 +263,53 @@ const claudeRecipe: ProviderRuntimeRecipe = { }, }; -function antigravityPlatform(target: ProviderRuntimeTarget): string { - const arch = target.arch === "arm64" ? "arm64" : "amd64"; - if (target.platform === "linux" && target.libc === "musl") return `linux_${arch}_musl`; - const os = target.platform === "win32" ? "windows" : target.platform; - return `${os}_${arch}`; -} - const antigravityRecipe: ProviderRuntimeRecipe = { provider: "antigravity", executableName: "agy", resolve: async (target, signal) => { - const manifestHost = "antigravity-cli-auto-updater-974169037036.us-central1.run.app"; - const platform = antigravityPlatform(target); const manifest = assertRecord( await fetchJson({ - url: `https://${manifestHost}/manifests/${platform}.json`, + url: antigravityManifestUrl(target), signal, - allowedHosts: [manifestHost], + allowedHosts: [ANTIGRAVITY_MANIFEST_HOST], }), "Antigravity release manifest", ); const version = requiredString(manifest, "version", "Antigravity release manifest"); - if (version !== "1.1.4") { + if (!isStableSemver(version)) { + throw new ProviderRuntimeRecipeError( + "Antigravity's stable release manifest returned an invalid version.", + ); + } + if (compareSemverVersions(version, MINIMUM_ANTIGRAVITY_CLI_VERSION) < 0) { + throw new ProviderRuntimeRecipeError( + `Antigravity ${version} is older than Scient's minimum compatible version ${MINIMUM_ANTIGRAVITY_CLI_VERSION}.`, + ); + } + let url: string; + try { + url = validateAntigravityArtifactUrl({ + url: requiredString(manifest, "url", "Antigravity release manifest"), + version, + }); + } catch (cause) { + throw new ProviderRuntimeRecipeError( + cause instanceof Error ? cause.message : "Antigravity returned an invalid artifact URL.", + { cause }, + ); + } + const digest = requiredString(manifest, "sha512", "Antigravity release manifest").toLowerCase(); + if (!/^[0-9a-f]{128}$/u.test(digest)) { throw new ProviderRuntimeRecipeError( - "A newer Antigravity release is available but has not yet passed Scient's runtime review.", + "Antigravity release manifest does not contain a valid SHA-512 digest.", ); } - const url = requiredString(manifest, "url", "Antigravity release manifest"); - const digest = requiredString(manifest, "sha512", "Antigravity release manifest"); return { provider: "antigravity", version, target, url, - allowedHosts: ["storage.googleapis.com"], + allowedHosts: ANTIGRAVITY_ARTIFACT_HOSTS, digestAlgorithm: "sha512", digest, archiveFormat: target.platform === "win32" ? "raw" : "tar.gz", diff --git a/apps/server/src/provider/providerUpdateRuntimePolicy.test.ts b/apps/server/src/provider/providerUpdateRuntimePolicy.test.ts new file mode 100644 index 000000000..d9297e0cf --- /dev/null +++ b/apps/server/src/provider/providerUpdateRuntimePolicy.test.ts @@ -0,0 +1,46 @@ +// FILE: providerUpdateRuntimePolicy.test.ts +// Purpose: Covers authoritative managed, external, and missing update routing. +// Layer: Provider runtime policy tests + +import { describe, expect, it } from "vitest"; + +import type { ResolvedProviderRuntime } from "./Services/ProviderRuntimeManager"; +import { providerExternalUpdateBlockReason } from "./providerUpdateRuntimePolicy"; + +function runtime(overrides: Partial = {}): ResolvedProviderRuntime { + return { + source: "system", + executable: "/Users/test/.local/bin/agy", + managedVersion: null, + canInstall: false, + canRepair: false, + canRollback: false, + canRemove: false, + message: null, + ...overrides, + }; +} + +describe("providerExternalUpdateBlockReason", () => { + it("allows only a resolved external executable", () => { + expect(providerExternalUpdateBlockReason("antigravity", runtime())).toBeNull(); + }); + + it("routes Scient-managed updates through the verified runtime lifecycle", () => { + expect( + providerExternalUpdateBlockReason( + "antigravity", + runtime({ source: "managed", managedVersion: "1.1.4" }), + ), + ).toContain("verified managed update flow"); + }); + + it("rejects a missing executable with a plain setup instruction", () => { + expect( + providerExternalUpdateBlockReason( + "antigravity", + runtime({ source: "missing", executable: null, canInstall: true }), + ), + ).toBe("Antigravity is not installed. Use Set up to install it before updating."); + }); +}); diff --git a/apps/server/src/provider/providerUpdateRuntimePolicy.ts b/apps/server/src/provider/providerUpdateRuntimePolicy.ts new file mode 100644 index 000000000..fdf709bb2 --- /dev/null +++ b/apps/server/src/provider/providerUpdateRuntimePolicy.ts @@ -0,0 +1,20 @@ +// FILE: providerUpdateRuntimePolicy.ts +// Purpose: Authoritatively route provider updates by runtime ownership and availability. +// Layer: Provider runtime policy + +import { PROVIDER_DISPLAY_NAMES, type ProviderKind } from "@synara/contracts"; + +import type { ResolvedProviderRuntime } from "./Services/ProviderRuntimeManager"; + +export function providerExternalUpdateBlockReason( + provider: ProviderKind, + runtime: ResolvedProviderRuntime, +): string | null { + if (runtime.source === "managed" || runtime.source === "bundled") { + return "This runtime is managed by Scient. Use Scient's verified managed update flow instead."; + } + if (!runtime.executable) { + return `${PROVIDER_DISPLAY_NAMES[provider]} is not installed. Use Set up to install it before updating.`; + } + return null; +} diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index ce57540ec..66e216d58 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -58,6 +58,7 @@ import { ProviderAdapterRegistry } from "./provider/Services/ProviderAdapterRegi import { ProviderHealth } from "./provider/Services/ProviderHealth"; import { ProviderConnection } from "./provider/Services/ProviderConnection"; import { ProviderClientStatusProjection } from "./provider/Services/ProviderClientStatusProjection"; +import { providerExternalUpdateBlockReason } from "./provider/providerUpdateRuntimePolicy"; import { ProviderRuntimeManager } from "./provider/Services/ProviderRuntimeManager"; import { ProviderService } from "./provider/Services/ProviderService"; import { listProviderUsage } from "./providerUsage"; @@ -1096,20 +1097,20 @@ export const makeWsRpcLayer = () => settings.providers[input.provider].binaryPath, ), ), - Effect.flatMap((runtime) => - runtime.source === "managed" || runtime.source === "bundled" + Effect.flatMap((runtime) => { + const blockReason = providerExternalUpdateBlockReason(input.provider, runtime); + return blockReason ? Effect.fail( new ServerProviderUpdateError({ provider: input.provider, - reason: - "This runtime is managed by Scient. Use the managed install, repair, or rollback controls instead.", + reason: blockReason, }), ) : providerHealth.updateProvider(input).pipe( Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), - ), - ), + ); + }), ), [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), [WS_METHODS.serverListLocalServers]: () => diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index e677c78fc..a384685b3 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -992,7 +992,7 @@ describe("ProviderConnectionDialog", () => { } }); - it("requires reviewed consent before starting a managed installation", async () => { + it("requires explicit consent before installing the trusted latest release", async () => { const initialProvider = { provider: "antigravity", status: "error", @@ -1030,16 +1030,16 @@ describe("ProviderConnectionDialog", () => { status: "downloading", startedAt: checkedAt, finishedAt: null, - message: "Downloading Antigravity 1.1.4.", - version: "1.1.4", + message: "Downloading Antigravity 1.1.5.", + version: "1.1.5", bytesDownloaded: 0, totalBytes: 46_664_998, }, } satisfies ServerProviderStatus; const prepareProviderInstall = vi.fn().mockResolvedValue({ provider: "antigravity", - planToken: "reviewed-plan-1", - version: "1.1.4", + planToken: "trusted-plan-1", + version: "1.1.5", target: "darwin-arm64", sourceHost: "storage.googleapis.com", downloadBytes: 46_664_998, @@ -1063,17 +1063,105 @@ describe("ProviderConnectionDialog", () => { try { await page.getByRole("button", { name: "Install Antigravity" }).click(); - await expect.element(page.getByText("Ready to install version 1.1.4")).toBeVisible(); + await expect.element(page.getByText("Ready to install version 1.1.5")).toBeVisible(); expect(installProvider).not.toHaveBeenCalled(); await page.getByRole("button", { name: "Download and install" }).click(); await vi.waitFor(() => { expect(installProvider).toHaveBeenCalledWith({ provider: "antigravity", - planToken: "reviewed-plan-1", + planToken: "trusted-plan-1", + }); + }); + await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Cancel installation" })).toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("updates a managed runtime through the verified install lifecycle", async () => { + const currentProvider = { + provider: "antigravity", + status: "ready", + available: true, + authStatus: "authenticated", + version: "1.1.4", + checkedAt, + runtime: { + source: "managed", + managedVersion: "1.1.4", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + versionAdvisory: { + status: "unknown", + currentVersion: "1.1.4", + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt, + message: "Updates for this runtime are managed by Scient.", + }, + } satisfies ServerProviderStatus; + const updatingProvider = { + ...currentProvider, + installationState: { + operationId: "update-antigravity-1", + operation: "install", + status: "downloading", + startedAt: checkedAt, + finishedAt: null, + message: "Downloading Antigravity 1.1.5.", + version: "1.1.5", + bytesDownloaded: 0, + totalBytes: null, + }, + } satisfies ServerProviderStatus; + const prepareProviderInstall = vi.fn().mockResolvedValue({ + provider: "antigravity", + planToken: "managed-update-plan-1", + version: "1.1.5", + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: null, + expiresAt: "2026-07-21T12:10:00.000Z", + }); + const installProvider = vi.fn().mockResolvedValue({ providers: [updatingProvider] }); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [currentProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + prepareProviderInstall, + installProvider, + }); + const queryClient = createQueryClient(currentProvider); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "managed_update"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByRole("heading", { name: "Update Antigravity" })).toBeVisible(); + await page.getByRole("button", { name: "Check latest version" }).click(); + await expect.element(page.getByText("Ready to update from 1.1.4 to 1.1.5")).toBeVisible(); + expect(installProvider).not.toHaveBeenCalled(); + + await page.getByRole("button", { name: "Download and update" }).click(); + await vi.waitFor(() => { + expect(installProvider).toHaveBeenCalledWith({ + provider: "antigravity", + planToken: "managed-update-plan-1", }); }); - await expect.element(page.getByText("Downloading Antigravity 1.1.4.")).toBeVisible(); + await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); await expect.element(page.getByRole("button", { name: "Cancel installation" })).toBeVisible(); } finally { await screen.unmount(); diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index 725b359ca..79edf714c 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -3,12 +3,14 @@ // Layer: Shared UI component import type { ServerProviderConnectionMethod, ServerProviderInstallPlan } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, + describeManagedProviderUpdate, providerConnectionMethod, providerInstallUrl, } from "~/lib/providerConnectionPresentation"; @@ -48,6 +50,7 @@ export function ProviderConnectionDialog() { const [actionPending, setActionPending] = useState(false); const [actionError, setActionError] = useState(null); const [installPlan, setInstallPlan] = useState(null); + const [managedUpdateStarted, setManagedUpdateStarted] = useState(false); const [clockMs, setClockMs] = useState(() => Date.now()); const [runtimeReconnectBaselineOperationId, setRuntimeReconnectBaselineOperationId] = useState< string | null | undefined @@ -69,11 +72,22 @@ export function ProviderConnectionDialog() { status?.connectionState?.status === "connected" && status.connectionState.operationId !== runtimeReconnectBaselineOperationId; const runtimeReconnectRequired = runtimeReauthenticationFlow && !runtimeReconnectCompleted; - const presentation = provider + const connectionPresentation = provider ? describeProviderConnection(provider, status, { forceReconnect: runtimeReconnectRequired, }) : null; + const managedUpdateFlow = + source === "managed_update" && status?.runtime?.source === "managed" && provider !== null; + const presentation = + managedUpdateFlow && provider && status + ? describeManagedProviderUpdate({ + provider, + status, + plan: installPlan, + updateStarted: managedUpdateStarted, + }) + : connectionPresentation; const Icon = provider ? PROVIDER_ICON_COMPONENT_BY_PROVIDER[provider] : null; const activeConnection = status?.connectionState && @@ -84,6 +98,7 @@ export function ProviderConnectionDialog() { useEffect(() => { setRuntimeReconnectBaselineOperationId(undefined); + setManagedUpdateStarted(false); }, [isOpen, provider, source]); useEffect(() => { @@ -291,6 +306,7 @@ export function ProviderConnectionDialog() { provider, planToken: installPlan.planToken, }); + if (managedUpdateFlow) setManagedUpdateStarted(true); setInstallPlan(null); applyProviderStatusesToCache(queryClient, result.providers); }); @@ -457,7 +473,13 @@ export function ProviderConnectionDialog() { {installPlan ? (
-

Ready to install version {installPlan.version}

+

+ {managedUpdateFlow && status?.runtime?.managedVersion + ? compareSemverVersions(installPlan.version, status.runtime.managedVersion) > 0 + ? `Ready to update from ${status.runtime.managedVersion} to ${installPlan.version}` + : `Latest stable version: ${installPlan.version}` + : `Ready to install version ${installPlan.version}`} +

{installPlan.downloadBytes ? `${(installPlan.downloadBytes / 1_048_576).toFixed(1)} MB from ${installPlan.sourceHost}` @@ -477,11 +499,13 @@ export function ProviderConnectionDialog() { ) : null}

- {provider === "antigravity" && startsProviderSignIn - ? "Passwords and account tokens stay with Google. Scient sends this one-time code only to the local Antigravity process and never stores it." - : startsProviderSignIn - ? "Scient starts the provider's official sign-in. Passwords and account tokens stay with the provider and are never stored in Scient." - : "Installation and sign-in happen directly with the provider. Passwords and account tokens are never entered into or stored in Scient."} + {managedUpdateFlow + ? "Scient downloads the latest compatible release from the provider's trusted stable channel, verifies its digest, tests it, and keeps the previous working release available for rollback." + : provider === "antigravity" && startsProviderSignIn + ? "Passwords and account tokens stay with Google. Scient sends this one-time code only to the local Antigravity process and never stores it." + : startsProviderSignIn + ? "Scient starts the provider's official sign-in. Passwords and account tokens stay with the provider and are never stored in Scient." + : "Installation and sign-in happen directly with the provider. Passwords and account tokens are never entered into or stored in Scient."}

@@ -509,7 +533,11 @@ export function ProviderConnectionDialog() { {presentation.primaryAction !== "none" ? ( ) : null} diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index 01d391913..bdc474a52 100644 --- a/apps/web/src/lib/providerConnectionPresentation.test.ts +++ b/apps/web/src/lib/providerConnectionPresentation.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, + describeManagedProviderUpdate, providerConnectionMethod, providerInstallUrl, } from "./providerConnectionPresentation"; @@ -17,6 +18,71 @@ const BASE_STATUS: ServerProviderStatus = { }; describe("provider connection presentation", () => { + const managedAntigravityStatus = { + ...BASE_STATUS, + provider: "antigravity", + status: "ready", + authStatus: "authenticated", + version: "1.1.4", + runtime: { + source: "managed", + managedVersion: "1.1.4", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + } as const satisfies ServerProviderStatus; + + it("reviews a newer trusted managed release before updating", () => { + const checking = describeManagedProviderUpdate({ + provider: "antigravity", + status: managedAntigravityStatus, + plan: null, + updateStarted: false, + }); + const ready = describeManagedProviderUpdate({ + provider: "antigravity", + status: managedAntigravityStatus, + plan: { + provider: "antigravity", + planToken: "plan-1", + version: "1.1.5", + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: null, + expiresAt: "2026-07-21T11:00:00.000Z", + }, + updateStarted: false, + }); + + expect(checking.primaryLabel).toBe("Check latest version"); + expect(ready.primaryAction).toBe("install"); + expect(ready.primaryLabel).toBe("Update Antigravity"); + expect(ready.description).toContain("trusted stable channel"); + }); + + it("does not reinstall or downgrade a managed provider", () => { + for (const version of ["1.1.4", "1.1.3"]) { + const presentation = describeManagedProviderUpdate({ + provider: "antigravity", + status: managedAntigravityStatus, + plan: { + provider: "antigravity", + planToken: `plan-${version}`, + version, + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: null, + expiresAt: "2026-07-21T11:00:00.000Z", + }, + updateStarted: false, + }); + expect(presentation.primaryAction).toBe("done"); + } + }); + it("maps supported providers to fixed browser sign-in methods", () => { expect(providerConnectionMethod("codex")).toBe("codex_browser"); expect(providerConnectionMethod("claudeAgent")).toBe("claude_account"); diff --git a/apps/web/src/lib/providerConnectionPresentation.ts b/apps/web/src/lib/providerConnectionPresentation.ts index 372fa75e3..33bbc81c8 100644 --- a/apps/web/src/lib/providerConnectionPresentation.ts +++ b/apps/web/src/lib/providerConnectionPresentation.ts @@ -6,8 +6,10 @@ import { PROVIDER_DISPLAY_NAMES, type ProviderKind, type ServerProviderConnectionMethod, + type ServerProviderInstallPlan, type ServerProviderStatus, } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; const PROVIDER_INSTALL_URLS: Partial> = { codex: "https://help.openai.com/en/articles/11096431", @@ -84,6 +86,72 @@ export interface ProviderConnectionPresentation { readonly canRestart?: boolean; } +export function describeManagedProviderUpdate(input: { + readonly provider: ProviderKind; + readonly status: ServerProviderStatus; + readonly plan: ServerProviderInstallPlan | null; + readonly updateStarted: boolean; +}): ProviderConnectionPresentation { + const label = PROVIDER_DISPLAY_NAMES[input.provider] ?? input.provider; + const title = `Update ${label}`; + const installation = input.status.installationState; + if ( + input.updateStarted && + installation && + !["installed", "succeeded", "failed", "cancelled"].includes(installation.status) + ) { + return { + title, + description: installation.message, + primaryAction: "none", + primaryLabel: "Updating…", + busy: true, + canCancel: true, + }; + } + if (input.updateStarted && installation?.status === "installed") { + return { + title, + description: + `${label} ${installation.version ?? ""} is installed, verified, and ready to use.`.replace( + /\s+/gu, + " ", + ), + primaryAction: "done", + primaryLabel: "Done", + busy: false, + canCancel: false, + }; + } + const currentVersion = input.status.runtime?.managedVersion; + if (input.plan && currentVersion) { + const comparison = compareSemverVersions(input.plan.version, currentVersion); + if (comparison <= 0) { + return { + title, + description: + comparison === 0 + ? `${label} ${currentVersion} is already the latest stable version.` + : `${label} ${currentVersion} is newer than the provider's current stable version ${input.plan.version}. Scient will not downgrade it.`, + primaryAction: "done", + primaryLabel: "Done", + busy: false, + canCancel: false, + }; + } + } + return { + title, + description: input.plan + ? `Scient found ${label} ${input.plan.version} on the provider's trusted stable channel. The verified download is ready below.` + : `Scient will check ${label}'s trusted stable channel and prepare the latest compatible version.`, + primaryAction: "install", + primaryLabel: input.plan ? `Update ${label}` : "Check latest version", + busy: false, + canCancel: false, + }; +} + export function describeProviderConnection( provider: ProviderKind, status: ServerProviderStatus | null | undefined, diff --git a/apps/web/src/providerConnectionDialogStore.ts b/apps/web/src/providerConnectionDialogStore.ts index 609bc5730..1bb6f804e 100644 --- a/apps/web/src/providerConnectionDialogStore.ts +++ b/apps/web/src/providerConnectionDialogStore.ts @@ -12,7 +12,8 @@ export type ProviderConnectionSource = | "settings" | "empty_state" | "runtime_error" - | "runtime_authentication_error"; + | "runtime_authentication_error" + | "managed_update"; interface ProviderConnectionDialogStore { isOpen: boolean; diff --git a/apps/web/src/providerUpdates.test.ts b/apps/web/src/providerUpdates.test.ts index 56e6321c8..ca67d107c 100644 --- a/apps/web/src/providerUpdates.test.ts +++ b/apps/web/src/providerUpdates.test.ts @@ -269,4 +269,80 @@ describe("shouldOfferProviderUpdateAction", () => { ), ).toBe(true); }); + + it("never offers an update action when the provider executable is unavailable", () => { + expect( + shouldOfferProviderUpdateAction( + providerStatus("antigravity", { + available: false, + status: "error", + authStatus: "unknown", + version: null, + versionAdvisory: { + status: "unknown", + currentVersion: null, + latestVersion: null, + updateCommand: "agy update", + canUpdate: true, + checkedAt: "2026-07-21T10:00:00.000Z", + message: null, + }, + }), + ), + ).toBe(false); + }); + + it("routes an available Scient-managed runtime through its managed update flow", () => { + expect( + shouldOfferProviderUpdateAction( + providerStatus("antigravity", { + runtime: { + source: "managed", + managedVersion: "1.1.4", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + versionAdvisory: { + status: "unknown", + currentVersion: "1.1.4", + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt: "2026-07-21T10:00:00.000Z", + message: "Updates for this runtime are managed by Scient.", + }, + }), + ), + ).toBe(true); + }); + + it("does not broaden managed latest-channel updates to other providers", () => { + expect( + shouldOfferProviderUpdateAction( + providerStatus("grok", { + runtime: { + source: "managed", + managedVersion: "0.1.0", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + versionAdvisory: { + status: "unknown", + currentVersion: "0.1.0", + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt: "2026-07-21T10:00:00.000Z", + message: "Updates for this runtime are managed by Scient.", + }, + }), + ), + ).toBe(false); + }); }); diff --git a/apps/web/src/providerUpdates.ts b/apps/web/src/providerUpdates.ts index 5439fac96..ad6c79f83 100644 --- a/apps/web/src/providerUpdates.ts +++ b/apps/web/src/providerUpdates.ts @@ -78,6 +78,10 @@ export function isProviderUpdateActive(provider: ServerProviderStatus): boolean export function shouldOfferProviderUpdateAction(provider: ServerProviderStatus): boolean { const advisory = provider.versionAdvisory; + if (!provider.available) return false; + if (provider.runtime?.source === "managed") { + return provider.provider === "antigravity" && provider.runtime.managedVersion !== null; + } return ( advisory?.canUpdate === true && advisory.updateCommand !== null && @@ -101,6 +105,7 @@ export function shouldShowProviderUpdateStatus(input: ProviderUpdateVisibilityIn const hiddenProviderSet = input.hiddenProviderSet ?? new Set(input.hiddenProviders ?? []); if ( !advisory || + !input.provider.available || input.serverSettings?.enableProviderUpdateChecks === false || advisory.status !== "behind_latest" || advisory.latestVersion === null || diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 9f98f8358..d4da3a2f3 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1231,10 +1231,15 @@ function SettingsRouteView() { ); const runProviderUpdate = useCallback( - async (provider: ProviderKind) => { + async (providerStatus: ServerProviderStatus) => { + const provider = providerStatus.provider; if (updatingProviders.has(provider)) { return; } + if (providerStatus.runtime?.source === "managed") { + useProviderConnectionDialogStore.getState().openDialog(provider, "managed_update"); + return; + } setUpdatingProviders((current) => new Set(current).add(provider)); try { const result = await withProviderUpdateTimeout({ @@ -3082,7 +3087,7 @@ function SettingsRouteView() { updateState === "running" || updatingProviders.has(providerStatus.provider); const canUpdateProvider = - updateAdvisory?.canUpdate === true && !isProviderUpdateActive; + shouldOfferProviderUpdateAction(providerStatus) && !isProviderUpdateActive; const updateLabel = providerUpdateStatusLabel(providerStatus); return ( @@ -3091,18 +3096,18 @@ function SettingsRouteView() { title={PROVIDER_DISPLAY_NAMES[providerStatus.provider]} description={updateLabel || undefined} actions={ - updateAdvisory?.canUpdate ? ( + shouldOfferProviderUpdateAction(providerStatus) ? (
- +

{presentation.description}

@@ -387,22 +388,38 @@ export function ProviderConnectionDialog() { )}

) : null} + {presentation.busy && activeConnection ? ( +
+ {(provider === "grok" || provider === "antigravity") && + activeConnection.authorizationUrl ? ( + + ) : null} + {presentation.canRestart ? ( + + ) : null} +
+ ) : null} ) : null} - {(provider === "grok" || provider === "antigravity") && - activeConnection?.authorizationUrl ? ( - - ) : null} - {provider === "antigravity" && activeConnection?.status === "waiting_for_browser" ? (
- - {presentation.canRestart ? ( + + {presentation.canCancel ? ( ) : null} - {presentation.canCancel ? ( + {presentation.primaryAction !== "none" ? ( - ) : null} - {presentation.primaryAction !== "none" ? ( -