fix(grokbuild): complete proxy and deep-link integrations (#5677)

* fix(grokbuild): complete proxy integration

* fix(deeplink): preview GrokBuild configs safely

* test(app): stabilize provider integration suite

* fix(grokbuild): address review feedback

* fix(grokbuild): resolve remaining review findings

* fix(grokbuild): use native sessions and harden previews
This commit is contained in:
Thefool
2026-07-31 14:56:42 +08:00
committed by GitHub
parent b884595a23
commit c49cf96a16
16 changed files with 566 additions and 111 deletions
+11 -62
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from "react";
import { listen } from "@tauri-apps/api/event";
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
import {
Dialog,
DialogContent,
@@ -228,62 +229,10 @@ export function DeepLinkImportDialog() {
? "url"
: null;
// Parse config file content for display
interface ParsedConfig {
type: "claude" | "codex" | "gemini";
env?: Record<string, string>;
auth?: Record<string, string>;
tomlConfig?: string;
raw: Record<string, unknown>;
}
// Helper to decode base64 with UTF-8 support
const b64ToUtf8 = (str: string): string => {
try {
const binString = atob(str);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
return new TextDecoder().decode(bytes);
} catch (e) {
console.error("Failed to decode base64:", e);
return atob(str);
}
};
const parsedConfig = useMemo((): ParsedConfig | null => {
if (!request?.config) return null;
try {
const decoded = b64ToUtf8(request.config);
const parsed = JSON.parse(decoded) as Record<string, unknown>;
if (request.app === "claude") {
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
raw: parsed,
};
} else if (request.app === "codex") {
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: (parsed.config as string) || "",
raw: parsed,
};
} else if (request.app === "gemini") {
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
return {
type: "gemini",
env: parsed as Record<string, string>,
raw: parsed,
};
}
return null;
} catch (e) {
console.error("Failed to parse config:", e);
return null;
}
}, [request?.config, request?.app]);
const parsedConfig = useMemo(
() => (request ? parseDeepLinkConfigPreview(request) : null),
[request],
);
/**
* env 行:值经 `maskValue` 脱敏,键命中加载器控制变量时标记。
@@ -573,9 +522,11 @@ export function DeepLinkImportDialog() {
)}
{/* Codex config */}
{parsedConfig.type === "codex" && (
{(parsedConfig.type === "codex" ||
parsedConfig.type === "grokbuild") && (
<div className="space-y-2">
{parsedConfig.auth &&
{parsedConfig.type === "codex" &&
parsedConfig.auth &&
Object.keys(parsedConfig.auth).length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
@@ -599,10 +550,8 @@ export function DeepLinkImportDialog() {
<div className="text-xs text-muted-foreground">
TOML Config:
</div>
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
{parsedConfig.tomlConfig.substring(0, 300)}
{parsedConfig.tomlConfig.length > 300 &&
"..."}
<pre className="text-xs font-mono bg-background p-2 rounded overflow-auto max-h-24 whitespace-pre-wrap break-all">
{parsedConfig.tomlConfig}
</pre>
</div>
)}
+14 -5
View File
@@ -25,6 +25,13 @@ interface ProxyTabContentProps {
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
}
export const FAILOVER_APPS = [
{ id: "claude", label: "Claude" },
{ id: "codex", label: "Codex" },
{ id: "gemini", label: "Gemini" },
{ id: "grokbuild", label: "Grok Build" },
] as const;
export function ProxyTabContent({
settings,
onAutoSave,
@@ -172,12 +179,14 @@ export function ProxyTabContent({
)}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
<TabsList className="grid w-full grid-cols-4">
{FAILOVER_APPS.map(({ id, label }) => (
<TabsTrigger key={id} value={id}>
{label}
</TabsTrigger>
))}
</TabsList>
{(["claude", "codex", "gemini"] as const).map((appType) => {
{FAILOVER_APPS.map(({ id: appType }) => {
const failoverDisabled =
!isRunning || !(takeoverStatus?.[appType] ?? false);
return (
+9 -2
View File
@@ -7,7 +7,14 @@ export interface DeepLinkImportRequest {
resource: ResourceType;
// Common fields
app?: "claude" | "codex" | "gemini";
app?:
| "claude"
| "codex"
| "gemini"
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
name?: string;
enabled?: boolean;
@@ -27,7 +34,7 @@ export interface DeepLinkImportRequest {
description?: string;
// MCP fields
apps?: string; // "claude,codex,gemini"
apps?: string; // Comma-separated application IDs
// Skill fields
repo?: string;
+2 -2
View File
@@ -7,7 +7,7 @@ import type { EnvConflict, BackupInfo } from "@/types/env";
/**
* 检查指定应用的环境变量冲突
* @param appType 应用类型 ("claude" | "codex" | "gemini")
* @param appType 应用类型 ("claude" | "codex" | "gemini" | "grokbuild")
* @returns 环境变量冲突列表
*/
export async function checkEnvConflicts(
@@ -42,7 +42,7 @@ export async function restoreEnvBackup(backupPath: string): Promise<void> {
export async function checkAllEnvConflicts(): Promise<
Record<string, EnvConflict[]>
> {
const apps = ["claude", "codex", "gemini"];
const apps = ["claude", "codex", "gemini", "grokbuild"];
const results: Record<string, EnvConflict[]> = {};
await Promise.all(
+11 -2
View File
@@ -13,6 +13,10 @@ function toStandardBase64Alphabet(value: string): string {
return value.replace(/ /g, "+").replace(/-/g, "+").replace(/_/g, "/");
}
function trimOuterLineBreaks(value: string): string {
return value.replace(/^[\r\n]+|[\r\n]+$/g, "");
}
/**
* Decode Base64 encoded UTF-8 string
*
@@ -27,7 +31,10 @@ function toStandardBase64Alphabet(value: string): string {
*/
export function decodeBase64Utf8(str: string): string {
try {
let cleaned = toStandardBase64Alphabet(str.trim());
// Keep spaces intact until they are restored to `+`. Using `trim()` here
// would discard a URL-decoded `+` at either edge and diverge from the
// backend decoder, which trims only CR/LF characters.
let cleaned = toStandardBase64Alphabet(trimOuterLineBreaks(str));
// Try to decode with standard Base64 first
try {
@@ -48,7 +55,9 @@ export function decodeBase64Utf8(str: string): string {
console.error("Base64 decode error:", e, "Input:", str);
// Last resort fallback using deprecated but sometimes working method
try {
return decodeURIComponent(escape(atob(toStandardBase64Alphabet(str))));
return decodeURIComponent(
escape(atob(toStandardBase64Alphabet(trimOuterLineBreaks(str)))),
);
} catch {
// If all else fails, return original string
return str;
+95
View File
@@ -0,0 +1,95 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import type { DeepLinkImportRequest } from "@/lib/api/deeplink";
import { decodeBase64Utf8 } from "@/lib/utils/base64";
import { isSensitiveConfigKey, maskSensitiveValue } from "@/utils/deeplinkRisk";
export interface ParsedDeepLinkConfig {
type: "claude" | "codex" | "gemini" | "grokbuild";
env?: Record<string, string>;
auth?: Record<string, string>;
tomlConfig?: string;
}
const maskStructuredSecrets = (
value: unknown,
key = "",
inheritedSensitive = false,
): unknown => {
const sensitive = inheritedSensitive || isSensitiveConfigKey(key);
if (typeof value === "string") {
return sensitive ? maskSensitiveValue(value) : value;
}
if (Array.isArray(value)) {
return value.map((item) => maskStructuredSecrets(item, key, sensitive));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(
([childKey, childValue]) => [
childKey,
maskStructuredSecrets(childValue, childKey, sensitive),
],
),
);
}
return value;
};
const sanitizeTomlForPreview = (configToml: string): string => {
const parsed = parseToml(configToml) as Record<string, unknown>;
return `${stringifyToml(maskStructuredSecrets(parsed) as Record<string, unknown>).trim()}\n`;
};
export function parseDeepLinkConfigPreview(
request: Pick<DeepLinkImportRequest, "app" | "config" | "configFormat">,
): ParsedDeepLinkConfig | null {
if (!request.config) return null;
try {
const decoded = decodeBase64Utf8(request.config);
const format = request.configFormat?.trim().toLowerCase();
if (request.app === "grokbuild" && format === "toml") {
return {
type: "grokbuild",
tomlConfig: sanitizeTomlForPreview(decoded),
};
}
const parsed = JSON.parse(decoded) as Record<string, unknown>;
if (request.app === "claude") {
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
};
}
if (request.app === "codex") {
const config = typeof parsed.config === "string" ? parsed.config : "";
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: config ? sanitizeTomlForPreview(config) : "",
};
}
if (request.app === "gemini") {
return {
type: "gemini",
env: parsed as Record<string, string>,
};
}
if (request.app === "grokbuild") {
const config =
typeof parsed.config === "string"
? parsed.config
: stringifyToml(parsed);
return {
type: "grokbuild",
tomlConfig: sanitizeTomlForPreview(config),
};
}
return null;
} catch (error) {
console.error("Failed to parse deep link config preview:", error);
return null;
}
}
+10 -3
View File
@@ -194,12 +194,19 @@ describe("decodeDeeplinkPayload", () => {
describe("maskValue", () => {
it("masks credential-shaped keys but keeps ordinary values readable", () => {
expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe(
"sk-ant-1************",
"sk-a************",
);
expect(maskValue("ANTHROPIC_BASE_URL", "https://example.com")).toBe(
"https://example.com",
);
// 短值不脱敏,否则连"是不是空的"都看不出来
expect(maskValue("API_KEY", "short")).toBe("short");
expect(maskValue("API_KEY", "short")).toBe("****");
expect(maskValue("Authorization", "Basic abcd")).not.toContain("abcd");
expect(maskValue("Cookie", "sid=1234")).toBe("****");
expect(maskValue("Credential", "credential-value")).not.toContain(
"credential-value",
);
expect(maskValue("auth", "short")).toBe("****");
expect(maskValue("bearer", "short")).toBe("****");
expect(maskValue("API_KEY", "")).toBe("");
});
});
+29 -6
View File
@@ -7,6 +7,34 @@
export type RiskKind = "envHijack" | "privateEndpoint" | "shellCommand";
const SENSITIVE_CONFIG_KEY_MARKERS = [
"TOKEN",
"KEY",
"SECRET",
"PASSWORD",
"AUTHORIZATION",
"COOKIE",
"CREDENTIAL",
];
const SENSITIVE_CONFIG_KEY_NAMES = new Set(["AUTH", "BEARER"]);
export function isSensitiveConfigKey(key: string): boolean {
const normalizedKey = key.toUpperCase();
return (
SENSITIVE_CONFIG_KEY_NAMES.has(normalizedKey) ||
SENSITIVE_CONFIG_KEY_MARKERS.some((marker) =>
normalizedKey.includes(marker),
)
);
}
export function maskSensitiveValue(value: string): string {
if (value.length === 0) return value;
return value.length > 8
? `${value.substring(0, 4)}${"*".repeat(12)}`
: "****";
}
/**
* 能改变子进程加载行为的环境变量。
*
@@ -199,12 +227,7 @@ export function classifyCommand(
* 为了两处共用同一套规则——各写一份迟早会漂移成两种脱敏口径。
*/
export function maskValue(key: string, value: string): string {
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
const isSensitive = sensitiveKeys.some((k) => key.toUpperCase().includes(k));
if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`;
}
return value;
return isSensitiveConfigKey(key) ? maskSensitiveValue(value) : value;
}
/** 风险种类 → i18n key。 */