mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 11:01:22 +08:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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。 */
|
||||
|
||||
Reference in New Issue
Block a user