mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Refactor router to support new model routing flow
This commit is contained in:
parent
a400111f8a
commit
87db7d8b7b
26 changed files with 1985 additions and 120 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
"name": "Agent Console",
|
"name": "Agent Console",
|
||||||
"description": "Launches the local Agent Console Electron renderer as a CCR-hosted app and routes Codex/Claude agents through the CCR gateway.",
|
"description": "Launches the local Agent Console Electron renderer as a CCR-hosted app and routes Codex/Claude agents through the CCR gateway.",
|
||||||
"capabilities": ["Wrapper runtime", "PWA bridge", "System launcher", "Agent Console", "Model routing"],
|
"capabilities": ["Wrapper runtime", "PWA bridge", "System launcher", "Agent Console", "Model routing"],
|
||||||
|
"permissions": ["apps", "gateway-routes", "system-launcher"],
|
||||||
"module": "plugins/agent-console/index.cjs",
|
"module": "plugins/agent-console/index.cjs",
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"name": "Claude Design",
|
"name": "Claude Design",
|
||||||
"description": "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.",
|
"description": "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.",
|
||||||
"capabilities": ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"],
|
"capabilities": ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"],
|
||||||
|
"permissions": ["gateway-routes", "proxy-routes", "http-backends", "sqlite-store"],
|
||||||
"module": "../examples/plugins/claude-design-plugin.cjs"
|
"module": "../examples/plugins/claude-design-plugin.cjs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -29,6 +31,7 @@
|
||||||
"name": "Cursor Proxy",
|
"name": "Cursor Proxy",
|
||||||
"description": "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.",
|
"description": "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.",
|
||||||
"capabilities": ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"],
|
"capabilities": ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"],
|
||||||
|
"permissions": ["gateway-routes", "proxy-routes", "http-backends"],
|
||||||
"module": "../examples/plugins/cursor-proxy-plugin.cjs"
|
"module": "../examples/plugins/cursor-proxy-plugin.cjs"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ const DEFAULT_ROUTE_PREFIX = "/plugins/agent-console";
|
||||||
const DEFAULT_RENDERER_ENTRY_PATH = "/pages/home/";
|
const DEFAULT_RENDERER_ENTRY_PATH = "/pages/home/";
|
||||||
const DEFAULT_LAUNCHER_NAME = "Agent Console";
|
const DEFAULT_LAUNCHER_NAME = "Agent Console";
|
||||||
const LEGACY_LAUNCHER_NAME = "CCR Agent Console";
|
const LEGACY_LAUNCHER_NAME = "CCR Agent Console";
|
||||||
|
const MAC_LAUNCHER_APPS_DIR_NAME = "CCR Apps";
|
||||||
const DEFAULT_LAUNCHER_BUNDLE_ID = "com.claudecoderouter.plugin.agent-console.launcher";
|
const DEFAULT_LAUNCHER_BUNDLE_ID = "com.claudecoderouter.plugin.agent-console.launcher";
|
||||||
const READY_PREFIX = "AGENT_CONSOLE_HEADLESS_READY ";
|
const READY_PREFIX = "AGENT_CONSOLE_HEADLESS_READY ";
|
||||||
const DEFAULT_STARTUP_WAIT_MS = 15000;
|
const DEFAULT_STARTUP_WAIT_MS = 15000;
|
||||||
|
const OPENAI_REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
|
||||||
|
let modelCatalogIndex;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async setup(ctx) {
|
async setup(ctx) {
|
||||||
|
|
@ -44,6 +47,7 @@ module.exports = {
|
||||||
const gatewayApiKey = stringValue(options.gatewayApiKey) || configuredGatewayApiKey(ctx.config);
|
const gatewayApiKey = stringValue(options.gatewayApiKey) || configuredGatewayApiKey(ctx.config);
|
||||||
const appUrl = buildRendererAppUrl(gatewayUrl, routePrefix, bridgeUrl);
|
const appUrl = buildRendererAppUrl(gatewayUrl, routePrefix, bridgeUrl);
|
||||||
const launcherUrl = `ccr://plugin/${encodeURIComponent(PLUGIN_ID)}/open`;
|
const launcherUrl = `ccr://plugin/${encodeURIComponent(PLUGIN_ID)}/open`;
|
||||||
|
const launcherBundleId = stringValue(options.launcherBundleId) || DEFAULT_LAUNCHER_BUNDLE_ID;
|
||||||
const runtimeConfigFile = path.join(ctx.paths.pluginDataDir, "ccr-runtime-config.json");
|
const runtimeConfigFile = path.join(ctx.paths.pluginDataDir, "ccr-runtime-config.json");
|
||||||
const modelCatalogFile = path.join(ctx.paths.pluginDataDir, "ccr-codex-model-catalog.json");
|
const modelCatalogFile = path.join(ctx.paths.pluginDataDir, "ccr-codex-model-catalog.json");
|
||||||
const runtimeConfig = buildRuntimeConfig(ctx.config, {
|
const runtimeConfig = buildRuntimeConfig(ctx.config, {
|
||||||
|
|
@ -88,7 +92,12 @@ module.exports = {
|
||||||
fs.writeFileSync(runtimeConfigFile, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
|
fs.writeFileSync(runtimeConfigFile, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
|
||||||
fs.writeFileSync(modelCatalogFile, `${JSON.stringify(buildCodexModelCatalog(runtimeConfig.models), null, 2)}\n`, "utf8");
|
fs.writeFileSync(modelCatalogFile, `${JSON.stringify(buildCodexModelCatalog(runtimeConfig.models), null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
const launcher = ensureSystemLauncher(ctx, options, launcherUrl);
|
const launcher = canUsePermission(ctx, "system-launcher")
|
||||||
|
? ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId)
|
||||||
|
: {
|
||||||
|
error: "Agent Console system launcher requires the system-launcher permission.",
|
||||||
|
installed: false
|
||||||
|
};
|
||||||
runtime.launcherError = launcher.error || "";
|
runtime.launcherError = launcher.error || "";
|
||||||
runtime.launcherInstalled = launcher.installed;
|
runtime.launcherInstalled = launcher.installed;
|
||||||
runtime.launcherPath = launcher.path || "";
|
runtime.launcherPath = launcher.path || "";
|
||||||
|
|
@ -137,8 +146,11 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stop() {
|
stop(event) {
|
||||||
stopAgentConsole(runtime);
|
stopAgentConsole(runtime);
|
||||||
|
if (event?.reason === "disabled") {
|
||||||
|
removeSystemLauncher(ctx, runtime, launcherBundleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +325,7 @@ function statusPayload(runtime) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureSystemLauncher(ctx, options, launcherUrl) {
|
function ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId) {
|
||||||
if (options.systemLauncher === false || options.createSystemLauncher === false) {
|
if (options.systemLauncher === false || options.createSystemLauncher === false) {
|
||||||
return { installed: false };
|
return { installed: false };
|
||||||
}
|
}
|
||||||
|
|
@ -325,22 +337,28 @@ function ensureSystemLauncher(ctx, options, launcherUrl) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const launcherName = stringValue(options.launcherName) || DEFAULT_LAUNCHER_NAME;
|
const launcherName = stringValue(options.launcherName) || DEFAULT_LAUNCHER_NAME;
|
||||||
const launcherBundleId = stringValue(options.launcherBundleId) || DEFAULT_LAUNCHER_BUNDLE_ID;
|
|
||||||
const explicitLauncherPath = Boolean(stringValue(options.launcherPath));
|
const explicitLauncherPath = Boolean(stringValue(options.launcherPath));
|
||||||
const launcherPath = path.resolve(
|
const launcherPath = path.resolve(
|
||||||
stringValue(options.launcherPath) ||
|
stringValue(options.launcherPath) ||
|
||||||
path.join(os.homedir(), "Applications", `${safeMacFileName(launcherName)}.app`)
|
defaultMacLauncherAppPath(launcherName)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!explicitLauncherPath && launcherName === DEFAULT_LAUNCHER_NAME) {
|
if (!explicitLauncherPath) {
|
||||||
try {
|
const legacyLauncherPaths = [legacyMacLauncherAppPath(launcherName)];
|
||||||
migrateLegacyMacLauncherApp({
|
if (launcherName === DEFAULT_LAUNCHER_NAME) {
|
||||||
bundleId: launcherBundleId,
|
legacyLauncherPaths.push(legacyMacLauncherAppPath(LEGACY_LAUNCHER_NAME));
|
||||||
legacyPath: path.join(os.homedir(), "Applications", `${safeMacFileName(LEGACY_LAUNCHER_NAME)}.app`),
|
}
|
||||||
launcherPath
|
|
||||||
});
|
for (const legacyPath of legacyLauncherPaths) {
|
||||||
} catch (error) {
|
try {
|
||||||
ctx.logger.warn(`Failed to rename legacy Agent Console launcher: ${formatError(error)}`);
|
migrateLegacyMacLauncherApp({
|
||||||
|
bundleId: launcherBundleId,
|
||||||
|
legacyPath,
|
||||||
|
launcherPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
ctx.logger.warn(`Failed to rename legacy Agent Console launcher: ${formatError(error)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,6 +384,10 @@ function ensureSystemLauncher(ctx, options, launcherUrl) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUsePermission(ctx, permission) {
|
||||||
|
return !Array.isArray(ctx.permissions) || ctx.permissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
function migrateLegacyMacLauncherApp({ bundleId, legacyPath, launcherPath }) {
|
function migrateLegacyMacLauncherApp({ bundleId, legacyPath, launcherPath }) {
|
||||||
if (legacyPath === launcherPath || fs.existsSync(launcherPath) || !fs.existsSync(legacyPath)) {
|
if (legacyPath === launcherPath || fs.existsSync(launcherPath) || !fs.existsSync(legacyPath)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -384,9 +406,22 @@ function migrateLegacyMacLauncherApp({ bundleId, legacyPath, launcherPath }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(launcherPath), { recursive: true });
|
||||||
fs.renameSync(legacyPath, launcherPath);
|
fs.renameSync(legacyPath, launcherPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultMacLauncherAppPath(launcherName) {
|
||||||
|
return path.join(macLauncherAppsDir(), `${safeMacFileName(launcherName)}.app`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyMacLauncherAppPath(launcherName) {
|
||||||
|
return path.join(os.homedir(), "Applications", `${safeMacFileName(launcherName)}.app`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function macLauncherAppsDir() {
|
||||||
|
return path.join(os.homedir(), "Applications", MAC_LAUNCHER_APPS_DIR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
function installMacLauncherApp({ bundleId, launcherName, launcherPath, launcherUrl }) {
|
function installMacLauncherApp({ bundleId, launcherName, launcherPath, launcherUrl }) {
|
||||||
if (fs.existsSync(launcherPath) && !fs.statSync(launcherPath).isDirectory()) {
|
if (fs.existsSync(launcherPath) && !fs.statSync(launcherPath).isDirectory()) {
|
||||||
throw new Error(`${launcherPath} exists and is not a directory.`);
|
throw new Error(`${launcherPath} exists and is not a directory.`);
|
||||||
|
|
@ -410,6 +445,52 @@ function installMacLauncherApp({ bundleId, launcherName, launcherPath, launcherU
|
||||||
fs.chmodSync(executablePath, 0o755);
|
fs.chmodSync(executablePath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeSystemLauncher(ctx, runtime, bundleId) {
|
||||||
|
if (process.platform !== "darwin" || !runtime.launcherInstalled || !runtime.launcherPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uninstallMacLauncherApp({
|
||||||
|
bundleId,
|
||||||
|
launcherPath: runtime.launcherPath
|
||||||
|
});
|
||||||
|
runtime.launcherInstalled = false;
|
||||||
|
runtime.launcherPath = "";
|
||||||
|
} catch (error) {
|
||||||
|
ctx.logger.warn(`Failed to remove Agent Console system launcher: ${formatError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallMacLauncherApp({ bundleId, launcherPath }) {
|
||||||
|
const resolvedLauncherPath = path.resolve(launcherPath);
|
||||||
|
if (!resolvedLauncherPath.endsWith(".app") || !fs.existsSync(resolvedLauncherPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.statSync(resolvedLauncherPath).isDirectory()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoPath = path.join(resolvedLauncherPath, "Contents", "Info.plist");
|
||||||
|
if (!fs.existsSync(infoPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = fs.readFileSync(infoPath, "utf8");
|
||||||
|
if (!info.includes(`<string>${escapeXml(bundleId)}</string>`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(resolvedLauncherPath, { force: true, recursive: true });
|
||||||
|
if (path.dirname(resolvedLauncherPath) === macLauncherAppsDir()) {
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(macLauncherAppsDir());
|
||||||
|
} catch {
|
||||||
|
// Keep the shared launcher directory when it still contains other apps.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function macLauncherInfoPlist({ bundleId, executableName, launcherName }) {
|
function macLauncherInfoPlist({ bundleId, executableName, launcherName }) {
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
|
@ -1243,22 +1324,27 @@ function buildRuntimeConfig(config, options) {
|
||||||
|
|
||||||
function buildCodexModelCatalog(models) {
|
function buildCodexModelCatalog(models) {
|
||||||
return {
|
return {
|
||||||
models: models.map((model, index) => ({
|
models: models.map((model, index) => {
|
||||||
additional_speed_tiers: [],
|
const reasoning = codexModelReasoningProfile(model.model);
|
||||||
availability_nux: null,
|
return {
|
||||||
base_instructions: "You are Codex, a coding agent.",
|
additional_speed_tiers: [],
|
||||||
default_reasoning_level: null,
|
availability_nux: null,
|
||||||
description: `CCR gateway model ${model.model}`,
|
base_instructions: "You are Codex, a coding agent.",
|
||||||
display_name: model.displayName || model.model,
|
default_reasoning_level: reasoning.defaultReasoningLevel,
|
||||||
priority: index,
|
default_reasoning_summary: "none",
|
||||||
service_tiers: [],
|
description: `CCR gateway model ${model.model}`,
|
||||||
shell_type: "shell_command",
|
display_name: model.displayName || model.model,
|
||||||
slug: model.model,
|
priority: index,
|
||||||
supported_in_api: true,
|
service_tiers: [],
|
||||||
supported_reasoning_levels: [],
|
shell_type: "shell_command",
|
||||||
upgrade: null,
|
slug: model.model,
|
||||||
visibility: "list"
|
supported_in_api: true,
|
||||||
}))
|
supported_reasoning_levels: reasoning.supportedReasoningLevels,
|
||||||
|
supports_reasoning_summaries: reasoning.supportsReasoning,
|
||||||
|
upgrade: null,
|
||||||
|
visibility: "list"
|
||||||
|
};
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1271,12 +1357,11 @@ function availableGatewayModels(config) {
|
||||||
const modelName = stringValue(rawModel);
|
const modelName = stringValue(rawModel);
|
||||||
if (!modelName) continue;
|
if (!modelName) continue;
|
||||||
const id = `${providerName}/${modelName}`;
|
const id = `${providerName}/${modelName}`;
|
||||||
baseEntries.push({
|
baseEntries.push(runtimeModelEntry(id, {
|
||||||
displayName: displayModelName(provider, modelName, id),
|
displayName: displayModelName(provider, modelName, id),
|
||||||
id,
|
|
||||||
isDefault: id === stringValue(config?.Router?.default),
|
isDefault: id === stringValue(config?.Router?.default),
|
||||||
model: id
|
model: id
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1286,37 +1371,348 @@ function availableGatewayModels(config) {
|
||||||
const displayName = stringValue(profile.displayName || profile.key || profile.id);
|
const displayName = stringValue(profile.displayName || profile.key || profile.id);
|
||||||
for (const entry of baseEntries) {
|
for (const entry of baseEntries) {
|
||||||
for (const prefix of normalizeStringArray(profile.match?.prefixes) || []) {
|
for (const prefix of normalizeStringArray(profile.match?.prefixes) || []) {
|
||||||
virtualEntries.push({
|
const model = `${providerNameFromModel(entry.model)}/${prefix}${modelNameFromModel(entry.model)}`;
|
||||||
|
virtualEntries.push(runtimeModelEntry(model, {
|
||||||
displayName: displayName || `${prefix}${entry.displayName}`,
|
displayName: displayName || `${prefix}${entry.displayName}`,
|
||||||
id: `${providerNameFromModel(entry.model)}/${prefix}${modelNameFromModel(entry.model)}`,
|
model,
|
||||||
model: `${providerNameFromModel(entry.model)}/${prefix}${modelNameFromModel(entry.model)}`
|
reasoningModel: entry.model
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
for (const suffix of normalizeStringArray(profile.match?.suffixes) || []) {
|
for (const suffix of normalizeStringArray(profile.match?.suffixes) || []) {
|
||||||
virtualEntries.push({
|
const model = `${providerNameFromModel(entry.model)}/${modelNameFromModel(entry.model)}${suffix}`;
|
||||||
|
virtualEntries.push(runtimeModelEntry(model, {
|
||||||
displayName: displayName || `${entry.displayName}${suffix}`,
|
displayName: displayName || `${entry.displayName}${suffix}`,
|
||||||
id: `${providerNameFromModel(entry.model)}/${modelNameFromModel(entry.model)}${suffix}`,
|
model,
|
||||||
model: `${providerNameFromModel(entry.model)}/${modelNameFromModel(entry.model)}${suffix}`
|
reasoningModel: entry.model
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const alias of normalizeStringArray(profile.match?.exactAliases) || []) {
|
for (const alias of normalizeStringArray(profile.match?.exactAliases) || []) {
|
||||||
const model = alias.toLowerCase().startsWith("fusion/") ? alias : `Fusion/${alias}`;
|
const model = alias.toLowerCase().startsWith("fusion/") ? alias : `Fusion/${alias}`;
|
||||||
virtualEntries.push({
|
virtualEntries.push(runtimeModelEntry(model, {
|
||||||
displayName: displayName || alias,
|
displayName: displayName || alias,
|
||||||
id: model,
|
|
||||||
model
|
model
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return uniqueModels([...baseEntries, ...virtualEntries]);
|
return uniqueModels([...baseEntries, ...virtualEntries]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeModelEntry(model, options = {}) {
|
||||||
|
const reasoning = codexModelReasoningProfile(options.reasoningModel || model);
|
||||||
|
return {
|
||||||
|
displayName: options.displayName || model,
|
||||||
|
id: options.id || model,
|
||||||
|
isDefault: options.isDefault === true,
|
||||||
|
model,
|
||||||
|
...(reasoning.defaultReasoningEffort ? { defaultReasoningEffort: reasoning.defaultReasoningEffort } : {}),
|
||||||
|
supportedReasoningEfforts: reasoning.supportedReasoningEfforts,
|
||||||
|
supportedSpeeds: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function displayModelName(provider, modelName, fallback) {
|
function displayModelName(provider, modelName, fallback) {
|
||||||
const displayNames = isRecord(provider.modelDisplayNames) ? provider.modelDisplayNames : {};
|
const displayNames = isRecord(provider.modelDisplayNames) ? provider.modelDisplayNames : {};
|
||||||
const descriptions = isRecord(provider.modelDescriptions) ? provider.modelDescriptions : {};
|
return stringValue(displayNames[modelName]) || fallback;
|
||||||
return stringValue(displayNames[modelName]) || stringValue(descriptions[modelName]) || fallback;
|
}
|
||||||
|
|
||||||
|
function codexModelReasoningProfile(model) {
|
||||||
|
const entry = findModelCatalogEntry(model);
|
||||||
|
const capabilities = isRecord(entry?.capabilities) ? entry.capabilities : {};
|
||||||
|
const effortConfig = modelCatalogReasoningEffortConfig(entry, providerNameFromModel(model));
|
||||||
|
const fallbackEfforts = effortConfig.efforts.length === 0 && openAiGptReasoningFallbackApplies(model)
|
||||||
|
? OPENAI_REASONING_EFFORTS
|
||||||
|
: [];
|
||||||
|
const reasoningConfig = fallbackEfforts.length > 0
|
||||||
|
? { ...effortConfig, defaultEffort: "medium", efforts: fallbackEfforts, supportsReasoning: true }
|
||||||
|
: effortConfig;
|
||||||
|
const supportsReasoning = capabilities.reasoning === true || reasoningConfig.supportsReasoning;
|
||||||
|
return {
|
||||||
|
defaultReasoningEffort: defaultReasoningEffort(reasoningConfig),
|
||||||
|
defaultReasoningLevel: defaultReasoningLevel(reasoningConfig),
|
||||||
|
supportedReasoningEfforts: reasoningConfig.efforts,
|
||||||
|
supportedReasoningLevels: reasoningConfig.efforts.map(reasoningLevel),
|
||||||
|
supportsReasoning
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAiGptReasoningFallbackApplies(model) {
|
||||||
|
const provider = normalizeModelCatalogToken(providerNameFromModel(model));
|
||||||
|
const modelName = normalizeModelCatalogToken(modelNameFromModel(model));
|
||||||
|
const openAiProvider = provider.includes("openai") || provider.includes("codex-api");
|
||||||
|
return openAiProvider && (/^gpt-[0-9]/.test(modelName) || /^o[0-9]/.test(modelName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelCatalogReasoningEffortConfig(entry, providerName) {
|
||||||
|
if (!entry) {
|
||||||
|
return { defaultEffort: "", efforts: [], supportsReasoning: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = sourceRecordsForProvider(entry.sourceRecords, providerName);
|
||||||
|
const metadataValues = [
|
||||||
|
entry.metadata,
|
||||||
|
...records.map((record) => record.metadata)
|
||||||
|
].filter(isRecord);
|
||||||
|
|
||||||
|
let defaultEffort = "";
|
||||||
|
let supportsReasoning = false;
|
||||||
|
const efforts = [];
|
||||||
|
for (const metadata of metadataValues) {
|
||||||
|
const config = reasoningConfigFromMetadata(metadata);
|
||||||
|
if (config.supportsReasoning) {
|
||||||
|
supportsReasoning = true;
|
||||||
|
}
|
||||||
|
if (!defaultEffort && config.defaultEffort) {
|
||||||
|
defaultEffort = config.defaultEffort;
|
||||||
|
}
|
||||||
|
for (const effort of config.efforts) {
|
||||||
|
if (!efforts.includes(effort)) {
|
||||||
|
efforts.push(effort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultEffort, efforts, supportsReasoning };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceRecordsForProvider(records, providerName) {
|
||||||
|
const normalizedProviderName = normalizeModelCatalogToken(providerName);
|
||||||
|
if (!normalizedProviderName) return [];
|
||||||
|
return records.filter((record) => {
|
||||||
|
const provider = normalizeModelCatalogToken(record.provider);
|
||||||
|
const displayName = normalizeModelCatalogToken(record.providerName);
|
||||||
|
return [provider, displayName].some((value) =>
|
||||||
|
value &&
|
||||||
|
(
|
||||||
|
value === normalizedProviderName ||
|
||||||
|
value.includes(normalizedProviderName) ||
|
||||||
|
normalizedProviderName.includes(value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningConfigFromMetadata(metadata) {
|
||||||
|
const efforts = [];
|
||||||
|
let defaultEffort = "";
|
||||||
|
let supportsReasoning = false;
|
||||||
|
|
||||||
|
const reasoning = isRecord(metadata.reasoning) ? metadata.reasoning : undefined;
|
||||||
|
if (reasoning) {
|
||||||
|
supportsReasoning = true;
|
||||||
|
for (const effort of normalizeReasoningEfforts(reasoning.supported_efforts)) {
|
||||||
|
if (!efforts.includes(effort)) {
|
||||||
|
efforts.push(effort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultEffort = normalizeReasoningEffort(reasoning.default_effort);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = Array.isArray(metadata.reasoningOptions) ? metadata.reasoningOptions : [];
|
||||||
|
for (const option of options) {
|
||||||
|
if (!isRecord(option)) continue;
|
||||||
|
const type = stringValue(option.type).toLowerCase();
|
||||||
|
if (type === "toggle" || type === "budget_tokens") {
|
||||||
|
supportsReasoning = true;
|
||||||
|
}
|
||||||
|
if (type !== "effort") continue;
|
||||||
|
supportsReasoning = true;
|
||||||
|
for (const effort of normalizeReasoningEfforts(option.values)) {
|
||||||
|
if (!efforts.includes(effort)) {
|
||||||
|
efforts.push(effort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultEffort, efforts, supportsReasoning };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReasoningEfforts(value) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map(normalizeReasoningEffort)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((effort, index, efforts) => efforts.indexOf(effort) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReasoningEffort(value) {
|
||||||
|
const normalized = stringValue(value).toLowerCase().replace(/[_\s-]+/g, "");
|
||||||
|
if (!normalized || normalized === "default") return "";
|
||||||
|
if (normalized === "none" || normalized === "off" || normalized === "disabled") return "none";
|
||||||
|
if (normalized === "minimal") return "minimal";
|
||||||
|
if (normalized === "low") return "low";
|
||||||
|
if (normalized === "medium") return "medium";
|
||||||
|
if (normalized === "high") return "high";
|
||||||
|
if (normalized === "xhigh" || normalized === "extrahigh" || normalized === "max") return "xhigh";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReasoningLevel(config) {
|
||||||
|
if (!config.defaultEffort || config.defaultEffort === "none") return null;
|
||||||
|
return config.efforts.includes(config.defaultEffort) ? config.defaultEffort : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReasoningEffort(config) {
|
||||||
|
return defaultReasoningLevel(config) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningLevel(effort) {
|
||||||
|
const descriptions = {
|
||||||
|
high: "High reasoning",
|
||||||
|
low: "Low reasoning",
|
||||||
|
medium: "Medium reasoning",
|
||||||
|
minimal: "Minimal reasoning",
|
||||||
|
none: "No reasoning",
|
||||||
|
xhigh: "Extra high reasoning"
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
effort,
|
||||||
|
description: descriptions[effort] || `${effort} reasoning`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findModelCatalogEntry(model) {
|
||||||
|
const index = loadModelCatalogIndex();
|
||||||
|
const candidates = modelCatalogLookupKeys(model);
|
||||||
|
for (const key of candidates) {
|
||||||
|
const entry = index.byKey.get(key);
|
||||||
|
if (entry) return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const modelKey = modelCatalogLastSegmentKey(key);
|
||||||
|
if (!modelKey) continue;
|
||||||
|
const entry = index.byModelKey.get(modelKey);
|
||||||
|
if (entry) return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModelCatalogIndex() {
|
||||||
|
if (modelCatalogIndex) return modelCatalogIndex;
|
||||||
|
|
||||||
|
const payload = loadModelCatalogPayload();
|
||||||
|
modelCatalogIndex = buildModelCatalogIndex(payload);
|
||||||
|
return modelCatalogIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModelCatalogPayload() {
|
||||||
|
for (const candidate of modelCatalogPathCandidates()) {
|
||||||
|
if (!fs.existsSync(candidate)) continue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(candidate, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelCatalogPathCandidates() {
|
||||||
|
return uniqueStrings([
|
||||||
|
stringValue(process.env.CCR_MODEL_CATALOG_PATH),
|
||||||
|
stringValue(process.env.CCR_MODELS_JSON_PATH),
|
||||||
|
path.resolve(process.cwd(), "models.json"),
|
||||||
|
path.resolve(process.cwd(), "packages", "core", "models.json"),
|
||||||
|
path.resolve(process.cwd(), "packages", "cli", "models.json"),
|
||||||
|
path.resolve(__dirname, "models.json"),
|
||||||
|
path.resolve(__dirname, "..", "models.json"),
|
||||||
|
path.resolve(__dirname, "..", "..", "models.json"),
|
||||||
|
path.resolve(__dirname, "..", "..", "..", "models.json"),
|
||||||
|
path.resolve(__dirname, "..", "..", "..", "packages", "core", "models.json"),
|
||||||
|
path.resolve(__dirname, "..", "..", "..", "packages", "cli", "models.json")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelCatalogIndex(payload) {
|
||||||
|
const byKey = new Map();
|
||||||
|
const byModelKey = new Map();
|
||||||
|
const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : [];
|
||||||
|
|
||||||
|
for (const item of models) {
|
||||||
|
const entry = parseModelCatalogEntry(item);
|
||||||
|
if (!entry) continue;
|
||||||
|
|
||||||
|
for (const key of modelCatalogEntryKeys(entry)) {
|
||||||
|
byKey.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortKeys = uniqueStrings([
|
||||||
|
entry.model ? normalizeModelCatalogToken(entry.model) : "",
|
||||||
|
...entry.aliases.map((alias) => modelCatalogLastSegmentKey(normalizeModelCatalogKey(alias)))
|
||||||
|
]);
|
||||||
|
for (const key of shortKeys) {
|
||||||
|
if (!key) continue;
|
||||||
|
if (byModelKey.has(key) && byModelKey.get(key) !== entry) {
|
||||||
|
byModelKey.set(key, undefined);
|
||||||
|
} else {
|
||||||
|
byModelKey.set(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { byKey, byModelKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModelCatalogEntry(value) {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
const id = stringValue(value.id);
|
||||||
|
if (!id) return undefined;
|
||||||
|
return {
|
||||||
|
aliases: uniqueStrings([id, ...stringListValue(value.aliases)]),
|
||||||
|
capabilities: isRecord(value.capabilities) ? value.capabilities : undefined,
|
||||||
|
id,
|
||||||
|
metadata: isRecord(value.metadata) ? value.metadata : undefined,
|
||||||
|
model: stringValue(value.model),
|
||||||
|
providers: stringListValue(value.providers),
|
||||||
|
sourceRecords: sourceRecordListValue(value.sourceRecords)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceRecordListValue(value) {
|
||||||
|
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelCatalogEntryKeys(entry) {
|
||||||
|
return uniqueStrings([
|
||||||
|
normalizeModelCatalogKey(entry.id),
|
||||||
|
...entry.aliases.map(normalizeModelCatalogKey),
|
||||||
|
...entry.providers.map((provider) => entry.model ? normalizeModelCatalogKey(`${provider}/${entry.model}`) : "")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelCatalogLookupKeys(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
const normalized = normalizeModelCatalogKey(raw);
|
||||||
|
const withoutClaudePrefix = raw.toLowerCase().startsWith("claude-") && raw.includes("/")
|
||||||
|
? normalizeModelCatalogKey(raw.replace(/^claude-/i, ""))
|
||||||
|
: "";
|
||||||
|
return uniqueStrings([normalized, withoutClaudePrefix]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelCatalogKey(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.split("/")
|
||||||
|
.map(normalizeModelCatalogToken)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelCatalogToken(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^hf:/i, "")
|
||||||
|
.replace(/^@/, "")
|
||||||
|
.replace(/[_\s]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelCatalogLastSegmentKey(value) {
|
||||||
|
return value.split("/").filter(Boolean).at(-1) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleVirtualProfile(profile) {
|
function isVisibleVirtualProfile(profile) {
|
||||||
|
|
@ -1438,6 +1834,22 @@ function normalizeStringArray(value) {
|
||||||
return items.length ? items : undefined;
|
return items.length ? items : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringListValue(value) {
|
||||||
|
return Array.isArray(value) ? value.map((item) => stringValue(item)).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values) {
|
||||||
|
const seen = new Set();
|
||||||
|
const strings = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = stringValue(value);
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
strings.push(trimmed);
|
||||||
|
}
|
||||||
|
return strings;
|
||||||
|
}
|
||||||
|
|
||||||
function stringValue(value) {
|
function stringValue(value) {
|
||||||
return typeof value === "string" ? value.trim() : "";
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"id": "agent-console",
|
"id": "agent-console",
|
||||||
"name": "Agent Console",
|
"name": "Agent Console",
|
||||||
"module": "index.cjs",
|
"module": "index.cjs",
|
||||||
|
"permissions": ["apps", "gateway-routes", "system-launcher"],
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"id": "agent-console",
|
"id": "agent-console",
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,15 @@ import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr
|
||||||
import { launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
|
import { launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
|
||||||
import { loadAppConfig } from "@ccr/core/config/config";
|
import { loadAppConfig } from "@ccr/core/config/config";
|
||||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||||
|
import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat";
|
||||||
import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service";
|
import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service";
|
||||||
import { ensureProfileGateway } from "@ccr/core/profiles/launch-service";
|
import { ensureProfileGateway } from "@ccr/core/profiles/launch-service";
|
||||||
import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
|
import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
|
||||||
import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server";
|
import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server";
|
||||||
import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app";
|
import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app";
|
||||||
|
|
||||||
|
installSocketTypeOfServiceCompat();
|
||||||
|
|
||||||
type ProfileCliOptions = {
|
type ProfileCliOptions = {
|
||||||
agentArgs: string[];
|
agentArgs: string[];
|
||||||
command: "profile";
|
command: "profile";
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,16 @@ import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, Virtual
|
||||||
import {
|
import {
|
||||||
findModelCatalogEntry,
|
findModelCatalogEntry,
|
||||||
modelCatalogMaxInputTokens,
|
modelCatalogMaxInputTokens,
|
||||||
|
modelCatalogReasoningEffortConfig,
|
||||||
readCatalogCapability,
|
readCatalogCapability,
|
||||||
|
type ModelCatalogReasoningEffortConfig,
|
||||||
type ModelCatalogEntry
|
type ModelCatalogEntry
|
||||||
} from "@ccr/core/gateway/model-catalog";
|
} from "@ccr/core/gateway/model-catalog";
|
||||||
|
|
||||||
const fusionModelProviderName = "Fusion";
|
const fusionModelProviderName = "Fusion";
|
||||||
const codexDefaultContextWindow = 128_000;
|
const codexDefaultContextWindow = 128_000;
|
||||||
const codexEffectiveContextWindowPercent = 95;
|
const codexEffectiveContextWindowPercent = 95;
|
||||||
|
const openAiReasoningEfforts = ["minimal", "low", "medium", "high"];
|
||||||
|
|
||||||
export type CodexModelCatalog = {
|
export type CodexModelCatalog = {
|
||||||
models: CodexModelCatalogItem[];
|
models: CodexModelCatalogItem[];
|
||||||
|
|
@ -120,7 +123,7 @@ function codexModelCatalogItem(
|
||||||
availability_nux: null,
|
availability_nux: null,
|
||||||
base_instructions: "You are Codex, a coding agent.",
|
base_instructions: "You are Codex, a coding agent.",
|
||||||
context_window: contextWindow,
|
context_window: contextWindow,
|
||||||
default_reasoning_level: profile.supportsReasoning ? "medium" : null,
|
default_reasoning_level: profile.defaultReasoningLevel,
|
||||||
default_reasoning_summary: "none",
|
default_reasoning_summary: "none",
|
||||||
description: `CCR gateway model ${model}`,
|
description: `CCR gateway model ${model}`,
|
||||||
display_name: model,
|
display_name: model,
|
||||||
|
|
@ -149,6 +152,7 @@ function codexModelCatalogItem(
|
||||||
type CodexCapabilityProfile = {
|
type CodexCapabilityProfile = {
|
||||||
applyPatchToolType: string | null;
|
applyPatchToolType: string | null;
|
||||||
catalogEntry?: ModelCatalogEntry;
|
catalogEntry?: ModelCatalogEntry;
|
||||||
|
defaultReasoningLevel: string | null;
|
||||||
inputModalities: string[];
|
inputModalities: string[];
|
||||||
supportedReasoningLevels: Array<{ description: string; effort: string }>;
|
supportedReasoningLevels: Array<{ description: string; effort: string }>;
|
||||||
supportsImageInput: boolean;
|
supportsImageInput: boolean;
|
||||||
|
|
@ -168,7 +172,14 @@ function codexModelCapabilityProfile(
|
||||||
const providerProtocol = provider ? codexProviderProtocol(provider) : undefined;
|
const providerProtocol = provider ? codexProviderProtocol(provider) : undefined;
|
||||||
const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false;
|
const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false;
|
||||||
const supportsFusionWebSearch = codexVirtualModelSupportsFusionWebSearch(model, config);
|
const supportsFusionWebSearch = codexVirtualModelSupportsFusionWebSearch(model, config);
|
||||||
const supportsReasoning = readCatalogCapability(capabilities, "reasoning");
|
const reasoningConfig = modelCatalogReasoningEffortConfig(catalogEntry, selector?.provider);
|
||||||
|
const fallbackEfforts = reasoningConfig.efforts.length === 0 && openAiGptReasoningFallbackApplies(model, selector?.provider)
|
||||||
|
? openAiReasoningEfforts
|
||||||
|
: [];
|
||||||
|
const effectiveReasoningConfig = fallbackEfforts.length > 0
|
||||||
|
? { ...reasoningConfig, defaultEffort: "medium", efforts: fallbackEfforts, supportsReasoning: true }
|
||||||
|
: reasoningConfig;
|
||||||
|
const supportsReasoning = readCatalogCapability(capabilities, "reasoning") || effectiveReasoningConfig.supportsReasoning;
|
||||||
const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry);
|
const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry);
|
||||||
const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling");
|
const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling");
|
||||||
const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config)
|
const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config)
|
||||||
|
|
@ -188,8 +199,9 @@ function codexModelCapabilityProfile(
|
||||||
return {
|
return {
|
||||||
applyPatchToolType,
|
applyPatchToolType,
|
||||||
catalogEntry,
|
catalogEntry,
|
||||||
|
defaultReasoningLevel: defaultReasoningLevel(effectiveReasoningConfig),
|
||||||
inputModalities: supportsImageInput ? ["text", "image"] : ["text"],
|
inputModalities: supportsImageInput ? ["text", "image"] : ["text"],
|
||||||
supportedReasoningLevels: supportsReasoning ? supportedReasoningLevels(capabilities) : [],
|
supportedReasoningLevels: effectiveReasoningConfig.efforts.map(reasoningLevel),
|
||||||
supportsImageInput,
|
supportsImageInput,
|
||||||
supportsParallelToolCalls,
|
supportsParallelToolCalls,
|
||||||
supportsReasoning,
|
supportsReasoning,
|
||||||
|
|
@ -210,16 +222,42 @@ function catalogEntrySupportsImageInput(entry: ModelCatalogEntry | undefined): b
|
||||||
readCatalogCapability(capabilities, "multimodal");
|
readCatalogCapability(capabilities, "multimodal");
|
||||||
}
|
}
|
||||||
|
|
||||||
function supportedReasoningLevels(capabilities: Record<string, unknown>): Array<{ description: string; effort: string }> {
|
function openAiGptReasoningFallbackApplies(model: string, providerName?: string): boolean {
|
||||||
const levels = [
|
const selector = parseModelSelector(model);
|
||||||
{ effort: "low", description: "Low reasoning" },
|
const provider = normalizeReasoningFallbackToken(providerName ?? selector?.provider ?? "");
|
||||||
{ effort: "medium", description: "Medium reasoning" },
|
const modelName = normalizeReasoningFallbackToken(selector?.model ?? model);
|
||||||
{ effort: "high", description: "High reasoning" }
|
const openAiProvider = provider.includes("openai") || provider.includes("codex-api");
|
||||||
];
|
return openAiProvider && (/^gpt-[0-9]/.test(modelName) || /^o[0-9]/.test(modelName));
|
||||||
if (readCatalogCapability(capabilities, "xhighReasoningEffort") || readCatalogCapability(capabilities, "maxReasoningEffort")) {
|
}
|
||||||
levels.push({ effort: "xhigh", description: "Extra high reasoning" });
|
|
||||||
}
|
function normalizeReasoningFallbackToken(value: string): string {
|
||||||
return levels;
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/^hf:/i, "")
|
||||||
|
.replace(/^@/, "")
|
||||||
|
.replace(/[_\s]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReasoningLevel(config: ModelCatalogReasoningEffortConfig): string | null {
|
||||||
|
if (!config.defaultEffort || config.defaultEffort === "none") return null;
|
||||||
|
return config.efforts.includes(config.defaultEffort) ? config.defaultEffort : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningLevel(effort: string): { description: string; effort: string } {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
high: "High reasoning",
|
||||||
|
low: "Low reasoning",
|
||||||
|
medium: "Medium reasoning",
|
||||||
|
minimal: "Minimal reasoning",
|
||||||
|
none: "No reasoning",
|
||||||
|
xhigh: "Extra high reasoning"
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
effort,
|
||||||
|
description: descriptions[effort] || `${effort} reasoning`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findConfiguredProvider(
|
function findConfiguredProvider(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/con
|
||||||
import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store";
|
import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store";
|
||||||
import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants";
|
import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants";
|
||||||
import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex";
|
import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex";
|
||||||
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app";
|
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, GATEWAY_PLUGIN_PERMISSION_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app";
|
||||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
|
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
|
||||||
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
|
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -21,6 +21,7 @@ import type {
|
||||||
GatewayPluginConfig,
|
GatewayPluginConfig,
|
||||||
GatewayPluginAppConfig,
|
GatewayPluginAppConfig,
|
||||||
GatewayPluginProxyRouteConfig,
|
GatewayPluginProxyRouteConfig,
|
||||||
|
GatewayPluginPermission,
|
||||||
GatewayProviderCapability,
|
GatewayProviderCapability,
|
||||||
GatewayProviderConfig,
|
GatewayProviderConfig,
|
||||||
GatewayProviderProtocol,
|
GatewayProviderProtocol,
|
||||||
|
|
@ -95,6 +96,7 @@ const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([
|
||||||
]);
|
]);
|
||||||
const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1";
|
const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1";
|
||||||
const GENERATED_GATEWAY_API_KEY_ID = "local-gateway";
|
const GENERATED_GATEWAY_API_KEY_ID = "local-gateway";
|
||||||
|
const GATEWAY_PLUGIN_PERMISSION_ID_SET = new Set<string>(GATEWAY_PLUGIN_PERMISSION_IDS);
|
||||||
|
|
||||||
const DEFAULT_CONFIG: AppConfig = createDefaultAppConfig({
|
const DEFAULT_CONFIG: AppConfig = createDefaultAppConfig({
|
||||||
coreHost: INTERNAL_GATEWAY_CORE_HOST,
|
coreHost: INTERNAL_GATEWAY_CORE_HOST,
|
||||||
|
|
@ -1955,6 +1957,7 @@ function parseGatewayPlugins(value: unknown): GatewayPluginConfig[] | undefined
|
||||||
const apps = parseGatewayPluginApps(item.apps);
|
const apps = parseGatewayPluginApps(item.apps);
|
||||||
const proxyRoutes = parseGatewayPluginProxyRoutes(isObject(item.proxy) ? item.proxy.routes : undefined);
|
const proxyRoutes = parseGatewayPluginProxyRoutes(isObject(item.proxy) ? item.proxy.routes : undefined);
|
||||||
const coreGateway = parseGatewayPluginCoreGateway(item.coreGateway);
|
const coreGateway = parseGatewayPluginCoreGateway(item.coreGateway);
|
||||||
|
const permissions = parseGatewayPluginPermissions(item.permissions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(apps ? { apps } : {}),
|
...(apps ? { apps } : {}),
|
||||||
|
|
@ -1963,6 +1966,7 @@ function parseGatewayPlugins(value: unknown): GatewayPluginConfig[] | undefined
|
||||||
enabled: typeof item.enabled === "boolean" ? item.enabled : true,
|
enabled: typeof item.enabled === "boolean" ? item.enabled : true,
|
||||||
id,
|
id,
|
||||||
...(modulePath ? { module: modulePath } : {}),
|
...(modulePath ? { module: modulePath } : {}),
|
||||||
|
...(permissions !== undefined ? { permissions } : {}),
|
||||||
...(proxyRoutes ? { proxy: { routes: proxyRoutes } } : {})
|
...(proxyRoutes ? { proxy: { routes: proxyRoutes } } : {})
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
@ -1971,6 +1975,100 @@ function parseGatewayPlugins(value: unknown): GatewayPluginConfig[] | undefined
|
||||||
return plugins.length ? plugins : undefined;
|
return plugins.length ? plugins : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGatewayPluginPermissions(value: unknown): GatewayPluginPermission[] | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: GatewayPluginPermission[] = [];
|
||||||
|
const seen = new Set<GatewayPluginPermission>();
|
||||||
|
const add = (rawValue: unknown): void => {
|
||||||
|
const permission = normalizeGatewayPluginPermission(rawValue);
|
||||||
|
if (!permission || seen.has(permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(permission);
|
||||||
|
permissions.push(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
add(value);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
value.forEach(add);
|
||||||
|
} else if (isObject(value)) {
|
||||||
|
for (const [key, enabled] of Object.entries(value)) {
|
||||||
|
if (enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isAllGatewayPluginPermissionsKey(key)) {
|
||||||
|
GATEWAY_PLUGIN_PERMISSION_IDS.forEach(add);
|
||||||
|
} else {
|
||||||
|
add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGatewayPluginPermission(value: unknown): GatewayPluginPermission | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
||||||
|
const mapped = gatewayPluginPermissionAlias(normalized);
|
||||||
|
return GATEWAY_PLUGIN_PERMISSION_ID_SET.has(mapped) ? mapped as GatewayPluginPermission : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewayPluginPermissionAlias(value: string): string {
|
||||||
|
switch (value) {
|
||||||
|
case "app":
|
||||||
|
case "browser-app":
|
||||||
|
case "browser-apps":
|
||||||
|
return "apps";
|
||||||
|
case "gateway-route":
|
||||||
|
case "route":
|
||||||
|
case "routes":
|
||||||
|
return "gateway-routes";
|
||||||
|
case "proxy":
|
||||||
|
case "proxy-route":
|
||||||
|
return "proxy-routes";
|
||||||
|
case "backend":
|
||||||
|
case "backends":
|
||||||
|
case "http-backend":
|
||||||
|
return "http-backends";
|
||||||
|
case "provider-account":
|
||||||
|
case "provider-account-connector":
|
||||||
|
return "provider-account-connectors";
|
||||||
|
case "core-gateway":
|
||||||
|
return "core-gateway-config";
|
||||||
|
case "provider-plugin":
|
||||||
|
case "provider-plugins":
|
||||||
|
case "core-provider-plugin":
|
||||||
|
return "core-provider-plugins";
|
||||||
|
case "fusion-profile":
|
||||||
|
case "fusion-profiles":
|
||||||
|
case "virtual-model":
|
||||||
|
case "virtual-models":
|
||||||
|
case "virtual-model-profile":
|
||||||
|
return "virtual-model-profiles";
|
||||||
|
case "sqlite":
|
||||||
|
case "data-store":
|
||||||
|
case "store":
|
||||||
|
return "sqlite-store";
|
||||||
|
case "launcher":
|
||||||
|
case "mac-launcher":
|
||||||
|
return "system-launcher";
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllGatewayPluginPermissionsKey(value: string): boolean {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized === "*" || normalized === "all";
|
||||||
|
}
|
||||||
|
|
||||||
function parseGatewayPluginApps(value: unknown): GatewayPluginAppConfig[] | undefined {
|
function parseGatewayPluginApps(value: unknown): GatewayPluginAppConfig[] | undefined {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,21 @@ export type GatewayPluginAppConfig = {
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GATEWAY_PLUGIN_PERMISSION_IDS = [
|
||||||
|
"apps",
|
||||||
|
"gateway-routes",
|
||||||
|
"proxy-routes",
|
||||||
|
"http-backends",
|
||||||
|
"provider-account-connectors",
|
||||||
|
"core-gateway-config",
|
||||||
|
"core-provider-plugins",
|
||||||
|
"virtual-model-profiles",
|
||||||
|
"sqlite-store",
|
||||||
|
"system-launcher"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GatewayPluginPermission = typeof GATEWAY_PLUGIN_PERMISSION_IDS[number];
|
||||||
|
|
||||||
export type GatewayMcpServerTransport = "stdio" | "streamable-http" | "sse";
|
export type GatewayMcpServerTransport = "stdio" | "streamable-http" | "sse";
|
||||||
export type GatewayMcpStdioMessageMode = "content-length" | "newline-json";
|
export type GatewayMcpStdioMessageMode = "content-length" | "newline-json";
|
||||||
|
|
||||||
|
|
@ -860,6 +875,7 @@ export type GatewayPluginConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
module?: string;
|
module?: string;
|
||||||
|
permissions?: GatewayPluginPermission[];
|
||||||
proxy?: {
|
proxy?: {
|
||||||
routes?: GatewayPluginProxyRouteConfig[];
|
routes?: GatewayPluginProxyRouteConfig[];
|
||||||
};
|
};
|
||||||
|
|
@ -869,6 +885,7 @@ export type PluginDependency = {
|
||||||
id: string;
|
id: string;
|
||||||
modulePath?: string;
|
modulePath?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
permissions?: GatewayPluginPermission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginDirectorySelection = {
|
export type PluginDirectorySelection = {
|
||||||
|
|
@ -878,6 +895,7 @@ export type PluginDirectorySelection = {
|
||||||
id: string;
|
id: string;
|
||||||
modulePath: string;
|
modulePath: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
permissions?: GatewayPluginPermission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginMarketplaceEntry = {
|
export type PluginMarketplaceEntry = {
|
||||||
|
|
@ -888,6 +906,7 @@ export type PluginMarketplaceEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
modulePath: string;
|
modulePath: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
permissions?: GatewayPluginPermission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProxyRuntimeConfig = {
|
export type ProxyRuntimeConfig = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat";
|
||||||
import { startWebManagementServer } from "@ccr/core/web/management-server";
|
import { startWebManagementServer } from "@ccr/core/web/management-server";
|
||||||
|
|
||||||
|
installSocketTypeOfServiceCompat();
|
||||||
|
|
||||||
type CoreServerOptions = {
|
type CoreServerOptions = {
|
||||||
help: boolean;
|
help: boolean;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,18 @@ type ModelCatalogModalities = {
|
||||||
output?: string[];
|
output?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ModelCatalogReasoningEffortConfig = {
|
||||||
|
defaultEffort: string;
|
||||||
|
efforts: string[];
|
||||||
|
supportsReasoning: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelCatalogSourceRecord = {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
provider?: string;
|
||||||
|
providerName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ModelCatalogEntry = {
|
export type ModelCatalogEntry = {
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
capabilities?: ModelCatalogCapabilities;
|
capabilities?: ModelCatalogCapabilities;
|
||||||
|
|
@ -25,9 +37,11 @@ export type ModelCatalogEntry = {
|
||||||
family?: string;
|
family?: string;
|
||||||
id: string;
|
id: string;
|
||||||
limits?: ModelCatalogLimits;
|
limits?: ModelCatalogLimits;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
modalities?: ModelCatalogModalities;
|
modalities?: ModelCatalogModalities;
|
||||||
model?: string;
|
model?: string;
|
||||||
providers?: string[];
|
providers?: string[];
|
||||||
|
sourceRecords?: ModelCatalogSourceRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModelCatalogIndex = {
|
type ModelCatalogIndex = {
|
||||||
|
|
@ -88,6 +102,38 @@ export function readCatalogCapability(capabilities: ModelCatalogCapabilities, ke
|
||||||
return capabilities[key] === true;
|
return capabilities[key] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function modelCatalogReasoningEffortConfig(entry: ModelCatalogEntry | undefined, providerName = ""): ModelCatalogReasoningEffortConfig {
|
||||||
|
if (!entry) {
|
||||||
|
return { defaultEffort: "", efforts: [], supportsReasoning: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = sourceRecordsForProvider(entry.sourceRecords ?? [], providerName);
|
||||||
|
const metadataValues = [
|
||||||
|
entry.metadata,
|
||||||
|
...records.map((record) => record.metadata)
|
||||||
|
].filter(isRecord);
|
||||||
|
|
||||||
|
let defaultEffort = "";
|
||||||
|
let supportsReasoning = false;
|
||||||
|
const efforts: string[] = [];
|
||||||
|
for (const metadata of metadataValues) {
|
||||||
|
const config = reasoningConfigFromMetadata(metadata);
|
||||||
|
if (config.supportsReasoning) {
|
||||||
|
supportsReasoning = true;
|
||||||
|
}
|
||||||
|
if (!defaultEffort && config.defaultEffort) {
|
||||||
|
defaultEffort = config.defaultEffort;
|
||||||
|
}
|
||||||
|
for (const effort of config.efforts) {
|
||||||
|
if (!efforts.includes(effort)) {
|
||||||
|
efforts.push(effort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultEffort, efforts, supportsReasoning };
|
||||||
|
}
|
||||||
|
|
||||||
function loadModelCatalogIndex(): ModelCatalogIndex {
|
function loadModelCatalogIndex(): ModelCatalogIndex {
|
||||||
if (modelCatalogIndex) {
|
if (modelCatalogIndex) {
|
||||||
return modelCatalogIndex;
|
return modelCatalogIndex;
|
||||||
|
|
@ -162,12 +208,26 @@ function parseModelCatalogEntry(value: unknown): ModelCatalogEntry | undefined {
|
||||||
family: stringValue(value.family),
|
family: stringValue(value.family),
|
||||||
id,
|
id,
|
||||||
limits,
|
limits,
|
||||||
|
metadata: isRecord(value.metadata) ? value.metadata : undefined,
|
||||||
modalities,
|
modalities,
|
||||||
model: stringValue(value.model),
|
model: stringValue(value.model),
|
||||||
providers: stringListValue(value.providers)
|
providers: stringListValue(value.providers),
|
||||||
|
sourceRecords: parseModelCatalogSourceRecords(value.sourceRecords)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseModelCatalogSourceRecords(value: unknown): ModelCatalogSourceRecord[] | undefined {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const records = value.filter(isRecord).map((record) => ({
|
||||||
|
metadata: isRecord(record.metadata) ? record.metadata : undefined,
|
||||||
|
provider: stringValue(record.provider),
|
||||||
|
providerName: stringValue(record.providerName)
|
||||||
|
}));
|
||||||
|
return records.length > 0 ? records : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function parseModelCatalogLimits(value: unknown): ModelCatalogLimits | undefined {
|
function parseModelCatalogLimits(value: unknown): ModelCatalogLimits | undefined {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -233,6 +293,90 @@ function modelCatalogLastSegmentKey(value: string): string {
|
||||||
return value.split("/").filter(Boolean).at(-1) ?? "";
|
return value.split("/").filter(Boolean).at(-1) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceRecordsForProvider(records: ModelCatalogSourceRecord[], providerName: string): ModelCatalogSourceRecord[] {
|
||||||
|
const normalizedProviderName = normalizeModelCatalogToken(providerName);
|
||||||
|
if (!normalizedProviderName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return records.filter((record) => {
|
||||||
|
const provider = normalizeModelCatalogToken(record.provider ?? "");
|
||||||
|
const displayName = normalizeModelCatalogToken(record.providerName ?? "");
|
||||||
|
return [provider, displayName].some((value) =>
|
||||||
|
value &&
|
||||||
|
(
|
||||||
|
value === normalizedProviderName ||
|
||||||
|
value.includes(normalizedProviderName) ||
|
||||||
|
normalizedProviderName.includes(value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningConfigFromMetadata(metadata: Record<string, unknown>): ModelCatalogReasoningEffortConfig {
|
||||||
|
const efforts: string[] = [];
|
||||||
|
let defaultEffort = "";
|
||||||
|
let supportsReasoning = false;
|
||||||
|
|
||||||
|
const reasoning = isRecord(metadata.reasoning) ? metadata.reasoning : undefined;
|
||||||
|
if (reasoning) {
|
||||||
|
supportsReasoning = true;
|
||||||
|
for (const effort of normalizeReasoningEfforts(reasoning.supported_efforts)) {
|
||||||
|
if (!efforts.includes(effort)) {
|
||||||
|
efforts.push(effort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultEffort = normalizeReasoningEffort(reasoning.default_effort);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = Array.isArray(metadata.reasoningOptions) ? metadata.reasoningOptions : [];
|
||||||
|
for (const option of options) {
|
||||||
|
if (!isRecord(option)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = stringValue(option.type)?.toLowerCase();
|
||||||
|
if (type === "toggle" || type === "budget_tokens") {
|
||||||
|
supportsReasoning = true;
|
||||||
|
}
|
||||||
|
if (type !== "effort") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
supportsReasoning = true;
|
||||||
|
for (const effort of normalizeReasoningEfforts(option.values)) {
|
||||||
|
if (!efforts.includes(effort)) {
|
||||||
|
efforts.push(effort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultEffort, efforts, supportsReasoning };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReasoningEfforts(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const efforts: string[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
const effort = normalizeReasoningEffort(item);
|
||||||
|
if (effort && !efforts.includes(effort)) {
|
||||||
|
efforts.push(effort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return efforts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReasoningEffort(value: unknown): string {
|
||||||
|
const normalized = stringValue(value)?.toLowerCase().replace(/[_\s-]+/g, "") ?? "";
|
||||||
|
if (!normalized || normalized === "default") return "";
|
||||||
|
if (normalized === "none" || normalized === "off" || normalized === "disabled") return "none";
|
||||||
|
if (normalized === "minimal") return "minimal";
|
||||||
|
if (normalized === "low") return "low";
|
||||||
|
if (normalized === "medium") return "medium";
|
||||||
|
if (normalized === "high") return "high";
|
||||||
|
if (normalized === "xhigh" || normalized === "extrahigh" || normalized === "max") return "xhigh";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function readCatalogPositiveInteger(value: unknown): number | undefined {
|
function readCatalogPositiveInteger(value: unknown): number | undefined {
|
||||||
const parsed = numberValue(value);
|
const parsed = numberValue(value);
|
||||||
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ type ApiKeyLimitRule = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type GatewayStopOptions = {
|
type GatewayStopOptions = {
|
||||||
|
nextConfig?: AppConfig;
|
||||||
proxyRestoreTimeoutMs?: number;
|
proxyRestoreTimeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -352,7 +353,7 @@ class GatewayService {
|
||||||
state: "error"
|
state: "error"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await this.stop();
|
await this.stop({ nextConfig: config });
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.coreAuthToken = generateCoreGatewayAuthToken();
|
this.coreAuthToken = generateCoreGatewayAuthToken();
|
||||||
this.plugin = new ClaudeCodeRouterPlugin(config);
|
this.plugin = new ClaudeCodeRouterPlugin(config);
|
||||||
|
|
@ -445,7 +446,7 @@ class GatewayService {
|
||||||
}
|
}
|
||||||
|
|
||||||
await proxyService.stop(options.proxyRestoreTimeoutMs);
|
await proxyService.stop(options.proxyRestoreTimeoutMs);
|
||||||
await pluginService.stop();
|
await pluginService.stop({ nextConfig: options.nextConfig });
|
||||||
await backendService.stopAll();
|
await backendService.stopAll();
|
||||||
await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => {
|
await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => {
|
||||||
console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`);
|
console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`);
|
||||||
|
|
|
||||||
47
packages/core/src/platform/socket-compat.ts
Normal file
47
packages/core/src/platform/socket-compat.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Socket } from "node:net";
|
||||||
|
|
||||||
|
const socketTypeOfServiceCompatState = Symbol.for("ccr.socketTypeOfServiceCompatState");
|
||||||
|
|
||||||
|
type SocketSetTypeOfService = (this: Socket, typeOfService: number) => Socket;
|
||||||
|
|
||||||
|
type SocketTypeOfServiceCompatState = {
|
||||||
|
original: SocketSetTypeOfService;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SocketPrototypeWithCompatState = Socket & {
|
||||||
|
[socketTypeOfServiceCompatState]?: SocketTypeOfServiceCompatState;
|
||||||
|
setTypeOfService?: SocketSetTypeOfService;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function installSocketTypeOfServiceCompat(): void {
|
||||||
|
const prototype = Socket.prototype as SocketPrototypeWithCompatState;
|
||||||
|
if (prototype[socketTypeOfServiceCompatState] || typeof prototype.setTypeOfService !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = prototype.setTypeOfService;
|
||||||
|
Object.defineProperty(prototype, socketTypeOfServiceCompatState, {
|
||||||
|
configurable: true,
|
||||||
|
value: { original }
|
||||||
|
});
|
||||||
|
prototype.setTypeOfService = function setTypeOfServiceCompat(this: Socket, typeOfService: number) {
|
||||||
|
try {
|
||||||
|
return original.call(this, typeOfService);
|
||||||
|
} catch (error) {
|
||||||
|
if (isIgnorableSocketTypeOfServiceError(error)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIgnorableSocketTypeOfServiceError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const code = typeof (error as NodeJS.ErrnoException).code === "string"
|
||||||
|
? (error as NodeJS.ErrnoException).code
|
||||||
|
: "";
|
||||||
|
return code === "EINVAL" && /\bsetTypeOfService\b/.test(error.message);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DATADIR } from "@ccr/core/config/constants";
|
import { DATADIR } from "@ccr/core/config/constants";
|
||||||
import type { GatewayPluginAppConfig, PluginDependency, PluginMarketplaceEntry } from "@ccr/core/contracts/app";
|
import { GATEWAY_PLUGIN_PERMISSION_IDS, type GatewayPluginAppConfig, type GatewayPluginPermission, type PluginDependency, type PluginMarketplaceEntry } from "@ccr/core/contracts/app";
|
||||||
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
||||||
|
|
||||||
type MarketplaceCache = {
|
type MarketplaceCache = {
|
||||||
|
|
@ -19,6 +19,7 @@ const marketplaceFetchTimeoutMs = 10_000;
|
||||||
const marketplaceCacheTtlMs = 5 * 60 * 1000;
|
const marketplaceCacheTtlMs = 5 * 60 * 1000;
|
||||||
const maxMarketplaceManifestBytes = 1024 * 1024;
|
const maxMarketplaceManifestBytes = 1024 * 1024;
|
||||||
const maxMarketplaceModuleBytes = 8 * 1024 * 1024;
|
const maxMarketplaceModuleBytes = 8 * 1024 * 1024;
|
||||||
|
const gatewayPluginPermissionIdSet = new Set<string>(GATEWAY_PLUGIN_PERMISSION_IDS);
|
||||||
|
|
||||||
let marketplaceCache: MarketplaceCache | undefined;
|
let marketplaceCache: MarketplaceCache | undefined;
|
||||||
let marketplaceRequest: Promise<PluginMarketplaceEntry[]> | undefined;
|
let marketplaceRequest: Promise<PluginMarketplaceEntry[]> | undefined;
|
||||||
|
|
@ -152,7 +153,8 @@ async function normalizeMarketplaceEntry(
|
||||||
description,
|
description,
|
||||||
id,
|
id,
|
||||||
modulePath,
|
modulePath,
|
||||||
name
|
name,
|
||||||
|
permissions: parsePluginPermissions(value.permissions)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +209,8 @@ async function parsePluginDependency(
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
...(modulePath ? { modulePath } : {}),
|
...(modulePath ? { modulePath } : {}),
|
||||||
...(name ? { name } : {})
|
...(name ? { name } : {}),
|
||||||
|
permissions: parsePluginPermissions(value.permissions)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,6 +320,100 @@ function readStringArray(value: unknown): string[] {
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePluginPermissions(value: unknown): GatewayPluginPermission[] | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: GatewayPluginPermission[] = [];
|
||||||
|
const seen = new Set<GatewayPluginPermission>();
|
||||||
|
const add = (rawValue: unknown): void => {
|
||||||
|
const permission = normalizePluginPermission(rawValue);
|
||||||
|
if (!permission || seen.has(permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(permission);
|
||||||
|
permissions.push(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
add(value);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
value.forEach(add);
|
||||||
|
} else if (isRecord(value)) {
|
||||||
|
for (const [key, enabled] of Object.entries(value)) {
|
||||||
|
if (enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isAllPluginPermissionsKey(key)) {
|
||||||
|
GATEWAY_PLUGIN_PERMISSION_IDS.forEach(add);
|
||||||
|
} else {
|
||||||
|
add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginPermission(value: unknown): GatewayPluginPermission | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
||||||
|
const mapped = pluginPermissionAlias(normalized);
|
||||||
|
return gatewayPluginPermissionIdSet.has(mapped) ? mapped as GatewayPluginPermission : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginPermissionAlias(value: string): string {
|
||||||
|
switch (value) {
|
||||||
|
case "app":
|
||||||
|
case "browser-app":
|
||||||
|
case "browser-apps":
|
||||||
|
return "apps";
|
||||||
|
case "gateway-route":
|
||||||
|
case "route":
|
||||||
|
case "routes":
|
||||||
|
return "gateway-routes";
|
||||||
|
case "proxy":
|
||||||
|
case "proxy-route":
|
||||||
|
return "proxy-routes";
|
||||||
|
case "backend":
|
||||||
|
case "backends":
|
||||||
|
case "http-backend":
|
||||||
|
return "http-backends";
|
||||||
|
case "provider-account":
|
||||||
|
case "provider-account-connector":
|
||||||
|
return "provider-account-connectors";
|
||||||
|
case "core-gateway":
|
||||||
|
return "core-gateway-config";
|
||||||
|
case "provider-plugin":
|
||||||
|
case "provider-plugins":
|
||||||
|
case "core-provider-plugin":
|
||||||
|
return "core-provider-plugins";
|
||||||
|
case "fusion-profile":
|
||||||
|
case "fusion-profiles":
|
||||||
|
case "virtual-model":
|
||||||
|
case "virtual-models":
|
||||||
|
case "virtual-model-profile":
|
||||||
|
return "virtual-model-profiles";
|
||||||
|
case "sqlite":
|
||||||
|
case "data-store":
|
||||||
|
case "store":
|
||||||
|
return "sqlite-store";
|
||||||
|
case "launcher":
|
||||||
|
case "mac-launcher":
|
||||||
|
return "system-launcher";
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllPluginPermissionsKey(value: string): boolean {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized === "*" || normalized === "all";
|
||||||
|
}
|
||||||
|
|
||||||
function readString(value: unknown): string | undefined {
|
function readString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -338,7 +435,11 @@ function cloneMarketplaceEntry(entry: PluginMarketplaceEntry): PluginMarketplace
|
||||||
...entry,
|
...entry,
|
||||||
apps: entry.apps?.map((app) => ({ ...app })),
|
apps: entry.apps?.map((app) => ({ ...app })),
|
||||||
capabilities: [...entry.capabilities],
|
capabilities: [...entry.capabilities],
|
||||||
dependencies: entry.dependencies.map((dependency) => ({ ...dependency }))
|
dependencies: entry.dependencies.map((dependency) => ({
|
||||||
|
...dependency,
|
||||||
|
permissions: dependency.permissions ? [...dependency.permissions] : undefined
|
||||||
|
})),
|
||||||
|
permissions: entry.permissions ? [...entry.permissions] : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,18 @@ import { createRequire } from "node:module";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import type {
|
import {
|
||||||
AppConfig,
|
GATEWAY_PLUGIN_PERMISSION_IDS,
|
||||||
GatewayPluginAppConfig,
|
type AppConfig,
|
||||||
GatewayPluginConfig,
|
type GatewayPluginAppConfig,
|
||||||
GatewayPluginProxyRouteConfig,
|
type GatewayPluginConfig,
|
||||||
GatewayProviderConfig,
|
type GatewayPluginPermission,
|
||||||
InstalledBrowserApp,
|
type GatewayPluginProxyRouteConfig,
|
||||||
ProviderAccountMeter,
|
type GatewayProviderConfig,
|
||||||
ProviderAccountPluginConnectorConfig,
|
type InstalledBrowserApp,
|
||||||
ProviderAccountSnapshot
|
type ProviderAccountMeter,
|
||||||
|
type ProviderAccountPluginConnectorConfig,
|
||||||
|
type ProviderAccountSnapshot
|
||||||
} from "@ccr/core/contracts/app";
|
} from "@ccr/core/contracts/app";
|
||||||
import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "@ccr/core/plugins/backend-service";
|
import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "@ccr/core/plugins/backend-service";
|
||||||
import { CONFIGDIR, DATADIR } from "@ccr/core/config/constants";
|
import { CONFIGDIR, DATADIR } from "@ccr/core/config/constants";
|
||||||
|
|
@ -65,6 +67,14 @@ export type GatewayPluginProviderAccountConnector = {
|
||||||
resolve: (request: GatewayPluginProviderAccountRequest) => MaybePromise<ProviderAccountMeter[] | ProviderAccountSnapshot | undefined>;
|
resolve: (request: GatewayPluginProviderAccountRequest) => MaybePromise<ProviderAccountMeter[] | ProviderAccountSnapshot | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GatewayPluginStopReason = "disabled" | "reload" | "stop";
|
||||||
|
|
||||||
|
export type GatewayPluginStopEvent = {
|
||||||
|
reason: GatewayPluginStopReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GatewayPluginStopHandler = (event?: GatewayPluginStopEvent) => MaybePromise<void>;
|
||||||
|
|
||||||
export type GatewayPluginRegistration = {
|
export type GatewayPluginRegistration = {
|
||||||
apps?: GatewayPluginAppConfig[];
|
apps?: GatewayPluginAppConfig[];
|
||||||
coreGateway?: {
|
coreGateway?: {
|
||||||
|
|
@ -73,10 +83,10 @@ export type GatewayPluginRegistration = {
|
||||||
virtualModelProfiles?: unknown[];
|
virtualModelProfiles?: unknown[];
|
||||||
};
|
};
|
||||||
gatewayRoutes?: GatewayPluginRouteRegistration[];
|
gatewayRoutes?: GatewayPluginRouteRegistration[];
|
||||||
onStop?: () => MaybePromise<void>;
|
onStop?: GatewayPluginStopHandler;
|
||||||
providerAccountConnectors?: GatewayPluginProviderAccountConnector[];
|
providerAccountConnectors?: GatewayPluginProviderAccountConnector[];
|
||||||
proxyRoutes?: GatewayPluginProxyRouteRegistration[];
|
proxyRoutes?: GatewayPluginProxyRouteRegistration[];
|
||||||
stop?: () => MaybePromise<void>;
|
stop?: GatewayPluginStopHandler;
|
||||||
virtualModelProfiles?: unknown[];
|
virtualModelProfiles?: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -90,6 +100,7 @@ export type GatewayPluginContext = {
|
||||||
};
|
};
|
||||||
pluginConfig: unknown;
|
pluginConfig: unknown;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
permissions: GatewayPluginPermission[];
|
||||||
openSqliteStore: (options?: PluginSqliteStoreOptions) => Promise<PluginSqliteStore>;
|
openSqliteStore: (options?: PluginSqliteStoreOptions) => Promise<PluginSqliteStore>;
|
||||||
registerCoreGatewayProviderPlugin: (providerPlugin: unknown) => void;
|
registerCoreGatewayProviderPlugin: (providerPlugin: unknown) => void;
|
||||||
registerCoreGatewayVirtualModelProfile: (profile: unknown) => void;
|
registerCoreGatewayVirtualModelProfile: (profile: unknown) => void;
|
||||||
|
|
@ -102,7 +113,7 @@ export type GatewayPluginContext = {
|
||||||
|
|
||||||
export type GatewayPluginRouteContext = Pick<
|
export type GatewayPluginRouteContext = Pick<
|
||||||
GatewayPluginContext,
|
GatewayPluginContext,
|
||||||
"config" | "logger" | "openSqliteStore" | "paths" | "pluginConfig" | "pluginId"
|
"config" | "logger" | "openSqliteStore" | "paths" | "permissions" | "pluginConfig" | "pluginId"
|
||||||
> & {
|
> & {
|
||||||
readBody: (request: IncomingMessage) => Promise<Buffer>;
|
readBody: (request: IncomingMessage) => Promise<Buffer>;
|
||||||
readJson: (request: IncomingMessage) => Promise<unknown>;
|
readJson: (request: IncomingMessage) => Promise<unknown>;
|
||||||
|
|
@ -141,7 +152,18 @@ type RegisteredProxyRoute = Omit<GatewayPluginProxyRouteRegistration, "host" | "
|
||||||
type LoadedPlugin = {
|
type LoadedPlugin = {
|
||||||
activate?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
activate?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
||||||
setup?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
setup?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
||||||
stop?: () => MaybePromise<void>;
|
stop?: GatewayPluginStopHandler;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StopHook = {
|
||||||
|
pluginId: string;
|
||||||
|
stop: GatewayPluginStopHandler;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginPermissionAccess = {
|
||||||
|
explicit: boolean;
|
||||||
|
permissions: Set<GatewayPluginPermission>;
|
||||||
|
pluginId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireFromHere = createRequire(__filename);
|
const requireFromHere = createRequire(__filename);
|
||||||
|
|
@ -154,13 +176,14 @@ class GatewayPluginService {
|
||||||
private gatewayRoutes: RegisteredGatewayRoute[] = [];
|
private gatewayRoutes: RegisteredGatewayRoute[] = [];
|
||||||
private proxyRoutes: RegisteredProxyRoute[] = [];
|
private proxyRoutes: RegisteredProxyRoute[] = [];
|
||||||
private providerAccountConnectors = new Map<string, GatewayPluginProviderAccountConnector>();
|
private providerAccountConnectors = new Map<string, GatewayPluginProviderAccountConnector>();
|
||||||
|
private legacyPermissionWarningIds = new Set<string>();
|
||||||
private resourceOwnerIds = new Set<string>();
|
private resourceOwnerIds = new Set<string>();
|
||||||
private running = false;
|
private running = false;
|
||||||
private stopHooks: Array<() => MaybePromise<void>> = [];
|
private stopHooks: StopHook[] = [];
|
||||||
private virtualModelProfiles: unknown[] = [];
|
private virtualModelProfiles: unknown[] = [];
|
||||||
|
|
||||||
async start(config: AppConfig): Promise<void> {
|
async start(config: AppConfig): Promise<void> {
|
||||||
await this.stop();
|
await this.stop({ nextConfig: config });
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.running = true;
|
this.running = true;
|
||||||
|
|
||||||
|
|
@ -173,13 +196,14 @@ class GatewayPluginService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(options: { nextConfig?: AppConfig } = {}): Promise<void> {
|
||||||
const stopHooks = [...this.stopHooks].reverse();
|
const stopHooks = [...this.stopHooks].reverse();
|
||||||
|
const nextEnabledPluginIds = options.nextConfig ? enabledPluginIds(options.nextConfig) : undefined;
|
||||||
this.stopHooks = [];
|
this.stopHooks = [];
|
||||||
|
|
||||||
for (const stopHook of stopHooks) {
|
for (const stopHook of stopHooks) {
|
||||||
try {
|
try {
|
||||||
await stopHook();
|
await stopHook.stop({ reason: stopReasonForPlugin(stopHook.pluginId, nextEnabledPluginIds) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[plugin] Stop hook failed: ${formatError(error)}`);
|
console.warn(`[plugin] Stop hook failed: ${formatError(error)}`);
|
||||||
}
|
}
|
||||||
|
|
@ -287,8 +311,12 @@ class GatewayPluginService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadConfiguredPlugin(pluginConfig: GatewayPluginConfig): Promise<void> {
|
private async loadConfiguredPlugin(pluginConfig: GatewayPluginConfig): Promise<void> {
|
||||||
this.registerConfiguredCoreGateway(pluginConfig);
|
const permissions = pluginPermissionAccess(pluginConfig);
|
||||||
this.registerConfiguredApps(pluginConfig);
|
this.registerConfiguredCoreGateway(pluginConfig, permissions);
|
||||||
|
this.registerConfiguredApps(pluginConfig, permissions);
|
||||||
|
if ((pluginConfig.proxy?.routes ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "proxy-routes", "register configured proxy routes");
|
||||||
|
}
|
||||||
for (const route of pluginConfig.proxy?.routes ?? []) {
|
for (const route of pluginConfig.proxy?.routes ?? []) {
|
||||||
this.registerProxyRoute(pluginConfig.id, route);
|
this.registerProxyRoute(pluginConfig.id, route);
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +328,7 @@ class GatewayPluginService {
|
||||||
|
|
||||||
const loadedPlugin = await loadPluginModule(modulePath);
|
const loadedPlugin = await loadPluginModule(modulePath);
|
||||||
const plugin = normalizeLoadedPlugin(loadedPlugin);
|
const plugin = normalizeLoadedPlugin(loadedPlugin);
|
||||||
const context = this.createPluginContext(pluginConfig);
|
const context = this.createPluginContext(pluginConfig, permissions);
|
||||||
const registration = plugin.setup
|
const registration = plugin.setup
|
||||||
? await plugin.setup(context)
|
? await plugin.setup(context)
|
||||||
: plugin.activate
|
: plugin.activate
|
||||||
|
|
@ -308,29 +336,50 @@ class GatewayPluginService {
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (registration) {
|
if (registration) {
|
||||||
this.applyPluginRegistration(pluginConfig.id, registration);
|
this.applyPluginRegistration(pluginConfig.id, registration, permissions);
|
||||||
}
|
}
|
||||||
if (plugin.stop) {
|
if (plugin.stop) {
|
||||||
this.stopHooks.push(() => plugin.stop?.());
|
this.stopHooks.push({
|
||||||
|
pluginId: pluginConfig.id,
|
||||||
|
stop: (event) => plugin.stop?.(event)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPluginRegistration(pluginId: string, registration: GatewayPluginRegistration): void {
|
private applyPluginRegistration(pluginId: string, registration: GatewayPluginRegistration, permissions: PluginPermissionAccess): void {
|
||||||
|
if ((registration.apps ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "apps", "register browser apps");
|
||||||
|
}
|
||||||
for (const app of registration.apps ?? []) {
|
for (const app of registration.apps ?? []) {
|
||||||
this.registerApp(pluginId, app);
|
this.registerApp(pluginId, app);
|
||||||
}
|
}
|
||||||
|
if ((registration.gatewayRoutes ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "gateway-routes", "register gateway routes");
|
||||||
|
}
|
||||||
for (const route of registration.gatewayRoutes ?? []) {
|
for (const route of registration.gatewayRoutes ?? []) {
|
||||||
this.registerGatewayRoute(pluginId, route);
|
this.registerGatewayRoute(pluginId, route);
|
||||||
}
|
}
|
||||||
|
if ((registration.proxyRoutes ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "proxy-routes", "register proxy routes");
|
||||||
|
}
|
||||||
for (const route of registration.proxyRoutes ?? []) {
|
for (const route of registration.proxyRoutes ?? []) {
|
||||||
this.registerProxyRoute(pluginId, route);
|
this.registerProxyRoute(pluginId, route);
|
||||||
}
|
}
|
||||||
|
if ((registration.providerAccountConnectors ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "provider-account-connectors", "register provider account connectors");
|
||||||
|
}
|
||||||
for (const connector of registration.providerAccountConnectors ?? []) {
|
for (const connector of registration.providerAccountConnectors ?? []) {
|
||||||
this.registerProviderAccountConnector(pluginId, connector);
|
this.registerProviderAccountConnector(pluginId, connector);
|
||||||
}
|
}
|
||||||
|
if ((registration.coreGateway?.providerPlugins ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "core-provider-plugins", "register core provider plugins");
|
||||||
|
}
|
||||||
for (const providerPlugin of registration.coreGateway?.providerPlugins ?? []) {
|
for (const providerPlugin of registration.coreGateway?.providerPlugins ?? []) {
|
||||||
this.coreProviderPlugins.push(providerPlugin);
|
this.coreProviderPlugins.push(providerPlugin);
|
||||||
}
|
}
|
||||||
|
if (((registration.coreGateway?.virtualModelProfiles ?? []).length + (registration.virtualModelProfiles ?? []).length) > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "virtual-model-profiles", "register virtual model profiles");
|
||||||
|
}
|
||||||
for (const profile of [
|
for (const profile of [
|
||||||
...(registration.coreGateway?.virtualModelProfiles ?? []),
|
...(registration.coreGateway?.virtualModelProfiles ?? []),
|
||||||
...(registration.virtualModelProfiles ?? [])
|
...(registration.virtualModelProfiles ?? [])
|
||||||
|
|
@ -338,20 +387,24 @@ class GatewayPluginService {
|
||||||
this.virtualModelProfiles.push(profile);
|
this.virtualModelProfiles.push(profile);
|
||||||
}
|
}
|
||||||
if (registration.coreGateway?.config) {
|
if (registration.coreGateway?.config) {
|
||||||
|
this.requirePluginPermission(permissions, "core-gateway-config", "register core gateway config");
|
||||||
this.coreGatewayConfig = {
|
this.coreGatewayConfig = {
|
||||||
...this.coreGatewayConfig,
|
...this.coreGatewayConfig,
|
||||||
...registration.coreGateway.config
|
...registration.coreGateway.config
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (registration.stop) {
|
if (registration.stop) {
|
||||||
this.stopHooks.push(registration.stop);
|
this.stopHooks.push({ pluginId, stop: registration.stop });
|
||||||
}
|
}
|
||||||
if (registration.onStop) {
|
if (registration.onStop) {
|
||||||
this.stopHooks.push(registration.onStop);
|
this.stopHooks.push({ pluginId, stop: registration.onStop });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerConfiguredApps(pluginConfig: GatewayPluginConfig): void {
|
private registerConfiguredApps(pluginConfig: GatewayPluginConfig, permissions: PluginPermissionAccess): void {
|
||||||
|
if ((pluginConfig.apps ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "apps", "register configured browser apps");
|
||||||
|
}
|
||||||
for (const app of pluginConfig.apps ?? []) {
|
for (const app of pluginConfig.apps ?? []) {
|
||||||
this.registerApp(pluginConfig.id, app);
|
this.registerApp(pluginConfig.id, app);
|
||||||
}
|
}
|
||||||
|
|
@ -366,14 +419,21 @@ class GatewayPluginService {
|
||||||
this.apps.push(normalized);
|
this.apps.push(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerConfiguredCoreGateway(pluginConfig: GatewayPluginConfig): void {
|
private registerConfiguredCoreGateway(pluginConfig: GatewayPluginConfig, permissions: PluginPermissionAccess): void {
|
||||||
|
if ((pluginConfig.coreGateway?.providerPlugins ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "core-provider-plugins", "register configured core provider plugins");
|
||||||
|
}
|
||||||
for (const providerPlugin of pluginConfig.coreGateway?.providerPlugins ?? []) {
|
for (const providerPlugin of pluginConfig.coreGateway?.providerPlugins ?? []) {
|
||||||
this.coreProviderPlugins.push(providerPlugin);
|
this.coreProviderPlugins.push(providerPlugin);
|
||||||
}
|
}
|
||||||
|
if ((pluginConfig.coreGateway?.virtualModelProfiles ?? []).length > 0) {
|
||||||
|
this.requirePluginPermission(permissions, "virtual-model-profiles", "register configured virtual model profiles");
|
||||||
|
}
|
||||||
for (const profile of pluginConfig.coreGateway?.virtualModelProfiles ?? []) {
|
for (const profile of pluginConfig.coreGateway?.virtualModelProfiles ?? []) {
|
||||||
this.virtualModelProfiles.push(profile);
|
this.virtualModelProfiles.push(profile);
|
||||||
}
|
}
|
||||||
if (pluginConfig.coreGateway?.config) {
|
if (pluginConfig.coreGateway?.config) {
|
||||||
|
this.requirePluginPermission(permissions, "core-gateway-config", "register configured core gateway config");
|
||||||
this.coreGatewayConfig = {
|
this.coreGatewayConfig = {
|
||||||
...this.coreGatewayConfig,
|
...this.coreGatewayConfig,
|
||||||
...pluginConfig.coreGateway.config
|
...pluginConfig.coreGateway.config
|
||||||
|
|
@ -412,10 +472,11 @@ class GatewayPluginService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createPluginContext(pluginConfig: GatewayPluginConfig): GatewayPluginContext {
|
private createPluginContext(pluginConfig: GatewayPluginConfig, permissions: PluginPermissionAccess): GatewayPluginContext {
|
||||||
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginConfig.id));
|
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginConfig.id));
|
||||||
mkdirSync(pluginDataDir, { recursive: true });
|
mkdirSync(pluginDataDir, { recursive: true });
|
||||||
const logger = createPluginLogger(pluginConfig.id);
|
const logger = createPluginLogger(pluginConfig.id);
|
||||||
|
const pluginPermissions = pluginPermissionList(permissions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: this.config ?? ({} as AppConfig),
|
config: this.config ?? ({} as AppConfig),
|
||||||
|
|
@ -427,18 +488,39 @@ class GatewayPluginService {
|
||||||
},
|
},
|
||||||
pluginConfig: pluginConfig.config,
|
pluginConfig: pluginConfig.config,
|
||||||
pluginId: pluginConfig.id,
|
pluginId: pluginConfig.id,
|
||||||
openSqliteStore: (options) => this.openSqliteStore(pluginConfig.id, pluginDataDir, options),
|
permissions: pluginPermissions,
|
||||||
|
openSqliteStore: (options) => {
|
||||||
|
this.requirePluginPermission(permissions, "sqlite-store", "open a SQLite store");
|
||||||
|
return this.openSqliteStore(pluginConfig.id, pluginDataDir, options);
|
||||||
|
},
|
||||||
registerCoreGatewayProviderPlugin: (providerPlugin) => {
|
registerCoreGatewayProviderPlugin: (providerPlugin) => {
|
||||||
|
this.requirePluginPermission(permissions, "core-provider-plugins", "register core provider plugins");
|
||||||
this.coreProviderPlugins.push(providerPlugin);
|
this.coreProviderPlugins.push(providerPlugin);
|
||||||
},
|
},
|
||||||
registerCoreGatewayVirtualModelProfile: (profile) => {
|
registerCoreGatewayVirtualModelProfile: (profile) => {
|
||||||
|
this.requirePluginPermission(permissions, "virtual-model-profiles", "register virtual model profiles");
|
||||||
this.virtualModelProfiles.push(profile);
|
this.virtualModelProfiles.push(profile);
|
||||||
},
|
},
|
||||||
registerApp: (app) => this.registerApp(pluginConfig.id, app),
|
registerApp: (app) => {
|
||||||
registerGatewayRoute: (route) => this.registerGatewayRoute(pluginConfig.id, route),
|
this.requirePluginPermission(permissions, "apps", "register browser apps");
|
||||||
registerHttpBackend: (backend) => this.registerHttpBackend(pluginConfig.id, pluginDataDir, logger, backend),
|
this.registerApp(pluginConfig.id, app);
|
||||||
registerProviderAccountConnector: (connector) => this.registerProviderAccountConnector(pluginConfig.id, connector),
|
},
|
||||||
registerProxyRoute: (route) => this.registerProxyRoute(pluginConfig.id, route)
|
registerGatewayRoute: (route) => {
|
||||||
|
this.requirePluginPermission(permissions, "gateway-routes", "register gateway routes");
|
||||||
|
this.registerGatewayRoute(pluginConfig.id, route);
|
||||||
|
},
|
||||||
|
registerHttpBackend: (backend) => {
|
||||||
|
this.requirePluginPermission(permissions, "http-backends", "register HTTP backends");
|
||||||
|
return this.registerHttpBackend(pluginConfig.id, pluginDataDir, logger, permissions, backend);
|
||||||
|
},
|
||||||
|
registerProviderAccountConnector: (connector) => {
|
||||||
|
this.requirePluginPermission(permissions, "provider-account-connectors", "register provider account connectors");
|
||||||
|
this.registerProviderAccountConnector(pluginConfig.id, connector);
|
||||||
|
},
|
||||||
|
registerProxyRoute: (route) => {
|
||||||
|
this.requirePluginPermission(permissions, "proxy-routes", "register proxy routes");
|
||||||
|
this.registerProxyRoute(pluginConfig.id, route);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,6 +536,9 @@ class GatewayPluginService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRouteContext(pluginId: string): GatewayPluginRouteContext {
|
private createRouteContext(pluginId: string): GatewayPluginRouteContext {
|
||||||
|
const pluginConfig = this.config?.plugins.find((plugin) => plugin.id === pluginId);
|
||||||
|
const permissions = pluginPermissionAccess(pluginConfig ?? { id: pluginId });
|
||||||
|
const pluginPermissions = pluginPermissionList(permissions);
|
||||||
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginId));
|
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginId));
|
||||||
const logger = createPluginLogger(pluginId);
|
const logger = createPluginLogger(pluginId);
|
||||||
return {
|
return {
|
||||||
|
|
@ -464,9 +549,13 @@ class GatewayPluginService {
|
||||||
dataDir: DATADIR,
|
dataDir: DATADIR,
|
||||||
pluginDataDir
|
pluginDataDir
|
||||||
},
|
},
|
||||||
pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config,
|
permissions: pluginPermissions,
|
||||||
|
pluginConfig: pluginConfig?.config,
|
||||||
pluginId,
|
pluginId,
|
||||||
openSqliteStore: (options) => this.openSqliteStore(pluginId, pluginDataDir, options),
|
openSqliteStore: (options) => {
|
||||||
|
this.requirePluginPermission(permissions, "sqlite-store", "open a SQLite store");
|
||||||
|
return this.openSqliteStore(pluginId, pluginDataDir, options);
|
||||||
|
},
|
||||||
readBody,
|
readBody,
|
||||||
readJson,
|
readJson,
|
||||||
sendJson
|
sendJson
|
||||||
|
|
@ -477,8 +566,10 @@ class GatewayPluginService {
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
pluginDataDir: string,
|
pluginDataDir: string,
|
||||||
logger: PluginLogger,
|
logger: PluginLogger,
|
||||||
|
permissions: PluginPermissionAccess,
|
||||||
backend: GatewayPluginHttpBackendRegistration
|
backend: GatewayPluginHttpBackendRegistration
|
||||||
): Promise<RegisteredHttpBackend> {
|
): Promise<RegisteredHttpBackend> {
|
||||||
|
const pluginPermissions = pluginPermissionList(permissions);
|
||||||
return backendService.registerHttpBackend(pluginId, {
|
return backendService.registerHttpBackend(pluginId, {
|
||||||
host: backend.host,
|
host: backend.host,
|
||||||
id: backend.id,
|
id: backend.id,
|
||||||
|
|
@ -492,9 +583,13 @@ class GatewayPluginService {
|
||||||
dataDir: DATADIR,
|
dataDir: DATADIR,
|
||||||
pluginDataDir
|
pluginDataDir
|
||||||
},
|
},
|
||||||
|
permissions: pluginPermissions,
|
||||||
pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config,
|
pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config,
|
||||||
pluginId,
|
pluginId,
|
||||||
openSqliteStore: (options) => this.openSqliteStore(pluginId, pluginDataDir, options),
|
openSqliteStore: (options) => {
|
||||||
|
this.requirePluginPermission(permissions, "sqlite-store", "open a SQLite store");
|
||||||
|
return this.openSqliteStore(pluginId, pluginDataDir, options);
|
||||||
|
},
|
||||||
readBody,
|
readBody,
|
||||||
readJson,
|
readJson,
|
||||||
sendJson
|
sendJson
|
||||||
|
|
@ -509,10 +604,40 @@ class GatewayPluginService {
|
||||||
): Promise<PluginSqliteStore> {
|
): Promise<PluginSqliteStore> {
|
||||||
return backendService.openSqliteStore(pluginId, pluginDataDir, options);
|
return backendService.openSqliteStore(pluginId, pluginDataDir, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requirePluginPermission(
|
||||||
|
access: PluginPermissionAccess,
|
||||||
|
permission: GatewayPluginPermission,
|
||||||
|
action: string
|
||||||
|
): void {
|
||||||
|
if (!access.explicit) {
|
||||||
|
if (!this.legacyPermissionWarningIds.has(access.pluginId)) {
|
||||||
|
this.legacyPermissionWarningIds.add(access.pluginId);
|
||||||
|
console.warn(`[plugin] ${access.pluginId} has no permissions declaration; allowing CCR plugin APIs in compatibility mode.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (access.permissions.has(permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Plugin ${access.pluginId} requires permission "${permission}" to ${action}.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pluginService = new GatewayPluginService();
|
export const pluginService = new GatewayPluginService();
|
||||||
|
|
||||||
|
function pluginPermissionAccess(pluginConfig: Pick<GatewayPluginConfig, "id" | "permissions">): PluginPermissionAccess {
|
||||||
|
return {
|
||||||
|
explicit: pluginConfig.permissions !== undefined,
|
||||||
|
permissions: new Set(pluginConfig.permissions ?? []),
|
||||||
|
pluginId: pluginConfig.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginPermissionList(access: PluginPermissionAccess): GatewayPluginPermission[] {
|
||||||
|
return access.explicit ? [...access.permissions] : [...GATEWAY_PLUGIN_PERMISSION_IDS];
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPluginModule(modulePath: string): Promise<unknown> {
|
async function loadPluginModule(modulePath: string): Promise<unknown> {
|
||||||
const resolved = resolvePluginModule(modulePath);
|
const resolved = resolvePluginModule(modulePath);
|
||||||
return import(pathToFileURL(resolved).href);
|
return import(pathToFileURL(resolved).href);
|
||||||
|
|
@ -738,6 +863,19 @@ function providerAccountConnectorKey(pluginId: string, connectorId: string): str
|
||||||
return `${pluginId.trim()}:${connectorId.trim()}`;
|
return `${pluginId.trim()}:${connectorId.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enabledPluginIds(config: AppConfig): Set<string> {
|
||||||
|
return new Set((config.plugins ?? [])
|
||||||
|
.filter((plugin) => plugin.enabled !== false)
|
||||||
|
.map((plugin) => plugin.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopReasonForPlugin(pluginId: string, nextEnabledPluginIds: Set<string> | undefined): GatewayPluginStopReason {
|
||||||
|
if (!nextEnabledPluginIds) {
|
||||||
|
return "stop";
|
||||||
|
}
|
||||||
|
return nextEnabledPluginIds.has(pluginId) ? "reload" : "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,10 @@ import trayController from "./tray-controller";
|
||||||
import { appUpdateService } from "./update-service";
|
import { appUpdateService } from "./update-service";
|
||||||
import { getUsageStats } from "@ccr/core/usage/store";
|
import { getUsageStats } from "@ccr/core/usage/store";
|
||||||
import windowsManager from "./windows";
|
import windowsManager from "./windows";
|
||||||
import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, ProfileApplyResult, ProfileOpenRequest, ProviderAccountResetRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "@ccr/core/contracts/app";
|
import { GATEWAY_PLUGIN_PERMISSION_IDS, type AgentAnalysisFilter, type AgentAnalysisTracePayloadRequest, type ApiKeyConfig, type AppCaptureElementPngRequest, type AppCaptureElementPngResult, type AppConfig, type AppDataExportResult, type AppImageExportTargetRequest, type AppImageExportTargetResult, type AppInfo, type AppRenderHtmlPngRequest, type AppRenderHtmlPngResult, type AppSaveConfigOptions, type BotGatewayQrLoginCancelRequest, type BotGatewayQrLoginStartRequest, type BotGatewayQrLoginWaitRequest, type BotGatewayQrWindowCloseRequest, type BotGatewayQrWindowOpenRequest, type GatewayPluginAppConfig, type GatewayPluginPermission, type GatewayProviderConnectivityCheckRequest, type GatewayProviderProbeCandidatesRequest, type GatewayProviderProbeRequest, type GatewayStatus, type LocalAgentProviderImportRequest, type PluginDependency, type PluginDirectorySelection, type ProfileApplyResult, type ProfileOpenRequest, type ProviderAccountResetRequest, type ProviderAccountSnapshotRequestOptions, type ProviderAccountTestRequest, type ProviderCatalogModelsRequest, type ProviderIconDetectionRequest, type ProviderManifestFetchRequest, type RequestLogListFilter, type UsageStatsFilter, type UsageStatsRange } from "@ccr/core/contracts/app";
|
||||||
const onboardingFinishedAtSettingKey = "onboardingFinishedAt";
|
const onboardingFinishedAtSettingKey = "onboardingFinishedAt";
|
||||||
const imageExportTargets = new Map<string, string>();
|
const imageExportTargets = new Map<string, string>();
|
||||||
|
const gatewayPluginPermissionIdSet = new Set<string>(GATEWAY_PLUGIN_PERMISSION_IDS);
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -951,13 +952,15 @@ function inspectPluginDirectory(directory: string): PluginDirectorySelection {
|
||||||
"plugin";
|
"plugin";
|
||||||
const name = readString(manifest?.name) || readString(packageJson?.displayName) || readString(packageJson?.name);
|
const name = readString(manifest?.name) || readString(packageJson?.displayName) || readString(packageJson?.name);
|
||||||
const apps = readPluginApps(manifest, packageJson);
|
const apps = readPluginApps(manifest, packageJson);
|
||||||
|
const permissions = readPluginPermissions(manifest, packageJson);
|
||||||
return {
|
return {
|
||||||
...(apps.length ? { apps } : {}),
|
...(apps.length ? { apps } : {}),
|
||||||
dependencies: readPluginDependencies(directory, manifest, packageJson),
|
dependencies: readPluginDependencies(directory, manifest, packageJson),
|
||||||
directory,
|
directory,
|
||||||
id,
|
id,
|
||||||
modulePath: resolvePluginDirectoryModule(directory, moduleValue),
|
modulePath: resolvePluginDirectoryModule(directory, moduleValue),
|
||||||
...(name ? { name } : {})
|
...(name ? { name } : {}),
|
||||||
|
...(permissions ? { permissions } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1014,6 +1017,124 @@ function parsePluginAppItem(value: unknown): GatewayPluginAppConfig | undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPluginPermissions(
|
||||||
|
manifest: Record<string, unknown> | undefined,
|
||||||
|
packageJson: Record<string, unknown> | undefined
|
||||||
|
): GatewayPluginPermission[] | undefined {
|
||||||
|
const values = [
|
||||||
|
manifest?.permissions,
|
||||||
|
readRecord(manifest?.ccr)?.permissions,
|
||||||
|
readRecord(manifest?.ccrPlugin)?.permissions,
|
||||||
|
readRecord(packageJson?.ccr)?.permissions,
|
||||||
|
readRecord(packageJson?.ccrPlugin)?.permissions
|
||||||
|
];
|
||||||
|
const parsedValues = values.map(parsePluginPermissions).filter((value): value is GatewayPluginPermission[] => Boolean(value));
|
||||||
|
return parsedValues.length > 0 ? uniquePluginPermissions(parsedValues.flat()) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePluginPermissions(value: unknown): GatewayPluginPermission[] | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: GatewayPluginPermission[] = [];
|
||||||
|
const seen = new Set<GatewayPluginPermission>();
|
||||||
|
const add = (rawValue: unknown): void => {
|
||||||
|
const permission = normalizePluginPermission(rawValue);
|
||||||
|
if (!permission || seen.has(permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(permission);
|
||||||
|
permissions.push(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
add(value);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
value.forEach(add);
|
||||||
|
} else {
|
||||||
|
const record = readRecord(value);
|
||||||
|
if (!record) {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
for (const [key, enabled] of Object.entries(record)) {
|
||||||
|
if (enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isAllPluginPermissionsKey(key)) {
|
||||||
|
GATEWAY_PLUGIN_PERMISSION_IDS.forEach(add);
|
||||||
|
} else {
|
||||||
|
add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginPermission(value: unknown): GatewayPluginPermission | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
||||||
|
const mapped = pluginPermissionAlias(normalized);
|
||||||
|
return gatewayPluginPermissionIdSet.has(mapped) ? mapped as GatewayPluginPermission : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginPermissionAlias(value: string): string {
|
||||||
|
switch (value) {
|
||||||
|
case "app":
|
||||||
|
case "browser-app":
|
||||||
|
case "browser-apps":
|
||||||
|
return "apps";
|
||||||
|
case "gateway-route":
|
||||||
|
case "route":
|
||||||
|
case "routes":
|
||||||
|
return "gateway-routes";
|
||||||
|
case "proxy":
|
||||||
|
case "proxy-route":
|
||||||
|
return "proxy-routes";
|
||||||
|
case "backend":
|
||||||
|
case "backends":
|
||||||
|
case "http-backend":
|
||||||
|
return "http-backends";
|
||||||
|
case "provider-account":
|
||||||
|
case "provider-account-connector":
|
||||||
|
return "provider-account-connectors";
|
||||||
|
case "core-gateway":
|
||||||
|
return "core-gateway-config";
|
||||||
|
case "provider-plugin":
|
||||||
|
case "provider-plugins":
|
||||||
|
case "core-provider-plugin":
|
||||||
|
return "core-provider-plugins";
|
||||||
|
case "fusion-profile":
|
||||||
|
case "fusion-profiles":
|
||||||
|
case "virtual-model":
|
||||||
|
case "virtual-models":
|
||||||
|
case "virtual-model-profile":
|
||||||
|
return "virtual-model-profiles";
|
||||||
|
case "sqlite":
|
||||||
|
case "data-store":
|
||||||
|
case "store":
|
||||||
|
return "sqlite-store";
|
||||||
|
case "launcher":
|
||||||
|
case "mac-launcher":
|
||||||
|
return "system-launcher";
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePluginPermissions(values: GatewayPluginPermission[]): GatewayPluginPermission[] | undefined {
|
||||||
|
const unique = [...new Set(values)];
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllPluginPermissionsKey(value: string): boolean {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized === "*" || normalized === "all";
|
||||||
|
}
|
||||||
|
|
||||||
function readPluginDependencies(
|
function readPluginDependencies(
|
||||||
directory: string,
|
directory: string,
|
||||||
manifest: Record<string, unknown> | undefined,
|
manifest: Record<string, unknown> | undefined,
|
||||||
|
|
@ -1087,10 +1208,12 @@ function parsePluginDependencyItem(value: unknown, directory: string): PluginDep
|
||||||
const moduleValue = readString(record.module) || readString(record.path) || readString(record.modulePath);
|
const moduleValue = readString(record.module) || readString(record.path) || readString(record.modulePath);
|
||||||
const modulePath = moduleValue ? resolveDependencyModulePath(directory, moduleValue) : undefined;
|
const modulePath = moduleValue ? resolveDependencyModulePath(directory, moduleValue) : undefined;
|
||||||
const name = readString(record.name);
|
const name = readString(record.name);
|
||||||
|
const permissions = parsePluginPermissions(record.permissions);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
...(modulePath ? { modulePath } : {}),
|
...(modulePath ? { modulePath } : {}),
|
||||||
...(name ? { name } : {})
|
...(name ? { name } : {}),
|
||||||
|
...(permissions ? { permissions } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { app, dialog } from "electron";
|
import { app, dialog } from "electron";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat";
|
||||||
import { resolveRuntimeDataDir, setRuntimeAppPaths } from "@ccr/core/runtime/app-paths";
|
import { resolveRuntimeDataDir, setRuntimeAppPaths } from "@ccr/core/runtime/app-paths";
|
||||||
import { copyMissingDirectoryContents, sameFilesystemPath } from "@ccr/core/storage/migration";
|
import { copyMissingDirectoryContents, sameFilesystemPath } from "@ccr/core/storage/migration";
|
||||||
|
|
||||||
|
installSocketTypeOfServiceCompat();
|
||||||
|
|
||||||
const appDataPath = app.getPath("appData");
|
const appDataPath = app.getPath("appData");
|
||||||
const homePath = app.getPath("home");
|
const homePath = app.getPath("home");
|
||||||
setRuntimeAppPaths({
|
setRuntimeAppPaths({
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets,
|
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets,
|
||||||
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
|
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
|
||||||
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
|
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
|
||||||
OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft,
|
OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parsePluginPermissionsSettingsText, parseProviderAccountDraft,
|
||||||
providerCredentialsFromDraft,
|
providerCredentialsFromDraft,
|
||||||
persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft,
|
persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft,
|
||||||
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
||||||
|
|
@ -1758,6 +1758,7 @@ function App() {
|
||||||
key: selection.id,
|
key: selection.id,
|
||||||
marketplaceId: "",
|
marketplaceId: "",
|
||||||
modulePath: selection.modulePath,
|
modulePath: selection.modulePath,
|
||||||
|
permissions: selection.permissions,
|
||||||
selectedName: selection.name || selection.id
|
selectedName: selection.name || selection.id
|
||||||
}));
|
}));
|
||||||
setExtensionInstallError("");
|
setExtensionInstallError("");
|
||||||
|
|
@ -1778,7 +1779,8 @@ function App() {
|
||||||
dependencies: extensionInstallDraft.dependencies,
|
dependencies: extensionInstallDraft.dependencies,
|
||||||
id: extensionInstallDraft.key.trim(),
|
id: extensionInstallDraft.key.trim(),
|
||||||
modulePath: extensionInstallDraft.modulePath.trim(),
|
modulePath: extensionInstallDraft.modulePath.trim(),
|
||||||
name: extensionInstallDraft.selectedName
|
name: extensionInstallDraft.selectedName,
|
||||||
|
permissions: extensionInstallDraft.permissions
|
||||||
},
|
},
|
||||||
pluginMarketplace,
|
pluginMarketplace,
|
||||||
draftConfig.plugins ?? []
|
draftConfig.plugins ?? []
|
||||||
|
|
@ -1796,7 +1798,8 @@ function App() {
|
||||||
...(item.apps?.length ? { apps: item.apps } : {}),
|
...(item.apps?.length ? { apps: item.apps } : {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
module: item.modulePath
|
module: item.modulePath,
|
||||||
|
...(item.permissions !== undefined ? { permissions: item.permissions } : {})
|
||||||
}));
|
}));
|
||||||
config.plugins = [...(config.plugins ?? []), ...pluginsToAdd];
|
config.plugins = [...(config.plugins ?? []), ...pluginsToAdd];
|
||||||
return config;
|
return config;
|
||||||
|
|
@ -1862,6 +1865,12 @@ function App() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permissionsResult = parsePluginPermissionsSettingsText(pluginSettingsDraft.permissionsText);
|
||||||
|
if (!permissionsResult.ok) {
|
||||||
|
setPluginSettingsError(permissionsResult.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateConfig((config) => {
|
updateConfig((config) => {
|
||||||
const values = [...(config.plugins ?? [])];
|
const values = [...(config.plugins ?? [])];
|
||||||
const item = values[extensionConfigTarget.index];
|
const item = values[extensionConfigTarget.index];
|
||||||
|
|
@ -1874,7 +1883,8 @@ function App() {
|
||||||
...(appsResult.value && appsResult.value.length > 0 ? { apps: appsResult.value } : { apps: undefined }),
|
...(appsResult.value && appsResult.value.length > 0 ? { apps: appsResult.value } : { apps: undefined }),
|
||||||
config: nextConfig,
|
config: nextConfig,
|
||||||
enabled: pluginSettingsDraft.enabled,
|
enabled: pluginSettingsDraft.enabled,
|
||||||
module: pluginSettingsDraft.modulePath.trim()
|
module: pluginSettingsDraft.modulePath.trim(),
|
||||||
|
...(permissionsResult.value !== undefined ? { permissions: permissionsResult.value } : { permissions: undefined })
|
||||||
};
|
};
|
||||||
config.plugins = values;
|
config.plugins = values;
|
||||||
return config;
|
return config;
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,10 @@ export function PluginSettingsDialog({
|
||||||
<TextAreaControl minHeight={132} value={draft.appsText} onChange={(appsText) => onChange({ appsText })} />
|
<TextAreaControl minHeight={132} value={draft.appsText} onChange={(appsText) => onChange({ appsText })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label={t("Plugin permissions JSON")}>
|
||||||
|
<TextAreaControl minHeight={120} value={draft.permissionsText} onChange={(permissionsText) => onChange({ permissionsText })} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label={t("Plugin config JSON")}>
|
<Field label={t("Plugin config JSON")}>
|
||||||
<TextAreaControl minHeight={160} value={draft.configText} onChange={(configText) => onChange({ configText })} />
|
<TextAreaControl minHeight={160} value={draft.configText} onChange={(configText) => onChange({ configText })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
||||||
|
|
@ -1163,6 +1163,7 @@ export function InstallExtensionDialog({
|
||||||
dependencies: entry.dependencies,
|
dependencies: entry.dependencies,
|
||||||
marketplaceId: entry.id,
|
marketplaceId: entry.id,
|
||||||
modulePath: entry.modulePath,
|
modulePath: entry.modulePath,
|
||||||
|
permissions: entry.permissions,
|
||||||
selectedName: entry.name
|
selectedName: entry.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1198,6 +1199,9 @@ export function InstallExtensionDialog({
|
||||||
<span className="truncate font-semibold text-foreground">{entry.name}</span>
|
<span className="truncate font-semibold text-foreground">{entry.name}</span>
|
||||||
<span className="line-clamp-2 text-[11px] text-muted-foreground">{entry.description}</span>
|
<span className="line-clamp-2 text-[11px] text-muted-foreground">{entry.description}</span>
|
||||||
<span className="truncate text-[10px] text-muted-foreground/80">{entry.capabilities.join(", ")}</span>
|
<span className="truncate text-[10px] text-muted-foreground/80">{entry.capabilities.join(", ")}</span>
|
||||||
|
{entry.permissions?.length ? (
|
||||||
|
<span className="truncate text-[10px] text-muted-foreground/80">{t("Permissions")}: {entry.permissions.join(", ")}</span>
|
||||||
|
) : null}
|
||||||
{entry.dependencies.length > 0 ? (
|
{entry.dependencies.length > 0 ? (
|
||||||
<span className="truncate text-[10px] text-muted-foreground/80">{t("Dependencies")}: {formatPluginDependencies(entry.dependencies)}</span>
|
<span className="truncate text-[10px] text-muted-foreground/80">{t("Dependencies")}: {formatPluginDependencies(entry.dependencies)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ import {
|
||||||
DEFAULT_TRAY_WIDGETS,
|
DEFAULT_TRAY_WIDGETS,
|
||||||
DEFAULT_TRAY_WINDOW_MODULES,
|
DEFAULT_TRAY_WINDOW_MODULES,
|
||||||
enforceSingleEnabledGlobalProfilePerAgent,
|
enforceSingleEnabledGlobalProfilePerAgent,
|
||||||
|
GATEWAY_PLUGIN_PERMISSION_IDS,
|
||||||
normalizeProfileScopeValue,
|
normalizeProfileScopeValue,
|
||||||
OVERVIEW_WIDGET_SIZE_VALUES,
|
OVERVIEW_WIDGET_SIZE_VALUES,
|
||||||
TRAY_SINGLETON_WIDGET_TYPES,
|
TRAY_SINGLETON_WIDGET_TYPES,
|
||||||
|
|
@ -158,6 +159,7 @@ import type {
|
||||||
GatewayProviderConfig,
|
GatewayProviderConfig,
|
||||||
GatewayProviderCapability,
|
GatewayProviderCapability,
|
||||||
GatewayPluginAppConfig,
|
GatewayPluginAppConfig,
|
||||||
|
GatewayPluginPermission,
|
||||||
GatewayProviderConnectivityCheckModelResult,
|
GatewayProviderConnectivityCheckModelResult,
|
||||||
GatewayProviderConnectivityCheckReport,
|
GatewayProviderConnectivityCheckReport,
|
||||||
GatewayProviderProbeCandidate,
|
GatewayProviderProbeCandidate,
|
||||||
|
|
@ -376,12 +378,15 @@ import { isPlainRecord, stringValue } from "./common";
|
||||||
import { isClaudeDesignPluginConfig, isCursorProxyPluginConfig, readClaudeDesignRoutingConfig } from "./routing";
|
import { isClaudeDesignPluginConfig, isCursorProxyPluginConfig, readClaudeDesignRoutingConfig } from "./routing";
|
||||||
import type { ExtensionInstallDraft, ExtensionListItem, ExtensionSource, PluginInstallCandidate, PluginSettingsDraft } from "./types";
|
import type { ExtensionInstallDraft, ExtensionListItem, ExtensionSource, PluginInstallCandidate, PluginSettingsDraft } from "./types";
|
||||||
|
|
||||||
|
const gatewayPluginPermissionIdSet = new Set<string>(GATEWAY_PLUGIN_PERMISSION_IDS);
|
||||||
|
|
||||||
export function createPluginSettingsDraft(plugin?: AppConfig["plugins"][number]): PluginSettingsDraft {
|
export function createPluginSettingsDraft(plugin?: AppConfig["plugins"][number]): PluginSettingsDraft {
|
||||||
return {
|
return {
|
||||||
appsText: formatEditableJson(plugin?.apps ?? []),
|
appsText: formatEditableJson(plugin?.apps ?? []),
|
||||||
configText: formatEditableJson(pluginSettingsConfigWithoutRouting(plugin?.config)),
|
configText: formatEditableJson(pluginSettingsConfigWithoutRouting(plugin?.config)),
|
||||||
enabled: plugin?.enabled !== false,
|
enabled: plugin?.enabled !== false,
|
||||||
modulePath: plugin?.module ?? ""
|
modulePath: plugin?.module ?? "",
|
||||||
|
permissionsText: formatEditableJson(plugin?.permissions ?? [])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,6 +457,118 @@ export function parsePluginConfigSettingsText(value: string): { ok: true; value?
|
||||||
return { ok: true, value: rest };
|
return { ok: true, value: rest };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parsePluginPermissionsSettingsText(value: string): { ok: true; value?: GatewayPluginPermission[] } | { ok: false; message: string } {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: "Invalid JSON." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = parsePluginPermissionsValue(parsed);
|
||||||
|
if (!permissions) {
|
||||||
|
return { ok: false, message: "Plugin permissions must be a JSON array, string, or object." };
|
||||||
|
}
|
||||||
|
return { ok: true, value: permissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePluginPermissionsValue(value: unknown): GatewayPluginPermission[] | undefined {
|
||||||
|
const permissions: GatewayPluginPermission[] = [];
|
||||||
|
const seen = new Set<GatewayPluginPermission>();
|
||||||
|
const add = (rawValue: unknown) => {
|
||||||
|
const permission = normalizePluginPermission(rawValue);
|
||||||
|
if (!permission || seen.has(permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(permission);
|
||||||
|
permissions.push(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
add(value);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
value.forEach(add);
|
||||||
|
} else if (isPlainRecord(value)) {
|
||||||
|
for (const [key, enabled] of Object.entries(value)) {
|
||||||
|
if (enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isAllPluginPermissionsKey(key)) {
|
||||||
|
GATEWAY_PLUGIN_PERMISSION_IDS.forEach(add);
|
||||||
|
} else {
|
||||||
|
add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginPermission(value: unknown): GatewayPluginPermission | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
||||||
|
const mapped = pluginPermissionAlias(normalized);
|
||||||
|
return gatewayPluginPermissionIdSet.has(mapped) ? mapped as GatewayPluginPermission : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginPermissionAlias(value: string): string {
|
||||||
|
switch (value) {
|
||||||
|
case "app":
|
||||||
|
case "browser-app":
|
||||||
|
case "browser-apps":
|
||||||
|
return "apps";
|
||||||
|
case "gateway-route":
|
||||||
|
case "route":
|
||||||
|
case "routes":
|
||||||
|
return "gateway-routes";
|
||||||
|
case "proxy":
|
||||||
|
case "proxy-route":
|
||||||
|
return "proxy-routes";
|
||||||
|
case "backend":
|
||||||
|
case "backends":
|
||||||
|
case "http-backend":
|
||||||
|
return "http-backends";
|
||||||
|
case "provider-account":
|
||||||
|
case "provider-account-connector":
|
||||||
|
return "provider-account-connectors";
|
||||||
|
case "core-gateway":
|
||||||
|
return "core-gateway-config";
|
||||||
|
case "provider-plugin":
|
||||||
|
case "provider-plugins":
|
||||||
|
case "core-provider-plugin":
|
||||||
|
return "core-provider-plugins";
|
||||||
|
case "fusion-profile":
|
||||||
|
case "fusion-profiles":
|
||||||
|
case "virtual-model":
|
||||||
|
case "virtual-models":
|
||||||
|
case "virtual-model-profile":
|
||||||
|
return "virtual-model-profiles";
|
||||||
|
case "sqlite":
|
||||||
|
case "data-store":
|
||||||
|
case "store":
|
||||||
|
return "sqlite-store";
|
||||||
|
case "launcher":
|
||||||
|
case "mac-launcher":
|
||||||
|
return "system-launcher";
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllPluginPermissionsKey(value: string): boolean {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized === "*" || normalized === "all";
|
||||||
|
}
|
||||||
|
|
||||||
export function pluginSettingsConfigFromDraft(previousConfig: unknown, nonRoutingConfig: Record<string, unknown> | undefined): unknown {
|
export function pluginSettingsConfigFromDraft(previousConfig: unknown, nonRoutingConfig: Record<string, unknown> | undefined): unknown {
|
||||||
const output: Record<string, unknown> = nonRoutingConfig ? { ...nonRoutingConfig } : {};
|
const output: Record<string, unknown> = nonRoutingConfig ? { ...nonRoutingConfig } : {};
|
||||||
if (isPlainRecord(previousConfig) && Object.prototype.hasOwnProperty.call(previousConfig, "routing")) {
|
if (isPlainRecord(previousConfig) && Object.prototype.hasOwnProperty.call(previousConfig, "routing")) {
|
||||||
|
|
@ -596,7 +713,8 @@ export function pluginDependencyCandidate(
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
id: dependency.id,
|
id: dependency.id,
|
||||||
modulePath: dependency.modulePath,
|
modulePath: dependency.modulePath,
|
||||||
name: dependency.name
|
name: dependency.name,
|
||||||
|
permissions: dependency.permissions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,7 +727,8 @@ export function pluginDependencyCandidate(
|
||||||
dependencies: entry.dependencies,
|
dependencies: entry.dependencies,
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
modulePath: entry.modulePath,
|
modulePath: entry.modulePath,
|
||||||
name: entry.name
|
name: entry.name,
|
||||||
|
permissions: entry.permissions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -681,6 +800,10 @@ export function extensionMatchesQuery(extension: ExtensionListItem, query: strin
|
||||||
export function wrapperPluginCapability(item: Record<string, unknown>): string {
|
export function wrapperPluginCapability(item: Record<string, unknown>): string {
|
||||||
const capabilities: string[] = ["Wrapper runtime"];
|
const capabilities: string[] = ["Wrapper runtime"];
|
||||||
if (stringValue(item.module)) capabilities.push("Module");
|
if (stringValue(item.module)) capabilities.push("Module");
|
||||||
|
const permissions = Array.isArray(item.permissions)
|
||||||
|
? item.permissions.map(stringValue).filter((value): value is string => Boolean(value))
|
||||||
|
: [];
|
||||||
|
if (permissions.length > 0) capabilities.push(`Permissions: ${permissions.join(", ")}`);
|
||||||
const apps = Array.isArray(item.apps) ? item.apps.length : 0;
|
const apps = Array.isArray(item.apps) ? item.apps.length : 0;
|
||||||
if (apps > 0) capabilities.push(`${apps} browser ${apps === 1 ? "app" : "apps"}`);
|
if (apps > 0) capabilities.push(`${apps} browser ${apps === 1 ? "app" : "apps"}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ import type {
|
||||||
GatewayProviderConfig,
|
GatewayProviderConfig,
|
||||||
GatewayProviderCapability,
|
GatewayProviderCapability,
|
||||||
GatewayPluginAppConfig,
|
GatewayPluginAppConfig,
|
||||||
|
GatewayPluginPermission,
|
||||||
GatewayProviderConnectivityCheckModelResult,
|
GatewayProviderConnectivityCheckModelResult,
|
||||||
GatewayProviderConnectivityCheckReport,
|
GatewayProviderConnectivityCheckReport,
|
||||||
GatewayProviderProbeCandidate,
|
GatewayProviderProbeCandidate,
|
||||||
|
|
@ -663,6 +664,7 @@ export type ExtensionInstallDraft = {
|
||||||
key: string;
|
key: string;
|
||||||
marketplaceId: string;
|
marketplaceId: string;
|
||||||
modulePath: string;
|
modulePath: string;
|
||||||
|
permissions?: GatewayPluginPermission[];
|
||||||
selectedName: string;
|
selectedName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -710,6 +712,7 @@ export type PluginInstallCandidate = {
|
||||||
id: string;
|
id: string;
|
||||||
modulePath: string;
|
modulePath: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
permissions?: GatewayPluginPermission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginSettingsDraft = {
|
export type PluginSettingsDraft = {
|
||||||
|
|
@ -717,6 +720,7 @@ export type PluginSettingsDraft = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
modulePath: string;
|
modulePath: string;
|
||||||
configText: string;
|
configText: string;
|
||||||
|
permissionsText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoutingRuleRow = {
|
export type RoutingRuleRow = {
|
||||||
|
|
|
||||||
305
tests/main/agent-console-launcher.test.mjs
Normal file
305
tests/main/agent-console-launcher.test.mjs
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import agentConsolePlugin from "../../marketplace/plugins/agent-console/index.cjs";
|
||||||
|
|
||||||
|
const DEFAULT_LAUNCHER_BUNDLE_ID = "com.claudecoderouter.plugin.agent-console.launcher";
|
||||||
|
|
||||||
|
test("Agent Console catalog uses model ids for display names and model-specific reasoning levels", async () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||||
|
try {
|
||||||
|
await withHome(home, async () => {
|
||||||
|
const pluginDataDir = path.join(home, "plugin-data");
|
||||||
|
await agentConsolePlugin.setup(createPluginContext({
|
||||||
|
config: {
|
||||||
|
Providers: [
|
||||||
|
{
|
||||||
|
modelDescriptions: {
|
||||||
|
"glm-4.5": "性能强劲,但是速度很慢",
|
||||||
|
"glm-4-air": "性能一般,速度快"
|
||||||
|
},
|
||||||
|
models: ["glm-4.5", "glm-4-air", "glm-5.2"],
|
||||||
|
name: "Zhipu AI (China) - Coding Plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
models: ["z-ai/glm-5.2"],
|
||||||
|
name: "OpenRouter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
models: ["gpt-5.5"],
|
||||||
|
name: "Codex API"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pluginConfig: { bridgePort: 34567, launch: false, systemLauncher: false },
|
||||||
|
pluginDataDir,
|
||||||
|
routes: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtimeConfig = JSON.parse(readFileSync(path.join(pluginDataDir, "ccr-runtime-config.json"), "utf8"));
|
||||||
|
const catalog = JSON.parse(readFileSync(path.join(pluginDataDir, "ccr-codex-model-catalog.json"), "utf8"));
|
||||||
|
|
||||||
|
assert.equal(runtimeConfig.models[0].displayName, "Zhipu AI (China) - Coding Plan/glm-4.5");
|
||||||
|
const runtimeToggleReasoningModel = runtimeConfig.models.find((item) => item.model === "Zhipu AI (China) - Coding Plan/glm-4.5");
|
||||||
|
const runtimeBasicModel = runtimeConfig.models.find((item) => item.model === "Zhipu AI (China) - Coding Plan/glm-4-air");
|
||||||
|
const runtimeZhipuEffortReasoningModel = runtimeConfig.models.find((item) => item.model === "Zhipu AI (China) - Coding Plan/glm-5.2");
|
||||||
|
const runtimeEffortReasoningModel = runtimeConfig.models.find((item) => item.model === "OpenRouter/z-ai/glm-5.2");
|
||||||
|
const runtimeOpenAiReasoningModel = runtimeConfig.models.find((item) => item.model === "Codex API/gpt-5.5");
|
||||||
|
const toggleReasoningModel = catalog.models.find((item) => item.slug === "Zhipu AI (China) - Coding Plan/glm-4.5");
|
||||||
|
const basicModel = catalog.models.find((item) => item.slug === "Zhipu AI (China) - Coding Plan/glm-4-air");
|
||||||
|
const zhipuEffortReasoningModel = catalog.models.find((item) => item.slug === "Zhipu AI (China) - Coding Plan/glm-5.2");
|
||||||
|
const effortReasoningModel = catalog.models.find((item) => item.slug === "OpenRouter/z-ai/glm-5.2");
|
||||||
|
const openAiReasoningModel = catalog.models.find((item) => item.slug === "Codex API/gpt-5.5");
|
||||||
|
|
||||||
|
assert.ok(runtimeToggleReasoningModel);
|
||||||
|
assert.ok(runtimeBasicModel);
|
||||||
|
assert.ok(runtimeZhipuEffortReasoningModel);
|
||||||
|
assert.ok(runtimeEffortReasoningModel);
|
||||||
|
assert.ok(runtimeOpenAiReasoningModel);
|
||||||
|
assert.ok(toggleReasoningModel);
|
||||||
|
assert.ok(basicModel);
|
||||||
|
assert.ok(zhipuEffortReasoningModel);
|
||||||
|
assert.ok(effortReasoningModel);
|
||||||
|
assert.ok(openAiReasoningModel);
|
||||||
|
assert.deepEqual(runtimeToggleReasoningModel.supportedReasoningEfforts, []);
|
||||||
|
assert.deepEqual(runtimeToggleReasoningModel.supportedSpeeds, []);
|
||||||
|
assert.deepEqual(runtimeBasicModel.supportedReasoningEfforts, []);
|
||||||
|
assert.deepEqual(runtimeBasicModel.supportedSpeeds, []);
|
||||||
|
assert.deepEqual(runtimeZhipuEffortReasoningModel.supportedReasoningEfforts, ["high", "xhigh"]);
|
||||||
|
assert.equal(runtimeZhipuEffortReasoningModel.defaultReasoningEffort, undefined);
|
||||||
|
assert.deepEqual(runtimeZhipuEffortReasoningModel.supportedSpeeds, []);
|
||||||
|
assert.deepEqual(runtimeEffortReasoningModel.supportedReasoningEfforts, ["xhigh", "high"]);
|
||||||
|
assert.equal(runtimeEffortReasoningModel.defaultReasoningEffort, "high");
|
||||||
|
assert.deepEqual(runtimeEffortReasoningModel.supportedSpeeds, []);
|
||||||
|
assert.deepEqual(runtimeOpenAiReasoningModel.supportedReasoningEfforts, ["minimal", "low", "medium", "high"]);
|
||||||
|
assert.equal(runtimeOpenAiReasoningModel.defaultReasoningEffort, "medium");
|
||||||
|
assert.deepEqual(runtimeOpenAiReasoningModel.supportedSpeeds, []);
|
||||||
|
assert.equal(toggleReasoningModel.display_name, toggleReasoningModel.slug);
|
||||||
|
assert.equal(basicModel.display_name, basicModel.slug);
|
||||||
|
assert.equal(zhipuEffortReasoningModel.display_name, zhipuEffortReasoningModel.slug);
|
||||||
|
assert.equal(effortReasoningModel.display_name, effortReasoningModel.slug);
|
||||||
|
assert.equal(openAiReasoningModel.display_name, openAiReasoningModel.slug);
|
||||||
|
assert.equal(toggleReasoningModel.default_reasoning_level, null);
|
||||||
|
assert.deepEqual(toggleReasoningModel.supported_reasoning_levels, []);
|
||||||
|
assert.equal(toggleReasoningModel.supports_reasoning_summaries, true);
|
||||||
|
assert.equal(zhipuEffortReasoningModel.default_reasoning_level, null);
|
||||||
|
assert.deepEqual(zhipuEffortReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["high", "xhigh"]);
|
||||||
|
assert.equal(zhipuEffortReasoningModel.supports_reasoning_summaries, true);
|
||||||
|
assert.equal(effortReasoningModel.default_reasoning_level, "high");
|
||||||
|
assert.deepEqual(effortReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["xhigh", "high"]);
|
||||||
|
assert.equal(effortReasoningModel.supports_reasoning_summaries, true);
|
||||||
|
assert.equal(openAiReasoningModel.default_reasoning_level, "medium");
|
||||||
|
assert.deepEqual(openAiReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["minimal", "low", "medium", "high"]);
|
||||||
|
assert.equal(openAiReasoningModel.supports_reasoning_summaries, true);
|
||||||
|
assert.equal(basicModel.default_reasoning_level, null);
|
||||||
|
assert.deepEqual(basicModel.supported_reasoning_levels, []);
|
||||||
|
assert.equal(basicModel.supports_reasoning_summaries, false);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent Console macOS launcher defaults to the CCR Apps folder", { skip: process.platform !== "darwin" }, async () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||||
|
try {
|
||||||
|
await withHome(home, async () => {
|
||||||
|
const routes = [];
|
||||||
|
await agentConsolePlugin.setup(createPluginContext({
|
||||||
|
pluginConfig: { bridgePort: 34567, launch: false },
|
||||||
|
pluginDataDir: path.join(home, "plugin-data"),
|
||||||
|
routes
|
||||||
|
}));
|
||||||
|
|
||||||
|
const expectedLauncherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||||
|
const status = readStatusPayload(routes);
|
||||||
|
|
||||||
|
assert.equal(status.launcherInstalled, true);
|
||||||
|
assert.equal(status.launcherPath, expectedLauncherPath);
|
||||||
|
assert.ok(existsSync(path.join(expectedLauncherPath, "Contents", "MacOS", "AgentConsole")));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent Console macOS launcher migrates the previous default app location", { skip: process.platform !== "darwin" }, async () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||||
|
try {
|
||||||
|
await withHome(home, async () => {
|
||||||
|
const legacyLauncherPath = path.join(home, "Applications", "Agent Console.app");
|
||||||
|
writeLauncherInfoPlist(legacyLauncherPath);
|
||||||
|
|
||||||
|
const routes = [];
|
||||||
|
await agentConsolePlugin.setup(createPluginContext({
|
||||||
|
pluginConfig: { bridgePort: 34567, launch: false },
|
||||||
|
pluginDataDir: path.join(home, "plugin-data"),
|
||||||
|
routes
|
||||||
|
}));
|
||||||
|
|
||||||
|
const expectedLauncherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||||
|
const status = readStatusPayload(routes);
|
||||||
|
|
||||||
|
assert.equal(status.launcherInstalled, true);
|
||||||
|
assert.equal(status.launcherPath, expectedLauncherPath);
|
||||||
|
assert.equal(existsSync(legacyLauncherPath), false);
|
||||||
|
assert.ok(existsSync(path.join(expectedLauncherPath, "Contents", "MacOS", "AgentConsole")));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent Console macOS launcher keeps an explicit launcherPath", { skip: process.platform !== "darwin" }, async () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||||
|
try {
|
||||||
|
await withHome(home, async () => {
|
||||||
|
const explicitLauncherPath = path.join(home, "Custom Launchers", "Console.app");
|
||||||
|
const routes = [];
|
||||||
|
await agentConsolePlugin.setup(createPluginContext({
|
||||||
|
pluginConfig: {
|
||||||
|
bridgePort: 34567,
|
||||||
|
launch: false,
|
||||||
|
launcherPath: explicitLauncherPath
|
||||||
|
},
|
||||||
|
pluginDataDir: path.join(home, "plugin-data"),
|
||||||
|
routes
|
||||||
|
}));
|
||||||
|
|
||||||
|
const status = readStatusPayload(routes);
|
||||||
|
|
||||||
|
assert.equal(status.launcherInstalled, true);
|
||||||
|
assert.equal(status.launcherPath, explicitLauncherPath);
|
||||||
|
assert.ok(existsSync(path.join(explicitLauncherPath, "Contents", "MacOS", "AgentConsole")));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent Console macOS launcher is removed when the plugin is disabled", { skip: process.platform !== "darwin" }, async () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||||
|
try {
|
||||||
|
await withHome(home, async () => {
|
||||||
|
const routes = [];
|
||||||
|
const registration = await agentConsolePlugin.setup(createPluginContext({
|
||||||
|
pluginConfig: { bridgePort: 34567, launch: false },
|
||||||
|
pluginDataDir: path.join(home, "plugin-data"),
|
||||||
|
routes
|
||||||
|
}));
|
||||||
|
const launcherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||||
|
|
||||||
|
assert.ok(existsSync(launcherPath));
|
||||||
|
await registration.stop({ reason: "disabled" });
|
||||||
|
assert.equal(existsSync(launcherPath), false);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent Console macOS launcher is kept during a normal gateway stop", { skip: process.platform !== "darwin" }, async () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||||
|
try {
|
||||||
|
await withHome(home, async () => {
|
||||||
|
const routes = [];
|
||||||
|
const registration = await agentConsolePlugin.setup(createPluginContext({
|
||||||
|
pluginConfig: { bridgePort: 34567, launch: false },
|
||||||
|
pluginDataDir: path.join(home, "plugin-data"),
|
||||||
|
routes
|
||||||
|
}));
|
||||||
|
const launcherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||||
|
|
||||||
|
assert.ok(existsSync(launcherPath));
|
||||||
|
await registration.stop({ reason: "stop" });
|
||||||
|
assert.ok(existsSync(launcherPath));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent Console macOS launcher removal skips apps with a different bundle id", { skip: process.platform !== "darwin" }, async () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||||
|
try {
|
||||||
|
await withHome(home, async () => {
|
||||||
|
const routes = [];
|
||||||
|
const registration = await agentConsolePlugin.setup(createPluginContext({
|
||||||
|
pluginConfig: { bridgePort: 34567, launch: false },
|
||||||
|
pluginDataDir: path.join(home, "plugin-data"),
|
||||||
|
routes
|
||||||
|
}));
|
||||||
|
const launcherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||||
|
writeLauncherInfoPlist(launcherPath, "com.example.other-launcher");
|
||||||
|
|
||||||
|
await registration.stop({ reason: "disabled" });
|
||||||
|
assert.ok(existsSync(launcherPath));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function withHome(home, run) {
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
try {
|
||||||
|
process.env.HOME = home;
|
||||||
|
await run();
|
||||||
|
} finally {
|
||||||
|
if (previousHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLauncherInfoPlist(launcherPath, bundleId = DEFAULT_LAUNCHER_BUNDLE_ID) {
|
||||||
|
const contentsDir = path.join(launcherPath, "Contents");
|
||||||
|
mkdirSync(contentsDir, { recursive: true });
|
||||||
|
writeFileSync(path.join(contentsDir, "Info.plist"), [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
||||||
|
"<plist version=\"1.0\">",
|
||||||
|
"<dict>",
|
||||||
|
" <key>CFBundleIdentifier</key>",
|
||||||
|
` <string>${bundleId}</string>`,
|
||||||
|
"</dict>",
|
||||||
|
"</plist>",
|
||||||
|
""
|
||||||
|
].join("\n"), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginContext({ config = {}, pluginConfig, pluginDataDir, routes }) {
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
logger: {
|
||||||
|
debug() {},
|
||||||
|
info() {},
|
||||||
|
warn() {}
|
||||||
|
},
|
||||||
|
paths: { pluginDataDir },
|
||||||
|
pluginConfig,
|
||||||
|
registerApp() {},
|
||||||
|
registerGatewayRoute(route) {
|
||||||
|
routes.push(route);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStatusPayload(routes) {
|
||||||
|
const statusRoute = routes.find((route) => route.id === "agent-console-status");
|
||||||
|
assert.ok(statusRoute, "Agent Console status route should be registered.");
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
statusRoute.handler({}, {}, {
|
||||||
|
sendJson(_response, statusCode, body) {
|
||||||
|
assert.equal(statusCode, 200);
|
||||||
|
payload = body;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(payload, "Agent Console status route should return a payload.");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
@ -56,11 +56,47 @@ test("codex catalog enables multimodal reasoning and search when provider protoc
|
||||||
assert.equal(model.supports_reasoning_summaries, true);
|
assert.equal(model.supports_reasoning_summaries, true);
|
||||||
assert.equal(model.supports_search_tool, true);
|
assert.equal(model.supports_search_tool, true);
|
||||||
assert.equal(model.web_search_tool_type, "text_and_image");
|
assert.equal(model.web_search_tool_type, "text_and_image");
|
||||||
assert.deepEqual(model.supported_reasoning_levels.map((level) => level.effort), ["low", "medium", "high"]);
|
assert.deepEqual(model.supported_reasoning_levels, []);
|
||||||
assert.equal(model.default_reasoning_level, "medium");
|
assert.equal(model.default_reasoning_level, null);
|
||||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("codex catalog uses provider-specific reasoning effort levels when declared", () => {
|
||||||
|
const model = catalogModelFor({
|
||||||
|
Providers: [
|
||||||
|
{ name: "openrouter", type: "openai_responses", models: ["z-ai/glm-5.2"] }
|
||||||
|
]
|
||||||
|
}, "openrouter/z-ai/glm-5.2");
|
||||||
|
|
||||||
|
assert.equal(model.supports_reasoning_summaries, true);
|
||||||
|
assert.deepEqual(model.supported_reasoning_levels.map((level) => level.effort), ["xhigh", "high"]);
|
||||||
|
assert.equal(model.default_reasoning_level, "high");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("codex catalog provides GPT reasoning levels for Codex API models", () => {
|
||||||
|
const model = catalogModelFor({
|
||||||
|
Providers: [
|
||||||
|
{ name: "Codex API", type: "openai_responses", models: ["gpt-5.5"] }
|
||||||
|
]
|
||||||
|
}, "Codex API/gpt-5.5");
|
||||||
|
|
||||||
|
assert.equal(model.supports_reasoning_summaries, true);
|
||||||
|
assert.deepEqual(model.supported_reasoning_levels.map((level) => level.effort), ["minimal", "low", "medium", "high"]);
|
||||||
|
assert.equal(model.default_reasoning_level, "medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("codex catalog does not merge reasoning efforts from unrelated providers", () => {
|
||||||
|
const model = catalogModelFor({
|
||||||
|
Providers: [
|
||||||
|
{ name: "wrapped-zhipu", type: "openai_responses", models: ["glm-5.2"] }
|
||||||
|
]
|
||||||
|
}, "wrapped-zhipu/glm-5.2");
|
||||||
|
|
||||||
|
assert.equal(model.supports_reasoning_summaries, true);
|
||||||
|
assert.deepEqual(model.supported_reasoning_levels, []);
|
||||||
|
assert.equal(model.default_reasoning_level, null);
|
||||||
|
});
|
||||||
|
|
||||||
test("codex catalog enables native search for Gemini Interactions providers", () => {
|
test("codex catalog enables native search for Gemini Interactions providers", () => {
|
||||||
const model = catalogModelFor({
|
const model = catalogModelFor({
|
||||||
Providers: [
|
Providers: [
|
||||||
|
|
@ -94,6 +130,8 @@ test("codex catalog keeps freeform apply_patch for GPT-named chat-compatible mod
|
||||||
}, "gateway/gpt-compatible-coder");
|
}, "gateway/gpt-compatible-coder");
|
||||||
|
|
||||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||||
|
assert.deepEqual(model.supported_reasoning_levels, []);
|
||||||
|
assert.equal(model.default_reasoning_level, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("codex catalog keeps freeform apply_patch when provider advertises Responses capability", () => {
|
test("codex catalog keeps freeform apply_patch when provider advertises Responses capability", () => {
|
||||||
|
|
|
||||||
116
tests/main/plugin-permissions.test.mjs
Normal file
116
tests/main/plugin-permissions.test.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import os 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 permissions gate dynamic gateway route registration", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-plugin-permissions-"));
|
||||||
|
try {
|
||||||
|
const pluginFile = path.join(dir, "route-plugin.cjs");
|
||||||
|
writeFileSync(pluginFile, [
|
||||||
|
"\"use strict\";",
|
||||||
|
"module.exports = {",
|
||||||
|
" setup(ctx) {",
|
||||||
|
" ctx.registerGatewayRoute({",
|
||||||
|
" auth: \"none\",",
|
||||||
|
" id: \"status\",",
|
||||||
|
" path: \"/plugins/permission-test\",",
|
||||||
|
" handler(_request, response, helpers) {",
|
||||||
|
" helpers.sendJson(response, 200, { ok: true });",
|
||||||
|
" }",
|
||||||
|
" });",
|
||||||
|
" }",
|
||||||
|
"};",
|
||||||
|
""
|
||||||
|
].join("\n"), "utf8");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => pluginService.start(configWithPlugin(dir, pluginFile, [])),
|
||||||
|
/Plugin permission-test requires permission "gateway-routes" to register gateway routes\./
|
||||||
|
);
|
||||||
|
await pluginService.stop();
|
||||||
|
|
||||||
|
await pluginService.start(configWithPlugin(dir, pluginFile, ["gateway-routes"]));
|
||||||
|
assert.equal(pluginService.hasGatewayRoutes(), true);
|
||||||
|
} finally {
|
||||||
|
await pluginService.stop();
|
||||||
|
rmSync(dir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plugins without permissions declarations keep legacy compatibility", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-plugin-legacy-permissions-"));
|
||||||
|
try {
|
||||||
|
const pluginFile = path.join(dir, "legacy-route-plugin.cjs");
|
||||||
|
writeFileSync(pluginFile, [
|
||||||
|
"\"use strict\";",
|
||||||
|
"module.exports = {",
|
||||||
|
" setup(ctx) {",
|
||||||
|
" ctx.registerGatewayRoute({",
|
||||||
|
" auth: \"none\",",
|
||||||
|
" id: \"legacy-status\",",
|
||||||
|
" path: \"/plugins/legacy-permission-test\",",
|
||||||
|
" handler(_request, response, helpers) {",
|
||||||
|
" helpers.sendJson(response, 200, { ok: true });",
|
||||||
|
" }",
|
||||||
|
" });",
|
||||||
|
" }",
|
||||||
|
"};",
|
||||||
|
""
|
||||||
|
].join("\n"), "utf8");
|
||||||
|
|
||||||
|
await pluginService.start(configWithPlugin(dir, pluginFile, undefined));
|
||||||
|
assert.equal(pluginService.hasGatewayRoutes(), true);
|
||||||
|
} finally {
|
||||||
|
await pluginService.stop();
|
||||||
|
rmSync(dir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plugin permissions gate configured browser apps", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-plugin-static-permissions-"));
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => pluginService.start({
|
||||||
|
...baseConfig(dir),
|
||||||
|
plugins: [{
|
||||||
|
apps: [{ id: "app", name: "App", url: "/app" }],
|
||||||
|
enabled: true,
|
||||||
|
id: "static-app",
|
||||||
|
permissions: []
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
/Plugin static-app requires permission "apps" to register configured browser apps\./
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pluginService.stop();
|
||||||
|
rmSync(dir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function configWithPlugin(dir, pluginFile, permissions) {
|
||||||
|
const plugin = {
|
||||||
|
enabled: true,
|
||||||
|
id: "permission-test",
|
||||||
|
module: pluginFile
|
||||||
|
};
|
||||||
|
if (permissions !== undefined) {
|
||||||
|
plugin.permissions = permissions;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseConfig(dir),
|
||||||
|
plugins: [plugin]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseConfig(dir) {
|
||||||
|
const config = createDefaultAppConfig({
|
||||||
|
generatedConfigFile: path.join(dir, "gateway.config.json")
|
||||||
|
});
|
||||||
|
config.gateway.enabled = false;
|
||||||
|
config.plugins = [];
|
||||||
|
return config;
|
||||||
|
}
|
||||||
59
tests/main/plugin-stop-reason.test.mjs
Normal file
59
tests/main/plugin-stop-reason.test.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
|
||||||
|
import { gatewayService } from "../../packages/core/src/gateway/service.ts";
|
||||||
|
import { pluginService } from "../../packages/core/src/plugins/service.ts";
|
||||||
|
|
||||||
|
test("gateway restart reports disabled reason to removed plugin stop hooks", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-plugin-stop-reason-"));
|
||||||
|
const previousReasonFile = process.env.CCR_TEST_PLUGIN_STOP_REASON_FILE;
|
||||||
|
try {
|
||||||
|
const reasonFile = path.join(dir, "stop-reasons.log");
|
||||||
|
const pluginFile = path.join(dir, "stop-reason-plugin.cjs");
|
||||||
|
process.env.CCR_TEST_PLUGIN_STOP_REASON_FILE = reasonFile;
|
||||||
|
writeFileSync(pluginFile, [
|
||||||
|
"\"use strict\";",
|
||||||
|
"const fs = require(\"node:fs\");",
|
||||||
|
"module.exports = {",
|
||||||
|
" setup() {",
|
||||||
|
" return {",
|
||||||
|
" stop(event) {",
|
||||||
|
" fs.appendFileSync(process.env.CCR_TEST_PLUGIN_STOP_REASON_FILE, `${event?.reason || \"missing\"}\\n`);",
|
||||||
|
" }",
|
||||||
|
" };",
|
||||||
|
" }",
|
||||||
|
"};",
|
||||||
|
""
|
||||||
|
].join("\n"), "utf8");
|
||||||
|
|
||||||
|
await pluginService.start(configWithPlugin(dir, pluginFile, true));
|
||||||
|
await gatewayService.start(configWithPlugin(dir, pluginFile, false));
|
||||||
|
|
||||||
|
assert.equal(readFileSync(reasonFile, "utf8"), "disabled\n");
|
||||||
|
} finally {
|
||||||
|
await gatewayService.stop();
|
||||||
|
await pluginService.stop();
|
||||||
|
if (previousReasonFile === undefined) {
|
||||||
|
delete process.env.CCR_TEST_PLUGIN_STOP_REASON_FILE;
|
||||||
|
} else {
|
||||||
|
process.env.CCR_TEST_PLUGIN_STOP_REASON_FILE = previousReasonFile;
|
||||||
|
}
|
||||||
|
rmSync(dir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function configWithPlugin(dir, pluginFile, enabled) {
|
||||||
|
const config = createDefaultAppConfig({
|
||||||
|
generatedConfigFile: path.join(dir, "gateway.config.json")
|
||||||
|
});
|
||||||
|
config.gateway.enabled = false;
|
||||||
|
config.plugins = [{
|
||||||
|
enabled,
|
||||||
|
id: "stop-reason-plugin",
|
||||||
|
module: pluginFile
|
||||||
|
}];
|
||||||
|
return config;
|
||||||
|
}
|
||||||
68
tests/main/socket-compat.test.mjs
Normal file
68
tests/main/socket-compat.test.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Socket } from "node:net";
|
||||||
|
import test from "node:test";
|
||||||
|
import { installSocketTypeOfServiceCompat, isIgnorableSocketTypeOfServiceError } from "../../packages/core/src/platform/socket-compat.ts";
|
||||||
|
|
||||||
|
test("socket type-of-service compat ignores Electron EINVAL failures", () => {
|
||||||
|
withSocketSetTypeOfService(() => {
|
||||||
|
const error = new Error("setTypeOfService EINVAL");
|
||||||
|
error.code = "EINVAL";
|
||||||
|
throw error;
|
||||||
|
}, () => {
|
||||||
|
installSocketTypeOfServiceCompat();
|
||||||
|
const socket = new Socket();
|
||||||
|
|
||||||
|
assert.equal(socket.setTypeOfService(0), socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("socket type-of-service compat rethrows unrelated failures", () => {
|
||||||
|
withSocketSetTypeOfService(() => {
|
||||||
|
const error = new Error("setTypeOfService failed");
|
||||||
|
error.code = "EACCES";
|
||||||
|
throw error;
|
||||||
|
}, () => {
|
||||||
|
installSocketTypeOfServiceCompat();
|
||||||
|
const socket = new Socket();
|
||||||
|
|
||||||
|
assert.throws(() => socket.setTypeOfService(0), /setTypeOfService failed/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("socket type-of-service error matcher is specific to setTypeOfService EINVAL", () => {
|
||||||
|
const error = new Error("setTypeOfService EINVAL");
|
||||||
|
error.code = "EINVAL";
|
||||||
|
const connectError = new Error("connect EINVAL");
|
||||||
|
connectError.code = "EINVAL";
|
||||||
|
|
||||||
|
assert.equal(isIgnorableSocketTypeOfServiceError(error), true);
|
||||||
|
assert.equal(isIgnorableSocketTypeOfServiceError(connectError), false);
|
||||||
|
assert.equal(isIgnorableSocketTypeOfServiceError(new Error("setTypeOfService failed")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function withSocketSetTypeOfService(fakeSetTypeOfService, run) {
|
||||||
|
const stateSymbol = Symbol.for("ccr.socketTypeOfServiceCompatState");
|
||||||
|
const prototype = Socket.prototype;
|
||||||
|
const previousMethodDescriptor = Object.getOwnPropertyDescriptor(prototype, "setTypeOfService");
|
||||||
|
const previousStateDescriptor = Object.getOwnPropertyDescriptor(prototype, stateSymbol);
|
||||||
|
try {
|
||||||
|
delete prototype[stateSymbol];
|
||||||
|
Object.defineProperty(prototype, "setTypeOfService", {
|
||||||
|
configurable: true,
|
||||||
|
value: fakeSetTypeOfService,
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
if (previousMethodDescriptor) {
|
||||||
|
Object.defineProperty(prototype, "setTypeOfService", previousMethodDescriptor);
|
||||||
|
} else {
|
||||||
|
delete prototype.setTypeOfService;
|
||||||
|
}
|
||||||
|
if (previousStateDescriptor) {
|
||||||
|
Object.defineProperty(prototype, stateSymbol, previousStateDescriptor);
|
||||||
|
} else {
|
||||||
|
delete prototype[stateSymbol];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,13 @@ import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { normalizeExternalHttpTarget } from "../../packages/core/src/web/management-server.ts";
|
import { normalizeExternalHttpTarget } from "../../packages/core/src/web/management-server.ts";
|
||||||
|
|
||||||
test("normalizeExternalHttpTarget only accepts absolute http and https URLs", () => {
|
test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", () => {
|
||||||
assert.equal(normalizeExternalHttpTarget(""), undefined);
|
assert.equal(normalizeExternalHttpTarget(""), undefined);
|
||||||
assert.equal(normalizeExternalHttpTarget(undefined), undefined);
|
assert.equal(normalizeExternalHttpTarget(undefined), undefined);
|
||||||
assert.equal(normalizeExternalHttpTarget("about:blank"), undefined);
|
assert.equal(normalizeExternalHttpTarget("about:blank"), undefined);
|
||||||
assert.equal(normalizeExternalHttpTarget(" https://example.com/path?q=1 "), "https://example.com/path?q=1");
|
assert.equal(normalizeExternalHttpTarget(" https://example.com/path?q=1 "), "https://example.com/path?q=1");
|
||||||
assert.equal(normalizeExternalHttpTarget("http://localhost:3458/"), "http://localhost:3458/");
|
assert.equal(normalizeExternalHttpTarget("http://localhost:3458/"), "http://localhost:3458/");
|
||||||
assert.throws(() => normalizeExternalHttpTarget("file:///etc/passwd"), /Only http and https/);
|
assert.throws(() => normalizeExternalHttpTarget("file:///etc/passwd"), /Only http, https, and CCR plugin URLs/);
|
||||||
assert.throws(() => normalizeExternalHttpTarget("javascript:alert(1)"), /Only http and https/);
|
assert.throws(() => normalizeExternalHttpTarget("javascript:alert(1)"), /Only http, https, and CCR plugin URLs/);
|
||||||
assert.throws(() => normalizeExternalHttpTarget("example.com"), /valid absolute URL/);
|
assert.throws(() => normalizeExternalHttpTarget("example.com"), /valid absolute URL/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue