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" ? (