feat(grokbuild): add Grok Official provider with official-state import

Add a "Grok Official" preset and seed (grokbuild-official) whose empty
config represents the official login state: no custom [model.*] tables
are written, so Grok CLI falls back to its built-in xAI OAuth login and
cc-switch never touches those credentials.

Backend:
- Seed entry in OFFICIAL_SEEDS plus ensure_grokbuild_official_provider
  command for on-demand repair (the one-shot master seeding flag is
  already set for existing databases).
- Split validation into syntax-only (empty allowed) for live reads,
  writes and official snapshots, keeping the full custom-model shape
  check for non-official provider writes and imports. Backup/restore
  can now round-trip an official-state live file.
- Manual import (command layer only) recognizes an official-state live
  config and imports it as the official entry set as current, matching
  the Codex official-login import outcome. Startup auto-import keeps
  rejecting official-state live so a deleted official entry is never
  resurrected on launch: startup import only captures real user data
  as "default" and never manufactures official entries.
- Manual import also ensures the official entry before importing
  (claude-desktop precedent) and after a successful custom import, so
  first-time users end up with default + official like other apps.
- Proxy takeover guards skip or reject official-state live configs in
  all three takeover paths, consistent with the official-provider
  takeover ban.

Frontend:
- Grok Official preset entry in the GrokBuild form: official category
  hides connection fields and passes the raw config through untouched.
- Filter managed-OAuth presets out of the GrokBuild preset list; they
  were never wired for this app and produced keyless broken configs.

Tests cover seed presence, official round-trip, ensure-after-deletion,
and the four import scenarios including startup non-resurrection.
This commit is contained in:
Jason
2026-07-21 15:31:27 +08:00
parent a5aa1fd82b
commit f733def452
16 changed files with 700 additions and 144 deletions
@@ -20,6 +20,7 @@ import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig";
import { GROKBUILD_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
@@ -33,6 +34,7 @@ interface AddProviderDialogProps {
suggestedDefaults?: OpenClawSuggestedDefaults;
ensureClaudeDesktopOfficialSeed?: boolean;
ensureCodexOfficialSeed?: boolean;
ensureGrokBuildOfficialSeed?: boolean;
},
) => Promise<void> | void;
}
@@ -120,6 +122,7 @@ export function AddProviderDialog({
suggestedDefaults?: OpenClawSuggestedDefaults;
ensureClaudeDesktopOfficialSeed?: boolean;
ensureCodexOfficialSeed?: boolean;
ensureGrokBuildOfficialSeed?: boolean;
} = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
@@ -149,6 +152,12 @@ export function AddProviderDialog({
preset?.category === "official";
}
if (appId === "grokbuild" && values.presetId) {
providerData.ensureGrokBuildOfficialSeed =
values.presetCategory === "official" &&
values.presetId === GROKBUILD_OFFICIAL_PROVIDER_ID;
}
// OpenCode/OpenClaw: pass providerKey for ID generation
if (
(appId === "opencode" || appId === "openclaw" || appId === "hermes") &&
@@ -49,15 +49,41 @@ import {
validateGrokBuildConfig,
} from "@/utils/grokBuildConfig";
import { resolveProviderIcon } from "@/utils/providerIcon";
import { GROKBUILD_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities";
type GrokBuildProviderFormProps = Omit<ProviderFormProps, "appId">;
// 官方条目与后端 seedproviders_seed.rs 的 "Grok Official")对应:
// 空 config = 不写自定义模型表,Grok CLI 回落到自带的 xAI OAuth 登录。
// 预设 id 复用固定 provider idAddProviderDialog 据此走 ensure seed 流程。
const grokOfficialPreset: CodexProviderPreset = {
name: "Grok Official",
websiteUrl: "https://x.ai/grok",
isOfficial: true,
category: "official",
auth: {},
config: "",
icon: "grok",
iconColor: "currentColor",
};
const grokPresetEntries: Array<{
id: string;
preset: CodexProviderPreset;
}> = codexProviderPresets
.map((preset, index) => ({ id: `grokbuild-${index}`, preset }))
.filter(({ preset }) => preset.category !== "official" && !preset.isOfficial);
}> = [
{ id: GROKBUILD_OFFICIAL_PROVIDER_ID, preset: grokOfficialPreset },
...codexProviderPresets
.map((preset, index) => ({ id: `grokbuild-${index}`, preset }))
.filter(
// 托管 OAuth 预设(如 "xAI (Grok) OAuth")只在 Codex 表单接线;
// Grok CLI 原生支持订阅登录,这里选中只会产出无 key 的坏配置,故排除。
({ preset }) =>
preset.category !== "official" &&
!preset.isOfficial &&
!preset.providerType &&
!preset.requiresOAuth,
),
];
export const grokApiBackendFromApiFormat = (format: CodexApiFormat): string => {
if (format === "openai_chat") return "chat_completions";
@@ -233,6 +259,20 @@ export function GrokBuildProviderForm({
return;
}
if (presetId === GROKBUILD_OFFICIAL_PROVIDER_ID) {
// 官方登录:无 API Key / 地址 / 模型表可填,提交走 ensure seed 流程
form.setValue("name", grokOfficialPreset.name);
form.setValue("websiteUrl", grokOfficialPreset.websiteUrl);
form.setValue("icon", grokOfficialPreset.icon ?? "");
form.setValue("iconColor", grokOfficialPreset.iconColor ?? "");
setCategory("official");
setIsPartner(false);
setPartnerPromotionKey(undefined);
setPresetEndpoints([]);
setRawConfig("");
return;
}
const entry = grokPresetEntries.find(
(candidate) => candidate.id === presetId,
);
@@ -292,6 +332,24 @@ export function GrokBuildProviderForm({
const handleSubmit = async (values: ProviderFormData) => {
const name = values.name.trim();
// 官方条目:config 快照原样透传(新增时为空),不做自定义模型字段校验,
// 也不重建 config —— 新增走 ensure seed,编辑只允许改名称/图标等元信息。
if (category === "official") {
await onSubmit({
...values,
name,
websiteUrl: values.websiteUrl?.trim() ?? "",
notes: values.notes?.trim() ?? "",
settingsConfig: JSON.stringify({ config: rawConfig }),
presetId: selectedPresetId ?? undefined,
presetCategory: "official",
isPartner: false,
meta: initialData?.meta,
});
return;
}
const parsedContextWindow = Number.parseInt(contextWindow, 10);
const envKey = parseGrokBuildConfig(rawConfig).envKey?.trim();
if (
@@ -411,141 +469,145 @@ export function GrokBuildProviderForm({
<BasicFormFields form={form} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormItem>
<FormLabel htmlFor="grokbuild-profile">
{t("grokBuild.profile", { defaultValue: "客户端模型档位" })}
</FormLabel>
<Input
id="grokbuild-profile"
value={profile}
onChange={(event) => {
const value = event.target.value;
setProfile(value);
syncStructuredConfig({ model: value });
{category !== "official" && (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormItem>
<FormLabel htmlFor="grokbuild-profile">
{t("grokBuild.profile", { defaultValue: "客户端模型档位" })}
</FormLabel>
<Input
id="grokbuild-profile"
value={profile}
onChange={(event) => {
const value = event.target.value;
setProfile(value);
syncStructuredConfig({ model: value });
}}
placeholder="grok-4.5"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-api-backend">
{t("grokBuild.apiBackend", { defaultValue: "API Backend" })}
</FormLabel>
<Input
id="grokbuild-api-backend"
value={apiBackend}
onChange={(event) => {
const value = event.target.value;
setApiBackend(value);
syncStructuredConfig({ apiBackend: value });
}}
placeholder="responses"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-context-window">
{t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })}
</FormLabel>
<Input
id="grokbuild-context-window"
type="number"
min={1}
step={1}
value={contextWindow}
onChange={(event) => {
const value = event.target.value;
setContextWindow(value);
syncStructuredConfig({
contextWindow: Number.parseInt(value, 10),
});
}}
/>
</FormItem>
</div>
<CodexFormFields
appId="grokbuild"
providerId={providerId}
codexApiKey={apiKey}
onApiKeyChange={(value) => {
setApiKey(value);
syncStructuredConfig({ apiKey: value });
}}
placeholder="grok-4.5"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-api-backend">
{t("grokBuild.apiBackend", { defaultValue: "API Backend" })}
</FormLabel>
<Input
id="grokbuild-api-backend"
value={apiBackend}
onChange={(event) => {
const value = event.target.value;
setApiBackend(value);
syncStructuredConfig({ apiBackend: value });
category={category}
shouldShowApiKeyLink={Boolean(websiteUrl)}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
shouldShowSpeedTest
codexBaseUrl={baseUrl}
onBaseUrlChange={(value) => {
setBaseUrl(value);
syncStructuredConfig({ baseUrl: value });
}}
placeholder="responses"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-context-window">
{t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })}
</FormLabel>
<Input
id="grokbuild-context-window"
type="number"
min={1}
step={1}
value={contextWindow}
onChange={(event) => {
const value = event.target.value;
setContextWindow(value);
syncStructuredConfig({
contextWindow: Number.parseInt(value, 10),
});
isFullUrl={isFullUrl}
onFullUrlChange={setIsFullUrl}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
codexModel={upstreamModel}
onModelChange={(value) => {
setUpstreamModel(value);
syncStructuredConfig({ upstreamModel: value });
}}
apiFormat={apiFormat}
onApiFormatChange={(value) => {
const backend = grokApiBackendFromApiFormat(value);
setApiFormat(value);
setApiBackend(backend);
syncStructuredConfig({ apiBackend: backend });
}}
anthropicAuthField={anthropicAuthField}
onAnthropicAuthFieldChange={setAnthropicAuthField}
impersonateClaudeCode={impersonateClaudeCode}
onImpersonateClaudeCodeChange={setImpersonateClaudeCode}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={setMaxOutputTokens}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
promptCacheRouting={promptCacheRouting}
onPromptCacheRoutingChange={setPromptCacheRouting}
speedTestEndpoints={speedTestEndpoints}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
localProxyHeadersOverride={headersOverride}
onLocalProxyHeadersOverrideChange={setHeadersOverride}
localProxyBodyOverride={bodyOverride}
onLocalProxyBodyOverrideChange={setBodyOverride}
/>
</FormItem>
</div>
<CodexFormFields
appId="grokbuild"
providerId={providerId}
codexApiKey={apiKey}
onApiKeyChange={(value) => {
setApiKey(value);
syncStructuredConfig({ apiKey: value });
}}
category={category}
shouldShowApiKeyLink={Boolean(websiteUrl)}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
shouldShowSpeedTest
codexBaseUrl={baseUrl}
onBaseUrlChange={(value) => {
setBaseUrl(value);
syncStructuredConfig({ baseUrl: value });
}}
isFullUrl={isFullUrl}
onFullUrlChange={setIsFullUrl}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
codexModel={upstreamModel}
onModelChange={(value) => {
setUpstreamModel(value);
syncStructuredConfig({ upstreamModel: value });
}}
apiFormat={apiFormat}
onApiFormatChange={(value) => {
const backend = grokApiBackendFromApiFormat(value);
setApiFormat(value);
setApiBackend(backend);
syncStructuredConfig({ apiBackend: backend });
}}
anthropicAuthField={anthropicAuthField}
onAnthropicAuthFieldChange={setAnthropicAuthField}
impersonateClaudeCode={impersonateClaudeCode}
onImpersonateClaudeCodeChange={setImpersonateClaudeCode}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={setMaxOutputTokens}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
promptCacheRouting={promptCacheRouting}
onPromptCacheRoutingChange={setPromptCacheRouting}
speedTestEndpoints={speedTestEndpoints}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
localProxyHeadersOverride={headersOverride}
onLocalProxyHeadersOverrideChange={setHeadersOverride}
localProxyBodyOverride={bodyOverride}
onLocalProxyBodyOverrideChange={setBodyOverride}
/>
<div className="space-y-2">
<FormLabel htmlFor="grokbuild-config-toml">
{t("grokBuild.rawConfig", { defaultValue: "config.toml" })}
</FormLabel>
<JsonEditor
value={rawConfig}
onChange={handleRawConfigChange}
placeholder=""
darkMode={isDarkMode}
rows={12}
showValidation={false}
language="javascript"
/>
{rawConfigError && (
<p className="text-xs text-destructive">
{t("grokBuild.invalidToml", {
error: rawConfigError,
defaultValue: `Invalid config.toml: ${rawConfigError}`,
})}
</p>
)}
</div>
<div className="space-y-2">
<FormLabel htmlFor="grokbuild-config-toml">
{t("grokBuild.rawConfig", { defaultValue: "config.toml" })}
</FormLabel>
<JsonEditor
value={rawConfig}
onChange={handleRawConfigChange}
placeholder=""
darkMode={isDarkMode}
rows={12}
showValidation={false}
language="javascript"
/>
{rawConfigError && (
<p className="text-xs text-destructive">
{t("grokBuild.invalidToml", {
error: rawConfigError,
defaultValue: `Invalid config.toml: ${rawConfigError}`,
})}
</p>
)}
</div>
</>
)}
<FormField
control={form.control}
+1
View File
@@ -84,6 +84,7 @@ export function useProviderActions(
addToLive?: boolean;
ensureClaudeDesktopOfficialSeed?: boolean;
ensureCodexOfficialSeed?: boolean;
ensureGrokBuildOfficialSeed?: boolean;
},
) => {
const enhanced = injectCodingPlanUsageScript(activeApp, provider);
+4
View File
@@ -107,6 +107,10 @@ export const providersApi = {
return await invoke("ensure_codex_official_provider");
},
async ensureGrokBuildOfficialProvider(): Promise<boolean> {
return await invoke("ensure_grokbuild_official_provider");
},
async getClaudeDesktopStatus(): Promise<ClaudeDesktopStatus> {
return await invoke("get_claude_desktop_status");
},
+16 -1
View File
@@ -10,7 +10,10 @@ import { generateUUID } from "@/utils/uuid";
import { openclawKeys } from "@/hooks/useOpenClaw";
import { invalidateHermesProviderCaches } from "@/hooks/useHermes";
import { usageKeys } from "@/lib/query/usage";
import { CODEX_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities";
import {
CODEX_OFFICIAL_PROVIDER_ID,
GROKBUILD_OFFICIAL_PROVIDER_ID,
} from "@/utils/providerCapabilities";
export const useAddProviderMutation = (appId: AppId) => {
const queryClient = useQueryClient();
@@ -23,6 +26,7 @@ export const useAddProviderMutation = (appId: AppId) => {
addToLive?: boolean;
ensureClaudeDesktopOfficialSeed?: boolean;
ensureCodexOfficialSeed?: boolean;
ensureGrokBuildOfficialSeed?: boolean;
},
) => {
const {
@@ -30,6 +34,7 @@ export const useAddProviderMutation = (appId: AppId) => {
addToLive,
ensureClaudeDesktopOfficialSeed,
ensureCodexOfficialSeed,
ensureGrokBuildOfficialSeed,
...rest
} = providerInput;
@@ -53,6 +58,16 @@ export const useAddProviderMutation = (appId: AppId) => {
return officialProvider;
}
if (appId === "grokbuild" && ensureGrokBuildOfficialSeed) {
await providersApi.ensureGrokBuildOfficialProvider();
const providers = await providersApi.getAll(appId);
const officialProvider = providers[GROKBUILD_OFFICIAL_PROVIDER_ID];
if (!officialProvider) {
throw new Error("Grok Build official provider was not created");
}
return officialProvider;
}
let id: string;
if (appId === "opencode" || appId === "openclaw" || appId === "hermes") {
+1
View File
@@ -8,6 +8,7 @@ import {
} from "@/utils/providerConfigUtils";
export const CODEX_OFFICIAL_PROVIDER_ID = "codex-official";
export const GROKBUILD_OFFICIAL_PROVIDER_ID = "grokbuild-official";
/** Keep the UI capability rule aligned with the Rust takeover policy. */
export function supportsOfficialProxyTakeover(