From 79e0beec053ef7a2c7c403baa82c4c0ceb9a496e Mon Sep 17 00:00:00 2001 From: mr-robot77 Date: Tue, 3 Nov 2026 14:17:12 +0100 Subject: [PATCH 1/7] feat: auto-authenticate Antigravity and add Ollama, Zed, Kiro, Cloudflare, AWS Bedrock providers --- apps/server/src/policy/managedProviders.ts | 12 + .../src/provider/AntigravityProvider.test.ts | 7 + .../src/provider/AntigravityProvider.ts | 31 +- .../src/provider/Drivers/AntigravityDriver.ts | 116 +++++ .../src/provider/Drivers/StandaloneDriver.ts | 415 ++++++++++++++++++ .../src/provider/antigravityAuthSupport.ts | 32 ++ apps/server/src/provider/builtInDrivers.ts | 14 +- apps/server/src/settingsSecrets.ts | 10 + .../client-runtime/src/providerClients.ts | 122 ++++- packages/contracts/src/model.ts | 12 + packages/contracts/src/settings.ts | 150 ++++++- packages/provider-kiro/src/client.ts | 2 +- 12 files changed, 914 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/provider/Drivers/StandaloneDriver.ts diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index cbf0f0a1d9b24..166b1498eb963 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -22,6 +22,13 @@ import { grokClient } from "@t3tools/provider-grok/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { kiroClient } from "@t3tools/provider-kiro/client"; +import { + bedrockClient, + cloudflareClient, + ollamaClient, + zedClient, +} from "@t3tools/client-runtime/providerClients"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; interface KnownDriver { @@ -45,9 +52,14 @@ const PACKAGED_CLIENTS: ReadonlyArray = [ cursorClient, grokClient, fxClient, + kiroClient, museClient, openCodeClient, piClient, + ollamaClient, + zedClient, + cloudflareClient, + bedrockClient, acpRegistryClient, ]; diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 44f8842fd9591..d6344b6edc666 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -135,6 +135,12 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( readonly enabled?: boolean; readonly safe?: boolean; readonly probeUsage?: Effect.Effect; + readonly probeAuth?: Effect.Effect<{ + readonly authenticated: boolean; + readonly type?: string; + readonly label?: string; + readonly email?: string; + }>; } = {}, ) { const initialProbe = yield* Deferred.make(); @@ -150,6 +156,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), ...(options.auth ? { auth: options.auth } : {}), ...(options.probeUsage ? { probeUsage: options.probeUsage } : {}), + ...(options.probeAuth ? { probeAuth: options.probeAuth } : {}), probe: Ref.update(probeCalls, (count) => count + 1).pipe( Effect.andThen(Ref.get(probe)), Effect.flatten, diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index ff8805f43b371..4c3f3ae2743a7 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -165,6 +165,12 @@ interface AntigravityProviderOptions { readonly clearUsage?: Effect.Effect; readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; readonly auth?: { readonly type: string; readonly label: string }; + readonly probeAuth?: Effect.Effect<{ + readonly authenticated: boolean; + readonly type?: string; + readonly label?: string; + readonly email?: string; + }>; } export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* ( @@ -287,16 +293,29 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; const supportsTextGeneration = initialized !== undefined ? yield* options.supportsTextGeneration : false; + const probedAuth = + initialized !== undefined && options.probeAuth + ? yield* options.probeAuth.pipe(Effect.orElseSucceed(() => undefined)) + : undefined; const updatedAt = DateTime.formatIso(yield* DateTime.now); const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { if (state.authRevision !== before.authRevision) return state; const { message: _previousMessage, ...draft } = state.draft; - const authenticated = draft.auth.status === "authenticated"; + const authStatus = + probedAuth !== undefined + ? probedAuth.authenticated + ? "authenticated" + : "unauthenticated" + : draft.auth.status; + const authenticated = authStatus === "authenticated"; + const authType = probedAuth?.type ?? draft.auth.type ?? options.auth?.type; + const authLabel = probedAuth?.label ?? draft.auth.label ?? options.auth?.label; + const authEmail = probedAuth?.email ?? draft.auth.email; const message = errorMessage ?? (authenticated ? undefined - : draft.auth.status === "unauthenticated" + : authStatus === "unauthenticated" ? signInMessage : authUncheckedMessage); return { @@ -307,6 +326,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func // Lightweight custom-launcher probes lack versions; preserve the real handshake's version. version: version === "unknown" ? draft.version : version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", + auth: { + status: authStatus, + ...(authType ? { type: authType } : {}), + ...(authLabel ? { label: authLabel } : {}), + ...(authEmail ? { email: authEmail } : {}), + }, checkedAt: updatedAt, usageLimits: missingInstallation ? undefined : draft.usageLimits, ...(missingInstallation @@ -321,7 +346,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ...(initialized !== undefined ? { supportsTextGeneration: - supportsTextGeneration && draft.auth.status !== "unauthenticated", + supportsTextGeneration && authStatus !== "unauthenticated", } : {}), ...(message ? { message } : {}), diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index a8f97b0f14743..a280fe82d3d20 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -36,6 +36,7 @@ import { isAntigravitySignInRequiredError, prepareAntigravityProfile, resolveAntigravityInstanceDirectories, + syncAntigravityGlobalCredentials, type AntigravityAuthConfig, } from "../antigravityAuthSupport.ts"; import { @@ -408,12 +409,127 @@ export const AntigravityDriver: ProviderDriver< Effect.provideService(Digest.Digest, digest), Effect.provideService(HttpClient.HttpClient, httpClient), ); + const probeAuth = Effect.gen(function* () { + switch (auth.authMethod) { + case "gemini-api-key": { + const authenticated = Boolean(auth.apiKey && auth.apiKey.trim().length > 0); + return { + authenticated, + type: "gemini-api-key", + label: antigravityAuthLabel("gemini-api-key"), + }; + } + case "agent-platform": { + const authenticated = Boolean( + (auth.apiKey && auth.apiKey.trim().length > 0) || + (auth.gcpProject && + auth.gcpProject.trim().length > 0 && + auth.gcpLocation && + auth.gcpLocation.trim().length > 0), + ); + return { + authenticated, + type: "agent-platform", + label: antigravityAuthLabel("agent-platform"), + }; + } + case "oauth-personal": + case "oauth-business": { + yield* syncAntigravityGlobalCredentials({ profileDirectory, userHome }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.ignore, + ); + const profileTokenPath = path.join(profileDirectory, "antigravity-acp", "acp_token.json"); + const userTokenPath = path.join(userHome, ".gemini", "antigravity-acp", "acp_token.json"); + const profileAccountsPath = path.join(profileDirectory, "google_accounts.json"); + const userAccountsPath = path.join(userHome, ".gemini", "google_accounts.json"); + + let tokenPath: string | null = null; + if (yield* fileSystem.exists(profileTokenPath).pipe(Effect.orElseSucceed(() => false))) { + tokenPath = profileTokenPath; + } else if (yield* fileSystem.exists(userTokenPath).pipe(Effect.orElseSucceed(() => false))) { + tokenPath = userTokenPath; + } + + if (!tokenPath) { + return { + authenticated: false, + type: auth.authMethod, + label: antigravityAuthLabel(auth.authMethod), + }; + } + + const tokenContent = yield* fileSystem + .readFileString(tokenPath) + .pipe(Effect.orElseSucceed(() => "")); + let hasValidToken = false; + try { + const parsed = JSON.parse(tokenContent); + if ( + parsed && + typeof parsed.client_id === "string" && + parsed.client_id.trim().length > 0 && + typeof parsed.refresh_token === "string" && + parsed.refresh_token.trim().length > 0 + ) { + hasValidToken = true; + } + } catch { + hasValidToken = false; + } + + if (!hasValidToken) { + return { + authenticated: false, + type: auth.authMethod, + label: antigravityAuthLabel(auth.authMethod), + }; + } + + let email: string | undefined = undefined; + let accountsPath: string | null = null; + if ( + yield* fileSystem.exists(profileAccountsPath).pipe(Effect.orElseSucceed(() => false)) + ) { + accountsPath = profileAccountsPath; + } else if ( + yield* fileSystem.exists(userAccountsPath).pipe(Effect.orElseSucceed(() => false)) + ) { + accountsPath = userAccountsPath; + } + + if (accountsPath) { + const accountsContent = yield* fileSystem + .readFileString(accountsPath) + .pipe(Effect.orElseSucceed(() => "")); + try { + const parsed = JSON.parse(accountsContent); + if (parsed && typeof parsed.active === "string" && parsed.active.trim().length > 0) { + email = parsed.active.trim(); + } + } catch { + // ignore + } + } + + return { + authenticated: true, + type: auth.authMethod, + label: antigravityAuthLabel(auth.authMethod), + ...(email ? { email } : {}), + }; + } + } + }); + const provider = yield* makeAntigravityProvider(settings, { stampIdentity: classifyModels, probe, probeUsage: usage.probe, clearUsage: usage.clear, auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, + probeAuth, supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/Drivers/StandaloneDriver.ts b/apps/server/src/provider/Drivers/StandaloneDriver.ts new file mode 100644 index 0000000000000..bf2a73dcbabac --- /dev/null +++ b/apps/server/src/provider/Drivers/StandaloneDriver.ts @@ -0,0 +1,415 @@ +import { + AwsBedrockSettings, + CloudflareSettings, + OllamaSettings, + ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderModel, + TextGenerationError, + ZedSettings, + type OrchestrationV2ProviderCapabilities, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/http/HttpClient"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { ProviderAdapterOpenSessionError } from "@t3tools/provider-core/server/ProviderAdapter"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; + +const makeUnsupportedOrchestrationAdapter = ( + driver: ProviderDriverKind, + instanceId: ProviderInstanceId, +): ProviderAdapter.ProviderAdapterV2["Service"] => ({ + driver, + instanceId, + capabilities: Effect.succeed({ + supportsModelSwitchInSession: false, + canReadThreadSnapshot: false, + supportsForkThread: false, + supportsRollbackThread: false, + supportsSteerTurn: false, + } as unknown as OrchestrationV2ProviderCapabilities), + openSession: (input) => + Effect.fail( + new ProviderAdapterOpenSessionError({ + driver, + providerSessionId: input.providerSessionId, + cause: new Error(`${driver} does not support live orchestration sessions.`), + }), + ), + closeSession: () => Effect.void, +}); + +const makeUnsupportedTextGeneration = (driver: ProviderDriverKind): ProviderTextGeneration => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: `${driver} does not provide application text generation.`, + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + generateReviewGuide: () => unsupported("generateReviewGuide"), + }; +}; + +export type StandaloneDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient; + +interface StandaloneDriverOptions { + readonly driverKind: ProviderDriverKind; + readonly displayName: string; + readonly configSchema: Schema.Schema; + readonly probe: ( + config: Config, + env: NodeJS.ProcessEnv, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + httpClient: HttpClient.HttpClient["Service"], + ) => Effect.Effect<{ + readonly installed: boolean; + readonly status: "ready" | "warning" | "error"; + readonly auth: { readonly status: "authenticated" | "unauthenticated" | "unknown"; readonly label?: string; readonly email?: string }; + readonly message?: string; + readonly version?: string | null; + readonly models: ReadonlyArray; + }>; +} + +function createStandaloneDriver( + options: StandaloneDriverOptions, +): ProviderDriver { + const decodeConfig = Schema.decodeSync(options.configSchema); + + return { + driverKind: options.driverKind, + metadata: { + displayName: options.displayName, + supportsMultipleInstances: true, + }, + configSchema: options.configSchema, + defaultConfig: () => decodeConfig({}), + create: Effect.fn("StandaloneDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled }; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: options.driverKind, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: options.driverKind, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + + const checkProvider = Effect.fn("checkStandaloneProvider")(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!enabled) { + const disabledDraft = buildServerProvider({ + driver: options.driverKind, + presentation: { displayName: options.displayName }, + enabled: false, + checkedAt, + models: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: `${options.displayName} is disabled in settings.`, + }, + }); + return stampIdentity(disabledDraft); + } + + const probed = yield* options + .probe(effectiveConfig, processEnv, spawner, httpClient) + .pipe( + Effect.catchAll(() => + Effect.succeed({ + installed: false, + status: "warning" as const, + auth: { status: "unknown" as const, label: options.displayName }, + message: `Could not verify ${options.displayName} status.`, + version: null, + models: [], + }), + ), + ); + + const draft: ServerProviderDraft = buildServerProvider({ + driver: options.driverKind, + presentation: { displayName: options.displayName }, + enabled: true, + checkedAt, + models: probed.models, + probe: { + installed: probed.installed, + version: probed.version ?? null, + status: probed.status, + auth: probed.auth, + ...(probed.message ? { message: probed.message } : {}), + }, + }); + + return stampIdentity(draft); + }); + + const managed = yield* makeManagedServerProvider({ + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: options.driverKind, + packageName: null, + }), + ), + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => checkProvider(), + checkProvider: checkProvider(), + }); + + return { + instanceId, + driverKind: options.driverKind, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: managed, + orchestrationAdapter: makeUnsupportedOrchestrationAdapter(options.driverKind, instanceId), + textGeneration: makeUnsupportedTextGeneration(options.driverKind), + } satisfies ProviderInstance; + }), + }; +} + +export const OllamaDriver: ProviderDriver = + createStandaloneDriver({ + driverKind: ProviderDriverKind.make("ollama"), + displayName: "Ollama", + configSchema: OllamaSettings, + probe: (config, env, spawner, httpClient) => + Effect.gen(function* () { + const endpoint = config.apiEndpoint || env.OLLAMA_HOST || "http://localhost:11434"; + const trimmedEndpoint = endpoint.replace(/\/+$/, ""); + const builtInModels: ReadonlyArray = [ + { slug: "llama3.3", name: "Llama 3.3", isDefault: true, isCustom: false }, + { slug: "deepseek-r1", name: "DeepSeek R1", isDefault: false, isCustom: false }, + { slug: "qwen2.5-coder", name: "Qwen 2.5 Coder", isDefault: false, isCustom: false }, + ]; + const models = providerModelsFromSettings(builtInModels, config.customModels, null); + + // Ping Ollama version endpoint + const reachable = yield* httpClient + .get(`${trimmedEndpoint}/api/version`) + .pipe( + Effect.timeout("2 seconds"), + Effect.map((res) => res.status === 200), + Effect.orElseSucceed(() => false), + ); + + if (reachable) { + return { + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const, label: "Ollama" }, + models, + }; + } + + // Try CLI check if HTTP ping failed + const cliCheck = yield* spawner + .spawn(ChildProcess.make("ollama", ["--version"])) + .pipe( + Effect.flatMap((proc) => proc.exitCode), + Effect.map((code) => Number(code) === 0), + Effect.scoped, + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => false), + ); + + if (cliCheck) { + return { + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const, label: "Ollama" }, + models, + }; + } + + return { + installed: false, + status: "warning" as const, + auth: { status: "unknown" as const, label: "Ollama" }, + message: "Ollama server is not running on configured endpoint.", + models, + }; + }), + }); + +export const ZedDriver: ProviderDriver = + createStandaloneDriver({ + driverKind: ProviderDriverKind.make("zed"), + displayName: "Zed", + configSchema: ZedSettings, + probe: (config, _env, spawner) => + Effect.gen(function* () { + const binary = config.binaryPath || "zed"; + const builtInModels: ReadonlyArray = [ + { slug: "default", name: "Zed Default", isDefault: true, isCustom: false }, + ]; + const models = providerModelsFromSettings(builtInModels, config.customModels, null); + + const cliCheck = yield* spawner + .spawn(ChildProcess.make(binary, ["--version"])) + .pipe( + Effect.flatMap((proc) => proc.exitCode), + Effect.map((code) => Number(code) === 0), + Effect.scoped, + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => false), + ); + + if (cliCheck) { + return { + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const, label: "Zed" }, + models, + }; + } + + return { + installed: false, + status: "warning" as const, + auth: { status: "unknown" as const, label: "Zed" }, + message: "Zed binary was not found on PATH.", + models, + }; + }), + }); + +export const CloudflareDriver: ProviderDriver = + createStandaloneDriver({ + driverKind: ProviderDriverKind.make("cloudflare"), + displayName: "Cloudflare", + configSchema: CloudflareSettings, + probe: (config, env) => + Effect.sync(() => { + const token = config.apiToken || env.CLOUDFLARE_API_TOKEN; + const hasToken = Boolean(token && token.trim().length > 0); + const builtInModels: ReadonlyArray = [ + { + slug: "@cf/meta/llama-3.3-70b-instruct", + name: "Llama 3.3 70B Instruct", + isDefault: true, + isCustom: false, + }, + { + slug: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", + name: "DeepSeek R1 Distill Qwen 32B", + isDefault: false, + isCustom: false, + }, + ]; + const models = providerModelsFromSettings(builtInModels, config.customModels, null); + + if (hasToken) { + return { + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const, label: "Cloudflare Workers AI" }, + models, + }; + } + + return { + installed: true, + status: "warning" as const, + auth: { status: "unauthenticated" as const, label: "Cloudflare API Token" }, + message: "Enter a Cloudflare API token in provider settings.", + models, + }; + }), + }); + +export const BedrockDriver: ProviderDriver = + createStandaloneDriver({ + driverKind: ProviderDriverKind.make("bedrock"), + displayName: "AWS Bedrock", + configSchema: AwsBedrockSettings, + probe: (config, env) => + Effect.sync(() => { + const accessKey = config.accessKeyId || env.AWS_ACCESS_KEY_ID; + const hasAccessKey = Boolean(accessKey && accessKey.trim().length > 0); + const builtInModels: ReadonlyArray = [ + { + slug: "anthropic.claude-3-7-sonnet-20250219-v1:0", + name: "Claude 3.7 Sonnet", + isDefault: true, + isCustom: false, + }, + { + slug: "amazon.nova-pro-v1:0", + name: "Amazon Nova Pro", + isDefault: false, + isCustom: false, + }, + ]; + const models = providerModelsFromSettings(builtInModels, config.customModels, null); + + if (hasAccessKey) { + return { + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const, label: "AWS Bedrock" }, + models, + }; + } + + return { + installed: true, + status: "warning" as const, + auth: { status: "unauthenticated" as const, label: "AWS Credentials" }, + message: "Enter AWS credentials in provider settings.", + models, + }; + }), + }); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 2b2ae029e5b0e..1c87813816601 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -433,9 +433,41 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( ), ); yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); + yield* syncAntigravityGlobalCredentials({ profileDirectory: geminiHome, userHome }); return profile; }); +export const syncAntigravityGlobalCredentials = Effect.fn( + "syncAntigravityGlobalCredentials", +)(function* (input: { + readonly profileDirectory: string; + readonly userHome: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profileTokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json"); + const userTokenPath = path.join(input.userHome, ".gemini", "antigravity-acp", "acp_token.json"); + const profileAccountsPath = path.join(input.profileDirectory, "google_accounts.json"); + const userAccountsPath = path.join(input.userHome, ".gemini", "google_accounts.json"); + + const profileHasToken = yield* fs.exists(profileTokenPath).pipe(Effect.orElseSucceed(() => false)); + if (!profileHasToken) { + const userHasToken = yield* fs.exists(userTokenPath).pipe(Effect.orElseSucceed(() => false)); + if (userHasToken) { + yield* fs.makeDirectory(path.dirname(profileTokenPath), { recursive: true }).pipe(Effect.ignore); + yield* fs.copyFile(userTokenPath, profileTokenPath).pipe(Effect.ignore); + } + } + + const profileHasAccounts = yield* fs.exists(profileAccountsPath).pipe(Effect.orElseSucceed(() => false)); + if (!profileHasAccounts) { + const userHasAccounts = yield* fs.exists(userAccountsPath).pipe(Effect.orElseSucceed(() => false)); + if (userHasAccounts) { + yield* fs.copyFile(userAccountsPath, profileAccountsPath).pipe(Effect.ignore); + } + } +}); + export function buildAntigravityAcpSpawnInput(input: { readonly installation: { readonly executablePath: string; diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 342a6780e9ad0..5f09402b7be28 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -16,6 +16,13 @@ import type { ProviderUsageReaderEnv, } from "@t3tools/provider-core/server/driver"; import { KiroDriver, type KiroDriverEnv } from "@t3tools/provider-kiro/server"; +import { + BedrockDriver, + CloudflareDriver, + OllamaDriver, + ZedDriver, + type StandaloneDriverEnv, +} from "./Drivers/StandaloneDriver.ts"; export type BuiltInDriversEnv = | AcpRegistryDriverEnv @@ -28,7 +35,8 @@ export type BuiltInDriversEnv = | KiroDriverEnv | OpenCodeDriverEnv | PiDriverEnv - | MuseDriverEnv; + | MuseDriverEnv + | StandaloneDriverEnv; export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, @@ -41,6 +49,10 @@ export const BUILT_IN_DRIVERS: ReadonlyArray diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index e23200653b963..2bfb08e0332c8 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -1,12 +1,16 @@ import { AntigravitySettings, + AwsBedrockSettings, ClaudeSettings, + CloudflareSettings, CodexSettings, isProviderDriverKind, + OllamaSettings, ProviderDriverKind, + ZedSettings, } from "@t3tools/contracts"; import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; -import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { defineProviderClient, makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { modelShortName } from "@t3tools/provider-core/modelShortName"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; @@ -16,6 +20,118 @@ import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +export const ollamaClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("ollama"), + label: "Ollama", + featured: true, + modelFamilies: ["llama", "mistral", "qwen", "gemma", "deepseek", "phi"], + settingsSchema: OllamaSettings, + environmentFields: [ + { + name: "OLLAMA_HOST", + label: "Ollama host", + description: "Host URL for Ollama API server (default: http://localhost:11434).", + placeholder: "http://localhost:11434", + }, + ], + icon: { + viewBox: "0 0 32 32", + fill: { light: "#111827", dark: "#F9FAFB" }, + paths: [ + { + d: "M10 4C8.9 4 8 4.9 8 6V14C8 15.1 8.9 16 10 16H11V22C11 23.1 11.9 24 13 24H15V28H18V24H21V28H24V20C24 17.8 22.2 16 20 16H18V12C18 7.6 14.4 4 10 4ZM11 7C11.6 7 12 7.4 12 8C12 8.6 11.6 9 11 9C10.4 9 10 8.6 10 8C10 7.4 10.4 7 11 7Z", + }, + ], + }, +}); + +export const zedClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("zed"), + label: "Zed", + featured: true, + settingsSchema: ZedSettings, + icon: { + viewBox: "0 0 24 24", + fill: { light: "#0842A0", dark: "#4E8BF5" }, + paths: [ + { + d: "M3 4.5C3 3.67 3.67 3 4.5 3H19.5C20.33 3 21 3.67 21 4.5V7C21 7.83 20.33 8.5 19.5 8.5H10.5L18.5 16.5H19.5C20.33 16.5 21 17.17 21 18V20.5C21 21.33 20.33 22 19.5 22H4.5C3.67 22 3 21.33 3 20.5V18C3 17.17 3.67 16.5 4.5 16.5H13.5L5.5 8.5H4.5C3.67 8.5 3 7.83 3 7V4.5Z", + }, + ], + }, +}); + +export const cloudflareClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("cloudflare"), + label: "Cloudflare", + featured: true, + modelFamilies: ["@cf/"], + settingsSchema: CloudflareSettings, + environmentFields: [ + { + name: "CLOUDFLARE_API_TOKEN", + label: "Cloudflare API token", + description: "API token with Workers AI permissions.", + placeholder: "Paste API token", + sensitive: true, + }, + { + name: "CLOUDFLARE_ACCOUNT_ID", + label: "Account ID", + description: "Cloudflare account identifier.", + placeholder: "Paste account ID", + }, + ], + icon: { + viewBox: "0 0 24 24", + fill: { light: "#F38020", dark: "#FAAD3F" }, + paths: [ + { + d: "M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM19 18H6c-2.21 0-4-1.79-4-4 0-2.05 1.53-3.76 3.56-3.97l1.07-.11.5-.95C8.08 7.14 9.94 6 12 6c2.62 0 4.88 1.86 5.39 4.43l.3 1.5 1.53.11c1.56.1 2.78 1.41 2.78 2.96 0 1.65-1.35 3-3 3z", + }, + ], + }, +}); + +export const bedrockClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("bedrock"), + label: "AWS Bedrock", + featured: true, + modelFamilies: ["anthropic.", "amazon.", "meta."], + settingsSchema: AwsBedrockSettings, + environmentFields: [ + { + name: "AWS_ACCESS_KEY_ID", + label: "Access key ID", + description: "AWS access key with Bedrock invoke permissions.", + placeholder: "AKIA...", + sensitive: true, + }, + { + name: "AWS_SECRET_ACCESS_KEY", + label: "Secret access key", + description: "AWS secret access key.", + placeholder: "Paste secret access key", + sensitive: true, + }, + { + name: "AWS_REGION", + label: "AWS Region", + description: "AWS region (default: us-east-1).", + placeholder: "us-east-1", + }, + ], + icon: { + viewBox: "0 0 24 24", + fill: { light: "#FF9900", dark: "#FF9900" }, + paths: [ + { + d: "M12 2L2 7V17L12 22L22 17V7L12 2ZM12 4.18L19.5 8.36L12 12.55L4.5 8.36L12 4.18ZM4 9.82L11 13.71V20.18L4 16.29V9.82ZM13 20.18V13.71L20 9.82V16.29L13 20.18Z", + }, + ], + }, +}); + export const providerClients = makeProviderClientRegistry([ { driverKind: ProviderDriverKind.make("codex"), @@ -45,6 +161,10 @@ export const providerClients = makeProviderClientRegistry([ kiroClient, museClient, piClient, + ollamaClient, + zedClient, + cloudflareClient, + bedrockClient, acpRegistryClient, ]); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1a2a519e1abc7..a1c0b38bb5e95 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -124,6 +124,10 @@ const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OLLAMA_DRIVER_KIND = ProviderDriverKind.make("ollama"); +const ZED_DRIVER_KIND = ProviderDriverKind.make("zed"); +const CLOUDFLARE_DRIVER_KIND = ProviderDriverKind.make("cloudflare"); +const BEDROCK_DRIVER_KIND = ProviderDriverKind.make("bedrock"); export const DEFAULT_MODEL = "gpt-6-astra"; @@ -155,6 +159,10 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", + [OLLAMA_DRIVER_KIND]: "Ollama", + [ZED_DRIVER_KIND]: "Zed", + [CLOUDFLARE_DRIVER_KIND]: "Cloudflare", + [BEDROCK_DRIVER_KIND]: "AWS Bedrock", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4bb3119561b08..92196eabe3113 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -854,6 +854,139 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; +export const OllamaSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + apiEndpoint: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("http://localhost:11434")), + Schema.annotateKey({ + title: "API endpoint", + description: "Base URL for the Ollama server API.", + providerSettingsForm: { + placeholder: "http://localhost:11434", + clearWhenEmpty: "persist", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["apiEndpoint"] }, +); +export type OllamaSettings = typeof OllamaSettings.Type; + +export const ZedSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("zed").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Zed binary on this machine.", + providerSettingsForm: { placeholder: "zed", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["binaryPath"] }, +); +export type ZedSettings = typeof ZedSettings.Type; + +export const CloudflareSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + accountId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Account ID", + description: "Cloudflare Account ID for Workers AI.", + providerSettingsForm: { placeholder: "Cloudflare Account ID", clearWhenEmpty: "omit" }, + }), + ), + apiToken: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + Schema.annotateKey({ + title: "API Token", + description: "Cloudflare API Token with Workers AI permissions.", + providerSettingsForm: { + placeholder: "Paste Cloudflare API token", + clearWhenEmpty: "omit", + sensitive: true, + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["accountId", "apiToken"] }, +); +export type CloudflareSettings = typeof CloudflareSettings.Type; + +export const AwsBedrockSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + region: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("us-east-1")), + Schema.annotateKey({ + title: "AWS Region", + description: "AWS region where Amazon Bedrock is deployed.", + providerSettingsForm: { placeholder: "us-east-1", clearWhenEmpty: "persist" }, + }), + ), + accessKeyId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + Schema.annotateKey({ + title: "Access Key ID", + description: "AWS Access Key ID.", + providerSettingsForm: { + placeholder: "AKIA...", + clearWhenEmpty: "omit", + sensitive: true, + }, + }), + ), + secretAccessKey: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + Schema.annotateKey({ + title: "Secret Access Key", + description: "AWS Secret Access Key.", + providerSettingsForm: { + placeholder: "Paste secret access key", + clearWhenEmpty: "omit", + sensitive: true, + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["region", "accessKeyId", "secretAccessKey"] }, +); +export type AwsBedrockSettings = typeof AwsBedrockSettings.Type; + export const UsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), @@ -1392,9 +1525,20 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un // Must match each driver's enabled decoding default. const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity"].map((driver) => - ProviderDriverKind.make(driver), - ), + [ + "cursor", + "grok", + "fx", + "kiro", + "muse", + "pi", + "opencode", + "antigravity", + "ollama", + "zed", + "cloudflare", + "bedrock", + ].map((driver) => ProviderDriverKind.make(driver)), ); const DEFAULT_ENABLED_DEFAULT_INSTANCES: ReadonlySet = new Set( diff --git a/packages/provider-kiro/src/client.ts b/packages/provider-kiro/src/client.ts index 06f551688bce6..04484215d825b 100644 --- a/packages/provider-kiro/src/client.ts +++ b/packages/provider-kiro/src/client.ts @@ -17,7 +17,7 @@ const black = { light: "#000000", dark: "#000000" }; export const kiroClient = defineProviderClient({ driverKind: ProviderDriverKind.make("kiro"), label: "Kiro", - featured: false, + featured: true, badgeLabel: "Early Access", settingsSchema: KiroSettings, environmentFields: [ From c52dfb09535d2bf36baec006c867cdc726510dbb Mon Sep 17 00:00:00 2001 From: mr-robot77 Date: Tue, 3 Nov 2026 17:16:58 +0100 Subject: [PATCH 2/7] feat: auto-authenticate OpenCode and Grok from local installations and credentials --- packages/provider-grok/src/server/status.ts | 144 +++++++++++++++++- .../provider-grok/src/server/usageLimits.ts | 2 +- packages/shared/src/shell.ts | 111 +++++++++++++- 3 files changed, 254 insertions(+), 3 deletions(-) diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index aa58c8babae7d..c9485531ace07 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -17,10 +17,14 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { readGrokAccount, readGrokCredential } from "./usageLimits.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { @@ -292,6 +296,80 @@ function displayNameFromGrokModelSlug(slug: string): string { .join(" "); } +function readGrokCachedModels( + environment: NodeJS.ProcessEnv, +): Effect.Effect< + ReadonlyArray, + never, + FileSystem.FileSystem | Path.Path | HostProcess.HomeDirectory +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.GROK_HOME?.trim() || + path.join( + environment.HOME || environment.USERPROFILE || (yield* HostProcess.HomeDirectory), + ".grok", + ); + const cachePath = path.join(home, "models_cache.json"); + const content = yield* fs.readFileString(cachePath).pipe( + Effect.catchTags({ + PlatformError: () => Effect.succeed("{}"), + }), + ); + let parsed: any; + try { + parsed = JSON.parse(content); + } catch { + return []; + } + if (parsed && typeof parsed.models === "object" && parsed.models !== null) { + const models: ServerProviderModel[] = []; + const keys = Object.keys(parsed.models); + for (let i = 0; i < keys.length; i++) { + const slug = keys[i]; + models.push({ + slug, + name: displayNameFromGrokModelSlug(slug), + isCustom: false, + ...(i === 0 ? { isDefault: true } : {}), + capabilities: EMPTY_CAPABILITIES, + }); + } + return models; + } + return []; + }).pipe(Effect.orElseSucceed(() => [])); +} + +function readGrokCachedVersion( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.GROK_HOME?.trim() || + path.join( + environment.HOME || environment.USERPROFILE || (yield* HostProcess.HomeDirectory), + ".grok", + ); + const cachePath = path.join(home, "models_cache.json"); + const content = yield* fs.readFileString(cachePath).pipe( + Effect.catchTags({ + PlatformError: () => Effect.succeed("{}"), + }), + ); + try { + const parsed = JSON.parse(content); + return typeof parsed?.grok_version === "string" ? parsed.grok_version : null; + } catch { + return null; + } + }).pipe(Effect.orElseSucceed(() => null)); +} + const runGrokCliCommand = Effect.fn("GrokProviderStatus.runGrokCliCommand")(function* ( grokSettings: GrokSettings, args: ReadonlyArray, @@ -436,6 +514,54 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func yield* Effect.logWarning("Grok CLI health check failed.", { errorTag: error._tag, }); + + const credentialOption = yield* Effect.option( + readGrokCredential(probeEnvironment).pipe(Effect.timeout("5 seconds")), + ); + const credential = Option.getOrUndefined(credentialOption); + const hasCachedAuth = Boolean(credential?.key); + + if (hasCachedAuth || apiKey) { + const email = credential?.email?.trim() || undefined; + const auth: ServerProviderAuth = apiKey + ? { status: "authenticated", type: "api_key", label: "xAI API key" } + : { + status: "authenticated", + type: "cached_token", + label: "Grok account", + ...(email ? { email } : {}), + }; + + const cachedModelsList = yield* readGrokCachedModels(probeEnvironment); + const models = + cachedModelsList.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, cachedModelsList) + : fallbackModels; + const cachedVersion = yield* readGrokCachedVersion(probeEnvironment); + + const accountOption = yield* Effect.option( + readGrokAccount(probeEnvironment).pipe(Effect.timeout("5 seconds")), + ); + const account = Option.getOrUndefined(accountOption); + + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: cachedVersion, + status: "ready", + auth, + ...(account?.usageLimits ? { usageLimits: account.usageLimits } : {}), + message: isCommandMissingCause(error) + ? "Authenticated via Grok account." + : undefined, + }, + }); + } + return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -503,7 +629,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func } } - const auth: ServerProviderAuth = apiKey + let auth: ServerProviderAuth = apiKey ? { status: "authenticated", type: "api_key", label: "xAI API key" } : cliModels.authenticated === true ? { status: "authenticated", type: "cached_token", label: "Grok account" } @@ -511,6 +637,22 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ? { status: "unauthenticated" } : { status: "unknown" }; + if (auth.status !== "authenticated") { + const credentialOption = yield* Effect.option( + readGrokCredential(probeEnvironment).pipe(Effect.timeout("5 seconds")), + ); + const credential = Option.getOrUndefined(credentialOption); + if (credential?.key) { + const email = credential.email?.trim() || undefined; + auth = { + status: "authenticated", + type: "cached_token", + label: "Grok account", + ...(email ? { email } : {}), + }; + } + } + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe( Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), Effect.orElseSucceed(() => []), diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index 8036df0dd3e99..5aee1ff3d61f6 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -91,7 +91,7 @@ export function grokUsageResponseToLimits( return makeUsageLimits({ checkedAt, windows: [window] }); } -const readGrokCredential = Effect.fn("readGrokCredential")(function* ( +export const readGrokCredential = Effect.fn("readGrokCredential")(function* ( environment: NodeJS.ProcessEnv, ) { // T3's ACP adapter explicitly selects API-key auth when this variable is set. diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index f3aaa4a11c4fd..1d5e94411a6b2 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -72,6 +72,115 @@ export type SpawnExecutableResolver = ( env: NodeJS.ProcessEnv, ) => string | undefined; +function resolveWellKnownToolCandidate( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + isExecutable: (candidate: string) => boolean, +): string | undefined { + const path = platform === "win32" ? NodePath.win32 : NodePath.posix; + const baseName = path.basename(command).toLowerCase(); + const normalizedCommand = + baseName.endsWith(".exe") || baseName.endsWith(".cmd") + ? baseName.slice(0, baseName.lastIndexOf(".")) + : baseName; + + if (normalizedCommand === "opencode" || normalizedCommand === "opencode-cli") { + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA ?? ""; + const appData = env.APPDATA ?? ""; + const candidates = [ + path.join(localAppData, "Programs", "@opencode-aidesktop", "resources", "opencode-cli.exe"), + path.join(localAppData, "OpenCode", "opencode-cli.exe"), + path.join(localAppData, "OpenCode", "opencode.exe"), + path.join(localAppData, "Programs", "OpenCode", "resources", "opencode-cli.exe"), + path.join(localAppData, "Programs", "OpenCode", "opencode-cli.exe"), + path.join(appData, "npm", "opencode.cmd"), + path.join(localAppData, "npm", "opencode.cmd"), + ]; + const cliDir = path.join(appData, "ai.opencode.desktop", "cli"); + try { + if (NodeFS.existsSync(cliDir)) { + for (const sub of NodeFS.readdirSync(cliDir)) { + candidates.push(path.join(cliDir, sub, "opencode-cli.exe")); + } + } + } catch {} + + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + } else if (platform === "darwin") { + const home = env.HOME ?? ""; + const candidates = [ + "/Applications/OpenCode.app/Contents/Resources/opencode-cli", + "/Applications/OpenCode AI.app/Contents/Resources/opencode-cli", + path.join(home, "Applications", "OpenCode.app", "Contents", "Resources", "opencode-cli"), + path.join(home, ".local", "bin", "opencode"), + "/usr/local/bin/opencode", + "/opt/homebrew/bin/opencode", + ]; + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + } else if (platform === "linux") { + const home = env.HOME ?? ""; + const candidates = [ + path.join(home, ".local", "bin", "opencode"), + "/usr/local/bin/opencode", + "/opt/OpenCode/resources/opencode-cli", + ]; + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + } + } + + if (normalizedCommand === "grok") { + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA ?? ""; + const appData = env.APPDATA ?? ""; + const userProfile = env.USERPROFILE ?? env.HOME ?? ""; + const candidates = [ + path.join(localAppData, "Programs", "Grok Bot", "resources", "app.asar.unpacked", "dist", "native", "grok.exe"), + path.join(localAppData, "Programs", "grok", "grok.exe"), + path.join(appData, "npm", "grok.cmd"), + path.join(localAppData, "npm", "grok.cmd"), + path.join(userProfile, ".grok", "bin", "grok.exe"), + path.join(userProfile, ".local", "bin", "grok.exe"), + ]; + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + } else if (platform === "darwin") { + const home = env.HOME ?? ""; + const candidates = [ + "/Applications/Grok.app/Contents/Resources/grok", + "/Applications/Grok Bot.app/Contents/Resources/grok", + path.join(home, ".grok", "bin", "grok"), + path.join(home, ".local", "bin", "grok"), + "/opt/homebrew/bin/grok", + "/usr/local/bin/grok", + ]; + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + } else if (platform === "linux") { + const home = env.HOME ?? ""; + const candidates = [ + path.join(home, ".grok", "bin", "grok"), + path.join(home, ".local", "bin", "grok"), + "/usr/local/bin/grok", + ]; + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + } + } + + return undefined; +} + function resolveSpawnExecutableWithNode( command: string, platform: NodeJS.Platform, @@ -109,7 +218,7 @@ function resolveSpawnExecutableWithNode( if (isExecutable(candidatePath)) return candidatePath; } } - return undefined; + return resolveWellKnownToolCandidate(command, platform, env, isExecutable); } export const SpawnExecutableResolution = Context.Reference( From 2be8a9191afd89e0d612330b6d62dbba465ec026 Mon Sep 17 00:00:00 2001 From: mr-robot77 Date: Tue, 3 Nov 2026 18:50:37 +0100 Subject: [PATCH 3/7] fix: resolve review feedback and CI lint checks on PR #30250 --- apps/server/package.json | 1 + .../src/provider/AntigravityProvider.test.ts | 62 +++ .../src/provider/AntigravityProvider.ts | 15 +- .../src/provider/Drivers/AntigravityDriver.ts | 78 ++-- .../src/provider/Drivers/StandaloneDriver.ts | 352 +++++++++++------- apps/server/src/provider/GrokProvider.test.ts | 6 +- .../acp/AntigravityAcpSupport.test.ts | 1 + .../src/provider/antigravityAuthSupport.ts | 4 + packages/client-runtime/package.json | 4 + packages/contracts/src/settings.ts | 10 +- packages/provider-grok/src/server/status.ts | 86 +++-- packages/shared/src/shell.ts | 121 +++--- pnpm-lock.yaml | 9 +- 13 files changed, 478 insertions(+), 271 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index efc25e865e715..b1548cf580acc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,6 +41,7 @@ "@ff-labs/fff-node": "0.11.0", "@muse-code/sdk": "1.4.6", "@napi-rs/keyring": "^1.3.0", + "@t3tools/client-runtime": "workspace:*", "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-acp-registry": "workspace:*", "@t3tools/provider-core": "workspace:*", diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index d6344b6edc666..7a4a2b045aa01 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -386,6 +386,68 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); + it.effect( + "updates status, email, and text generation when probeAuth transitions from authenticated to unauthenticated", + () => + Effect.scoped( + Effect.gen(function* () { + const probeAuthRef = yield* Ref.make< + Effect.Effect<{ + readonly authenticated: boolean; + readonly type?: string; + readonly label?: string; + readonly email?: string; + }> + >( + Effect.succeed({ + authenticated: true, + type: "oauth-personal", + label: "Google account", + email: "user@example.com", + }), + ); + const harness = yield* makeHarness({ + probeAuth: Effect.flatten(Ref.get(probeAuthRef)), + }); + yield* harness.initialize; + + const authenticatedSnapshot = yield* harness.provider.snapshot.getSnapshot; + expect(authenticatedSnapshot).toMatchObject({ + installed: true, + status: "ready", + auth: { + status: "authenticated", + type: "oauth-personal", + label: "Google account", + email: "user@example.com", + }, + supportsTextGeneration: true, + }); + + // Transition to unauthenticated + yield* Ref.set( + probeAuthRef, + Effect.succeed({ + authenticated: false, + type: "oauth-personal", + label: "Google account", + }), + ); + + const unauthenticatedSnapshot = yield* harness.provider.snapshot.refresh; + expect(unauthenticatedSnapshot).toMatchObject({ + installed: true, + status: "warning", + auth: { + status: "unauthenticated", + }, + supportsTextGeneration: false, + }); + expect(unauthenticatedSnapshot.auth).not.toHaveProperty("email"); + }), + ), + ); + it.effect("clears all account metadata on sign-out and authentication failure", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 4c3f3ae2743a7..232816fdb6f50 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -308,9 +308,18 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func : "unauthenticated" : draft.auth.status; const authenticated = authStatus === "authenticated"; - const authType = probedAuth?.type ?? draft.auth.type ?? options.auth?.type; - const authLabel = probedAuth?.label ?? draft.auth.label ?? options.auth?.label; - const authEmail = probedAuth?.email ?? draft.auth.email; + const authType = + probedAuth !== undefined + ? (probedAuth.authenticated ? (probedAuth.type ?? options.auth?.type) : undefined) + : (draft.auth.type ?? options.auth?.type); + const authLabel = + probedAuth !== undefined + ? (probedAuth.authenticated ? (probedAuth.label ?? options.auth?.label) : undefined) + : (draft.auth.label ?? options.auth?.label); + const authEmail = + probedAuth !== undefined + ? (probedAuth.authenticated ? probedAuth.email : undefined) + : draft.auth.email; const message = errorMessage ?? (authenticated diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index a280fe82d3d20..4c7d324740000 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -12,6 +12,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -73,6 +74,21 @@ const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); +const AntigravityTokenSchema = Schema.Struct({ + client_id: Schema.String, + refresh_token: Schema.String, +}); +const decodeAntigravityToken = Schema.decodeUnknownOption( + Schema.fromJsonString(AntigravityTokenSchema), +); + +const AntigravityAccountsSchema = Schema.Struct({ + active: Schema.String, +}); +const decodeAntigravityAccounts = Schema.decodeUnknownOption( + Schema.fromJsonString(AntigravityAccountsSchema), +); + export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation | ProviderHost.ProviderHost @@ -354,10 +370,17 @@ export const AntigravityDriver: ProviderDriver< makeRuntime: makeDisposableRuntime, // A new sign-in may be another account; drop any in-flight quota read first. onAuthenticated: (started, runtime) => - Effect.suspend(() => provider.onSignedIn).pipe( + fileSystem.remove(path.join(profileDirectory, ".signed_out")).pipe( + Effect.ignore, + Effect.andThen(Effect.suspend(() => provider.onSignedIn)), Effect.andThen(publishCatalog(started, runtime)), ), - onSignedOut: Effect.suspend(() => provider.onSignedOut), + onSignedOut: fileSystem + .writeFileString(path.join(profileDirectory, ".signed_out"), "signed_out") + .pipe( + Effect.ignore, + Effect.andThen(Effect.suspend(() => provider.onSignedOut)), + ), usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), }); @@ -435,11 +458,16 @@ export const AntigravityDriver: ProviderDriver< } case "oauth-personal": case "oauth-business": { - yield* syncAntigravityGlobalCredentials({ profileDirectory, userHome }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.ignore, - ); + const isSignedOut = yield* fileSystem + .exists(path.join(profileDirectory, ".signed_out")) + .pipe(Effect.orElseSucceed(() => false)); + if (!isSignedOut) { + yield* syncAntigravityGlobalCredentials({ profileDirectory, userHome }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.ignore, + ); + } const profileTokenPath = path.join(profileDirectory, "antigravity-acp", "acp_token.json"); const userTokenPath = path.join(userHome, ".gemini", "antigravity-acp", "acp_token.json"); const profileAccountsPath = path.join(profileDirectory, "google_accounts.json"); @@ -448,7 +476,10 @@ export const AntigravityDriver: ProviderDriver< let tokenPath: string | null = null; if (yield* fileSystem.exists(profileTokenPath).pipe(Effect.orElseSucceed(() => false))) { tokenPath = profileTokenPath; - } else if (yield* fileSystem.exists(userTokenPath).pipe(Effect.orElseSucceed(() => false))) { + } else if ( + !isSignedOut && + (yield* fileSystem.exists(userTokenPath).pipe(Effect.orElseSucceed(() => false))) + ) { tokenPath = userTokenPath; } @@ -463,21 +494,12 @@ export const AntigravityDriver: ProviderDriver< const tokenContent = yield* fileSystem .readFileString(tokenPath) .pipe(Effect.orElseSucceed(() => "")); - let hasValidToken = false; - try { - const parsed = JSON.parse(tokenContent); - if ( - parsed && - typeof parsed.client_id === "string" && - parsed.client_id.trim().length > 0 && - typeof parsed.refresh_token === "string" && - parsed.refresh_token.trim().length > 0 - ) { - hasValidToken = true; - } - } catch { - hasValidToken = false; - } + const parsedToken = Option.getOrUndefined(decodeAntigravityToken(tokenContent)); + const hasValidToken = Boolean( + parsedToken && + parsedToken.client_id.trim().length > 0 && + parsedToken.refresh_token.trim().length > 0, + ); if (!hasValidToken) { return { @@ -503,13 +525,9 @@ export const AntigravityDriver: ProviderDriver< const accountsContent = yield* fileSystem .readFileString(accountsPath) .pipe(Effect.orElseSucceed(() => "")); - try { - const parsed = JSON.parse(accountsContent); - if (parsed && typeof parsed.active === "string" && parsed.active.trim().length > 0) { - email = parsed.active.trim(); - } - } catch { - // ignore + const parsedAccounts = Option.getOrUndefined(decodeAntigravityAccounts(accountsContent)); + if (parsedAccounts && parsedAccounts.active.trim().length > 0) { + email = parsedAccounts.active.trim(); } } diff --git a/apps/server/src/provider/Drivers/StandaloneDriver.ts b/apps/server/src/provider/Drivers/StandaloneDriver.ts index bf2a73dcbabac..df95edce61fd2 100644 --- a/apps/server/src/provider/Drivers/StandaloneDriver.ts +++ b/apps/server/src/provider/Drivers/StandaloneDriver.ts @@ -20,12 +20,15 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterOpenSessionError } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -36,19 +39,23 @@ import { } from "@t3tools/provider-core/server/snapshotProbe"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; + const makeUnsupportedOrchestrationAdapter = ( driver: ProviderDriverKind, instanceId: ProviderInstanceId, ): ProviderAdapter.ProviderAdapterV2["Service"] => ({ driver, instanceId, - capabilities: Effect.succeed({ - supportsModelSwitchInSession: false, - canReadThreadSnapshot: false, - supportsForkThread: false, - supportsRollbackThread: false, - supportsSteerTurn: false, - } as unknown as OrchestrationV2ProviderCapabilities), + getCapabilities: () => + Effect.succeed({ + supportsModelSwitchInSession: false, + canReadThreadSnapshot: false, + supportsForkThread: false, + supportsRollbackThread: false, + supportsSteerTurn: false, + } as unknown as OrchestrationV2ProviderCapabilities), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), openSession: (input) => Effect.fail( new ProviderAdapterOpenSessionError({ @@ -57,7 +64,6 @@ const makeUnsupportedOrchestrationAdapter = ( cause: new Error(`${driver} does not support live orchestration sessions.`), }), ), - closeSession: () => Effect.void, }); const makeUnsupportedTextGeneration = (driver: ProviderDriverKind): ProviderTextGeneration => { @@ -78,26 +84,35 @@ const makeUnsupportedTextGeneration = (driver: ProviderDriverKind): ProviderText }; export type StandaloneDriverEnv = + | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | HttpClient.HttpClient; + | HttpClient.HttpClient + | HostProcess.HomeDirectory + | HostProcess.Platform; + +export interface StandaloneProbeResult { + readonly installed: boolean; + readonly status: "ready" | "warning" | "error"; + readonly auth: { + readonly status: "authenticated" | "unauthenticated" | "unknown"; + readonly label?: string; + readonly email?: string; + }; + readonly message?: string; + readonly version?: string | null; + readonly models: ReadonlyArray; +} interface StandaloneDriverOptions { readonly driverKind: ProviderDriverKind; readonly displayName: string; - readonly configSchema: Schema.Schema; + readonly configSchema: Schema.Codec; readonly probe: ( config: Config, env: NodeJS.ProcessEnv, spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - httpClient: HttpClient.HttpClient["Service"], - ) => Effect.Effect<{ - readonly installed: boolean; - readonly status: "ready" | "warning" | "error"; - readonly auth: { readonly status: "authenticated" | "unauthenticated" | "unknown"; readonly label?: string; readonly email?: string }; - readonly message?: string; - readonly version?: string | null; - readonly models: ReadonlyArray; - }>; + httpClient: HttpClient.HttpClient, + ) => Effect.Effect; } function createStandaloneDriver( @@ -112,7 +127,7 @@ function createStandaloneDriver( supportsMultipleInstances: true, }, configSchema: options.configSchema, - defaultConfig: () => decodeConfig({}), + defaultConfig: (): Config => decodeConfig({} as any) as Config, create: Effect.fn("StandaloneDriver.create")(function* ({ instanceId, displayName, @@ -124,7 +139,7 @@ function createStandaloneDriver( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const effectiveConfig = { ...config, enabled }; + const effectiveConfig = { ...config, enabled } as Config; const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: options.driverKind, instanceId, @@ -137,7 +152,7 @@ function createStandaloneDriver( continuationGroupKey: continuationIdentity.continuationKey, }); - const checkProvider = Effect.fn("checkStandaloneProvider")(function* () { + const checkProvider: Effect.Effect = Effect.gen(function* () { const checkedAt = DateTime.formatIso(yield* DateTime.now); if (!enabled) { const disabledDraft = buildServerProvider({ @@ -160,30 +175,35 @@ function createStandaloneDriver( const probed = yield* options .probe(effectiveConfig, processEnv, spawner, httpClient) .pipe( - Effect.catchAll(() => - Effect.succeed({ - installed: false, - status: "warning" as const, - auth: { status: "unknown" as const, label: options.displayName }, - message: `Could not verify ${options.displayName} status.`, - version: null, - models: [], - }), - ), + Effect.orElseSucceed(() => ({ + installed: false, + status: "warning" as const, + auth: { status: "unknown" as const, label: options.displayName }, + message: `Could not verify ${options.displayName} status.`, + version: null, + models: [] as ReadonlyArray, + })), ); + const status = + probed.status === "ready" ? ("warning" as const) : probed.status; + const message = + probed.status === "ready" + ? `${options.displayName} is connected, but live orchestration sessions are not supported yet.` + : probed.message; + const draft: ServerProviderDraft = buildServerProvider({ driver: options.driverKind, presentation: { displayName: options.displayName }, enabled: true, checkedAt, - models: probed.models, + models: [], probe: { installed: probed.installed, version: probed.version ?? null, - status: probed.status, + status, auth: probed.auth, - ...(probed.message ? { message: probed.message } : {}), + ...(message ? { message } : {}), }, }); @@ -201,9 +221,19 @@ function createStandaloneDriver( getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.empty, haveSettingsChanged: () => false, - initialSnapshot: () => checkProvider(), - checkProvider: checkProvider(), - }); + initialSnapshot: () => checkProvider, + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: options.driverKind, + instanceId, + detail: `Failed to build ${options.displayName} snapshot.`, + cause, + }), + ), + ); return { instanceId, @@ -220,110 +250,124 @@ function createStandaloneDriver( }; } +const probeOllama = Effect.fn("probeOllama")(function* ( + config: OllamaSettings, + env: NodeJS.ProcessEnv, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + httpClient: HttpClient.HttpClient, +): Effect.fn.Return { + const configuredEndpoint = config.apiEndpoint?.trim(); + const endpoint = + configuredEndpoint && + configuredEndpoint.length > 0 && + configuredEndpoint !== "http://localhost:11434" + ? configuredEndpoint + : env.OLLAMA_HOST?.trim() || configuredEndpoint || "http://localhost:11434"; + const trimmedEndpoint = endpoint.replace(/\/+$/, ""); + const builtInModels: ReadonlyArray = [ + { slug: "llama3.3", name: "Llama 3.3", isDefault: true, isCustom: false, capabilities: null }, + { slug: "deepseek-r1", name: "DeepSeek R1", isDefault: false, isCustom: false, capabilities: null }, + { slug: "qwen2.5-coder", name: "Qwen 2.5 Coder", isDefault: false, isCustom: false, capabilities: null }, + ]; + const models = providerModelsFromSettings(builtInModels, config.customModels, null); + + // Ping Ollama version endpoint + const reachable = yield* httpClient + .get(`${trimmedEndpoint}/api/version`) + .pipe( + Effect.timeout("2 seconds"), + Effect.map((res: { readonly status: number }) => res.status === 200), + Effect.orElseSucceed(() => false), + ); + + if (reachable) { + return { + installed: true, + status: "ready", + auth: { status: "authenticated", label: "Ollama" }, + models, + }; + } + + // Try CLI check if HTTP ping failed + const cliCheck = yield* spawner + .exitCode(ChildProcess.make("ollama", ["--version"])) + .pipe( + Effect.map((code) => Number(code) === 0), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => false), + ); + + if (cliCheck) { + return { + installed: true, + status: "warning", + auth: { status: "unknown", label: "Ollama" }, + message: `Ollama CLI is installed, but the server is not responding at ${trimmedEndpoint}.`, + models, + }; + } + + return { + installed: false, + status: "warning", + auth: { status: "unknown", label: "Ollama" }, + message: "Ollama server is not running on configured endpoint.", + models, + }; +}); + export const OllamaDriver: ProviderDriver = createStandaloneDriver({ driverKind: ProviderDriverKind.make("ollama"), displayName: "Ollama", configSchema: OllamaSettings, - probe: (config, env, spawner, httpClient) => - Effect.gen(function* () { - const endpoint = config.apiEndpoint || env.OLLAMA_HOST || "http://localhost:11434"; - const trimmedEndpoint = endpoint.replace(/\/+$/, ""); - const builtInModels: ReadonlyArray = [ - { slug: "llama3.3", name: "Llama 3.3", isDefault: true, isCustom: false }, - { slug: "deepseek-r1", name: "DeepSeek R1", isDefault: false, isCustom: false }, - { slug: "qwen2.5-coder", name: "Qwen 2.5 Coder", isDefault: false, isCustom: false }, - ]; - const models = providerModelsFromSettings(builtInModels, config.customModels, null); + probe: probeOllama, + }); - // Ping Ollama version endpoint - const reachable = yield* httpClient - .get(`${trimmedEndpoint}/api/version`) - .pipe( - Effect.timeout("2 seconds"), - Effect.map((res) => res.status === 200), - Effect.orElseSucceed(() => false), - ); +const probeZed = Effect.fn("probeZed")(function* ( + config: ZedSettings, + _env: NodeJS.ProcessEnv, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +): Effect.fn.Return { + const binary = config.binaryPath || "zed"; + const builtInModels: ReadonlyArray = [ + { slug: "default", name: "Zed Default", isDefault: true, isCustom: false, capabilities: null }, + ]; + const models = providerModelsFromSettings(builtInModels, config.customModels, null); - if (reachable) { - return { - installed: true, - status: "ready" as const, - auth: { status: "authenticated" as const, label: "Ollama" }, - models, - }; - } + const cliCheck = yield* spawner + .exitCode(ChildProcess.make(binary, ["--version"])) + .pipe( + Effect.map((code) => Number(code) === 0), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => false), + ); - // Try CLI check if HTTP ping failed - const cliCheck = yield* spawner - .spawn(ChildProcess.make("ollama", ["--version"])) - .pipe( - Effect.flatMap((proc) => proc.exitCode), - Effect.map((code) => Number(code) === 0), - Effect.scoped, - Effect.timeout("2 seconds"), - Effect.orElseSucceed(() => false), - ); + if (cliCheck) { + return { + installed: true, + status: "ready", + auth: { status: "authenticated", label: "Zed" }, + models, + }; + } - if (cliCheck) { - return { - installed: true, - status: "ready" as const, - auth: { status: "authenticated" as const, label: "Ollama" }, - models, - }; - } - - return { - installed: false, - status: "warning" as const, - auth: { status: "unknown" as const, label: "Ollama" }, - message: "Ollama server is not running on configured endpoint.", - models, - }; - }), - }); + return { + installed: false, + status: "warning", + auth: { status: "unknown", label: "Zed" }, + message: "Zed binary was not found on PATH.", + models, + }; +}); export const ZedDriver: ProviderDriver = createStandaloneDriver({ driverKind: ProviderDriverKind.make("zed"), displayName: "Zed", configSchema: ZedSettings, - probe: (config, _env, spawner) => - Effect.gen(function* () { - const binary = config.binaryPath || "zed"; - const builtInModels: ReadonlyArray = [ - { slug: "default", name: "Zed Default", isDefault: true, isCustom: false }, - ]; - const models = providerModelsFromSettings(builtInModels, config.customModels, null); - - const cliCheck = yield* spawner - .spawn(ChildProcess.make(binary, ["--version"])) - .pipe( - Effect.flatMap((proc) => proc.exitCode), - Effect.map((code) => Number(code) === 0), - Effect.scoped, - Effect.timeout("2 seconds"), - Effect.orElseSucceed(() => false), - ); - - if (cliCheck) { - return { - installed: true, - status: "ready" as const, - auth: { status: "authenticated" as const, label: "Zed" }, - models, - }; - } - - return { - installed: false, - status: "warning" as const, - auth: { status: "unknown" as const, label: "Zed" }, - message: "Zed binary was not found on PATH.", - models, - }; - }), + probe: probeZed, }); export const CloudflareDriver: ProviderDriver = @@ -331,40 +375,54 @@ export const CloudflareDriver: ProviderDriver + probe: (config, env): Effect.Effect => Effect.sync(() => { const token = config.apiToken || env.CLOUDFLARE_API_TOKEN; + const accountId = config.accountId || env.CLOUDFLARE_ACCOUNT_ID; const hasToken = Boolean(token && token.trim().length > 0); + const hasAccountId = Boolean(accountId && accountId.trim().length > 0); const builtInModels: ReadonlyArray = [ { slug: "@cf/meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B Instruct", isDefault: true, isCustom: false, + capabilities: null, }, { slug: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", name: "DeepSeek R1 Distill Qwen 32B", isDefault: false, isCustom: false, + capabilities: null, }, ]; const models = providerModelsFromSettings(builtInModels, config.customModels, null); - if (hasToken) { + if (hasToken && hasAccountId) { return { installed: true, - status: "ready" as const, - auth: { status: "authenticated" as const, label: "Cloudflare Workers AI" }, + status: "ready", + auth: { status: "authenticated", label: "Cloudflare Workers AI" }, + models, + }; + } + + if (hasToken && !hasAccountId) { + return { + installed: true, + status: "warning", + auth: { status: "unauthenticated", label: "Cloudflare Account ID" }, + message: "Enter a Cloudflare Account ID in provider settings.", models, }; } return { installed: true, - status: "warning" as const, - auth: { status: "unauthenticated" as const, label: "Cloudflare API Token" }, - message: "Enter a Cloudflare API token in provider settings.", + status: "warning", + auth: { status: "unauthenticated", label: "Cloudflare API Token" }, + message: "Enter a Cloudflare API token and Account ID in provider settings.", models, }; }), @@ -375,39 +433,53 @@ export const BedrockDriver: ProviderDriver + probe: (config, env): Effect.Effect => Effect.sync(() => { const accessKey = config.accessKeyId || env.AWS_ACCESS_KEY_ID; + const secretKey = config.secretAccessKey || env.AWS_SECRET_ACCESS_KEY; const hasAccessKey = Boolean(accessKey && accessKey.trim().length > 0); + const hasSecretKey = Boolean(secretKey && secretKey.trim().length > 0); const builtInModels: ReadonlyArray = [ { slug: "anthropic.claude-3-7-sonnet-20250219-v1:0", name: "Claude 3.7 Sonnet", isDefault: true, isCustom: false, + capabilities: null, }, { slug: "amazon.nova-pro-v1:0", name: "Amazon Nova Pro", isDefault: false, isCustom: false, + capabilities: null, }, ]; const models = providerModelsFromSettings(builtInModels, config.customModels, null); - if (hasAccessKey) { + if (hasAccessKey && hasSecretKey) { + return { + installed: true, + status: "ready", + auth: { status: "authenticated", label: "AWS Bedrock" }, + models, + }; + } + + if (hasAccessKey && !hasSecretKey) { return { installed: true, - status: "ready" as const, - auth: { status: "authenticated" as const, label: "AWS Bedrock" }, + status: "warning", + auth: { status: "unauthenticated", label: "AWS Secret Access Key" }, + message: "Enter an AWS Secret Access Key in provider settings.", models, }; } return { installed: true, - status: "warning" as const, - auth: { status: "unauthenticated" as const, label: "AWS Credentials" }, + status: "warning", + auth: { status: "unauthenticated", label: "AWS Credentials" }, message: "Enter AWS credentials in provider settings.", models, }; diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 9cc1a9a7e3238..d4979e86df330 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -13,7 +13,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestClock from "effect/testing/TestClock"; @@ -198,7 +198,9 @@ describe("buildGrokModelCapabilities", () => { }); }); -it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("checkGrokProviderStatus", (it) => { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, FetchHttpClient.layer))( + "checkGrokProviderStatus", + (it) => { it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { const secretStderr = "broken grok install: secret-token-value"; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index ea5efe230a234..67a7a448c6137 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -46,6 +46,7 @@ function makeModelRuntime( value: string | boolean, options?: { readonly allowUnlistedValue?: boolean }, ) { + yield* Effect.void; selections.push( `${configId}=${String(value)}${options?.allowUnlistedValue === true ? " (unlisted)" : ""}`, ); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 1c87813816601..a5a2d4ff30265 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -445,6 +445,10 @@ export const syncAntigravityGlobalCredentials = Effect.fn( }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const signedOutMarker = path.join(input.profileDirectory, ".signed_out"); + const isSignedOut = yield* fs.exists(signedOutMarker).pipe(Effect.orElseSucceed(() => false)); + if (isSignedOut) return; + const profileTokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json"); const userTokenPath = path.join(input.userHome, ".gemini", "antigravity-acp", "acp_token.json"); const profileAccountsPath = path.join(input.profileDirectory, "google_accounts.json"); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 6fd4550d93f31..5ed892e7ac332 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -518,6 +518,10 @@ "./provider-clients": { "types": "./src/providerClients.ts", "default": "./src/providerClients.ts" + }, + "./providerClients": { + "types": "./src/providerClients.ts", + "default": "./src/providerClients.ts" } }, "scripts": { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 92196eabe3113..b6803bc2381a8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -861,13 +861,13 @@ export const OllamaSettings = makeProviderSettingsSchema( Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), apiEndpoint: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("http://localhost:11434")), + Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "API endpoint", description: "Base URL for the Ollama server API.", providerSettingsForm: { placeholder: "http://localhost:11434", - clearWhenEmpty: "persist", + clearWhenEmpty: "omit", }, }), ), @@ -923,9 +923,9 @@ export const CloudflareSettings = makeProviderSettingsSchema( title: "API Token", description: "Cloudflare API Token with Workers AI permissions.", providerSettingsForm: { + control: "password", placeholder: "Paste Cloudflare API token", clearWhenEmpty: "omit", - sensitive: true, }, }), ), @@ -959,9 +959,9 @@ export const AwsBedrockSettings = makeProviderSettingsSchema( title: "Access Key ID", description: "AWS Access Key ID.", providerSettingsForm: { + control: "password", placeholder: "AKIA...", clearWhenEmpty: "omit", - sensitive: true, }, }), ), @@ -972,9 +972,9 @@ export const AwsBedrockSettings = makeProviderSettingsSchema( title: "Secret Access Key", description: "AWS Secret Access Key.", providerSettingsForm: { + control: "password", placeholder: "Paste secret access key", clearWhenEmpty: "omit", - sensitive: true, }, }), ), diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index c9485531ace07..9b6daab659987 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -19,7 +19,6 @@ import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -296,20 +295,16 @@ function displayNameFromGrokModelSlug(slug: string): string { .join(" "); } -function readGrokCachedModels( - environment: NodeJS.ProcessEnv, -): Effect.Effect< - ReadonlyArray, - never, - FileSystem.FileSystem | Path.Path | HostProcess.HomeDirectory -> { - return Effect.gen(function* () { +const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const readGrokCachedModels = Effect.fn("GrokProviderStatus.readGrokCachedModels")( + function* (environment: NodeJS.ProcessEnv) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = environment.GROK_HOME?.trim() || path.join( - environment.HOME || environment.USERPROFILE || (yield* HostProcess.HomeDirectory), + environment.HOME || environment.USERPROFILE || "", ".grok", ); const cachePath = path.join(home, "models_cache.json"); @@ -318,17 +313,21 @@ function readGrokCachedModels( PlatformError: () => Effect.succeed("{}"), }), ); - let parsed: any; - try { - parsed = JSON.parse(content); - } catch { - return []; - } - if (parsed && typeof parsed.models === "object" && parsed.models !== null) { + const parsed = Option.getOrUndefined(decodeUnknownJson(content)) as + | { models?: unknown } + | undefined; + if ( + parsed && + typeof parsed.models === "object" && + parsed.models !== null && + !Array.isArray(parsed.models) + ) { const models: ServerProviderModel[] = []; - const keys = Object.keys(parsed.models); + const modelMap = parsed.models as Record; + const keys = Object.keys(modelMap); for (let i = 0; i < keys.length; i++) { const slug = keys[i]; + if (!slug) continue; models.push({ slug, name: displayNameFromGrokModelSlug(slug), @@ -340,19 +339,18 @@ function readGrokCachedModels( return models; } return []; - }).pipe(Effect.orElseSucceed(() => [])); -} + }, + Effect.orElseSucceed(() => []), +); -function readGrokCachedVersion( - environment: NodeJS.ProcessEnv, -): Effect.Effect { - return Effect.gen(function* () { +const readGrokCachedVersion = Effect.fn("GrokProviderStatus.readGrokCachedVersion")( + function* (environment: NodeJS.ProcessEnv) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = environment.GROK_HOME?.trim() || path.join( - environment.HOME || environment.USERPROFILE || (yield* HostProcess.HomeDirectory), + environment.HOME || environment.USERPROFILE || "", ".grok", ); const cachePath = path.join(home, "models_cache.json"); @@ -361,14 +359,13 @@ function readGrokCachedVersion( PlatformError: () => Effect.succeed("{}"), }), ); - try { - const parsed = JSON.parse(content); - return typeof parsed?.grok_version === "string" ? parsed.grok_version : null; - } catch { - return null; - } - }).pipe(Effect.orElseSucceed(() => null)); -} + const parsed = Option.getOrUndefined(decodeUnknownJson(content)) as + | { grok_version?: unknown } + | undefined; + return typeof parsed?.grok_version === "string" ? parsed.grok_version : null; + }, + Effect.orElseSucceed(() => null), +); const runGrokCliCommand = Effect.fn("GrokProviderStatus.runGrokCliCommand")(function* ( grokSettings: GrokSettings, @@ -479,7 +476,11 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient > { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); @@ -550,14 +551,17 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func checkedAt, models, probe: { - installed: true, - version: cachedVersion, - status: "ready", + installed: !isCommandMissingCause(error), + version: isCommandMissingCause(error) ? null : cachedVersion, + status: isCommandMissingCause(error) ? "warning" : "ready", auth, ...(account?.usageLimits ? { usageLimits: account.usageLimits } : {}), - message: isCommandMissingCause(error) - ? "Authenticated via Grok account." - : undefined, + ...(isCommandMissingCause(error) + ? { + message: + "Grok account is linked, but Grok CLI (`grok`) is not installed or not on PATH.", + } + : {}), }, }); } @@ -637,7 +641,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ? { status: "unauthenticated" } : { status: "unknown" }; - if (auth.status !== "authenticated") { + if (!apiKey && cliModels.authenticated === null) { const credentialOption = yield* Effect.option( readGrokCredential(probeEnvironment).pipe(Effect.timeout("5 seconds")), ); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 1d5e94411a6b2..42b2762115ed1 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -87,36 +87,45 @@ function resolveWellKnownToolCandidate( if (normalizedCommand === "opencode" || normalizedCommand === "opencode-cli") { if (platform === "win32") { - const localAppData = env.LOCALAPPDATA ?? ""; - const appData = env.APPDATA ?? ""; - const candidates = [ - path.join(localAppData, "Programs", "@opencode-aidesktop", "resources", "opencode-cli.exe"), - path.join(localAppData, "OpenCode", "opencode-cli.exe"), - path.join(localAppData, "OpenCode", "opencode.exe"), - path.join(localAppData, "Programs", "OpenCode", "resources", "opencode-cli.exe"), - path.join(localAppData, "Programs", "OpenCode", "opencode-cli.exe"), - path.join(appData, "npm", "opencode.cmd"), - path.join(localAppData, "npm", "opencode.cmd"), - ]; - const cliDir = path.join(appData, "ai.opencode.desktop", "cli"); - try { - if (NodeFS.existsSync(cliDir)) { - for (const sub of NodeFS.readdirSync(cliDir)) { - candidates.push(path.join(cliDir, sub, "opencode-cli.exe")); + const localAppData = env.LOCALAPPDATA?.trim(); + const appData = env.APPDATA?.trim(); + const candidates: string[] = []; + if (localAppData) { + candidates.push( + path.join(localAppData, "Programs", "@opencode-aidesktop", "resources", "opencode-cli.exe"), + path.join(localAppData, "OpenCode", "opencode-cli.exe"), + path.join(localAppData, "OpenCode", "opencode.exe"), + path.join(localAppData, "Programs", "OpenCode", "resources", "opencode-cli.exe"), + path.join(localAppData, "Programs", "OpenCode", "opencode-cli.exe"), + path.join(localAppData, "npm", "opencode.cmd"), + ); + } + if (appData) { + candidates.push(path.join(appData, "npm", "opencode.cmd")); + const cliDir = path.join(appData, "ai.opencode.desktop", "cli"); + try { + if (NodeFS.existsSync(cliDir)) { + for (const sub of NodeFS.readdirSync(cliDir)) { + candidates.push(path.join(cliDir, sub, "opencode-cli.exe")); + } } - } - } catch {} + } catch {} + } for (const candidate of candidates) { if (isExecutable(candidate)) return candidate; } } else if (platform === "darwin") { - const home = env.HOME ?? ""; + const home = env.HOME?.trim(); const candidates = [ "/Applications/OpenCode.app/Contents/Resources/opencode-cli", "/Applications/OpenCode AI.app/Contents/Resources/opencode-cli", - path.join(home, "Applications", "OpenCode.app", "Contents", "Resources", "opencode-cli"), - path.join(home, ".local", "bin", "opencode"), + ...(home + ? [ + path.join(home, "Applications", "OpenCode.app", "Contents", "Resources", "opencode-cli"), + path.join(home, ".local", "bin", "opencode"), + ] + : []), "/usr/local/bin/opencode", "/opt/homebrew/bin/opencode", ]; @@ -124,9 +133,9 @@ function resolveWellKnownToolCandidate( if (isExecutable(candidate)) return candidate; } } else if (platform === "linux") { - const home = env.HOME ?? ""; + const home = env.HOME?.trim(); const candidates = [ - path.join(home, ".local", "bin", "opencode"), + ...(home ? [path.join(home, ".local", "bin", "opencode")] : []), "/usr/local/bin/opencode", "/opt/OpenCode/resources/opencode-cli", ]; @@ -138,27 +147,40 @@ function resolveWellKnownToolCandidate( if (normalizedCommand === "grok") { if (platform === "win32") { - const localAppData = env.LOCALAPPDATA ?? ""; - const appData = env.APPDATA ?? ""; - const userProfile = env.USERPROFILE ?? env.HOME ?? ""; - const candidates = [ - path.join(localAppData, "Programs", "Grok Bot", "resources", "app.asar.unpacked", "dist", "native", "grok.exe"), - path.join(localAppData, "Programs", "grok", "grok.exe"), - path.join(appData, "npm", "grok.cmd"), - path.join(localAppData, "npm", "grok.cmd"), - path.join(userProfile, ".grok", "bin", "grok.exe"), - path.join(userProfile, ".local", "bin", "grok.exe"), - ]; + const localAppData = env.LOCALAPPDATA?.trim(); + const appData = env.APPDATA?.trim(); + const userProfile = env.USERPROFILE?.trim() || env.HOME?.trim(); + const candidates: string[] = []; + if (localAppData) { + candidates.push( + path.join(localAppData, "Programs", "Grok Bot", "resources", "app.asar.unpacked", "dist", "native", "grok.exe"), + path.join(localAppData, "Programs", "grok", "grok.exe"), + path.join(localAppData, "npm", "grok.cmd"), + ); + } + if (appData) { + candidates.push(path.join(appData, "npm", "grok.cmd")); + } + if (userProfile) { + candidates.push( + path.join(userProfile, ".grok", "bin", "grok.exe"), + path.join(userProfile, ".local", "bin", "grok.exe"), + ); + } for (const candidate of candidates) { if (isExecutable(candidate)) return candidate; } } else if (platform === "darwin") { - const home = env.HOME ?? ""; + const home = env.HOME?.trim(); const candidates = [ "/Applications/Grok.app/Contents/Resources/grok", "/Applications/Grok Bot.app/Contents/Resources/grok", - path.join(home, ".grok", "bin", "grok"), - path.join(home, ".local", "bin", "grok"), + ...(home + ? [ + path.join(home, ".grok", "bin", "grok"), + path.join(home, ".local", "bin", "grok"), + ] + : []), "/opt/homebrew/bin/grok", "/usr/local/bin/grok", ]; @@ -166,10 +188,14 @@ function resolveWellKnownToolCandidate( if (isExecutable(candidate)) return candidate; } } else if (platform === "linux") { - const home = env.HOME ?? ""; + const home = env.HOME?.trim(); const candidates = [ - path.join(home, ".grok", "bin", "grok"), - path.join(home, ".local", "bin", "grok"), + ...(home + ? [ + path.join(home, ".grok", "bin", "grok"), + path.join(home, ".local", "bin", "grok"), + ] + : []), "/usr/local/bin/grok", ]; for (const candidate of candidates) { @@ -933,9 +959,6 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( ): Effect.fn.Return { const platform = yield* HostProcess.Platform; const isMacosGit = platform === "darwin" && command === "git"; - if (platform !== "win32" && !isMacosGit) { - return { command, args: [...args], shell: false }; - } const hostEnvironment = yield* HostProcess.Environment; const env = @@ -964,6 +987,8 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"), + env.LOCALAPPDATA ?? "", + env.APPDATA ?? "", env.MSYSTEM ? "msystem" : "", command, ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); @@ -974,12 +999,20 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( resolvedExecutable = cached.resolvedPath; } else { const found = resolveExecutable(command, platform, env); - resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); + resolvedExecutable = + found === undefined + ? null + : platform === "win32" + ? preferGitForWindowsBinary(found, env) + : found; if (!explicitPath && resolvedExecutable !== null) { cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); } } const resolvedCommand = resolvedExecutable ?? command; + if (platform !== "win32") { + return { command: resolvedCommand, args: [...args], shell: false }; + } const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); if (extension !== ".cmd" && extension !== ".bat") { return { command: resolvedCommand, args: [...args], shell: false }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 755f2f95c2ed1..2945936b6ed47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -854,6 +854,9 @@ importers: '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 + '@t3tools/client-runtime': + specifier: workspace:* + version: link:../../packages/client-runtime '@t3tools/provider-acp': specifier: workspace:* version: link:../../packages/provider-acp @@ -6068,10 +6071,6 @@ packages: '@pierre/highlights@0.2.0': resolution: {integrity: sha512-hTpcOU1EzDAP4bYtEhxmMu2jwMvtkUydRUBUpWMSHTs3dw0IaC3Ld5ansMy18L0GE3b6Vfl2OGkLK+pWWpnBmw==} - '@pierre/theme@1.1.0': - resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} - engines: {vscode: ^1.0.0} - '@pierre/theme@2.0.0': resolution: {integrity: sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==} engines: {vscode: ^1.0.0} @@ -15697,8 +15696,6 @@ snapshots: '@pierre/highlights@0.2.0': {} - '@pierre/theme@1.1.0': {} - '@pierre/theme@2.0.0': {} '@pierre/theming@1.0.0(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': From 0b78945f812c3e0d1e447e7115641626745c15ea Mon Sep 17 00:00:00 2001 From: mr-robot77 Date: Tue, 3 Nov 2026 20:21:49 +0100 Subject: [PATCH 4/7] fix: address review feedback on standalone providers, grok status, and shell resolution --- .../src/provider/AntigravityProvider.test.ts | 1 - .../src/provider/AntigravityProvider.ts | 8 +++++-- .../src/provider/Drivers/StandaloneDriver.ts | 23 ++++++++----------- packages/provider-grok/src/server/status.ts | 11 ++++----- packages/shared/src/shell.test.ts | 4 ++-- packages/shared/src/shell.ts | 2 ++ 6 files changed, 23 insertions(+), 26 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 7a4a2b045aa01..65030287d7ff9 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -433,7 +433,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { label: "Google account", }), ); - const unauthenticatedSnapshot = yield* harness.provider.snapshot.refresh; expect(unauthenticatedSnapshot).toMatchObject({ installed: true, diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 232816fdb6f50..fc623c8f4b437 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -310,11 +310,15 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const authenticated = authStatus === "authenticated"; const authType = probedAuth !== undefined - ? (probedAuth.authenticated ? (probedAuth.type ?? options.auth?.type) : undefined) + ? (probedAuth.authenticated || usesCredentials + ? (probedAuth.type ?? options.auth?.type) + : undefined) : (draft.auth.type ?? options.auth?.type); const authLabel = probedAuth !== undefined - ? (probedAuth.authenticated ? (probedAuth.label ?? options.auth?.label) : undefined) + ? (probedAuth.authenticated || usesCredentials + ? (probedAuth.label ?? options.auth?.label) + : undefined) : (draft.auth.label ?? options.auth?.label); const authEmail = probedAuth !== undefined diff --git a/apps/server/src/provider/Drivers/StandaloneDriver.ts b/apps/server/src/provider/Drivers/StandaloneDriver.ts index df95edce61fd2..c9d8490a04897 100644 --- a/apps/server/src/provider/Drivers/StandaloneDriver.ts +++ b/apps/server/src/provider/Drivers/StandaloneDriver.ts @@ -28,7 +28,6 @@ import { } from "@t3tools/provider-core/server/driver"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -86,9 +85,7 @@ const makeUnsupportedTextGeneration = (driver: ProviderDriverKind): ProviderText export type StandaloneDriverEnv = | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner - | HttpClient.HttpClient - | HostProcess.HomeDirectory - | HostProcess.Platform; + | HttpClient.HttpClient; export interface StandaloneProbeResult { readonly installed: boolean; @@ -189,7 +186,7 @@ function createStandaloneDriver( probed.status === "ready" ? ("warning" as const) : probed.status; const message = probed.status === "ready" - ? `${options.displayName} is connected, but live orchestration sessions are not supported yet.` + ? `${options.displayName} is configured, but live orchestration sessions are not supported yet.` : probed.message; const draft: ServerProviderDraft = buildServerProvider({ @@ -197,7 +194,7 @@ function createStandaloneDriver( presentation: { displayName: options.displayName }, enabled: true, checkedAt, - models: [], + models: probed.models, probe: { installed: probed.installed, version: probed.version ?? null, @@ -257,13 +254,11 @@ const probeOllama = Effect.fn("probeOllama")(function* ( httpClient: HttpClient.HttpClient, ): Effect.fn.Return { const configuredEndpoint = config.apiEndpoint?.trim(); - const endpoint = - configuredEndpoint && - configuredEndpoint.length > 0 && - configuredEndpoint !== "http://localhost:11434" - ? configuredEndpoint - : env.OLLAMA_HOST?.trim() || configuredEndpoint || "http://localhost:11434"; - const trimmedEndpoint = endpoint.replace(/\/+$/, ""); + const rawHost = configuredEndpoint || env.OLLAMA_HOST?.trim() || "http://localhost:11434"; + const normalizedHost = rawHost.startsWith("http://") || rawHost.startsWith("https://") + ? rawHost + : `http://${rawHost}`; + const trimmedEndpoint = normalizedHost.replace(/\/+$/, ""); const builtInModels: ReadonlyArray = [ { slug: "llama3.3", name: "Llama 3.3", isDefault: true, isCustom: false, capabilities: null }, { slug: "deepseek-r1", name: "DeepSeek R1", isDefault: false, isCustom: false, capabilities: null }, @@ -303,7 +298,7 @@ const probeOllama = Effect.fn("probeOllama")(function* ( installed: true, status: "warning", auth: { status: "unknown", label: "Ollama" }, - message: `Ollama CLI is installed, but the server is not responding at ${trimmedEndpoint}.`, + message: "Ollama CLI is installed, but the server is not responding at the configured endpoint.", models, }; } diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 9b6daab659987..fb5892ae7d373 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -553,15 +553,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: !isCommandMissingCause(error), version: isCommandMissingCause(error) ? null : cachedVersion, - status: isCommandMissingCause(error) ? "warning" : "ready", + status: isCommandMissingCause(error) ? "warning" : "error", auth, ...(account?.usageLimits ? { usageLimits: account.usageLimits } : {}), - ...(isCommandMissingCause(error) - ? { - message: - "Grok account is linked, but Grok CLI (`grok`) is not installed or not on PATH.", - } - : {}), + message: isCommandMissingCause(error) + ? "Grok account is linked, but Grok CLI (`grok`) is not installed or not on PATH." + : "Failed to execute Grok CLI health check.", }, }); } diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 6ae7401a48822..934b40ccf14ef 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -850,14 +850,14 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); - it.effect("keeps git when PATH has another git or the host is not macOS", () => + it.effect("keeps git when PATH has another git or resolves to Linux git", () => Effect.gen(function* () { const homebrew = setup({ gitOnPath: "/opt/homebrew/bin/git", xcrunGit: XCODE_GIT }); expect(yield* homebrew.resolve).toBe("git"); expect(homebrew.host.xcrunCalls).toBe(0); const linux = setup({ platform: "linux", gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT }); - expect(yield* linux.resolve).toBe("git"); + expect(yield* linux.resolve).toBe("/usr/bin/git"); }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 42b2762115ed1..56bf9b59bb1cd 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -987,6 +987,8 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"), + env.HOME ?? "", + env.USERPROFILE ?? "", env.LOCALAPPDATA ?? "", env.APPDATA ?? "", env.MSYSTEM ? "msystem" : "", From 0cd6c3717a73da7405ab02c1d1f5c9826eac0578 Mon Sep 17 00:00:00 2001 From: mr-robot77 Date: Wed, 4 Nov 2026 13:24:08 +0100 Subject: [PATCH 5/7] fix: resolve merge conflicts, formatting, and CodeRabbit review feedback on PR #30250 --- apps/server/src/policy/managedProviders.ts | 1 - apps/server/src/processRunner.ts | 10 +- .../src/provider/AntigravityProvider.test.ts | 5 +- .../src/provider/AntigravityProvider.ts | 19 +- .../src/provider/Drivers/AntigravityDriver.ts | 33 +- .../provider/Drivers/StandaloneDriver.test.ts | 95 +++ .../src/provider/Drivers/StandaloneDriver.ts | 121 ++-- apps/server/src/provider/GrokProvider.test.ts | 551 ++++++++++-------- .../src/provider/antigravityAuthSupport.ts | 67 ++- packages/contracts/src/settings.test.ts | 2 +- packages/provider-grok/src/server/status.ts | 10 +- packages/shared/src/shell.test.ts | 40 ++ packages/shared/src/shell.ts | 54 +- 13 files changed, 620 insertions(+), 388 deletions(-) create mode 100644 apps/server/src/provider/Drivers/StandaloneDriver.test.ts diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index 72c86c1129ffb..ed843e5915879 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -23,7 +23,6 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; -import { kiroClient } from "@t3tools/provider-kiro/client"; import { bedrockClient, cloudflareClient, diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index a345c67eca881..902d555afc3ce 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -272,11 +272,11 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; const extendEnv = input.env !== undefined; - const spawnCommand = yield* resolveSpawnCommand( - input.command, - input.args, - input.env === undefined ? {} : { env: input.env, extendEnv }, - ); + const spawnCwd = input.spawnCwd ?? input.cwd; + const spawnCommand = yield* resolveSpawnCommand(input.command, input.args, { + ...(input.env === undefined ? {} : { env: input.env, extendEnv }), + ...(spawnCwd ? { cwd: spawnCwd } : {}), + }); const toSpawnError = (cause: unknown) => new ProcessSpawnError({ diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index b202586c6d46b..40a7458356d2d 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -466,7 +466,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { Effect.scoped( Effect.gen(function* () { const auth = { type: "gemini-api-key", label: "Gemini API key" }; - const harness = yield* makeHarness({ auth }); + const harness = yield* makeHarness({ + auth, + probeAuth: Effect.succeed({ authenticated: false }), + }); yield* harness.initialize; for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { yield* harness.provider.onSessionStarted(started, "/workspace"); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index f115c1fac4233..d8d0a546c3ab8 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -315,19 +315,21 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const authenticated = authStatus === "authenticated"; const authType = probedAuth !== undefined - ? (probedAuth.authenticated || usesCredentials - ? (probedAuth.type ?? options.auth?.type) - : undefined) + ? probedAuth.authenticated || usesCredentials + ? (probedAuth.type ?? options.auth?.type) + : undefined : (draft.auth.type ?? options.auth?.type); const authLabel = probedAuth !== undefined - ? (probedAuth.authenticated || usesCredentials - ? (probedAuth.label ?? options.auth?.label) - : undefined) + ? probedAuth.authenticated || usesCredentials + ? (probedAuth.label ?? options.auth?.label) + : undefined : (draft.auth.label ?? options.auth?.label); const authEmail = probedAuth !== undefined - ? (probedAuth.authenticated ? probedAuth.email : undefined) + ? probedAuth.authenticated + ? probedAuth.email + : undefined : draft.auth.email; const message = errorMessage ?? @@ -363,8 +365,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func : {}), ...(initialized !== undefined ? { - supportsTextGeneration: - supportsTextGeneration && authStatus !== "unauthenticated", + supportsTextGeneration: supportsTextGeneration && authStatus !== "unauthenticated", } : {}), ...(message ? { message } : {}), diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 4c7d324740000..acab5a504aab7 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -370,17 +370,16 @@ export const AntigravityDriver: ProviderDriver< makeRuntime: makeDisposableRuntime, // A new sign-in may be another account; drop any in-flight quota read first. onAuthenticated: (started, runtime) => - fileSystem.remove(path.join(profileDirectory, ".signed_out")).pipe( - Effect.ignore, - Effect.andThen(Effect.suspend(() => provider.onSignedIn)), - Effect.andThen(publishCatalog(started, runtime)), - ), + fileSystem + .remove(path.join(profileDirectory, ".signed_out")) + .pipe( + Effect.ignore, + Effect.andThen(Effect.suspend(() => provider.onSignedIn)), + Effect.andThen(publishCatalog(started, runtime)), + ), onSignedOut: fileSystem .writeFileString(path.join(profileDirectory, ".signed_out"), "signed_out") - .pipe( - Effect.ignore, - Effect.andThen(Effect.suspend(() => provider.onSignedOut)), - ), + .pipe(Effect.ignore, Effect.andThen(Effect.suspend(() => provider.onSignedOut))), usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), }); @@ -445,10 +444,10 @@ export const AntigravityDriver: ProviderDriver< case "agent-platform": { const authenticated = Boolean( (auth.apiKey && auth.apiKey.trim().length > 0) || - (auth.gcpProject && - auth.gcpProject.trim().length > 0 && - auth.gcpLocation && - auth.gcpLocation.trim().length > 0), + (auth.gcpProject && + auth.gcpProject.trim().length > 0 && + auth.gcpLocation && + auth.gcpLocation.trim().length > 0), ); return { authenticated, @@ -497,8 +496,8 @@ export const AntigravityDriver: ProviderDriver< const parsedToken = Option.getOrUndefined(decodeAntigravityToken(tokenContent)); const hasValidToken = Boolean( parsedToken && - parsedToken.client_id.trim().length > 0 && - parsedToken.refresh_token.trim().length > 0, + parsedToken.client_id.trim().length > 0 && + parsedToken.refresh_token.trim().length > 0, ); if (!hasValidToken) { @@ -525,7 +524,9 @@ export const AntigravityDriver: ProviderDriver< const accountsContent = yield* fileSystem .readFileString(accountsPath) .pipe(Effect.orElseSucceed(() => "")); - const parsedAccounts = Option.getOrUndefined(decodeAntigravityAccounts(accountsContent)); + const parsedAccounts = Option.getOrUndefined( + decodeAntigravityAccounts(accountsContent), + ); if (parsedAccounts && parsedAccounts.active.trim().length > 0) { email = parsedAccounts.active.trim(); } diff --git a/apps/server/src/provider/Drivers/StandaloneDriver.test.ts b/apps/server/src/provider/Drivers/StandaloneDriver.test.ts new file mode 100644 index 0000000000000..3a4cfc0ef47f6 --- /dev/null +++ b/apps/server/src/provider/Drivers/StandaloneDriver.test.ts @@ -0,0 +1,95 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { OllamaSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as ProviderHostLive from "../ProviderHostLive.ts"; +import { OllamaDriver, normalizeOllamaEndpoint } from "./StandaloneDriver.ts"; + +const decodeOllamaSettings = Schema.decodeSync(OllamaSettings); + +const layerDeps = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-standalone-driver-config-", +}).pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), +); + +const layerTest = ProviderHostLive.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(layerDeps), +); + +describe("normalizeOllamaEndpoint", () => { + it("preserves Ollama's default port 11434 for bare hosts without a scheme or port", () => { + expect(normalizeOllamaEndpoint("127.0.0.1")).toBe("http://127.0.0.1:11434"); + expect(normalizeOllamaEndpoint("localhost")).toBe("http://localhost:11434"); + }); + + it("leaves explicitly supplied schemes and ports intact", () => { + expect(normalizeOllamaEndpoint("127.0.0.1:8080")).toBe("http://127.0.0.1:8080"); + expect(normalizeOllamaEndpoint("http://127.0.0.1")).toBe("http://127.0.0.1"); + expect(normalizeOllamaEndpoint("https://ollama.example.com")).toBe( + "https://ollama.example.com", + ); + }); +}); + +it.layer(layerTest)("OllamaDriver", (it) => { + it.effect("probes port 11434 when OLLAMA_HOST is a bare host without scheme or port", () => + Effect.scoped( + Effect.gen(function* () { + const requestedUrls: Array = []; + const httpClient = HttpClient.make((request) => { + requestedUrls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response('{"version":"0.6.0"}', { status: 200 }), + ), + ); + }); + const spawner = ChildProcessSpawner.make(() => + Effect.die(new Error("CLI fallback should not run when HTTP probe succeeds")), + ); + + const instance = yield* OllamaDriver.create({ + instanceId: ProviderInstanceId.make("ollama"), + displayName: "Ollama", + environment: [ + { + name: "OLLAMA_HOST", + value: "127.0.0.1", + sensitive: false, + }, + ], + enabled: true, + config: decodeOllamaSettings({ enabled: true }), + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshot = yield* instance.snapshot.getSnapshot; + expect(requestedUrls).toEqual(["http://127.0.0.1:11434/api/version"]); + expect(snapshot.installed).toBe(true); + expect(snapshot.auth.status).toBe("authenticated"); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Drivers/StandaloneDriver.ts b/apps/server/src/provider/Drivers/StandaloneDriver.ts index c9d8490a04897..41557f9a454ac 100644 --- a/apps/server/src/provider/Drivers/StandaloneDriver.ts +++ b/apps/server/src/provider/Drivers/StandaloneDriver.ts @@ -169,21 +169,18 @@ function createStandaloneDriver( return stampIdentity(disabledDraft); } - const probed = yield* options - .probe(effectiveConfig, processEnv, spawner, httpClient) - .pipe( - Effect.orElseSucceed(() => ({ - installed: false, - status: "warning" as const, - auth: { status: "unknown" as const, label: options.displayName }, - message: `Could not verify ${options.displayName} status.`, - version: null, - models: [] as ReadonlyArray, - })), - ); - - const status = - probed.status === "ready" ? ("warning" as const) : probed.status; + const probed = yield* options.probe(effectiveConfig, processEnv, spawner, httpClient).pipe( + Effect.orElseSucceed(() => ({ + installed: false, + status: "warning" as const, + auth: { status: "unknown" as const, label: options.displayName }, + message: `Could not verify ${options.displayName} status.`, + version: null, + models: [] as ReadonlyArray, + })), + ); + + const status = probed.status === "ready" ? ("warning" as const) : probed.status; const message = probed.status === "ready" ? `${options.displayName} is configured, but live orchestration sessions are not supported yet.` @@ -247,6 +244,27 @@ function createStandaloneDriver( }; } +export function normalizeOllamaEndpoint(rawHost: string): string { + const trimmed = rawHost.trim().replace(/\/+$/, ""); + if (trimmed.length === 0) { + return "http://localhost:11434"; + } + const hasExplicitScheme = /^https?:\/\//i.test(trimmed); + const withScheme = hasExplicitScheme ? trimmed : `http://${trimmed}`; + if (!hasExplicitScheme) { + try { + const parsed = new URL(withScheme); + if (parsed.port === "") { + parsed.port = "11434"; + return parsed.toString().replace(/\/+$/, ""); + } + } catch { + // Fall back to withScheme if URL parsing fails + } + } + return withScheme.replace(/\/+$/, ""); +} + const probeOllama = Effect.fn("probeOllama")(function* ( config: OllamaSettings, env: NodeJS.ProcessEnv, @@ -255,25 +273,32 @@ const probeOllama = Effect.fn("probeOllama")(function* ( ): Effect.fn.Return { const configuredEndpoint = config.apiEndpoint?.trim(); const rawHost = configuredEndpoint || env.OLLAMA_HOST?.trim() || "http://localhost:11434"; - const normalizedHost = rawHost.startsWith("http://") || rawHost.startsWith("https://") - ? rawHost - : `http://${rawHost}`; - const trimmedEndpoint = normalizedHost.replace(/\/+$/, ""); + const trimmedEndpoint = normalizeOllamaEndpoint(rawHost); const builtInModels: ReadonlyArray = [ { slug: "llama3.3", name: "Llama 3.3", isDefault: true, isCustom: false, capabilities: null }, - { slug: "deepseek-r1", name: "DeepSeek R1", isDefault: false, isCustom: false, capabilities: null }, - { slug: "qwen2.5-coder", name: "Qwen 2.5 Coder", isDefault: false, isCustom: false, capabilities: null }, + { + slug: "deepseek-r1", + name: "DeepSeek R1", + isDefault: false, + isCustom: false, + capabilities: null, + }, + { + slug: "qwen2.5-coder", + name: "Qwen 2.5 Coder", + isDefault: false, + isCustom: false, + capabilities: null, + }, ]; const models = providerModelsFromSettings(builtInModels, config.customModels, null); // Ping Ollama version endpoint - const reachable = yield* httpClient - .get(`${trimmedEndpoint}/api/version`) - .pipe( - Effect.timeout("2 seconds"), - Effect.map((res: { readonly status: number }) => res.status === 200), - Effect.orElseSucceed(() => false), - ); + const reachable = yield* httpClient.get(`${trimmedEndpoint}/api/version`).pipe( + Effect.timeout("2 seconds"), + Effect.map((res: { readonly status: number }) => res.status === 200), + Effect.orElseSucceed(() => false), + ); if (reachable) { return { @@ -285,20 +310,19 @@ const probeOllama = Effect.fn("probeOllama")(function* ( } // Try CLI check if HTTP ping failed - const cliCheck = yield* spawner - .exitCode(ChildProcess.make("ollama", ["--version"])) - .pipe( - Effect.map((code) => Number(code) === 0), - Effect.timeout("2 seconds"), - Effect.orElseSucceed(() => false), - ); + const cliCheck = yield* spawner.exitCode(ChildProcess.make("ollama", ["--version"])).pipe( + Effect.map((code) => Number(code) === 0), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => false), + ); if (cliCheck) { return { installed: true, status: "warning", auth: { status: "unknown", label: "Ollama" }, - message: "Ollama CLI is installed, but the server is not responding at the configured endpoint.", + message: + "Ollama CLI is installed, but the server is not responding at the configured endpoint.", models, }; } @@ -331,13 +355,11 @@ const probeZed = Effect.fn("probeZed")(function* ( ]; const models = providerModelsFromSettings(builtInModels, config.customModels, null); - const cliCheck = yield* spawner - .exitCode(ChildProcess.make(binary, ["--version"])) - .pipe( - Effect.map((code) => Number(code) === 0), - Effect.timeout("2 seconds"), - Effect.orElseSucceed(() => false), - ); + const cliCheck = yield* spawner.exitCode(ChildProcess.make(binary, ["--version"])).pipe( + Effect.map((code) => Number(code) === 0), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => false), + ); if (cliCheck) { return { @@ -357,13 +379,12 @@ const probeZed = Effect.fn("probeZed")(function* ( }; }); -export const ZedDriver: ProviderDriver = - createStandaloneDriver({ - driverKind: ProviderDriverKind.make("zed"), - displayName: "Zed", - configSchema: ZedSettings, - probe: probeZed, - }); +export const ZedDriver: ProviderDriver = createStandaloneDriver({ + driverKind: ProviderDriverKind.make("zed"), + displayName: "Zed", + configSchema: ZedSettings, + probe: probeZed, +}); export const CloudflareDriver: ProviderDriver = createStandaloneDriver({ diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 6a9e5823d9b4d..9bd5b3cd7a4e3 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -201,275 +201,322 @@ describe("buildGrokModelCapabilities", () => { it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, FetchHttpClient.layer))( "checkGrokProviderStatus", (it) => { - it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => - Effect.gen(function* () { - const secretStderr = "broken grok install: secret-token-value"; - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); - const grokPath = writeFakeCli({ - directory: dir, - name: "grok", - source: [ - `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, - "process.exit(2);", - "", - ].join("\n"), - }); - - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - ); - }), - ); + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const secretStderr = "broken grok install: secret-token-value"; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); + const grokPath = writeFakeCli({ + directory: dir, + name: "grok", + source: [ + `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, + "process.exit(2);", + "", + ].join("\n"), + }); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + ); + }), + ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); - expect(snapshot.message).not.toContain(secretStderr); - }), - ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); - it.effect.each([ - { - label: "answers after 6 s", - versionDelay: "6 seconds", - version: "1.0.52", - message: "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", - probesAfterVersion: true, - }, - { - label: "never answers", - versionDelay: "Infinity", - version: null, - message: "Grok CLI is installed but timed out while running `grok --version`.", - probesAfterVersion: false, - }, - ] as const)("gives `grok --version` 10 s when it $label", (row) => - Effect.gen(function* () { - // A fake CLI runs on wall time, so a stub answers `--version` on the test clock. - const spawned: Array = []; - const spawner = ChildProcessSpawner.make((command) => { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; - spawned.push(args.join(" ")); - if (!args.includes("--version")) { - return Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - description: "Only `grok --version` is stubbed.", - }), - ); - } - const handle = ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(900_000_201), - exitCode: Effect.delay(Effect.succeed(ChildProcessSpawner.ExitCode(0)), row.versionDelay), - isRunning: Effect.succeed(true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.fromEffect(Effect.delay(Effect.succeed("grok 1.0.52\n"), row.versionDelay)), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, + it.effect.each([ + { + label: "answers after 6 s", + versionDelay: "6 seconds", + version: "1.0.52", + message: + "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", + probesAfterVersion: true, + }, + { + label: "never answers", + versionDelay: "Infinity", + version: null, + message: "Grok CLI is installed but timed out while running `grok --version`.", + probesAfterVersion: false, + }, + ] as const)("gives `grok --version` 10 s when it $label", (row) => + Effect.gen(function* () { + // A fake CLI runs on wall time, so a stub answers `--version` on the test clock. + const spawned: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + spawned.push(args.join(" ")); + if (!args.includes("--version")) { + return Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + description: "Only `grok --version` is stubbed.", + }), + ); + } + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_201), + exitCode: Effect.delay( + Effect.succeed(ChildProcessSpawner.ExitCode(0)), + row.versionDelay, + ), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.fromEffect(Effect.delay(Effect.succeed("grok 1.0.52\n"), row.versionDelay)), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + return Effect.succeed(handle); + }); + const fiber = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true }), + {}, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* TestClock.adjust("10 seconds"); + const snapshot = yield* Fiber.join(fiber); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe(row.version); + expect(snapshot.message).toBe(row.message); + expect(spawned.length > 1).toBe(row.probesAfterVersion); + }), + ); + + // A stand-in for the Grok CLI: `--version` and `models` print canned text, + // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. + // A list of `models` outputs is served in call order, repeating the last one. + const writeFakeGrokCli = (input: { + readonly modelsOutput: string | ReadonlyArray; + readonly acp: boolean; + readonly updateLogPath?: string; + }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); + const mockAgentPath = NodePath.resolve(__dirname, "../../scripts/acp-mock-agent.ts"); + return writeFakeCli({ + directory: dir, + name: "grok", + source: [ + ...(input.updateLogPath + ? [ + 'import { appendFileSync as appendUpdateAttempt } from "node:fs";', + 'if (process.env.GROK_DISABLE_AUTOUPDATER !== "1") {', + ` appendUpdateAttempt(${JSON.stringify(input.updateLogPath)}, "update\\n");`, + "}", + ] + : []), + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("grok 1.0.13\\n");', + " process.exit(0);", + "}", + 'if (process.argv[2] === "models") {', + ' const fs = await import("node:fs");', + ` const callsPath = ${JSON.stringify(NodePath.join(dir, "models-calls"))};`, + ' const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").length : 0;', + ' fs.appendFileSync(callsPath, ".");', + ` const outputs = ${JSON.stringify([input.modelsOutput].flat())};`, + " process.stdout.write(outputs[Math.min(calls, outputs.length - 1)]);", + " process.exit(0);", + "}", + 'if (process.argv[2] !== "agent") process.exit(1);', + ...(input.acp + ? [execScriptSource({ scriptPath: mockAgentPath })] + : ["process.exit(3);"]), + "", + ].join("\n"), }); - return Effect.succeed(handle); }); - const fiber = yield* checkGrokProviderStatus(decodeGrokSettings({ enabled: true }), {}).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.forkChild, - ); - yield* TestClock.adjust("10 seconds"); - const snapshot = yield* Fiber.join(fiber); - expect(snapshot.installed).toBe(true); - expect(snapshot.version).toBe(row.version); - expect(snapshot.message).toBe(row.message); - expect(spawned.length > 1).toBe(row.probesAfterVersion); - }), - ); - // A stand-in for the Grok CLI: `--version` and `models` print canned text, - // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. - // A list of `models` outputs is served in call order, repeating the last one. - const writeFakeGrokCli = (input: { - readonly modelsOutput: string | ReadonlyArray; - readonly acp: boolean; - readonly updateLogPath?: string; - }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); - const mockAgentPath = NodePath.resolve(__dirname, "../../scripts/acp-mock-agent.ts"); - return writeFakeCli({ - directory: dir, - name: "grok", - source: [ - ...(input.updateLogPath - ? [ - 'import { appendFileSync as appendUpdateAttempt } from "node:fs";', - 'if (process.env.GROK_DISABLE_AUTOUPDATER !== "1") {', - ` appendUpdateAttempt(${JSON.stringify(input.updateLogPath)}, "update\\n");`, - "}", - ] - : []), - 'if (process.argv[2] === "--version") {', - ' process.stdout.write("grok 1.0.13\\n");', - " process.exit(0);", - "}", - 'if (process.argv[2] === "models") {', - ' const fs = await import("node:fs");', - ` const callsPath = ${JSON.stringify(NodePath.join(dir, "models-calls"))};`, - ' const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").length : 0;', - ' fs.appendFileSync(callsPath, ".");', - ` const outputs = ${JSON.stringify([input.modelsOutput].flat())};`, - " process.stdout.write(outputs[Math.min(calls, outputs.length - 1)]);", - " process.exit(0);", - "}", - 'if (process.argv[2] !== "agent") process.exit(1);', - ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), - "", - ].join("\n"), - }); - }); + it.effect("reports ready with ACP-discovered models without running startup updates", () => + Effect.gen(function* () { + const environment = { ...process.env, XAI_API_KEY: "", GROK_DISABLE_AUTOUPDATER: "0" }; + const { snapshot, updateRan } = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-updater-" }); + const updateLogPath = NodePath.join(dir, "updates.log"); + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: true, + updateLogPath, + }); + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + environment, + ); + return { snapshot, updateRan: yield* fs.exists(updateLogPath) }; + }), + ); - it.effect("reports ready with ACP-discovered models without running startup updates", () => - Effect.gen(function* () { - const environment = { ...process.env, XAI_API_KEY: "", GROK_DISABLE_AUTOUPDATER: "0" }; - const { snapshot, updateRan } = yield* Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-updater-" }); - const updateLogPath = NodePath.join(dir, "updates.log"); - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_IN_MODELS_OUTPUT, - acp: true, - updateLogPath, - }); - const snapshot = yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - environment, - ); - return { snapshot, updateRan: yield* fs.exists(updateLogPath) }; - }), - ); + expect(updateRan).toBe(false); + expect(environment.GROK_DISABLE_AUTOUPDATER).toBe("0"); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("1.0.13"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "cached_token", + label: "Grok account", + }); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + expect(snapshot.models[0]?.isDefault).toBe(true); + expect( + snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], + ).toEqual(["reasoningEffort"]); + }), + ); + + // An expired access token prints the logged-out banner, then the CLI refreshes it in the + // same run, so the next `grok models` reports the login. + it.effect("reports authenticated when `grok models` logs in after refreshing a token", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: [LOGGED_OUT_MODELS_OUTPUT, LOGGED_IN_MODELS_OUTPUT], + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); - expect(updateRan).toBe(false); - expect(environment.GROK_DISABLE_AUTOUPDATER).toBe("0"); - expect(snapshot.status).toBe("ready"); - expect(snapshot.version).toBe("1.0.13"); - expect(snapshot.auth).toEqual({ - status: "authenticated", - type: "cached_token", - label: "Grok account", - }); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); - expect(snapshot.models[0]?.isDefault).toBe(true); - expect( - snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], - ).toEqual(["reasoningEffort"]); - }), - ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + }), + ); - // An expired access token prints the logged-out banner, then the CLI refreshes it in the - // same run, so the next `grok models` reports the login. - it.effect("reports authenticated when `grok models` logs in after refreshing a token", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: [LOGGED_OUT_MODELS_OUTPUT, LOGGED_IN_MODELS_OUTPUT], - acp: true, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "" }, - ); - }), - ); + it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); - expect(snapshot.status).toBe("ready"); - expect(snapshot.auth.status).toBe("authenticated"); - }), - ); + expect(snapshot.status).toBe("warning"); + expect(snapshot.installed).toBe(true); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(snapshot.message).toContain("ACP initialize failed"); + expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]); + }), + ); + + it.effect("warns instead of reporting ready when no models are discovered", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: "You are logged in with grok.com.\n", + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "", T3_ACP_OMIT_INITIALIZE_MODEL_STATE: "1" }, + ); + }), + ); - it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_IN_MODELS_OUTPUT, - acp: false, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "" }, - ); - }), - ); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("did not list any models"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); + }), + ); - expect(snapshot.status).toBe("warning"); - expect(snapshot.installed).toBe(true); - expect(snapshot.auth.status).toBe("authenticated"); - expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ - ["grok-4.6", true], - ["grok-4.5", false], - ]); - expect(snapshot.message).toContain("ACP initialize failed"); - expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]); - }), - ); + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "xai-test-key" }, + ); + }), + ); - it.effect("warns instead of reporting ready when no models are discovered", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "api_key", + label: "xAI API key", + }); + expect(snapshot.status).toBe("warning"); + }), + ); + + it.effect( + "retains cached authentication with a warning when the Grok CLI is not installed", + () => Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: "You are logged in with grok.com.\n", - acp: true, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "", T3_ACP_OMIT_INITIALIZE_MODEL_STATE: "1" }, + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-missing-cli-" }); + const missingBinary = NodePath.join(dir, "missing-grok-binary"); + yield* fs.writeFileString( + NodePath.join(dir, "auth.json"), + JSON.stringify({ + "https://accounts.x.ai/sign-in": { + key: "cached-session-token", + email: "user@x.ai", + }, + }), + ); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: missingBinary }), + { ...process.env, XAI_API_KEY: "", GROK_HOME: dir }, + ); + }), ); - }), - ); - expect(snapshot.status).toBe("warning"); - expect(snapshot.message).toContain("did not list any models"); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); - }), - ); - - it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_OUT_MODELS_OUTPUT, - acp: false, + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("warning"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "cached_token", + label: "Grok account", + email: "user@x.ai", }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "xai-test-key" }, - ); + expect(snapshot.message).toContain("not installed or not on PATH"); }), - ); - - expect(snapshot.auth).toEqual({ - status: "authenticated", - type: "api_key", - label: "xAI API key", - }); - expect(snapshot.status).toBe("warning"); - }), - ); -}); + ); + }, +); describe("Grok usage limits", () => { const checkedAt = "2026-09-16T00:00:00.000Z"; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index a5a2d4ff30265..d8f5a858587d9 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -437,40 +437,45 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( return profile; }); -export const syncAntigravityGlobalCredentials = Effect.fn( - "syncAntigravityGlobalCredentials", -)(function* (input: { - readonly profileDirectory: string; - readonly userHome: string; -}) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const signedOutMarker = path.join(input.profileDirectory, ".signed_out"); - const isSignedOut = yield* fs.exists(signedOutMarker).pipe(Effect.orElseSucceed(() => false)); - if (isSignedOut) return; - - const profileTokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json"); - const userTokenPath = path.join(input.userHome, ".gemini", "antigravity-acp", "acp_token.json"); - const profileAccountsPath = path.join(input.profileDirectory, "google_accounts.json"); - const userAccountsPath = path.join(input.userHome, ".gemini", "google_accounts.json"); - - const profileHasToken = yield* fs.exists(profileTokenPath).pipe(Effect.orElseSucceed(() => false)); - if (!profileHasToken) { - const userHasToken = yield* fs.exists(userTokenPath).pipe(Effect.orElseSucceed(() => false)); - if (userHasToken) { - yield* fs.makeDirectory(path.dirname(profileTokenPath), { recursive: true }).pipe(Effect.ignore); - yield* fs.copyFile(userTokenPath, profileTokenPath).pipe(Effect.ignore); +export const syncAntigravityGlobalCredentials = Effect.fn("syncAntigravityGlobalCredentials")( + function* (input: { readonly profileDirectory: string; readonly userHome: string }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const signedOutMarker = path.join(input.profileDirectory, ".signed_out"); + const isSignedOut = yield* fs.exists(signedOutMarker).pipe(Effect.orElseSucceed(() => false)); + if (isSignedOut) return; + + const profileTokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json"); + const userTokenPath = path.join(input.userHome, ".gemini", "antigravity-acp", "acp_token.json"); + const profileAccountsPath = path.join(input.profileDirectory, "google_accounts.json"); + const userAccountsPath = path.join(input.userHome, ".gemini", "google_accounts.json"); + + const profileHasToken = yield* fs + .exists(profileTokenPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!profileHasToken) { + const userHasToken = yield* fs.exists(userTokenPath).pipe(Effect.orElseSucceed(() => false)); + if (userHasToken) { + yield* fs + .makeDirectory(path.dirname(profileTokenPath), { recursive: true }) + .pipe(Effect.ignore); + yield* fs.copyFile(userTokenPath, profileTokenPath).pipe(Effect.ignore); + } } - } - const profileHasAccounts = yield* fs.exists(profileAccountsPath).pipe(Effect.orElseSucceed(() => false)); - if (!profileHasAccounts) { - const userHasAccounts = yield* fs.exists(userAccountsPath).pipe(Effect.orElseSucceed(() => false)); - if (userHasAccounts) { - yield* fs.copyFile(userAccountsPath, profileAccountsPath).pipe(Effect.ignore); + const profileHasAccounts = yield* fs + .exists(profileAccountsPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!profileHasAccounts) { + const userHasAccounts = yield* fs + .exists(userAccountsPath) + .pipe(Effect.orElseSucceed(() => false)); + if (userHasAccounts) { + yield* fs.copyFile(userAccountsPath, profileAccountsPath).pipe(Effect.ignore); + } } - } -}); + }, +); export function buildAntigravityAcpSpawnInput(input: { readonly installation: { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 4ff7d2c9618f2..bebdb6eb5abc1 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -331,7 +331,7 @@ describe("provider enabled defaults", () => { expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); expect( resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), - ).toBe(true); + ).toBe(false); expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( false, diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index fb5892ae7d373..e285639ddf8a5 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -303,10 +303,7 @@ const readGrokCachedModels = Effect.fn("GrokProviderStatus.readGrokCachedModels" const path = yield* Path.Path; const home = environment.GROK_HOME?.trim() || - path.join( - environment.HOME || environment.USERPROFILE || "", - ".grok", - ); + path.join(environment.HOME || environment.USERPROFILE || "", ".grok"); const cachePath = path.join(home, "models_cache.json"); const content = yield* fs.readFileString(cachePath).pipe( Effect.catchTags({ @@ -349,10 +346,7 @@ const readGrokCachedVersion = Effect.fn("GrokProviderStatus.readGrokCachedVersio const path = yield* Path.Path; const home = environment.GROK_HOME?.trim() || - path.join( - environment.HOME || environment.USERPROFILE || "", - ".grok", - ); + path.join(environment.HOME || environment.USERPROFILE || "", ".grok"); const cachePath = path.join(home, "models_cache.json"); const content = yield* fs.readFileString(cachePath).pipe( Effect.catchTags({ diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index bcd8eaa716fe9..fc6233394f36e 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -932,6 +932,46 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }); }), ); + + it.effect("resolves relative PATH candidates against the supplied child cwd", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dirA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-spawn-cwd-a-" }); + const dirB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-spawn-cwd-b-" }); + const binDirA = path.join(dirA, "node_modules", ".bin"); + const binDirB = path.join(dirB, "node_modules", ".bin"); + yield* fs.makeDirectory(binDirA, { recursive: true }); + yield* fs.makeDirectory(binDirB, { recursive: true }); + const exeA = path.join(binDirA, "my-tool.exe"); + const exeB = path.join(binDirB, "my-tool.exe"); + yield* fs.writeFileString(exeA, ""); + yield* fs.writeFileString(exeB, ""); + + const cache = new Map(); + const env = { PATH: "node_modules\\.bin", PATHEXT: ".EXE" }; + + const resolvedA = yield* resolveSpawnCommand("my-tool.exe", ["--version"], { + env, + cwd: dirA, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(CommandResolutionCache, cache), + ); + const resolvedB = yield* resolveSpawnCommand("my-tool.exe", ["--version"], { + env, + cwd: dirB, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(CommandResolutionCache, cache), + ); + + expect(resolvedA.command).toBe(exeA); + expect(resolvedB.command).toBe(exeB); + }), + ), + ); }); effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 171fe877576d4..562fd52051894 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -74,6 +74,7 @@ export type SpawnExecutableResolver = ( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, + cwd?: string, ) => string | undefined; function resolveWellKnownToolCandidate( @@ -96,7 +97,13 @@ function resolveWellKnownToolCandidate( const candidates: string[] = []; if (localAppData) { candidates.push( - path.join(localAppData, "Programs", "@opencode-aidesktop", "resources", "opencode-cli.exe"), + path.join( + localAppData, + "Programs", + "@opencode-aidesktop", + "resources", + "opencode-cli.exe", + ), path.join(localAppData, "OpenCode", "opencode-cli.exe"), path.join(localAppData, "OpenCode", "opencode.exe"), path.join(localAppData, "Programs", "OpenCode", "resources", "opencode-cli.exe"), @@ -126,7 +133,14 @@ function resolveWellKnownToolCandidate( "/Applications/OpenCode AI.app/Contents/Resources/opencode-cli", ...(home ? [ - path.join(home, "Applications", "OpenCode.app", "Contents", "Resources", "opencode-cli"), + path.join( + home, + "Applications", + "OpenCode.app", + "Contents", + "Resources", + "opencode-cli", + ), path.join(home, ".local", "bin", "opencode"), ] : []), @@ -157,7 +171,16 @@ function resolveWellKnownToolCandidate( const candidates: string[] = []; if (localAppData) { candidates.push( - path.join(localAppData, "Programs", "Grok Bot", "resources", "app.asar.unpacked", "dist", "native", "grok.exe"), + path.join( + localAppData, + "Programs", + "Grok Bot", + "resources", + "app.asar.unpacked", + "dist", + "native", + "grok.exe", + ), path.join(localAppData, "Programs", "grok", "grok.exe"), path.join(localAppData, "npm", "grok.cmd"), ); @@ -180,10 +203,7 @@ function resolveWellKnownToolCandidate( "/Applications/Grok.app/Contents/Resources/grok", "/Applications/Grok Bot.app/Contents/Resources/grok", ...(home - ? [ - path.join(home, ".grok", "bin", "grok"), - path.join(home, ".local", "bin", "grok"), - ] + ? [path.join(home, ".grok", "bin", "grok"), path.join(home, ".local", "bin", "grok")] : []), "/opt/homebrew/bin/grok", "/usr/local/bin/grok", @@ -195,10 +215,7 @@ function resolveWellKnownToolCandidate( const home = env.HOME?.trim(); const candidates = [ ...(home - ? [ - path.join(home, ".grok", "bin", "grok"), - path.join(home, ".local", "bin", "grok"), - ] + ? [path.join(home, ".grok", "bin", "grok"), path.join(home, ".local", "bin", "grok")] : []), "/usr/local/bin/grok", ]; @@ -215,6 +232,7 @@ function resolveSpawnExecutableWithNode( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, + cwd?: string, ): string | undefined { const path = platform === "win32" ? NodePath.win32 : NodePath.posix; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; @@ -224,6 +242,8 @@ function resolveSpawnExecutableWithNode( windowsPathExtensions, path.extname, ); + const resolveAgainstCwd = (candidate: string) => + cwd && !path.isAbsolute(candidate) ? path.resolve(cwd, candidate) : candidate; const isExecutable = (candidate: string) => { try { if (!NodeFS.statSync(candidate).isFile()) return false; @@ -237,14 +257,18 @@ function resolveSpawnExecutableWithNode( }; if (command.includes("/") || command.includes("\\")) { - return candidates.find(isExecutable); + for (const candidate of candidates) { + const resolvedCandidate = resolveAgainstCwd(candidate); + if (isExecutable(resolvedCandidate)) return resolvedCandidate; + } + return undefined; } for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); if (normalizedPathEntry.length === 0) continue; for (const candidate of candidates) { - const candidatePath = path.join(normalizedPathEntry, candidate); + const candidatePath = resolveAgainstCwd(path.join(normalizedPathEntry, candidate)); if (isExecutable(candidatePath)) return candidatePath; } } @@ -958,6 +982,7 @@ const resolveMacosGit = Effect.fnUntraced(function* ( }); export interface SpawnCommandOptions extends CommandAvailabilityOptions { + readonly cwd?: string; /** Bypass npm cmd-shims; see npmShimExecutable for process-lifetime semantics. */ readonly bypassNpmShim?: boolean; } @@ -1002,6 +1027,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( env.LOCALAPPDATA ?? "", env.APPDATA ?? "", env.MSYSTEM ? "msystem" : "", + options.cwd ?? "", command, ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; @@ -1010,7 +1036,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( if (cached !== undefined && cached.expiresAtNanos > nowNanos) { resolvedExecutable = cached.resolvedPath; } else { - const found = resolveExecutable(command, platform, env); + const found = resolveExecutable(command, platform, env, options.cwd); resolvedExecutable = found === undefined ? null From 2a115a01cda9c6e6dc74cee1c7a92df400f2ed24 Mon Sep 17 00:00:00 2001 From: mr-robot77 Date: Wed, 4 Nov 2026 13:38:28 +0100 Subject: [PATCH 6/7] fix: preserve Antigravity session auth without token file and non-win32 spawn commands --- .../src/provider/Drivers/AntigravityDriver.ts | 12 +++--- packages/shared/src/shell.test.ts | 40 +++++++++++-------- packages/shared/src/shell.ts | 11 +++++ 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index acab5a504aab7..2e4ea503966d8 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -483,11 +483,13 @@ export const AntigravityDriver: ProviderDriver< } if (!tokenPath) { - return { - authenticated: false, - type: auth.authMethod, - label: antigravityAuthLabel(auth.authMethod), - }; + return isSignedOut + ? { + authenticated: false, + type: auth.authMethod, + label: antigravityAuthLabel(auth.authMethod), + } + : undefined; } const tokenContent = yield* fileSystem diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index fc6233394f36e..af2e4bc4e967a 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -886,7 +886,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { expect(homebrew.host.xcrunCalls).toBe(0); const linux = setup({ platform: "linux", gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT }); - expect(yield* linux.resolve).toBe("/usr/bin/git"); + expect(yield* linux.resolve).toBe("git"); }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); @@ -938,34 +938,40 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; const dirA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-spawn-cwd-a-" }); const dirB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-spawn-cwd-b-" }); const binDirA = path.join(dirA, "node_modules", ".bin"); const binDirB = path.join(dirB, "node_modules", ".bin"); yield* fs.makeDirectory(binDirA, { recursive: true }); yield* fs.makeDirectory(binDirB, { recursive: true }); - const exeA = path.join(binDirA, "my-tool.exe"); - const exeB = path.join(binDirB, "my-tool.exe"); - yield* fs.writeFileString(exeA, ""); - yield* fs.writeFileString(exeB, ""); + const binaryName = platform === "win32" ? "opencode.exe" : "opencode"; + const exeA = path.join(binDirA, binaryName); + const exeB = path.join(binDirB, binaryName); + yield* fs.writeFileString(exeA, "#!/bin/sh\n"); + yield* fs.writeFileString(exeB, "#!/bin/sh\n"); + if (platform !== "win32") { + yield* fs.chmod(exeA, 0o755); + yield* fs.chmod(exeB, 0o755); + } const cache = new Map(); - const env = { PATH: "node_modules\\.bin", PATHEXT: ".EXE" }; - - const resolvedA = yield* resolveSpawnCommand("my-tool.exe", ["--version"], { + const env = { + PATH: path.join("node_modules", ".bin"), + PATHEXT: ".EXE", + HOME: dirA, + LOCALAPPDATA: dirA, + APPDATA: dirA, + }; + + const resolvedA = yield* resolveSpawnCommand(binaryName, ["--version"], { env, cwd: dirA, - }).pipe( - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(CommandResolutionCache, cache), - ); - const resolvedB = yield* resolveSpawnCommand("my-tool.exe", ["--version"], { + }).pipe(Effect.provideService(CommandResolutionCache, cache)); + const resolvedB = yield* resolveSpawnCommand(binaryName, ["--version"], { env, cwd: dirB, - }).pipe( - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(CommandResolutionCache, cache), - ); + }).pipe(Effect.provideService(CommandResolutionCache, cache)); expect(resolvedA.command).toBe(exeA); expect(resolvedB.command).toBe(exeB); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 562fd52051894..6b470ec521566 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -994,6 +994,17 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( ): Effect.fn.Return { const platform = yield* HostProcess.Platform; const isMacosGit = platform === "darwin" && command === "git"; + const normalizedBaseName = (platform === "win32" ? NodePath.win32 : NodePath.posix) + .basename(command) + .toLowerCase() + .replace(/\.(exe|cmd)$/u, ""); + const isWellKnownTool = + normalizedBaseName === "opencode" || + normalizedBaseName === "opencode-cli" || + normalizedBaseName === "grok"; + if (platform !== "win32" && !isMacosGit && !isWellKnownTool) { + return { command, args: [...args], shell: false }; + } const hostEnvironment = yield* HostProcess.Environment; const env = From de50274ad6788b6e32c2bc88988e6006910a2281 Mon Sep 17 00:00:00 2001 From: mr-robot77 Date: Wed, 4 Nov 2026 13:44:35 +0100 Subject: [PATCH 7/7] fix: allow AntigravityProviderOptions.probeAuth to return undefined --- apps/server/src/provider/AntigravityProvider.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index d8d0a546c3ab8..61c2fd8066c11 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -165,12 +165,15 @@ interface AntigravityProviderOptions { readonly clearUsage?: Effect.Effect; readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; readonly auth?: { readonly type: string; readonly label: string }; - readonly probeAuth?: Effect.Effect<{ - readonly authenticated: boolean; - readonly type?: string; - readonly label?: string; - readonly email?: string; - }>; + readonly probeAuth?: Effect.Effect< + | { + readonly authenticated: boolean; + readonly type?: string; + readonly label?: string; + readonly email?: string; + } + | undefined + >; } export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* (