diff --git a/marketplace/plugins.json b/marketplace/plugins.json index 7f828af..35044a7 100644 --- a/marketplace/plugins.json +++ b/marketplace/plugins.json @@ -6,6 +6,7 @@ "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.", "capabilities": ["Wrapper runtime", "PWA bridge", "System launcher", "Agent Console", "Model routing"], + "permissions": ["apps", "gateway-routes", "system-launcher"], "module": "plugins/agent-console/index.cjs", "apps": [ { @@ -22,6 +23,7 @@ "name": "Claude Design", "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"], + "permissions": ["gateway-routes", "proxy-routes", "http-backends", "sqlite-store"], "module": "../examples/plugins/claude-design-plugin.cjs" }, { @@ -29,6 +31,7 @@ "name": "Cursor Proxy", "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"], + "permissions": ["gateway-routes", "proxy-routes", "http-backends"], "module": "../examples/plugins/cursor-proxy-plugin.cjs" } ] diff --git a/marketplace/plugins/agent-console/index.cjs b/marketplace/plugins/agent-console/index.cjs index e404b72..64a7c27 100644 --- a/marketplace/plugins/agent-console/index.cjs +++ b/marketplace/plugins/agent-console/index.cjs @@ -12,9 +12,12 @@ const DEFAULT_ROUTE_PREFIX = "/plugins/agent-console"; const DEFAULT_RENDERER_ENTRY_PATH = "/pages/home/"; const DEFAULT_LAUNCHER_NAME = "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 READY_PREFIX = "AGENT_CONSOLE_HEADLESS_READY "; const DEFAULT_STARTUP_WAIT_MS = 15000; +const OPENAI_REASONING_EFFORTS = ["minimal", "low", "medium", "high"]; +let modelCatalogIndex; module.exports = { async setup(ctx) { @@ -44,6 +47,7 @@ module.exports = { const gatewayApiKey = stringValue(options.gatewayApiKey) || configuredGatewayApiKey(ctx.config); const appUrl = buildRendererAppUrl(gatewayUrl, routePrefix, bridgeUrl); 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 modelCatalogFile = path.join(ctx.paths.pluginDataDir, "ccr-codex-model-catalog.json"); const runtimeConfig = buildRuntimeConfig(ctx.config, { @@ -88,7 +92,12 @@ module.exports = { fs.writeFileSync(runtimeConfigFile, `${JSON.stringify(runtimeConfig, 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.launcherInstalled = launcher.installed; runtime.launcherPath = launcher.path || ""; @@ -137,8 +146,11 @@ module.exports = { } return { - stop() { + stop(event) { 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) { return { installed: false }; } @@ -325,22 +337,28 @@ function ensureSystemLauncher(ctx, options, launcherUrl) { } const launcherName = stringValue(options.launcherName) || DEFAULT_LAUNCHER_NAME; - const launcherBundleId = stringValue(options.launcherBundleId) || DEFAULT_LAUNCHER_BUNDLE_ID; const explicitLauncherPath = Boolean(stringValue(options.launcherPath)); const launcherPath = path.resolve( stringValue(options.launcherPath) || - path.join(os.homedir(), "Applications", `${safeMacFileName(launcherName)}.app`) + defaultMacLauncherAppPath(launcherName) ); - if (!explicitLauncherPath && launcherName === DEFAULT_LAUNCHER_NAME) { - try { - migrateLegacyMacLauncherApp({ - bundleId: launcherBundleId, - legacyPath: path.join(os.homedir(), "Applications", `${safeMacFileName(LEGACY_LAUNCHER_NAME)}.app`), - launcherPath - }); - } catch (error) { - ctx.logger.warn(`Failed to rename legacy Agent Console launcher: ${formatError(error)}`); + if (!explicitLauncherPath) { + const legacyLauncherPaths = [legacyMacLauncherAppPath(launcherName)]; + if (launcherName === DEFAULT_LAUNCHER_NAME) { + legacyLauncherPaths.push(legacyMacLauncherAppPath(LEGACY_LAUNCHER_NAME)); + } + + for (const legacyPath of legacyLauncherPaths) { + try { + 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 }) { if (legacyPath === launcherPath || fs.existsSync(launcherPath) || !fs.existsSync(legacyPath)) { return; @@ -384,9 +406,22 @@ function migrateLegacyMacLauncherApp({ bundleId, legacyPath, launcherPath }) { return; } + fs.mkdirSync(path.dirname(launcherPath), { recursive: true }); 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 }) { if (fs.existsSync(launcherPath) && !fs.statSync(launcherPath).isDirectory()) { throw new Error(`${launcherPath} exists and is not a directory.`); @@ -410,6 +445,52 @@ function installMacLauncherApp({ bundleId, launcherName, launcherPath, launcherU 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(`${escapeXml(bundleId)}`)) { + 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 }) { return ` @@ -1243,22 +1324,27 @@ function buildRuntimeConfig(config, options) { function buildCodexModelCatalog(models) { return { - models: models.map((model, index) => ({ - additional_speed_tiers: [], - availability_nux: null, - base_instructions: "You are Codex, a coding agent.", - default_reasoning_level: null, - description: `CCR gateway model ${model.model}`, - display_name: model.displayName || model.model, - priority: index, - service_tiers: [], - shell_type: "shell_command", - slug: model.model, - supported_in_api: true, - supported_reasoning_levels: [], - upgrade: null, - visibility: "list" - })) + models: models.map((model, index) => { + const reasoning = codexModelReasoningProfile(model.model); + return { + additional_speed_tiers: [], + availability_nux: null, + base_instructions: "You are Codex, a coding agent.", + default_reasoning_level: reasoning.defaultReasoningLevel, + default_reasoning_summary: "none", + description: `CCR gateway model ${model.model}`, + display_name: model.displayName || model.model, + priority: index, + service_tiers: [], + shell_type: "shell_command", + slug: model.model, + 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); if (!modelName) continue; const id = `${providerName}/${modelName}`; - baseEntries.push({ + baseEntries.push(runtimeModelEntry(id, { displayName: displayModelName(provider, modelName, id), - id, isDefault: id === stringValue(config?.Router?.default), model: id - }); + })); } } @@ -1286,37 +1371,348 @@ function availableGatewayModels(config) { const displayName = stringValue(profile.displayName || profile.key || profile.id); for (const entry of baseEntries) { 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}`, - id: `${providerNameFromModel(entry.model)}/${prefix}${modelNameFromModel(entry.model)}`, - model: `${providerNameFromModel(entry.model)}/${prefix}${modelNameFromModel(entry.model)}` - }); + model, + reasoningModel: entry.model + })); } 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}`, - id: `${providerNameFromModel(entry.model)}/${modelNameFromModel(entry.model)}${suffix}`, - model: `${providerNameFromModel(entry.model)}/${modelNameFromModel(entry.model)}${suffix}` - }); + model, + reasoningModel: entry.model + })); } } for (const alias of normalizeStringArray(profile.match?.exactAliases) || []) { const model = alias.toLowerCase().startsWith("fusion/") ? alias : `Fusion/${alias}`; - virtualEntries.push({ + virtualEntries.push(runtimeModelEntry(model, { displayName: displayName || alias, - id: model, model - }); + })); } } 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) { const displayNames = isRecord(provider.modelDisplayNames) ? provider.modelDisplayNames : {}; - const descriptions = isRecord(provider.modelDescriptions) ? provider.modelDescriptions : {}; - return stringValue(displayNames[modelName]) || stringValue(descriptions[modelName]) || fallback; + return stringValue(displayNames[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) { @@ -1438,6 +1834,22 @@ function normalizeStringArray(value) { 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) { return typeof value === "string" ? value.trim() : ""; } diff --git a/marketplace/plugins/agent-console/plugin.json b/marketplace/plugins/agent-console/plugin.json index a575690..9a0216a 100644 --- a/marketplace/plugins/agent-console/plugin.json +++ b/marketplace/plugins/agent-console/plugin.json @@ -2,6 +2,7 @@ "id": "agent-console", "name": "Agent Console", "module": "index.cjs", + "permissions": ["apps", "gateway-routes", "system-launcher"], "apps": [ { "id": "agent-console", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 60cd3a8..e2fe280 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,12 +9,15 @@ import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr import { launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch"; import { loadAppConfig } from "@ccr/core/config/config"; import { CONFIGDIR } from "@ccr/core/config/constants"; +import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat"; import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service"; import { ensureProfileGateway } from "@ccr/core/profiles/launch-service"; import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server"; import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app"; +installSocketTypeOfServiceCompat(); + type ProfileCliOptions = { agentArgs: string[]; command: "profile"; diff --git a/packages/core/src/agents/codex/model-catalog.ts b/packages/core/src/agents/codex/model-catalog.ts index dd51f76..4c6b0c1 100644 --- a/packages/core/src/agents/codex/model-catalog.ts +++ b/packages/core/src/agents/codex/model-catalog.ts @@ -3,13 +3,16 @@ import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, Virtual import { findModelCatalogEntry, modelCatalogMaxInputTokens, + modelCatalogReasoningEffortConfig, readCatalogCapability, + type ModelCatalogReasoningEffortConfig, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog"; const fusionModelProviderName = "Fusion"; const codexDefaultContextWindow = 128_000; const codexEffectiveContextWindowPercent = 95; +const openAiReasoningEfforts = ["minimal", "low", "medium", "high"]; export type CodexModelCatalog = { models: CodexModelCatalogItem[]; @@ -120,7 +123,7 @@ function codexModelCatalogItem( availability_nux: null, base_instructions: "You are Codex, a coding agent.", context_window: contextWindow, - default_reasoning_level: profile.supportsReasoning ? "medium" : null, + default_reasoning_level: profile.defaultReasoningLevel, default_reasoning_summary: "none", description: `CCR gateway model ${model}`, display_name: model, @@ -149,6 +152,7 @@ function codexModelCatalogItem( type CodexCapabilityProfile = { applyPatchToolType: string | null; catalogEntry?: ModelCatalogEntry; + defaultReasoningLevel: string | null; inputModalities: string[]; supportedReasoningLevels: Array<{ description: string; effort: string }>; supportsImageInput: boolean; @@ -168,7 +172,14 @@ function codexModelCapabilityProfile( const providerProtocol = provider ? codexProviderProtocol(provider) : undefined; const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false; 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 supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling"); const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config) @@ -188,8 +199,9 @@ function codexModelCapabilityProfile( return { applyPatchToolType, catalogEntry, + defaultReasoningLevel: defaultReasoningLevel(effectiveReasoningConfig), inputModalities: supportsImageInput ? ["text", "image"] : ["text"], - supportedReasoningLevels: supportsReasoning ? supportedReasoningLevels(capabilities) : [], + supportedReasoningLevels: effectiveReasoningConfig.efforts.map(reasoningLevel), supportsImageInput, supportsParallelToolCalls, supportsReasoning, @@ -210,16 +222,42 @@ function catalogEntrySupportsImageInput(entry: ModelCatalogEntry | undefined): b readCatalogCapability(capabilities, "multimodal"); } -function supportedReasoningLevels(capabilities: Record): Array<{ description: string; effort: string }> { - const levels = [ - { effort: "low", description: "Low reasoning" }, - { effort: "medium", description: "Medium reasoning" }, - { effort: "high", description: "High reasoning" } - ]; - if (readCatalogCapability(capabilities, "xhighReasoningEffort") || readCatalogCapability(capabilities, "maxReasoningEffort")) { - levels.push({ effort: "xhigh", description: "Extra high reasoning" }); - } - return levels; +function openAiGptReasoningFallbackApplies(model: string, providerName?: string): boolean { + const selector = parseModelSelector(model); + const provider = normalizeReasoningFallbackToken(providerName ?? selector?.provider ?? ""); + const modelName = normalizeReasoningFallbackToken(selector?.model ?? model); + const openAiProvider = provider.includes("openai") || provider.includes("codex-api"); + return openAiProvider && (/^gpt-[0-9]/.test(modelName) || /^o[0-9]/.test(modelName)); +} + +function normalizeReasoningFallbackToken(value: string): string { + 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 = { + 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( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0cf78ab..82a6465 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4,7 +4,7 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/con 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 { 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 { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; import type { @@ -21,6 +21,7 @@ import type { GatewayPluginConfig, GatewayPluginAppConfig, GatewayPluginProxyRouteConfig, + GatewayPluginPermission, GatewayProviderCapability, GatewayProviderConfig, GatewayProviderProtocol, @@ -95,6 +96,7 @@ const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([ ]); const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1"; const GENERATED_GATEWAY_API_KEY_ID = "local-gateway"; +const GATEWAY_PLUGIN_PERMISSION_ID_SET = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); const DEFAULT_CONFIG: AppConfig = createDefaultAppConfig({ coreHost: INTERNAL_GATEWAY_CORE_HOST, @@ -1955,6 +1957,7 @@ function parseGatewayPlugins(value: unknown): GatewayPluginConfig[] | undefined const apps = parseGatewayPluginApps(item.apps); const proxyRoutes = parseGatewayPluginProxyRoutes(isObject(item.proxy) ? item.proxy.routes : undefined); const coreGateway = parseGatewayPluginCoreGateway(item.coreGateway); + const permissions = parseGatewayPluginPermissions(item.permissions); return { ...(apps ? { apps } : {}), @@ -1963,6 +1966,7 @@ function parseGatewayPlugins(value: unknown): GatewayPluginConfig[] | undefined enabled: typeof item.enabled === "boolean" ? item.enabled : true, id, ...(modulePath ? { module: modulePath } : {}), + ...(permissions !== undefined ? { permissions } : {}), ...(proxyRoutes ? { proxy: { routes: proxyRoutes } } : {}) }; }) @@ -1971,6 +1975,100 @@ function parseGatewayPlugins(value: unknown): GatewayPluginConfig[] | undefined return plugins.length ? plugins : undefined; } +function parseGatewayPluginPermissions(value: unknown): GatewayPluginPermission[] | undefined { + if (value === undefined) { + return undefined; + } + + const permissions: GatewayPluginPermission[] = []; + const seen = new Set(); + 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 { if (!Array.isArray(value)) { return undefined; diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 0dbfa56..9f44b52 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -607,6 +607,21 @@ export type GatewayPluginAppConfig = { 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 GatewayMcpStdioMessageMode = "content-length" | "newline-json"; @@ -860,6 +875,7 @@ export type GatewayPluginConfig = { enabled?: boolean; id: string; module?: string; + permissions?: GatewayPluginPermission[]; proxy?: { routes?: GatewayPluginProxyRouteConfig[]; }; @@ -869,6 +885,7 @@ export type PluginDependency = { id: string; modulePath?: string; name?: string; + permissions?: GatewayPluginPermission[]; }; export type PluginDirectorySelection = { @@ -878,6 +895,7 @@ export type PluginDirectorySelection = { id: string; modulePath: string; name?: string; + permissions?: GatewayPluginPermission[]; }; export type PluginMarketplaceEntry = { @@ -888,6 +906,7 @@ export type PluginMarketplaceEntry = { id: string; modulePath: string; name: string; + permissions?: GatewayPluginPermission[]; }; export type ProxyRuntimeConfig = { diff --git a/packages/core/src/entrypoints/server.ts b/packages/core/src/entrypoints/server.ts index 2059d8a..d6cb0de 100644 --- a/packages/core/src/entrypoints/server.ts +++ b/packages/core/src/entrypoints/server.ts @@ -1,6 +1,9 @@ #!/usr/bin/env node +import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat"; import { startWebManagementServer } from "@ccr/core/web/management-server"; +installSocketTypeOfServiceCompat(); + type CoreServerOptions = { help: boolean; host?: string; diff --git a/packages/core/src/gateway/model-catalog.ts b/packages/core/src/gateway/model-catalog.ts index b32e2e6..3c02f5a 100644 --- a/packages/core/src/gateway/model-catalog.ts +++ b/packages/core/src/gateway/model-catalog.ts @@ -18,6 +18,18 @@ type ModelCatalogModalities = { output?: string[]; }; +export type ModelCatalogReasoningEffortConfig = { + defaultEffort: string; + efforts: string[]; + supportsReasoning: boolean; +}; + +type ModelCatalogSourceRecord = { + metadata?: Record; + provider?: string; + providerName?: string; +}; + export type ModelCatalogEntry = { aliases: string[]; capabilities?: ModelCatalogCapabilities; @@ -25,9 +37,11 @@ export type ModelCatalogEntry = { family?: string; id: string; limits?: ModelCatalogLimits; + metadata?: Record; modalities?: ModelCatalogModalities; model?: string; providers?: string[]; + sourceRecords?: ModelCatalogSourceRecord[]; }; type ModelCatalogIndex = { @@ -88,6 +102,38 @@ export function readCatalogCapability(capabilities: ModelCatalogCapabilities, ke 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 { if (modelCatalogIndex) { return modelCatalogIndex; @@ -162,12 +208,26 @@ function parseModelCatalogEntry(value: unknown): ModelCatalogEntry | undefined { family: stringValue(value.family), id, limits, + metadata: isRecord(value.metadata) ? value.metadata : undefined, modalities, 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 { if (!isRecord(value)) { return undefined; @@ -233,6 +293,90 @@ function modelCatalogLastSegmentKey(value: string): string { 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): 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 { const parsed = numberValue(value); return parsed !== undefined && parsed > 0 ? parsed : undefined; diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 5936e7a..5378108 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -99,6 +99,7 @@ type ApiKeyLimitRule = { }; type GatewayStopOptions = { + nextConfig?: AppConfig; proxyRestoreTimeoutMs?: number; }; @@ -352,7 +353,7 @@ class GatewayService { state: "error" }; } - await this.stop(); + await this.stop({ nextConfig: config }); this.config = config; this.coreAuthToken = generateCoreGatewayAuthToken(); this.plugin = new ClaudeCodeRouterPlugin(config); @@ -445,7 +446,7 @@ class GatewayService { } await proxyService.stop(options.proxyRestoreTimeoutMs); - await pluginService.stop(); + await pluginService.stop({ nextConfig: options.nextConfig }); await backendService.stopAll(); await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => { console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`); diff --git a/packages/core/src/platform/socket-compat.ts b/packages/core/src/platform/socket-compat.ts new file mode 100644 index 0000000..0091037 --- /dev/null +++ b/packages/core/src/platform/socket-compat.ts @@ -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); +} diff --git a/packages/core/src/plugins/marketplace.ts b/packages/core/src/plugins/marketplace.ts index 45190de..29008cf 100644 --- a/packages/core/src/plugins/marketplace.ts +++ b/packages/core/src/plugins/marketplace.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; 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"; type MarketplaceCache = { @@ -19,6 +19,7 @@ const marketplaceFetchTimeoutMs = 10_000; const marketplaceCacheTtlMs = 5 * 60 * 1000; const maxMarketplaceManifestBytes = 1024 * 1024; const maxMarketplaceModuleBytes = 8 * 1024 * 1024; +const gatewayPluginPermissionIdSet = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); let marketplaceCache: MarketplaceCache | undefined; let marketplaceRequest: Promise | undefined; @@ -152,7 +153,8 @@ async function normalizeMarketplaceEntry( description, id, modulePath, - name + name, + permissions: parsePluginPermissions(value.permissions) }; } @@ -207,7 +209,8 @@ async function parsePluginDependency( return { id, ...(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(); + 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 { return typeof value === "string" && value.trim() ? value.trim() : undefined; } @@ -338,7 +435,11 @@ function cloneMarketplaceEntry(entry: PluginMarketplaceEntry): PluginMarketplace ...entry, apps: entry.apps?.map((app) => ({ ...app })), 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 }; } diff --git a/packages/core/src/plugins/service.ts b/packages/core/src/plugins/service.ts index c2d21f1..d1aeef3 100644 --- a/packages/core/src/plugins/service.ts +++ b/packages/core/src/plugins/service.ts @@ -4,16 +4,18 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { - AppConfig, - GatewayPluginAppConfig, - GatewayPluginConfig, - GatewayPluginProxyRouteConfig, - GatewayProviderConfig, - InstalledBrowserApp, - ProviderAccountMeter, - ProviderAccountPluginConnectorConfig, - ProviderAccountSnapshot +import { + GATEWAY_PLUGIN_PERMISSION_IDS, + type AppConfig, + type GatewayPluginAppConfig, + type GatewayPluginConfig, + type GatewayPluginPermission, + type GatewayPluginProxyRouteConfig, + type GatewayProviderConfig, + type InstalledBrowserApp, + type ProviderAccountMeter, + type ProviderAccountPluginConnectorConfig, + type ProviderAccountSnapshot } from "@ccr/core/contracts/app"; import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "@ccr/core/plugins/backend-service"; import { CONFIGDIR, DATADIR } from "@ccr/core/config/constants"; @@ -65,6 +67,14 @@ export type GatewayPluginProviderAccountConnector = { resolve: (request: GatewayPluginProviderAccountRequest) => MaybePromise; }; +export type GatewayPluginStopReason = "disabled" | "reload" | "stop"; + +export type GatewayPluginStopEvent = { + reason: GatewayPluginStopReason; +}; + +type GatewayPluginStopHandler = (event?: GatewayPluginStopEvent) => MaybePromise; + export type GatewayPluginRegistration = { apps?: GatewayPluginAppConfig[]; coreGateway?: { @@ -73,10 +83,10 @@ export type GatewayPluginRegistration = { virtualModelProfiles?: unknown[]; }; gatewayRoutes?: GatewayPluginRouteRegistration[]; - onStop?: () => MaybePromise; + onStop?: GatewayPluginStopHandler; providerAccountConnectors?: GatewayPluginProviderAccountConnector[]; proxyRoutes?: GatewayPluginProxyRouteRegistration[]; - stop?: () => MaybePromise; + stop?: GatewayPluginStopHandler; virtualModelProfiles?: unknown[]; }; @@ -90,6 +100,7 @@ export type GatewayPluginContext = { }; pluginConfig: unknown; pluginId: string; + permissions: GatewayPluginPermission[]; openSqliteStore: (options?: PluginSqliteStoreOptions) => Promise; registerCoreGatewayProviderPlugin: (providerPlugin: unknown) => void; registerCoreGatewayVirtualModelProfile: (profile: unknown) => void; @@ -102,7 +113,7 @@ export type GatewayPluginContext = { export type GatewayPluginRouteContext = Pick< GatewayPluginContext, - "config" | "logger" | "openSqliteStore" | "paths" | "pluginConfig" | "pluginId" + "config" | "logger" | "openSqliteStore" | "paths" | "permissions" | "pluginConfig" | "pluginId" > & { readBody: (request: IncomingMessage) => Promise; readJson: (request: IncomingMessage) => Promise; @@ -141,7 +152,18 @@ type RegisteredProxyRoute = Omit MaybePromise; setup?: (context: GatewayPluginContext) => MaybePromise; - stop?: () => MaybePromise; + stop?: GatewayPluginStopHandler; +}; + +type StopHook = { + pluginId: string; + stop: GatewayPluginStopHandler; +}; + +type PluginPermissionAccess = { + explicit: boolean; + permissions: Set; + pluginId: string; }; const requireFromHere = createRequire(__filename); @@ -154,13 +176,14 @@ class GatewayPluginService { private gatewayRoutes: RegisteredGatewayRoute[] = []; private proxyRoutes: RegisteredProxyRoute[] = []; private providerAccountConnectors = new Map(); + private legacyPermissionWarningIds = new Set(); private resourceOwnerIds = new Set(); private running = false; - private stopHooks: Array<() => MaybePromise> = []; + private stopHooks: StopHook[] = []; private virtualModelProfiles: unknown[] = []; async start(config: AppConfig): Promise { - await this.stop(); + await this.stop({ nextConfig: config }); this.config = config; this.running = true; @@ -173,13 +196,14 @@ class GatewayPluginService { } } - async stop(): Promise { + async stop(options: { nextConfig?: AppConfig } = {}): Promise { const stopHooks = [...this.stopHooks].reverse(); + const nextEnabledPluginIds = options.nextConfig ? enabledPluginIds(options.nextConfig) : undefined; this.stopHooks = []; for (const stopHook of stopHooks) { try { - await stopHook(); + await stopHook.stop({ reason: stopReasonForPlugin(stopHook.pluginId, nextEnabledPluginIds) }); } catch (error) { console.warn(`[plugin] Stop hook failed: ${formatError(error)}`); } @@ -287,8 +311,12 @@ class GatewayPluginService { } private async loadConfiguredPlugin(pluginConfig: GatewayPluginConfig): Promise { - this.registerConfiguredCoreGateway(pluginConfig); - this.registerConfiguredApps(pluginConfig); + const permissions = pluginPermissionAccess(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 ?? []) { this.registerProxyRoute(pluginConfig.id, route); } @@ -300,7 +328,7 @@ class GatewayPluginService { const loadedPlugin = await loadPluginModule(modulePath); const plugin = normalizeLoadedPlugin(loadedPlugin); - const context = this.createPluginContext(pluginConfig); + const context = this.createPluginContext(pluginConfig, permissions); const registration = plugin.setup ? await plugin.setup(context) : plugin.activate @@ -308,29 +336,50 @@ class GatewayPluginService { : undefined; if (registration) { - this.applyPluginRegistration(pluginConfig.id, registration); + this.applyPluginRegistration(pluginConfig.id, registration, permissions); } 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 ?? []) { this.registerApp(pluginId, app); } + if ((registration.gatewayRoutes ?? []).length > 0) { + this.requirePluginPermission(permissions, "gateway-routes", "register gateway routes"); + } for (const route of registration.gatewayRoutes ?? []) { this.registerGatewayRoute(pluginId, route); } + if ((registration.proxyRoutes ?? []).length > 0) { + this.requirePluginPermission(permissions, "proxy-routes", "register proxy routes"); + } for (const route of registration.proxyRoutes ?? []) { 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 ?? []) { 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 ?? []) { 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 [ ...(registration.coreGateway?.virtualModelProfiles ?? []), ...(registration.virtualModelProfiles ?? []) @@ -338,20 +387,24 @@ class GatewayPluginService { this.virtualModelProfiles.push(profile); } if (registration.coreGateway?.config) { + this.requirePluginPermission(permissions, "core-gateway-config", "register core gateway config"); this.coreGatewayConfig = { ...this.coreGatewayConfig, ...registration.coreGateway.config }; } if (registration.stop) { - this.stopHooks.push(registration.stop); + this.stopHooks.push({ pluginId, stop: registration.stop }); } 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 ?? []) { this.registerApp(pluginConfig.id, app); } @@ -366,14 +419,21 @@ class GatewayPluginService { 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 ?? []) { 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 ?? []) { this.virtualModelProfiles.push(profile); } if (pluginConfig.coreGateway?.config) { + this.requirePluginPermission(permissions, "core-gateway-config", "register configured core gateway config"); this.coreGatewayConfig = { ...this.coreGatewayConfig, ...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)); mkdirSync(pluginDataDir, { recursive: true }); const logger = createPluginLogger(pluginConfig.id); + const pluginPermissions = pluginPermissionList(permissions); return { config: this.config ?? ({} as AppConfig), @@ -427,18 +488,39 @@ class GatewayPluginService { }, pluginConfig: pluginConfig.config, 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) => { + this.requirePluginPermission(permissions, "core-provider-plugins", "register core provider plugins"); this.coreProviderPlugins.push(providerPlugin); }, registerCoreGatewayVirtualModelProfile: (profile) => { + this.requirePluginPermission(permissions, "virtual-model-profiles", "register virtual model profiles"); this.virtualModelProfiles.push(profile); }, - registerApp: (app) => this.registerApp(pluginConfig.id, app), - registerGatewayRoute: (route) => this.registerGatewayRoute(pluginConfig.id, route), - registerHttpBackend: (backend) => this.registerHttpBackend(pluginConfig.id, pluginDataDir, logger, backend), - registerProviderAccountConnector: (connector) => this.registerProviderAccountConnector(pluginConfig.id, connector), - registerProxyRoute: (route) => this.registerProxyRoute(pluginConfig.id, route) + registerApp: (app) => { + this.requirePluginPermission(permissions, "apps", "register browser apps"); + this.registerApp(pluginConfig.id, app); + }, + 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 { + 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 logger = createPluginLogger(pluginId); return { @@ -464,9 +549,13 @@ class GatewayPluginService { dataDir: DATADIR, pluginDataDir }, - pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config, + permissions: pluginPermissions, + pluginConfig: pluginConfig?.config, 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, readJson, sendJson @@ -477,8 +566,10 @@ class GatewayPluginService { pluginId: string, pluginDataDir: string, logger: PluginLogger, + permissions: PluginPermissionAccess, backend: GatewayPluginHttpBackendRegistration ): Promise { + const pluginPermissions = pluginPermissionList(permissions); return backendService.registerHttpBackend(pluginId, { host: backend.host, id: backend.id, @@ -492,9 +583,13 @@ class GatewayPluginService { dataDir: DATADIR, pluginDataDir }, + permissions: pluginPermissions, pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config, 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, readJson, sendJson @@ -509,10 +604,40 @@ class GatewayPluginService { ): Promise { 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(); +function pluginPermissionAccess(pluginConfig: Pick): 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 { const resolved = resolvePluginModule(modulePath); return import(pathToFileURL(resolved).href); @@ -738,6 +863,19 @@ function providerAccountConnectorKey(pluginId: string, connectorId: string): str return `${pluginId.trim()}:${connectorId.trim()}`; } +function enabledPluginIds(config: AppConfig): Set { + return new Set((config.plugins ?? []) + .filter((plugin) => plugin.enabled !== false) + .map((plugin) => plugin.id)); +} + +function stopReasonForPlugin(pluginId: string, nextEnabledPluginIds: Set | undefined): GatewayPluginStopReason { + if (!nextEnabledPluginIds) { + return "stop"; + } + return nextEnabledPluginIds.has(pluginId) ? "reload" : "disabled"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index db79d5f..653d549 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -33,9 +33,10 @@ import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "@ccr/core/usage/store"; 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 imageExportTargets = new Map(); +const gatewayPluginPermissionIdSet = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { return { @@ -951,13 +952,15 @@ function inspectPluginDirectory(directory: string): PluginDirectorySelection { "plugin"; const name = readString(manifest?.name) || readString(packageJson?.displayName) || readString(packageJson?.name); const apps = readPluginApps(manifest, packageJson); + const permissions = readPluginPermissions(manifest, packageJson); return { ...(apps.length ? { apps } : {}), dependencies: readPluginDependencies(directory, manifest, packageJson), directory, id, modulePath: resolvePluginDirectoryModule(directory, moduleValue), - ...(name ? { name } : {}) + ...(name ? { name } : {}), + ...(permissions ? { permissions } : {}) }; } @@ -1014,6 +1017,124 @@ function parsePluginAppItem(value: unknown): GatewayPluginAppConfig | undefined }; } +function readPluginPermissions( + manifest: Record | undefined, + packageJson: Record | 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(); + 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( directory: string, manifest: Record | undefined, @@ -1087,10 +1208,12 @@ function parsePluginDependencyItem(value: unknown, directory: string): PluginDep const moduleValue = readString(record.module) || readString(record.path) || readString(record.modulePath); const modulePath = moduleValue ? resolveDependencyModulePath(directory, moduleValue) : undefined; const name = readString(record.name); + const permissions = parsePluginPermissions(record.permissions); return { id, ...(modulePath ? { modulePath } : {}), - ...(name ? { name } : {}) + ...(name ? { name } : {}), + ...(permissions ? { permissions } : {}) }; } diff --git a/packages/electron/src/main/main.ts b/packages/electron/src/main/main.ts index 8873d24..d17260e 100644 --- a/packages/electron/src/main/main.ts +++ b/packages/electron/src/main/main.ts @@ -1,8 +1,11 @@ import { app, dialog } from "electron"; import { mkdirSync } from "node:fs"; +import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat"; import { resolveRuntimeDataDir, setRuntimeAppPaths } from "@ccr/core/runtime/app-paths"; import { copyMissingDirectoryContents, sameFilesystemPath } from "@ccr/core/storage/migration"; +installSocketTypeOfServiceCompat(); + const appDataPath = app.getPath("appData"); const homePath = app.getPath("home"); setRuntimeAppPaths({ diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 5bd3b5e..1903e17 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -21,7 +21,7 @@ import { navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, - OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft, + OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parsePluginPermissionsSettingsText, parseProviderAccountDraft, providerCredentialsFromDraft, persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, @@ -1758,6 +1758,7 @@ function App() { key: selection.id, marketplaceId: "", modulePath: selection.modulePath, + permissions: selection.permissions, selectedName: selection.name || selection.id })); setExtensionInstallError(""); @@ -1778,7 +1779,8 @@ function App() { dependencies: extensionInstallDraft.dependencies, id: extensionInstallDraft.key.trim(), modulePath: extensionInstallDraft.modulePath.trim(), - name: extensionInstallDraft.selectedName + name: extensionInstallDraft.selectedName, + permissions: extensionInstallDraft.permissions }, pluginMarketplace, draftConfig.plugins ?? [] @@ -1796,7 +1798,8 @@ function App() { ...(item.apps?.length ? { apps: item.apps } : {}), enabled: true, id: item.id, - module: item.modulePath + module: item.modulePath, + ...(item.permissions !== undefined ? { permissions: item.permissions } : {}) })); config.plugins = [...(config.plugins ?? []), ...pluginsToAdd]; return config; @@ -1862,6 +1865,12 @@ function App() { return; } + const permissionsResult = parsePluginPermissionsSettingsText(pluginSettingsDraft.permissionsText); + if (!permissionsResult.ok) { + setPluginSettingsError(permissionsResult.message); + return; + } + updateConfig((config) => { const values = [...(config.plugins ?? [])]; const item = values[extensionConfigTarget.index]; @@ -1874,7 +1883,8 @@ function App() { ...(appsResult.value && appsResult.value.length > 0 ? { apps: appsResult.value } : { apps: undefined }), config: nextConfig, enabled: pluginSettingsDraft.enabled, - module: pluginSettingsDraft.modulePath.trim() + module: pluginSettingsDraft.modulePath.trim(), + ...(permissionsResult.value !== undefined ? { permissions: permissionsResult.value } : { permissions: undefined }) }; config.plugins = values; return config; diff --git a/packages/ui/src/pages/home/components/extensions.tsx b/packages/ui/src/pages/home/components/extensions.tsx index 05e8b00..0bd16e1 100644 --- a/packages/ui/src/pages/home/components/extensions.tsx +++ b/packages/ui/src/pages/home/components/extensions.tsx @@ -230,6 +230,10 @@ export function PluginSettingsDialog({ onChange({ appsText })} /> + + onChange({ permissionsText })} /> + + onChange({ configText })} /> diff --git a/packages/ui/src/pages/home/components/virtual-models.tsx b/packages/ui/src/pages/home/components/virtual-models.tsx index 6e7c2c4..bf37725 100644 --- a/packages/ui/src/pages/home/components/virtual-models.tsx +++ b/packages/ui/src/pages/home/components/virtual-models.tsx @@ -1163,6 +1163,7 @@ export function InstallExtensionDialog({ dependencies: entry.dependencies, marketplaceId: entry.id, modulePath: entry.modulePath, + permissions: entry.permissions, selectedName: entry.name }); } @@ -1198,6 +1199,9 @@ export function InstallExtensionDialog({ {entry.name} {entry.description} {entry.capabilities.join(", ")} + {entry.permissions?.length ? ( + {t("Permissions")}: {entry.permissions.join(", ")} + ) : null} {entry.dependencies.length > 0 ? ( {t("Dependencies")}: {formatPluginDependencies(entry.dependencies)} ) : null} diff --git a/packages/ui/src/pages/home/shared/extensions.ts b/packages/ui/src/pages/home/shared/extensions.ts index 877fd31..328655c 100644 --- a/packages/ui/src/pages/home/shared/extensions.ts +++ b/packages/ui/src/pages/home/shared/extensions.ts @@ -129,6 +129,7 @@ import { DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, enforceSingleEnabledGlobalProfilePerAgent, + GATEWAY_PLUGIN_PERMISSION_IDS, normalizeProfileScopeValue, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, @@ -158,6 +159,7 @@ import type { GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, + GatewayPluginPermission, GatewayProviderConnectivityCheckModelResult, GatewayProviderConnectivityCheckReport, GatewayProviderProbeCandidate, @@ -376,12 +378,15 @@ import { isPlainRecord, stringValue } from "./common"; import { isClaudeDesignPluginConfig, isCursorProxyPluginConfig, readClaudeDesignRoutingConfig } from "./routing"; import type { ExtensionInstallDraft, ExtensionListItem, ExtensionSource, PluginInstallCandidate, PluginSettingsDraft } from "./types"; +const gatewayPluginPermissionIdSet = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); + export function createPluginSettingsDraft(plugin?: AppConfig["plugins"][number]): PluginSettingsDraft { return { appsText: formatEditableJson(plugin?.apps ?? []), configText: formatEditableJson(pluginSettingsConfigWithoutRouting(plugin?.config)), 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 }; } +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(); + 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 | undefined): unknown { const output: Record = nonRoutingConfig ? { ...nonRoutingConfig } : {}; if (isPlainRecord(previousConfig) && Object.prototype.hasOwnProperty.call(previousConfig, "routing")) { @@ -596,7 +713,8 @@ export function pluginDependencyCandidate( dependencies: [], id: dependency.id, modulePath: dependency.modulePath, - name: dependency.name + name: dependency.name, + permissions: dependency.permissions }; } @@ -609,7 +727,8 @@ export function pluginDependencyCandidate( dependencies: entry.dependencies, id: entry.id, 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 { const capabilities: string[] = ["Wrapper runtime"]; 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; if (apps > 0) capabilities.push(`${apps} browser ${apps === 1 ? "app" : "apps"}`); diff --git a/packages/ui/src/pages/home/shared/types.ts b/packages/ui/src/pages/home/shared/types.ts index 52f1f4c..fd85b04 100644 --- a/packages/ui/src/pages/home/shared/types.ts +++ b/packages/ui/src/pages/home/shared/types.ts @@ -158,6 +158,7 @@ import type { GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, + GatewayPluginPermission, GatewayProviderConnectivityCheckModelResult, GatewayProviderConnectivityCheckReport, GatewayProviderProbeCandidate, @@ -663,6 +664,7 @@ export type ExtensionInstallDraft = { key: string; marketplaceId: string; modulePath: string; + permissions?: GatewayPluginPermission[]; selectedName: string; }; @@ -710,6 +712,7 @@ export type PluginInstallCandidate = { id: string; modulePath: string; name?: string; + permissions?: GatewayPluginPermission[]; }; export type PluginSettingsDraft = { @@ -717,6 +720,7 @@ export type PluginSettingsDraft = { enabled: boolean; modulePath: string; configText: string; + permissionsText: string; }; export type RoutingRuleRow = { diff --git a/tests/main/agent-console-launcher.test.mjs b/tests/main/agent-console-launcher.test.mjs new file mode 100644 index 0000000..37e03ed --- /dev/null +++ b/tests/main/agent-console-launcher.test.mjs @@ -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"), [ + "", + "", + "", + " CFBundleIdentifier", + ` ${bundleId}`, + "", + "", + "" + ].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; +} diff --git a/tests/main/codex-model-catalog.test.mjs b/tests/main/codex-model-catalog.test.mjs index 1e68471..0d5f0f5 100644 --- a/tests/main/codex-model-catalog.test.mjs +++ b/tests/main/codex-model-catalog.test.mjs @@ -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_search_tool, true); assert.equal(model.web_search_tool_type, "text_and_image"); - assert.deepEqual(model.supported_reasoning_levels.map((level) => level.effort), ["low", "medium", "high"]); - assert.equal(model.default_reasoning_level, "medium"); + assert.deepEqual(model.supported_reasoning_levels, []); + assert.equal(model.default_reasoning_level, null); 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", () => { const model = catalogModelFor({ Providers: [ @@ -94,6 +130,8 @@ test("codex catalog keeps freeform apply_patch for GPT-named chat-compatible mod }, "gateway/gpt-compatible-coder"); 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", () => { diff --git a/tests/main/plugin-permissions.test.mjs b/tests/main/plugin-permissions.test.mjs new file mode 100644 index 0000000..9ddb2fb --- /dev/null +++ b/tests/main/plugin-permissions.test.mjs @@ -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; +} diff --git a/tests/main/plugin-stop-reason.test.mjs b/tests/main/plugin-stop-reason.test.mjs new file mode 100644 index 0000000..d4be5e8 --- /dev/null +++ b/tests/main/plugin-stop-reason.test.mjs @@ -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; +} diff --git a/tests/main/socket-compat.test.mjs b/tests/main/socket-compat.test.mjs new file mode 100644 index 0000000..65be2af --- /dev/null +++ b/tests/main/socket-compat.test.mjs @@ -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]; + } + } +} diff --git a/tests/main/web-management-server.test.mjs b/tests/main/web-management-server.test.mjs index d37f345..f283c31 100644 --- a/tests/main/web-management-server.test.mjs +++ b/tests/main/web-management-server.test.mjs @@ -2,13 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; 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), 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("http://localhost:3458/"), "http://localhost:3458/"); - assert.throws(() => normalizeExternalHttpTarget("file:///etc/passwd"), /Only http and https/); - assert.throws(() => normalizeExternalHttpTarget("javascript:alert(1)"), /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, https, and CCR plugin URLs/); assert.throws(() => normalizeExternalHttpTarget("example.com"), /valid absolute URL/); });