diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 0d2d1eb5..bb37d1ee 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -95,6 +95,62 @@ GPT-OSS 120B (Medium) }); }); + it("parses Antigravity 1.1.5 machine model slugs and preserves them for selection", () => { + expect( + parseAntigravityModelLines(` +gemini-3.5-flash-medium +gemini-3.5-flash-high +gemini-3.5-flash-low +gemini-3.1-pro-low +gemini-3.1-pro-high +claude-sonnet-4-6 +claude-opus-4-6-thinking +gpt-oss-120b-medium +`), + ).toEqual([ + { + slug: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + supportedReasoningEfforts: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + ], + defaultReasoningEffort: "medium", + }, + { + slug: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + supportedReasoningEfforts: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + defaultReasoningEffort: "low", + }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + supportedReasoningEfforts: [{ value: "thinking", label: "Thinking" }], + defaultReasoningEffort: "thinking", + }, + { + slug: "gpt-oss-120b", + name: "GPT OSS 120B", + supportedReasoningEfforts: [{ value: "medium", label: "Medium" }], + defaultReasoningEffort: "medium", + }, + ]); + + expect(resolveAntigravityCliModelLabel("gemini-3.5-flash", undefined, "medium")).toBe( + "gemini-3.5-flash-medium", + ); + expect(resolveAntigravityCliModelLabel("gemini-3.5-flash", { reasoningEffort: "high" })).toBe( + "gemini-3.5-flash-high", + ); + expect(resolveAntigravityCliModelLabel("claude-sonnet-4-6")).toBe("claude-sonnet-4-6"); + }); + it("discovers future CLI models without requiring a static catalog update", () => { expect( parseAntigravityModelLines(` diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 0c7c8d63..fc29c15f 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -297,9 +297,14 @@ const DEFAULT_EFFORT_BY_MODEL: Readonly> = { "Claude Sonnet 4.6": "thinking", "Claude Opus 4.6": "thinking", "GPT-OSS 120B": "medium", + "gemini-3.5-flash": "medium", + "gemini-3.1-pro": "low", + "claude-opus-4-6": "thinking", + "gpt-oss-120b": "medium", }; -const EFFORT_ORDER = ["low", "medium", "high", "thinking"] as const; +const EFFORT_ORDER = ["low", "medium", "high", "thinking", "ultra"] as const; +const MACHINE_MODEL_EFFORTS = new Set(EFFORT_ORDER); function effortLabel(value: string): string { return value @@ -309,6 +314,23 @@ function effortLabel(value: string): string { .join(" "); } +function isAntigravityMachineModelSlug(value: string): boolean { + return /^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)(?:-[a-z0-9.]+)+$/u.test(value); +} + +function antigravityModelDisplayName(model: string): string { + if (!isAntigravityMachineModelSlug(model)) return model; + return model + .split("-") + .map((part) => { + if (part === "gpt" || part === "oss") return part.toUpperCase(); + if (/^\d+b$/u.test(part)) return part.toUpperCase(); + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(" ") + .replace(/\b(\d+) (\d+)\b/gu, "$1.$2"); +} + export function parseAntigravityCliModelLabel( value: string, ): { model: string; effort?: string } | null { @@ -318,11 +340,23 @@ export function parseAntigravityCliModelLabel( .replace(/^(?:[*•-]\s+)+/u, ""); if (!trimmed) return null; const match = trimmed.match(/^(.*?)\s+\(([^()]+)\)$/u); - if (!match?.[1] || !match[2]) return { model: trimmed }; - return { - model: match[1].trim(), - effort: match[2].trim().toLowerCase(), - }; + if (match?.[1] && match[2]) { + return { + model: match[1].trim(), + effort: match[2].trim().toLowerCase(), + }; + } + if (isAntigravityMachineModelSlug(trimmed)) { + const separatorIndex = trimmed.lastIndexOf("-"); + const possibleEffort = trimmed.slice(separatorIndex + 1); + if (MACHINE_MODEL_EFFORTS.has(possibleEffort as (typeof EFFORT_ORDER)[number])) { + return { + model: trimmed.slice(0, separatorIndex), + effort: possibleEffort, + }; + } + } + return { model: trimmed }; } export function antigravityPromptCommandLineIssue( @@ -340,13 +374,14 @@ export function parseAntigravityModelLines(output: string): ProviderListModelsRe for (const line of output.split(/\r?\n/g)) { const parsed = parseAntigravityCliModelLabel(line); if ( - !parsed?.effort || - !/^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)\b/iu.test(parsed.model) + !parsed || + !/^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)\b/iu.test(parsed.model) || + (!parsed.effort && !isAntigravityMachineModelSlug(parsed.model)) ) { continue; } const efforts = groups.get(parsed.model) ?? []; - if (!efforts.includes(parsed.effort)) efforts.push(parsed.effort); + if (parsed.effort && !efforts.includes(parsed.effort)) efforts.push(parsed.effort); groups.set(parsed.model, efforts); } return [...groups.entries()].map(([model, discoveredEfforts]) => { @@ -361,7 +396,7 @@ export function parseAntigravityModelLines(output: string): ProviderListModelsRe const defaultEffort = DEFAULT_EFFORT_BY_MODEL[model] ?? efforts[0]; return { slug: model, - name: model, + name: antigravityModelDisplayName(model), ...(efforts.length > 0 ? { supportedReasoningEfforts: efforts.map((effort) => ({ @@ -387,7 +422,10 @@ export function resolveAntigravityCliModelLabel( options?.reasoningEffort?.trim().toLowerCase() ?? discoveredDefaultEffort?.trim().toLowerCase() ?? DEFAULT_EFFORT_BY_MODEL[parsed.model]; - return effort ? `${parsed.model} (${effortLabel(effort)})` : parsed.model; + if (!effort) return parsed.model; + return isAntigravityMachineModelSlug(parsed.model) + ? `${parsed.model}-${effort}` + : `${parsed.model} (${effortLabel(effort)})`; } function parseModelLines(output: string): ProviderListModelsResult["models"] { diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 144d473c..193b5908 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -129,6 +129,7 @@ function makeConnectionTestLayer(input?: { readonly antigravityCodeWindowCloseSignal?: Effect.Effect; readonly antigravityTimeout?: Duration.Duration; readonly antigravityAuthenticationProbeInterval?: Duration.Duration; + readonly antigravityAuthenticationSettleTimeout?: Duration.Duration; readonly beforeAntigravityOutputPublication?: Effect.Effect; readonly afterAntigravityCodeWindowInputClosed?: Effect.Effect; readonly onSpawn?: (command: CapturedCommand) => void; @@ -371,6 +372,11 @@ function makeConnectionTestLayer(input?: { antigravityAuthenticationProbeInterval: input.antigravityAuthenticationProbeInterval, } : {}), + ...(input?.antigravityAuthenticationSettleTimeout + ? { + antigravityAuthenticationSettleTimeout: input.antigravityAuthenticationSettleTimeout, + } + : {}), ...(input?.beforeAntigravityOutputPublication ? { beforeAntigravityOutputPublication: input.beforeAntigravityOutputPublication } : {}), @@ -525,7 +531,7 @@ describe("Antigravity OAuth authorization URL parsing", () => { "--model", "__scient_auth_only_operation-1", "--print-timeout", - "60s", + "600s", "--print", "Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task.", ]); @@ -893,6 +899,48 @@ describe("ProviderConnectionLive", () => { ); }); + it("accepts authentication that settles just after Antigravity's browser process exits", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + let authenticationVisible = false; + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + antigravityTimeout: Duration.millis(250), + antigravityAuthenticationProbeInterval: Duration.millis(5), + antigravityAuthenticationSettleTimeout: Duration.millis(150), + processForCommand: ({ args }) => + args.includes("--print") + ? { + code: 1, + delayMs: 20, + stdout: `Authentication required:\n${authorizationUrl}\n`, + } + : authenticationVisible + ? { code: 0, stdout: "Gemini 3.5 Flash (High)\n" } + : { + code: 1, + stdout: "Please sign in to view available models.\n", + }, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const connectedPublished = fixture.waitForConnectionState( + (state) => state?.status === "connected", + ); + yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + yield* Effect.sleep(Duration.millis(45)); + authenticationVisible = true; + yield* Effect.promise(() => connectedPublished); + expect(fixture.getConnectionState()?.status).toBe("connected"); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + it("delivers one transient authorization code only to the active Antigravity PTY", async () => { const authorizationUrl = "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index 708b1209..d5b6c470 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -48,12 +48,16 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import { parseAntigravityModelsAuthStatus } from "./ProviderHealth"; const CONNECTION_TIMEOUT = Duration.minutes(10); -const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(60); +const ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS = 10 * 60; +const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS); const ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL = Duration.millis(500); -// Antigravity keeps the provider-owned code window at 60 seconds. The local -// supervisor gets a small hidden grace period so a CLI exit at that boundary -// can still complete one bounded authentication probe. -const ANTIGRAVITY_CONNECTION_TIMEOUT = Duration.seconds(65); +const ANTIGRAVITY_AUTHENTICATION_SETTLE_TIMEOUT = Duration.seconds(30); +// Keep the provider-owned browser/code window aligned with the CLI print +// timeout, then allow a short hidden grace period for credentials written by +// the browser callback to become visible to `agy models`. +const ANTIGRAVITY_CONNECTION_TIMEOUT = Duration.seconds( + ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS + 30, +); const CONNECTION_OUTPUT_MAX_BYTES = 64 * 1024; interface ActiveConnection { @@ -180,7 +184,7 @@ export function parseAntigravityOAuthAuthorizationUrl(output: string): string | } /** - * Antigravity 1.1.4 has no login subcommand, and its hidden bare TUI does not + * Antigravity 1.1.4 and newer have no login subcommand, and the hidden bare TUI does not * advance to authentication. Print mode reaches provider-owned OAuth before * model selection. A per-operation impossible model plus sandboxed plan mode * prevents a real turn; the models health probe stops the process after auth. @@ -193,7 +197,7 @@ export function antigravityAuthenticationCommandArgs(operationId: string): Reado "--model", `__scient_auth_only_${operationId}`, "--print-timeout", - "60s", + `${ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS}s`, "--print", ANTIGRAVITY_AUTH_PROMPT, ]; @@ -257,6 +261,7 @@ export function makeProviderConnectionLive(options?: { readonly antigravityCodeWindowCloseSignal?: Effect.Effect; readonly antigravityTimeout?: Duration.Duration; readonly antigravityAuthenticationProbeInterval?: Duration.Duration; + readonly antigravityAuthenticationSettleTimeout?: Duration.Duration; readonly beforeAntigravityOutputPublication?: Effect.Effect; readonly afterAntigravityCodeWindowInputClosed?: Effect.Effect; readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; @@ -267,6 +272,8 @@ export function makeProviderConnectionLive(options?: { const antigravityTimeout = options?.antigravityTimeout ?? ANTIGRAVITY_CONNECTION_TIMEOUT; const antigravityAuthenticationProbeInterval = options?.antigravityAuthenticationProbeInterval ?? ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL; + const antigravityAuthenticationSettleTimeout = + options?.antigravityAuthenticationSettleTimeout ?? ANTIGRAVITY_AUTHENTICATION_SETTLE_TIMEOUT; return Layer.effect( ProviderConnection, @@ -671,6 +678,19 @@ export function makeProviderConnectionLive(options?: { yield* Deferred.succeed(authorizationCodeAccepted, undefined); return 0; } + // A validated OAuth URL means the provider-owned browser flow + // may still be committing credentials when the bootstrap PTY + // exits. Keep probing briefly instead of publishing a false + // failure while that callback finishes. + const authorizationStarted = yield* Effect.sync(() => outputPublicationClaimed); + if (authorizationStarted) { + const settledAuthentication = yield* waitForAuthentication.pipe( + Effect.timeoutOption(antigravityAuthenticationSettleTimeout), + ); + if (Option.isSome(settledAuthentication)) { + return settledAuthentication.value; + } + } return code || 1; }), ), diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 3e635c3f..fee0d280 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", @@ -2100,6 +2100,35 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("returns ready for Antigravity 1.1.5 machine model output", () => + Effect.gen(function* () { + const status = yield* checkAntigravityProviderStatus(); + assert.strictEqual(status.provider, "antigravity"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.version, "1.1.5"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args, command) => { + assert.strictEqual(command, "agy"); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "1.1.5\n", stderr: "", code: 0 }; + } + if (joined === "models") { + return { + stdout: "gemini-3.5-flash-medium\ngemini-3.5-flash-high\nclaude-sonnet-4-6\n", + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("maps Antigravity's clean-profile response to unauthenticated", () => Effect.gen(function* () { const status = yield* checkAntigravityProviderStatus(); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 6c016273..b73c9ad2 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 08751e3b..412ce1e2 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 eb6585b7..a10e7058 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -5,11 +5,13 @@ import Path from "node:path"; import { delimiter as pathDelimiter } from "node:path"; import { + PROVIDER_DISPLAY_NAMES, type ProviderKind, ServerProviderInstallationError, 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 +41,34 @@ 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 + ); +} + +function managedRuntimeDowngradeMessage(input: { + readonly provider: ProviderKind; + readonly currentVersion: string; + readonly candidateVersion: string; + readonly operation: "install" | "repair"; +}): string | null { + if ( + canActivateManagedRuntimeVersion({ + currentVersion: input.currentVersion, + candidateVersion: input.candidateVersion, + }) + ) { + return null; + } + const action = input.operation === "repair" ? "repair" : "replace"; + return `Scient will not ${action} ${PROVIDER_DISPLAY_NAMES[input.provider]} ${input.currentVersion} with older version ${input.candidateVersion}.`; +} + const PROVIDERS: ReadonlyArray = [ "codex", "claudeAgent", @@ -616,6 +646,17 @@ export const ProviderRuntimeManagerLive = Layer.effect( signal: controller.signal, }); + const current = records.get(provider) ?? null; + const downgradeMessage = current + ? managedRuntimeDowngradeMessage({ + provider, + currentVersion: current.runtimeVersion, + candidateVersion: artifact.version, + operation, + }) + : null; + if (downgradeMessage) throw new Error(downgradeMessage); + await FS.mkdir(Path.dirname(finalRelease), { recursive: true }); const existingFinal = await FS.stat(finalRelease).catch(() => null); if (existingFinal) { @@ -623,7 +664,6 @@ export const ProviderRuntimeManagerLive = Layer.effect( finalRelease = releaseRoot(config.stateDir, provider, releaseId); } await FS.rename(stagedRelease, finalRelease); - const current = records.get(provider) ?? null; const finalExecutable = Path.join(finalRelease, managedExecutableRelativePath); const record: ProviderRuntimeCurrentRecord = { version: 1, @@ -721,6 +761,23 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: "The installation plan expired. Review the provider download again.", }); } + const current = records.get(input.provider); + const downgradeMessage = current + ? managedRuntimeDowngradeMessage({ + provider: input.provider, + currentVersion: current.runtimeVersion, + candidateVersion: plan.artifact.version, + operation: "install", + }) + : null; + if (downgradeMessage) { + plans.delete(input.planToken); + return yield* installationError({ + provider: input.provider, + reason: "managed_runtime_unavailable", + message: downgradeMessage, + }); + } plans.delete(input.planToken); yield* startOperation({ provider: input.provider, @@ -752,7 +809,8 @@ export const ProviderRuntimeManagerLive = Layer.effect( const repair: ProviderRuntimeManagerShape["repair"] = (input) => Effect.gen(function* () { - if (!records.has(input.provider)) { + const current = records.get(input.provider); + if (!current) { return yield* installationError({ provider: input.provider, reason: "managed_runtime_unavailable", @@ -774,6 +832,19 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: errorMessage(cause), }), }); + const downgradeMessage = managedRuntimeDowngradeMessage({ + provider: input.provider, + currentVersion: current.runtimeVersion, + candidateVersion: artifact.version, + operation: "repair", + }); + if (downgradeMessage) { + return yield* installationError({ + provider: input.provider, + reason: "managed_runtime_unavailable", + message: downgradeMessage, + }); + } yield* startOperation({ provider: input.provider, operation: "repair", diff --git a/apps/server/src/provider/antigravityReleaseChannel.ts b/apps/server/src/provider/antigravityReleaseChannel.ts new file mode 100644 index 00000000..5789c3ba --- /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 25d7b3c2..a250161c 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 3540d1e9..51db4b9b 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 00000000..6eba8e66 --- /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 57166ae4..9667f344 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 00000000..d9297e0c --- /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 00000000..fdf709bb --- /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 ce57540e..66e216d5 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 e677c78f..14df1e47 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -105,6 +105,7 @@ function installNativeApi(overrides: { describe("ProviderConnectionDialog", () => { afterEach(() => { useProviderConnectionDialogStore.getState().setOpen(false); + document.documentElement.style.removeProperty("--app-font-size-ui"); document.body.innerHTML = ""; vi.restoreAllMocks(); }); @@ -162,7 +163,7 @@ describe("ProviderConnectionDialog", () => { await expect .element(page.getByText("Finish signing in in the browser window.")) .toBeVisible(); - await expect.element(page.getByRole("button", { name: "Cancel sign in" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Cancel sign-in" })).toBeVisible(); await expect.element(page.getByText(/sign in continues in the background/u)).toBeVisible(); } finally { await screen.unmount(); @@ -171,6 +172,58 @@ describe("ProviderConnectionDialog", () => { } }); + it("keeps every active sign-in action inside the dialog at the largest UI text size", async () => { + document.documentElement.style.setProperty("--app-font-size-ui", "18px"); + const activeProvider = { + provider: "codex", + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-large-text", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: checkedAt, + finishedAt: null, + message: "Finish signing in in the browser window.", + }, + } satisfies ServerProviderStatus; + const restoreNativeApi = installNativeApi({}); + const queryClient = createQueryClient(activeProvider); + useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); + + const screen = await render( + + + , + ); + + try { + await vi.waitFor(() => { + const popup = document.querySelector('[data-slot="dialog-popup"]'); + const buttons = Array.from( + popup?.querySelectorAll('[data-slot="button"]') ?? [], + ).filter((button) => button.getAttribute("aria-label") !== "Close"); + + expect(popup, "Expected the provider dialog popup.").toBeTruthy(); + expect(buttons).toHaveLength(3); + + const popupRect = popup!.getBoundingClientRect(); + for (const button of buttons) { + const buttonRect = button.getBoundingClientRect(); + expect(buttonRect.left).toBeGreaterThanOrEqual(popupRect.left); + expect(buttonRect.right).toBeLessThanOrEqual(popupRect.right); + } + }); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + it("forces a fresh Codex login after a classified runtime auth failure", async () => { const authenticatedProvider = { provider: "codex", @@ -690,7 +743,7 @@ describe("ProviderConnectionDialog", () => { ); try { - await expect.element(page.getByRole("button", { name: "Cancel sign in" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Cancel sign-in" })).toBeVisible(); await expect.element(page.getByRole("button", { name: "Restart sign in" })).toBeVisible(); await expect.element(page.getByText(/Automatic timeout in/u)).toBeVisible(); await page.getByRole("button", { name: "Restart sign in" }).click(); @@ -733,9 +786,27 @@ describe("ProviderConnectionDialog", () => { message: "Finish signing in to Grok.", authorizationUrl, }, + installationState: { + operationId: "install-grok-finished", + operation: "install", + status: "installed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Grok is installed and verified.", + }, } satisfies ServerProviderStatus; const openExternal = vi.fn().mockResolvedValue(undefined); - const restoreNativeApi = installNativeApi({ openExternal }); + const cancelled = { + ...active, + connectionState: { + ...active.connectionState, + status: "cancelled", + finishedAt: checkedAt, + message: "Sign in was cancelled.", + }, + } satisfies ServerProviderStatus; + const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelled] }); + const restoreNativeApi = installNativeApi({ openExternal, cancelProviderConnection }); const queryClient = createQueryClient(active); useProviderConnectionDialogStore.getState().openDialog("grok", "provider_picker"); @@ -746,11 +817,22 @@ describe("ProviderConnectionDialog", () => { ); try { - await page.getByRole("button", { name: "Open xAI sign-in again" }).click(); + const progressActions = page.getByRole("group", { name: "Sign-in progress actions" }); + await progressActions.getByRole("button", { name: "Open browser again" }).click(); await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); await expect .element(page.getByPlaceholder("Paste authorization code")) .not.toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Cancel installation" })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Cancel sign-in" }).click(); + await vi.waitFor(() => + expect(cancelProviderConnection).toHaveBeenCalledWith({ + provider: "grok", + operationId: "connect-grok-active", + }), + ); } finally { await screen.unmount(); queryClient.clear(); @@ -790,9 +872,9 @@ describe("ProviderConnectionDialog", () => { ); try { - await page.getByRole("button", { name: "Open Google sign-in again" }).click(); + await page.getByRole("button", { name: "Open browser again" }).click(); await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); - await expect.element(page.getByText(/Automatic timeout in (?:1:00|0:5\d)/u)).toBeVisible(); + await expect.element(page.getByText(/Automatic timeout in (?:10:00|9:5\d)/u)).toBeVisible(); } finally { await screen.unmount(); queryClient.clear(); @@ -904,7 +986,7 @@ describe("ProviderConnectionDialog", () => { await expect .element(page.getByPlaceholder("Paste authorization code")) .not.toBeInTheDocument(); - const cancelButton = page.getByRole("button", { name: "Cancel sign in" }); + const cancelButton = page.getByRole("button", { name: "Cancel sign-in" }); const restartButton = page.getByRole("button", { name: "Restart sign in" }); await expect.element(cancelButton).not.toBeDisabled(); await expect.element(restartButton).not.toBeDisabled(); @@ -992,7 +1074,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 +1112,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 +1145,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 725b359c..711a8a6d 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -3,17 +3,20 @@ // 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"; import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { applyProviderStatusesToCache } from "~/lib/providerStatusCache"; +import { cn } from "~/lib/utils"; import { ensureNativeApi } from "~/nativeApi"; import { useProviderConnectionDialogStore } from "~/providerConnectionDialogStore"; import { PROVIDER_ICON_COMPONENT_BY_PROVIDER } from "./ProviderIcon"; @@ -31,7 +34,7 @@ import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; const CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; -const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 60 * 1_000; +const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; function formatRemainingTime(startedAt: string, nowMs: number, timeoutMs: number): string { const elapsedMs = Math.max(0, nowMs - Date.parse(startedAt)); @@ -48,6 +51,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 +73,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 +99,7 @@ export function ProviderConnectionDialog() { useEffect(() => { setRuntimeReconnectBaselineOperationId(undefined); + setManagedUpdateStarted(false); }, [isOpen, provider, source]); useEffect(() => { @@ -291,6 +307,7 @@ export function ProviderConnectionDialog() { provider, planToken: installPlan.planToken, }); + if (managedUpdateFlow) setManagedUpdateStarted(true); setInstallPlan(null); applyProviderStatusesToCache(queryClient, result.providers); }); @@ -344,7 +361,7 @@ export function ProviderConnectionDialog() { - +

{presentation.description}

@@ -371,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" ? (
-

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,39 +516,42 @@ 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."}

- - {presentation.canRestart ? ( + + {presentation.canCancel ? ( ) : null} - {presentation.canCancel ? ( + {presentation.primaryAction !== "none" ? ( - ) : null} - {presentation.primaryAction !== "none" ? ( - ) : null} diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index 01d39191..a1091b8a 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,101 @@ 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.each(["failed", "cancelled"] as const)( + "shows the managed update %s message and offers a safe retry", + (status) => { + const presentation = describeManagedProviderUpdate({ + provider: "antigravity", + status: { + ...managedAntigravityStatus, + installationState: { + operationId: "operation-1", + operation: "install", + status, + startedAt: "2026-07-21T11:00:00.000Z", + finishedAt: "2026-07-21T11:01:00.000Z", + message: + status === "failed" + ? "The verified runtime failed its smoke test." + : "The update was cancelled.", + }, + }, + plan: null, + updateStarted: true, + }); + + expect(presentation.description).toContain(status === "failed" ? "smoke test" : "cancelled"); + expect(presentation.primaryAction).toBe("install"); + expect(presentation.primaryLabel).toBe("Try again"); + expect(presentation.canCancel).toBe(false); + }, + ); + 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 372fa75e..6bdc3dc9 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,86 @@ 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, + }; + } + if ( + input.updateStarted && + !input.plan && + (installation?.status === "failed" || installation?.status === "cancelled") + ) { + return { + title, + description: installation.message, + primaryAction: "install", + primaryLabel: "Try again", + 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 609bc573..1bb6f804 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 56e6321c..ca67d107 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 5439fac9..ad6c79f8 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 9f98f835..d4da3a2f 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) ? (