Merge remote-tracking branch 'origin/dev/3.1' into dev/3.1

This commit is contained in:
musi 2026-07-08 22:32:50 +08:00
commit 113c9059b4
11 changed files with 237 additions and 35 deletions

View file

@ -1157,8 +1157,8 @@ async function writeCoreGatewayConfig(
mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true });
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
const providerPlugins = withCodexOauthRuntimeDefaults([
...(config.providerPlugins ?? []),
...pluginService.getCoreProviderPlugins()
...(config.providerPlugins ?? []).filter(providerPluginEnabled),
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
]);
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
@ -1244,6 +1244,10 @@ function writePrivateTextFile(file: string, content: string): void {
}
}
function providerPluginEnabled(plugin: unknown): boolean {
return !isRecord(plugin) || plugin.enabled !== false;
}
export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] {
return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config));
}

View file

@ -144,6 +144,18 @@ type LoadedPlugin = {
stop?: () => MaybePromise<void>;
};
type PluginServiceStateSnapshot = {
apps: InstalledBrowserApp[];
coreGatewayConfig: Record<string, unknown>;
coreProviderPlugins: unknown[];
gatewayRoutes: RegisteredGatewayRoute[];
providerAccountConnectors: Map<string, GatewayPluginProviderAccountConnector>;
proxyRoutes: RegisteredProxyRoute[];
resourceOwnerIds: Set<string>;
stopHooks: Array<() => MaybePromise<void>>;
virtualModelProfiles: unknown[];
};
const requireFromHere = createRequire(__filename);
const builtInMarketplacePluginModules = new Map<string, string>([
["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")],
@ -172,8 +184,14 @@ class GatewayPluginService {
if (pluginConfig.enabled === false) {
continue;
}
const snapshot = this.createStateSnapshot();
this.resourceOwnerIds.add(pluginConfig.id);
await this.loadConfiguredPlugin(pluginConfig);
try {
await this.loadConfiguredPlugin(pluginConfig);
} catch (error) {
await this.rollbackConfiguredPluginLoad(pluginConfig.id, snapshot);
console.warn(`[plugin:${pluginConfig.id}] Disabled after startup failure: ${formatError(error)}`);
}
}
}
@ -513,6 +531,47 @@ class GatewayPluginService {
): Promise<PluginSqliteStore> {
return backendService.openSqliteStore(pluginId, pluginDataDir, options);
}
private createStateSnapshot(): PluginServiceStateSnapshot {
return {
apps: [...this.apps],
coreGatewayConfig: { ...this.coreGatewayConfig },
coreProviderPlugins: [...this.coreProviderPlugins],
gatewayRoutes: [...this.gatewayRoutes],
providerAccountConnectors: new Map(this.providerAccountConnectors),
proxyRoutes: [...this.proxyRoutes],
resourceOwnerIds: new Set(this.resourceOwnerIds),
stopHooks: [...this.stopHooks],
virtualModelProfiles: [...this.virtualModelProfiles]
};
}
private async rollbackConfiguredPluginLoad(pluginId: string, snapshot: PluginServiceStateSnapshot): Promise<void> {
const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse();
this.apps = snapshot.apps;
this.coreGatewayConfig = snapshot.coreGatewayConfig;
this.coreProviderPlugins = snapshot.coreProviderPlugins;
this.gatewayRoutes = snapshot.gatewayRoutes;
this.providerAccountConnectors = snapshot.providerAccountConnectors;
this.proxyRoutes = snapshot.proxyRoutes;
this.resourceOwnerIds = snapshot.resourceOwnerIds;
this.stopHooks = snapshot.stopHooks;
this.virtualModelProfiles = snapshot.virtualModelProfiles;
for (const stopHook of newStopHooks) {
try {
await stopHook();
} catch (error) {
console.warn(`[plugin:${pluginId}] Rollback stop hook failed: ${formatError(error)}`);
}
}
try {
await backendService.stopOwner(pluginId);
} catch (error) {
console.warn(`[plugin:${pluginId}] Rollback resource cleanup failed: ${formatError(error)}`);
}
}
}
export const pluginService = new GatewayPluginService();

View file

@ -70,6 +70,13 @@ type BrowserSessionRef = {
userId?: string;
};
type SnapshotOptions = {
limit?: number;
maxElements: number;
maxText?: number;
offset?: number;
};
type AttachedSession = {
attachedAt: number;
leaseId: string;
@ -140,6 +147,8 @@ const defaultSnapshotMaxElements = 80;
const defaultSnapshotMaxText = 3_000;
const defaultEventAwaitTimeoutMs = 10_000;
const defaultJavascriptTimeoutMs = 8_000;
const maxSnapshotTextLimit = 20_000;
const maxSnapshotTextOffset = 1_000_000_000;
const maxMcpRequestBytes = 2 * 1024 * 1024;
const maxSubscriptionEvents = 512;
const maxToolResultChars = 60_000;
@ -148,7 +157,7 @@ const maxToolResultStringChars = 2_000;
const maxToolResultObjectKeys = 120;
const maxSnapshotResultElements = 80;
const maxAxSnapshotResultNodes = 80;
const maxBrowserResultTextChars = 3_000;
const maxBrowserResultTextChars = maxSnapshotTextLimit;
const defaultWaitTimeoutMs = 10_000;
const sessionSchema = objectSchema({
@ -552,8 +561,10 @@ function browserLegacyAliasTools(): McpTool[] {
{
description: "Capture page text plus interactable element refs for browser automation. Use returned refs for browser_click, browser_type, browser_select, browser_press_key, or browser_scroll.",
inputSchema: objectSchema({
limit: { description: "Maximum page text characters to include. Overrides maxText when both are provided.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" },
maxElements: { description: "Maximum interactable elements to include.", maximum: 300, minimum: 1, type: "number" },
maxText: { description: "Maximum page text characters to include.", maximum: 20000, minimum: 0, type: "number" },
maxText: { description: "Legacy alias for limit.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" },
offset: { description: "Starting character offset into the normalized page text.", maximum: maxSnapshotTextOffset, minimum: 0, type: "number" },
tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }
}),
name: "browser_snapshot",
@ -1014,8 +1025,9 @@ class BrowserAutomationMcpService implements BrowserAutomationMcpIntegration {
builtInBrowserService.getAutomationWebContents(readString(args.tabId)),
readString(args.tabId),
{
limit: clampInteger(readNumber(args.limit) ?? readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, maxSnapshotTextLimit),
maxElements: clampInteger(readNumber(args.maxElements) ?? defaultSnapshotMaxElements, 1, 300),
maxText: clampInteger(readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, 20000)
offset: clampInteger(readNumber(args.offset) ?? 0, 0, maxSnapshotTextOffset)
}
);
case "browser_click":
@ -2339,7 +2351,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: s
}
}
async function captureSnapshot(webContents: WebContents, options: { maxElements: number; maxText: number }): Promise<unknown> {
async function captureSnapshot(webContents: WebContents, options: SnapshotOptions): Promise<unknown> {
return await executeJavaScriptWithTimeout(
webContents,
`(${snapshotScript})(${JSON.stringify(options)})`,
@ -2351,7 +2363,7 @@ async function captureSnapshot(webContents: WebContents, options: { maxElements:
async function captureSnapshotWithHandoff(
webContents: WebContents,
tabId: string | undefined,
options: { maxElements: number; maxText: number }
options: SnapshotOptions
): Promise<unknown> {
const snapshot = await captureSnapshot(webContents, options);
const handoff = await maybeRequestPageHandoff(webContents, {
@ -2670,9 +2682,11 @@ async function waitForPageCondition(
};
}
const snapshotScript = function(options: { maxElements: number; maxText: number }) {
const snapshotScript = function(options: { limit?: number; maxElements: number; maxText?: number; offset?: number }) {
const maxElements = Math.max(1, Math.min(300, Math.floor(options.maxElements || 80)));
const maxText = Math.max(0, Math.min(20000, Math.floor(options.maxText || 3000)));
const textLimitInput = options.limit ?? options.maxText ?? 3000;
const textLimit = Math.max(0, Math.min(20000, Math.floor(textLimitInput)));
const requestedTextOffset = Math.max(0, Math.floor(options.offset || 0));
const refAttribute = "data-ccr-browser-ref";
const elementSelector = [
"a[href]",
@ -2860,6 +2874,11 @@ const snapshotScript = function(options: { maxElements: number; maxText: number
};
});
const pageText = (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
const textOffset = Math.min(requestedTextOffset, pageText.length);
const textWindow = pageText.slice(textOffset, textOffset + textLimit);
const textNextOffset = textOffset + textWindow.length;
return {
activeElement: document.activeElement ? {
name: nameOf(document.activeElement).slice(0, 240),
@ -2868,7 +2887,15 @@ const snapshotScript = function(options: { maxElements: number; maxText: number
tag: document.activeElement.tagName.toLowerCase()
} : undefined,
elements,
text: (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, maxText),
text: textWindow,
textHasMore: textNextOffset < pageText.length,
textLength: pageText.length,
textLimit,
textNextOffset,
textOffset,
textRemaining: Math.max(0, pageText.length - textNextOffset),
textReturned: textWindow.length,
textRequestedOffset: requestedTextOffset !== textOffset ? requestedTextOffset : undefined,
title: document.title,
url: location.href
};
@ -3437,7 +3464,7 @@ function truncateString(value: string, maxChars: number): string {
function largeResultGuidance(toolName: string): string {
if (toolName === "browser_snapshot") {
return "Result was compacted. Re-run browser_snapshot with lower maxElements/maxText, or use browser_ax_query to fetch targeted elements.";
return "Result was compacted. Re-run browser_snapshot with lower maxElements/limit, page text with offset/limit, or use browser_ax_query to fetch targeted elements.";
}
if (toolName === "browser_ax_snapshot") {
return "Result was compacted. Re-run browser_ax_snapshot with lower limit or scope=outline, or use browser_ax_query with role/name/text filters.";

View file

@ -46,7 +46,7 @@ function Dialog({
return (
<motion.div
animate={{ opacity: 1 }}
className={cn("fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-black/28 p-3 sm:p-6", className)}
className={cn("fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/28 p-3 sm:p-6", className)}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
onMouseDown={(event) => {

View file

@ -827,12 +827,23 @@ function App() {
void checkForAppUpdate();
}
function openUpdateDownloadDialog() {
function openSidebarUpdateDialog() {
setUpdateDialogOpen(true);
setUpdateActionError("");
if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") {
void downloadAppUpdate();
return;
}
if (
updateDialogStatus.canInstall ||
updateDialogStatus.state === "checking" ||
updateDialogStatus.state === "downloading" ||
updateDialogStatus.state === "downloaded" ||
updateDialogStatus.state === "installing"
) {
return;
}
void checkForAppUpdate();
}
async function checkForAppUpdate() {
@ -2816,7 +2827,7 @@ function App() {
isMac={isMac}
needsTrafficLightSafeArea={needsTrafficLightSafeArea}
networkCaptureEnabled={networkCaptureEnabled}
onDownloadUpdate={openUpdateDownloadDialog}
onOpenUpdate={openSidebarUpdateDialog}
onOpenSettings={openSettingsDialog}
onSelectNavigationItem={selectNavigationItem}
onToggleSidebar={() => setSidebarOpen((current) => !current)}

View file

@ -3705,7 +3705,7 @@ function ToolPayloadDialog({
: undefined;
return (
<Dialog className="z-[70] items-start" onOpenChange={(open) => !open && onClose()} open>
<Dialog className="z-[110] items-start" onOpenChange={(open) => !open && onClose()} open>
<DialogContent className="h-[calc(100dvh-1.5rem)] max-w-[1180px] origin-top sm:h-[min(820px,calc(100dvh-3rem))]">
<DialogHeader>
<div className="min-w-0">

View file

@ -1,8 +1,8 @@
import type { ComponentProps } from "react";
import {
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar,
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, Check, cn, EndpointTitleBar,
AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase,
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen,
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, RefreshCw,
reducedMotionTransition, ServiceControlButton, Settings, ViewId,
ViewMotionShell, viewUsesInternalScroll
} from "../shared/index";
@ -63,7 +63,7 @@ export function MainLayout({
needsTrafficLightSafeArea,
agentAnalysisEnabled,
networkCaptureEnabled,
onDownloadUpdate,
onOpenUpdate,
onOpenSettings,
onSelectNavigationItem,
onToggleSidebar,
@ -86,7 +86,7 @@ export function MainLayout({
isMac: boolean;
needsTrafficLightSafeArea: boolean;
networkCaptureEnabled: boolean;
onDownloadUpdate: () => void;
onOpenUpdate: () => void;
onOpenSettings: () => void;
onSelectNavigationItem: (id: NavigationId) => void;
onToggleSidebar: () => void;
@ -99,15 +99,24 @@ export function MainLayout({
requestLogsEnabled: boolean;
visibleNavigation: MainNavigationItem[];
}) {
const windowControlSafeAreaWidth = isMac ? 152 : 88;
const showUpdateDownloadButton =
updateStatus.supported &&
(updateStatus.state === "available" || updateStatus.state === "downloading" || updateStatus.state === "downloaded");
const updateDownloadLabel = updateStatus.state === "downloaded"
const showUpdateButton = updateStatus.supported;
const windowControlSafeAreaWidth = showUpdateButton
? (isMac ? 188 : 124)
: (isMac ? 152 : 88);
const updateBusy =
updateActionBusy ||
updateStatus.state === "checking" ||
updateStatus.state === "downloading" ||
updateStatus.state === "installing";
const updateLabel = updateStatus.state === "downloaded"
? copy.text["Update ready to install"] ?? "Update ready to install"
: updateStatus.state === "downloading"
? copy.text["Downloading update"] ?? "Downloading update"
: copy.text["Download update"] ?? "Download update";
: updateStatus.state === "checking"
? copy.text["Checking for updates"] ?? "Checking for updates"
: updateStatus.state === "available"
? copy.text["Download update"] ?? "Download update"
: copy.text["Check for updates"] ?? "Check for updates";
return (
<>
@ -132,17 +141,25 @@ export function MainLayout({
onClick={toggleGatewayService}
state={gatewayStatus.state}
/>
{showUpdateDownloadButton ? (
{showUpdateButton ? (
<Button
aria-label={updateDownloadLabel}
aria-label={updateLabel}
className="app-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-transparent p-0 text-primary outline-none transition-colors hover:bg-primary/10 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring/25"
onMouseDown={(event) => event.stopPropagation()}
onClick={onDownloadUpdate}
title={updateDownloadLabel}
onClick={onOpenUpdate}
title={updateLabel}
type="button"
unstyled
>
{updateActionBusy || updateStatus.state === "downloading" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
{updateBusy ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : updateStatus.state === "downloaded" ? (
<Check className="h-3.5 w-3.5" />
) : updateStatus.state === "available" ? (
<Download className="h-3.5 w-3.5" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
) : null}
</div>

View file

@ -2516,7 +2516,7 @@ export function AddProviderDialog({
</Dialog>
{checkConfirmOpen ? (
<Dialog className="z-[60]" onOpenChange={(open) => !open && !checkConfirmBusy && setCheckConfirmOpen(false)}>
<Dialog className="z-[110]" onOpenChange={(open) => !open && !checkConfirmBusy && setCheckConfirmOpen(false)}>
<DialogContent className="max-w-[520px]">
<DialogHeader>
<div className="min-w-0">

View file

@ -636,7 +636,7 @@ function ToolHubMcpServerDialog({
const stdioMessageModeOptions = translateOptions(mcpStdioMessageModeOptions, t);
return (
<Dialog className="z-[80]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<Dialog className="z-[110]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<DialogContent className="max-w-[760px]">
<DialogHeader>
<div className="min-w-0">
@ -755,7 +755,7 @@ function ToolHubMcpJsonDialog({
const t = useAppText();
return (
<Dialog className="z-[80]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<Dialog className="z-[110]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<DialogContent className="max-w-[760px]">
<DialogHeader>
<div className="min-w-0">

View file

@ -605,7 +605,7 @@ function CustomMcpToolDialog({
}
return (
<Dialog className="z-[80]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<Dialog className="z-[110]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<DialogContent className="max-w-[760px]">
<DialogHeader>
<div className="min-w-0">

View file

@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
import { pluginService } from "../../packages/core/src/plugins/service.ts";
test("plugin service skips failed plugins and rolls back their registrations", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-plugin-service-test-"));
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args.map(String).join(" "));
try {
const brokenPlugin = path.join(dir, "broken-plugin.cjs");
const goodPlugin = path.join(dir, "good-plugin.cjs");
writeFileSync(brokenPlugin, `
module.exports = async function brokenPlugin(context) {
context.registerApp({ id: "broken-context-app", name: "Broken context", url: "http://broken-context.local" });
context.registerGatewayRoute({ id: "broken-context-route", path: "/broken-context", handler(_request, response) { response.end("broken"); } });
context.registerCoreGatewayProviderPlugin({ key: "broken-context-provider" });
context.registerCoreGatewayVirtualModelProfile({ id: "broken-context-vm" });
context.registerProviderAccountConnector({ id: "broken-account", resolve() { return []; } });
throw new Error("broken plugin setup failed");
};
`);
writeFileSync(goodPlugin, `
module.exports = {
setup(context) {
context.registerCoreGatewayProviderPlugin({ key: "good-context-provider" });
context.registerCoreGatewayVirtualModelProfile({ id: "good-context-vm" });
return {
apps: [{ id: "good-app", name: "Good app", url: "http://good.local" }],
coreGateway: {
config: { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } },
providerPlugins: [{ key: "good-provider" }],
virtualModelProfiles: [{ id: "good-vm" }]
},
gatewayRoutes: [{ id: "good-route", path: "/good", handler(_request, response) { response.end("good"); } }],
providerAccountConnectors: [{ id: "good-account", resolve() { return []; } }],
proxyRoutes: [{ host: "good.local", upstream: "http://127.0.0.1" }]
};
}
};
`);
const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") });
config.plugins = [
{
apps: [{ id: "broken-config-app", name: "Broken config", url: "http://broken-config.local" }],
coreGateway: {
config: { agent: { mcpServers: [{ name: "broken-config-mcp", command: "broken" }] } },
providerPlugins: [{ key: "broken-config-provider" }],
virtualModelProfiles: [{ id: "broken-config-vm" }]
},
id: "broken",
module: brokenPlugin,
proxy: { routes: [{ host: "broken.local", upstream: "http://127.0.0.1" }] }
},
{
id: "good",
module: goodPlugin
}
];
await pluginService.start(config);
assert.match(warnings.join("\n"), /plugin:broken.*Disabled after startup failure.*broken plugin setup failed/);
assert.deepEqual(pluginService.getApps().map((app) => app.id), ["good-app"]);
assert.equal(pluginService.matchGatewayRoute("GET", "/broken-context"), undefined);
assert.equal(pluginService.matchGatewayRoute("GET", "/good")?.id, "good-route");
assert.deepEqual(pluginService.getProxyRouteTargets(), [{ host: "good.local", paths: undefined }]);
assert.deepEqual(pluginService.getCoreProviderPlugins().map((plugin) => plugin.key), ["good-context-provider", "good-provider"]);
assert.deepEqual(pluginService.getVirtualModelProfiles().map((profile) => profile.id), ["good-context-vm", "good-vm"]);
assert.deepEqual(pluginService.getCoreGatewayConfig(), { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } });
assert.equal(pluginService.getProviderAccountConnector("broken", "broken-account"), undefined);
assert.equal(typeof pluginService.getProviderAccountConnector("good", "good-account")?.resolve, "function");
} finally {
console.warn = originalWarn;
await pluginService.stop();
rmSync(dir, { force: true, recursive: true });
}
});