Refactor router config and provider handling

This commit is contained in:
musistudio 2026-06-29 16:45:47 +08:00
parent 81882616b9
commit 1534059fd6
18 changed files with 1148 additions and 374 deletions

View file

@ -14,8 +14,24 @@ concurrency:
jobs:
macos:
name: macOS
runs-on: macos-14
name: macOS (${{ matrix.display_name }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
arch_flag: --arm64
runner: macos-14
display_name: Apple Silicon
release_label: Apple-Silicon
artifact_name: macos-apple-silicon-arm64
- arch: x64
arch_flag: --x64
runner: macos-15-intel
display_name: Intel
release_label: Intel
artifact_name: macos-intel-x64
steps:
- name: Check out repository
uses: actions/checkout@v4
@ -39,16 +55,92 @@ jobs:
exit 1
fi
- name: Build and publish ad-hoc macOS artifacts
- name: Build ad-hoc macOS artifacts
run: |
npm run build:assets
npx electron-builder \
--config build/electron-builder.local.cjs \
--mac ${{ matrix.arch_flag }} \
--publish never \
-c.mac.artifactName='Claude-Code-Router_${version}-mac-${{ matrix.release_label }}-${arch}.${ext}'
- name: Upload macOS release files
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: |
release-local/Claude-Code-Router_*-mac-${{ matrix.release_label }}-${{ matrix.arch }}.*
release-local/latest-mac.yml
if-no-files-found: error
publish-macos:
name: Publish macOS
needs: macos
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install metadata merge dependencies
run: npm ci --ignore-scripts
- name: Download macOS release files
uses: actions/download-artifact@v4
with:
pattern: macos-*
path: macos-release-artifacts
- name: Merge macOS update metadata
run: node build/merge-macos-update-metadata.mjs latest-mac.yml macos-release-artifacts/*/latest-mac.yml
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run build:assets
npx electron-builder --config build/electron-builder.local.cjs --mac --publish always
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes
- name: Upload macOS release assets
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
shopt -s nullglob
assets=(macos-release-artifacts/*/Claude-Code-Router_*-mac-*.*)
if (( ${#assets[@]} == 0 )); then
echo "No macOS release assets were found."
exit 1
fi
upload_args=()
for asset in "${assets[@]}"; do
name="$(basename "$asset")"
case "$name" in
*Apple-Silicon-arm64.dmg) label="macOS Apple Silicon DMG (arm64)" ;;
*Apple-Silicon-arm64.zip) label="macOS Apple Silicon ZIP (arm64)" ;;
*Apple-Silicon-arm64.dmg.blockmap) label="macOS Apple Silicon DMG blockmap (arm64)" ;;
*Apple-Silicon-arm64.zip.blockmap) label="macOS Apple Silicon ZIP blockmap (arm64)" ;;
*Intel-x64.dmg) label="macOS Intel DMG (x64)" ;;
*Intel-x64.zip) label="macOS Intel ZIP (x64)" ;;
*Intel-x64.dmg.blockmap) label="macOS Intel DMG blockmap (x64)" ;;
*Intel-x64.zip.blockmap) label="macOS Intel ZIP blockmap (x64)" ;;
*) label="$name" ;;
esac
upload_args+=("${asset}#${label}")
done
upload_args+=("latest-mac.yml#macOS update metadata")
gh release upload "$GITHUB_REF_NAME" "${upload_args[@]}" --clobber
windows:
name: Windows
needs: macos
needs: publish-macos
runs-on: windows-latest
steps:
- name: Check out repository
@ -82,7 +174,7 @@ jobs:
linux:
name: Linux
needs: macos
needs: publish-macos
runs-on: ubuntu-latest
steps:
- name: Check out repository

View file

@ -62,7 +62,8 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st
1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
2. Download the package for your platform:
- macOS: `Claude Code Router_<version>.dmg` or `.zip`
- macOS Apple Silicon: `Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` or `.zip`
- macOS Intel: `Claude-Code-Router_<version>-mac-Intel-x64.dmg` or `.zip`
- Windows: `Claude Code Router_<version>.exe`
- Linux: `Claude Code Router_<version>.AppImage`
3. Install and launch **Claude Code Router**.

View file

@ -62,7 +62,8 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
2. 按系统下载对应安装包:
- macOS`Claude Code Router_<version>.dmg``.zip`
- macOS Apple 芯片:`Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg``.zip`
- macOS Intel 芯片:`Claude-Code-Router_<version>-mac-Intel-x64.dmg``.zip`
- Windows`Claude Code Router_<version>.exe`
- Linux`Claude Code Router_<version>.AppImage`
3. 安装并启动 **Claude Code Router**

View file

@ -140,7 +140,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {}
minify: mode === "production",
outdir: mainOutDir,
platform: "node",
plugins: [electronNodeShimPlugin(), ...plugins],
plugins: [forbidCliElectronPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "node22"
};
@ -292,12 +292,18 @@ function rendererAliasPlugin() {
};
}
function electronNodeShimPlugin() {
function forbidCliElectronPlugin() {
return {
name: "electron-node-shim",
name: "forbid-cli-electron",
setup(build) {
build.onResolve({ filter: /^electron$/ }, () => {
return { path: path.join(projectRoot, "src", "main", "electron-node-shim.ts") };
return {
errors: [
{
text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary."
}
]
};
});
}
};

View file

@ -0,0 +1,78 @@
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import yaml from "js-yaml";
const [outputFile, ...inputFiles] = process.argv.slice(2);
if (!outputFile || inputFiles.length < 2) {
console.error("Usage: node build/merge-macos-update-metadata.mjs <output> <latest-mac.yml> <latest-mac.yml> [...]");
process.exit(1);
}
const updateInfos = inputFiles.map((file) => {
const info = yaml.load(readFileSync(file, "utf8"));
if (!info || typeof info !== "object") {
throw new Error(`${file} is not a valid update metadata object`);
}
if (!Array.isArray(info.files) || info.files.length === 0) {
throw new Error(`${file} does not contain any update files`);
}
return { file, info };
});
const version = updateInfos[0].info.version;
if (!version || updateInfos.some(({ info }) => info.version !== version)) {
throw new Error("All macOS update metadata files must have the same version");
}
const files = uniqueByUrl(
updateInfos
.flatMap(({ info }) => info.files)
.filter((file) => file && typeof file.url === "string")
.sort(compareMacUpdateFile)
);
const defaultZip = files.find((file) => file.url.endsWith(".zip") && file.url.includes("arm64")) ?? files.find((file) => file.url.endsWith(".zip"));
if (!defaultZip?.sha512) {
throw new Error("Merged macOS update metadata must include at least one ZIP with sha512");
}
const releaseDate = updateInfos
.map(({ info }) => info.releaseDate)
.filter(Boolean)
.sort()
.at(-1);
const { files: _files, path: _path, sha512: _sha512, releaseDate: _releaseDate, ...baseInfo } = updateInfos[0].info;
const mergedInfo = {
...baseInfo,
version,
files,
path: defaultZip.url,
sha512: defaultZip.sha512,
...(releaseDate ? { releaseDate } : {})
};
writeFileSync(outputFile, yaml.dump(mergedInfo, { lineWidth: 120, noRefs: true }), "utf8");
console.log(`Wrote ${path.relative(process.cwd(), outputFile)} with ${files.length} macOS artifacts.`);
function uniqueByUrl(files) {
const seen = new Set();
return files.filter((file) => {
if (seen.has(file.url)) {
return false;
}
seen.add(file.url);
return true;
});
}
function compareMacUpdateFile(left, right) {
return fileRank(left) - fileRank(right) || left.url.localeCompare(right.url);
}
function fileRank(file) {
const isZip = file.url.endsWith(".zip") ? 0 : 10;
const isAppleSilicon = file.url.includes("arm64") ? 0 : 1;
return isZip + isAppleSilicon;
}

65
src/main/app-paths.ts Normal file
View file

@ -0,0 +1,65 @@
import os from "node:os";
import path from "node:path";
export const APP_NAME = "Claude Code Router";
export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router");
const homeDirEnv = "CCR_INTERNAL_HOME_DIR";
const appDataDirEnv = "CCR_INTERNAL_APP_DATA_DIR";
const userDataDirEnv = "CCR_INTERNAL_USER_DATA_DIR";
type RuntimePathName = "appData" | "home" | "userData";
export type RuntimeAppPaths = Partial<Record<RuntimePathName, string>>;
export function setRuntimeAppPaths(paths: RuntimeAppPaths): void {
setPathEnv(homeDirEnv, paths.home);
setPathEnv(appDataDirEnv, paths.appData);
setPathEnv(userDataDirEnv, paths.userData);
}
export function resolveRuntimeAppPath(name: RuntimePathName): string {
const configured = readConfiguredPath(name);
if (configured) {
return configured;
}
if (name === "home") {
return os.homedir();
}
if (name === "appData") {
return fallbackAppDataDir();
}
return fallbackUserDataDir();
}
function readConfiguredPath(name: RuntimePathName): string | undefined {
const key = name === "home"
? homeDirEnv
: name === "appData"
? appDataDirEnv
: userDataDirEnv;
const value = process.env[key]?.trim();
return value || undefined;
}
function setPathEnv(key: string, value: string | undefined): void {
if (value?.trim()) {
process.env[key] = value;
}
}
function fallbackAppDataDir(): string {
if (process.platform === "win32") {
return process.env.APPDATA ||
process.env.LOCALAPPDATA ||
(process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming"));
}
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
}
function fallbackUserDataDir(): string {
if (process.platform === "win32") {
return path.join(fallbackAppDataDir(), APP_NAME);
}
return path.join(LEGACY_CONFIGDIR, "app-data");
}

View file

@ -1,8 +1,8 @@
import * as electron from "electron";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { resolveRuntimeAppPath } from "./app-paths";
import { saveAppConfig } from "./config";
import { CONFIGDIR } from "./constants";
import {
@ -174,6 +174,33 @@ export function restoreClaudeAppGatewayConfig(): void {
rmSync(CLAUDE_APP_GATEWAY_BACKUP_FILE, { force: true });
}
export function readClaudeAppGatewayApiKeyCandidates(): string[] {
return uniqueStrings([
readClaudeAppGatewayApiKey(getClaudeAppGatewayPaths().configLibraryFile)
]);
}
function readClaudeAppGatewayApiKey(file: string): string {
const config = readJsonRecord(file);
return typeof config?.inferenceGatewayApiKey === "string"
? config.inferenceGatewayApiKey.trim()
: "";
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function ensureClaudeAppGatewayState(config: AppConfig): ClaudeAppApplyState {
const currentApiKey = findReusableApiKey(config);
const gatewayEnabledConfig = config.gateway.enabled
@ -246,20 +273,7 @@ function getClaudeApp3pDataDir(): string {
}
function appPath(name: "appData" | "home"): string {
const electronApp = electronAppOrUndefined();
if (electronApp) {
return electronApp.getPath(name);
}
if (name === "home") {
return os.homedir();
}
return process.env.APPDATA ||
process.env.LOCALAPPDATA ||
(process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), ".config"));
}
function electronAppOrUndefined(): Electron.App | undefined {
return typeof electron.app?.getPath === "function" ? electron.app : undefined;
return resolveRuntimeAppPath(name);
}
function backupClaudeAppGatewayConfig(paths: ClaudeAppGatewayPaths): void {

View file

@ -7,6 +7,8 @@ import { botGatewayProfileEnv } from "./bot-gateway-env";
import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch";
import { loadAppConfig } from "./config";
import { CONFIGDIR } from "./constants";
import { applyProfileConfig, applyProfileRuntimeConfig } from "./profile-service";
import { ensureProfileGateway } from "./profile-launch-service";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core";
import { startWebManagementServer } from "./web-management-server";
@ -88,9 +90,19 @@ async function main(): Promise<void> {
const configDir = CONFIGDIR;
const config = await loadAppConfig();
await applyProfileConfig(config);
const profile = findProfileForOpen(config, profileOptions.profileRef);
const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli");
const resolvedSurface = resolveProfileOpenSurface(profile, surface);
const launchConfig = resolvedSurface === "cli"
? await ensureProfileGateway(config, profile, profile.name || profile.id || "profile", { reuseExisting: true, startIfMissing: false })
: config;
if (resolvedSurface === "cli") {
const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY);
if (!runtimeResult.ok) {
throw new Error(runtimeResult.message);
}
}
if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) {
throw new Error("ZCode profiles can only open the app; agent arguments are not supported.");
}
@ -122,7 +134,7 @@ async function main(): Promise<void> {
const childEnv = {
...process.env,
...plan.env,
...botGatewayProfileEnv(config, profile, resolvedSurface)
...botGatewayProfileEnv(launchConfig, profile, resolvedSurface)
};
delete childEnv.ELECTRON_RUN_AS_NODE;
@ -275,10 +287,7 @@ async function startService(options: WebCliOptions): Promise<void> {
];
const child = spawn(process.execPath, childArgs, {
detached: true,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: undefined
},
env: serviceChildEnv(),
stdio: "ignore",
windowsHide: true
});
@ -295,6 +304,16 @@ async function startService(options: WebCliOptions): Promise<void> {
process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`);
}
function serviceChildEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
if (process.versions.electron) {
env.ELECTRON_RUN_AS_NODE = "1";
} else {
delete env.ELECTRON_RUN_AS_NODE;
}
return env;
}
async function runWebServer(options: WebCliOptions): Promise<void> {
const runtime = await startWebManagementServer({
host: options.host,

View file

@ -58,6 +58,7 @@ async function main() {
async function runClaudeCodeCliWrapper(args) {
const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude");
log("claude_code_wrapper_start", { realCli, args });
const captureStdout = shouldCaptureClaudeCodeCliStdout(args);
const remoteSync = createRemoteSyncClient({
args,
cwd: process.cwd(),
@ -67,7 +68,7 @@ async function runClaudeCodeCliWrapper(args) {
const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN");
const child = childProcess.spawn(realCli, args, {
env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
stdio: [injectRemoteStdin ? "pipe" : "inherit", "pipe", "inherit"]
stdio: [injectRemoteStdin ? "pipe" : "inherit", captureStdout ? "pipe" : "inherit", "inherit"]
});
if (injectRemoteStdin && child.stdin) {
process.stdin.pipe(child.stdin);
@ -88,18 +89,20 @@ async function runClaudeCodeCliWrapper(args) {
remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" });
});
let pending = "";
child.stdout.on("data", (chunk) => {
process.stdout.write(chunk);
pending += chunk.toString("utf8");
const lines = pending.split(/\r?\n/g);
pending = lines.pop() || "";
for (const line of lines) {
botBridge().handleClaudeCliLine(line);
remoteSync.postEvent("claude.stdout", { line }, { text: line });
}
});
if (captureStdout && child.stdout) {
child.stdout.on("data", (chunk) => {
process.stdout.write(chunk);
pending += chunk.toString("utf8");
const lines = pending.split(/\r?\n/g);
pending = lines.pop() || "";
for (const line of lines) {
botBridge().handleClaudeCliLine(line);
remoteSync.postEvent("claude.stdout", { line }, { text: line });
}
});
}
const code = await waitForChild(child);
if (pending.trim()) {
if (captureStdout && pending.trim()) {
botBridge().handleClaudeCliLine(pending);
remoteSync.postEvent("claude.stdout", { line: pending }, { text: pending });
}
@ -109,6 +112,26 @@ async function runClaudeCodeCliWrapper(args) {
process.exitCode = code;
}
function shouldCaptureClaudeCodeCliStdout(args) {
if (boolEnv("CCR_CLAUDE_CODE_CAPTURE_STDOUT") || boolEnv("CODEXL_CLAUDE_CODE_CAPTURE_STDOUT")) {
return true;
}
if (botGatewayCliCaptureEnabled()) {
return true;
}
return claudeCodeArgsUsePrintMode(args);
}
function botGatewayCliCaptureEnabled() {
const enabled = boolEnv("CCR_BOT_GATEWAY_ENABLED") || boolEnv("CODEXL_BOT_GATEWAY_ENABLED");
const platform = normalizeBotGatewayPlatform(nonEmptyEnv("CCR_BOT_GATEWAY_PLATFORM") || nonEmptyEnv("CODEXL_BOT_GATEWAY_PLATFORM") || "none");
return enabled && platform !== "none";
}
function claudeCodeArgsUsePrintMode(args) {
return args.some((arg) => arg === "--print" || arg === "-p");
}
function defaultCodexArgs() {
return normalizeProfileSurface(nonEmptyEnv("CCR_PROFILE_SURFACE") || nonEmptyEnv("CODEXL_PROFILE_SURFACE")) === "cli"
? []

View file

@ -1,35 +1,17 @@
import * as electron from "electron";
import os from "node:os";
import path from "node:path";
import { APP_NAME, LEGACY_CONFIGDIR, resolveRuntimeAppPath } from "./app-paths";
export { IPC_CHANNELS } from "../shared/ipc-channels";
export const APP_NAME = "Claude Code Router";
const electronApp = typeof electron.app?.getPath === "function" ? electron.app : undefined;
export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router");
export const LEGACY_CONFIG_FILE = path.join(LEGACY_CONFIGDIR, "config.json");
function appPath(name: "appData" | "home" | "userData"): string {
if (electronApp) {
return electronApp.getPath(name);
}
if (name === "home") {
return os.homedir();
}
if (name === "appData") {
return process.env.APPDATA ||
process.env.LOCALAPPDATA ||
(process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming"));
}
return path.join(LEGACY_CONFIGDIR, "app-data");
}
export { APP_NAME, LEGACY_CONFIGDIR };
export const CONFIGDIR = process.platform === "win32"
? path.join(appPath("appData"), APP_NAME)
? path.join(resolveRuntimeAppPath("appData"), APP_NAME)
: LEGACY_CONFIGDIR;
export const CONFIG_FILE = path.join(CONFIGDIR, "config.json");
export const ONBOARDING_FINISHED_FILE = path.join(CONFIGDIR, ".onboard_finished");
export const DATADIR = appPath("userData");
export const DATADIR = resolveRuntimeAppPath("userData");
export const APP_CONFIG_DB_FILE = path.join(CONFIGDIR, "config.sqlite");
export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite");
export const CERTDIR = path.join(DATADIR, "certs");

View file

@ -1,14 +0,0 @@
export const app = undefined;
export const BrowserWindow = undefined;
export const clipboard = undefined;
export const contextBridge = undefined;
export const dialog = undefined;
export const ipcMain = undefined;
export const ipcRenderer = undefined;
export const Menu = undefined;
export const nativeImage = undefined;
export const screen = undefined;
export const session = undefined;
export const shell = undefined;
export const Tray = undefined;
export const WebContentsView = undefined;

246
src/main/main-app.ts Normal file
View file

@ -0,0 +1,246 @@
import { app, BrowserWindow, dialog, shell } from "electron";
import { setupApplicationMenu } from "./app-menu";
import { loadAppConfig } from "./config";
import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service";
import { deepLinkService } from "./deep-link";
import { gatewayService } from "../server/gateway/service";
import "./ipc";
import { applyProfileConfig } from "./profile-service";
import { ensureCcrCliLauncher } from "./profile-launch-service";
import { proxyService } from "../server/proxy/service";
import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import windowsManager from "./windows";
const gotTheLock = app.requestSingleInstanceLock();
const quitProxyRestoreTimeoutMs = 30_000;
let quitPrepared = false;
let stoppingForQuit = false;
let ensureProxyModePromise: Promise<void> | undefined;
let startServicesPromise: Promise<void> | undefined;
let stopForQuitPromise: Promise<void> | undefined;
if (!gotTheLock) {
app.quit();
} else {
startPrimaryInstance();
}
function startPrimaryInstance(): void {
deepLinkService.register();
deepLinkService.handleArgv(process.argv);
app.on("second-instance", (_event, argv) => {
windowsManager.showMainWindow();
deepLinkService.handleArgv(argv);
queueEnsureConfiguredProxyModeActive("second-instance");
});
app.whenReady().then(() => {
configureProxyDesktopIntegration();
try {
ensureCcrCliLauncher();
} catch (error) {
console.error(`Failed to install ccr CLI launcher: ${formatError(error)}`);
}
setupApplicationMenu();
windowsManager.createMainWindow();
trayController.start();
appUpdateService.start();
appUpdateService.setInstallPreparation(prepareForUpdateInstall);
void startConfiguredServices("startup");
app.on("activate", () => {
windowsManager.showMainWindow();
queueEnsureConfiguredProxyModeActive("activate");
});
});
app.on("before-quit", (event) => {
if (quitPrepared || appUpdateService.isInstallingUpdate()) {
return;
}
event.preventDefault();
prepareAndQuit();
});
app.on("will-quit", (event) => {
if (quitPrepared || appUpdateService.isInstallingUpdate()) {
return;
}
event.preventDefault();
prepareAndQuit();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
process.once("SIGINT", () => handleTerminationSignal("SIGINT"));
process.once("SIGTERM", () => handleTerminationSignal("SIGTERM"));
}
function configureProxyDesktopIntegration(): void {
proxyService.setDesktopIntegration({
requestMacosCertificateInstallPermission,
revealFile: async (file) => {
shell.showItemInFolder(file);
}
});
}
async function requestMacosCertificateInstallPermission(): Promise<boolean> {
const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
const options = {
buttons: ["Continue", "Cancel"],
cancelId: 1,
defaultId: 0,
detail:
"macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.",
message: "Install CCR Proxy CA into the macOS System keychain?",
noLink: true,
type: "warning" as const
};
const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options);
return result.response === 0;
}
function prepareAndQuit(): void {
if (stoppingForQuit) {
return;
}
stoppingForQuit = true;
void stopServicesForQuit().finally(() => {
quitPrepared = true;
app.quit();
});
}
async function prepareForUpdateInstall(): Promise<void> {
if (!stoppingForQuit) {
stoppingForQuit = true;
}
await stopServicesForQuit();
quitPrepared = true;
}
function handleTerminationSignal(signal: NodeJS.Signals): void {
if (stoppingForQuit) {
return;
}
stoppingForQuit = true;
void stopServicesForQuit().finally(() => {
quitPrepared = true;
app.exit(signal === "SIGINT" ? 130 : 143);
});
}
function stopServicesForQuit(): Promise<void> {
if (!stopForQuitPromise) {
stopForQuitPromise = gatewayService
.stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs })
.then(() => undefined)
.catch((error) => {
console.error(`Failed to stop services before quit: ${formatError(error)}`);
})
.finally(() => {
try {
restoreClaudeAppGatewayConfig();
} catch (error) {
console.error(`Failed to restore Claude App gateway config before quit: ${formatError(error)}`);
}
trayController.destroy();
});
}
return stopForQuitPromise;
}
function startConfiguredServices(reason: string): Promise<void> {
if (!startServicesPromise) {
startServicesPromise = loadAppConfig()
.then(async (config) => {
try {
config = (await syncClaudeAppGatewayConfig(config)).config;
} catch (error) {
console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
}
const status = await gatewayService.start(config);
if (status.state === "error") {
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
}
if (status.state === "running") {
const profileResult = await applyProfileConfig(config);
for (const client of profileResult.clients) {
if (!client.ok) {
console.error(`Failed to apply ${client.client} profile during ${reason}: ${client.message}`);
}
}
}
if (config.proxy.enabled && config.proxy.systemProxy) {
const proxyStatus = await proxyService.ensureSystemProxyActive();
logProxySystemProxyIssue(reason, proxyStatus);
}
})
.catch((error) => {
console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`);
})
.finally(() => {
trayController.refreshUsageTitle();
startServicesPromise = undefined;
});
}
return startServicesPromise;
}
function queueEnsureConfiguredProxyModeActive(reason: string): void {
void (startServicesPromise ?? Promise.resolve())
.then(() => ensureConfiguredProxyModeActive(reason))
.catch((error) => {
console.error(`Failed to ensure proxy mode during ${reason}: ${formatError(error)}`);
});
}
function ensureConfiguredProxyModeActive(reason: string): Promise<void> {
if (stoppingForQuit) {
return Promise.resolve();
}
if (!ensureProxyModePromise) {
ensureProxyModePromise = loadAppConfig()
.then(async (config) => {
if (!config.proxy.enabled || !config.proxy.systemProxy) {
return;
}
const proxyStatus = proxyService.getStatus();
if (proxyStatus.state !== "running") {
await startConfiguredServices(reason);
return;
}
const ensuredStatus = await proxyService.ensureSystemProxyActive();
logProxySystemProxyIssue(reason, ensuredStatus);
})
.finally(() => {
ensureProxyModePromise = undefined;
});
}
return ensureProxyModePromise;
}
function logProxySystemProxyIssue(reason: string, status: ReturnType<typeof proxyService.getStatus>): void {
if (status.systemProxy.state === "active") {
return;
}
const details = status.systemProxy.lastError ? `: ${status.systemProxy.lastError}` : "";
console.error(`Proxy mode is enabled, but system proxy is ${status.systemProxy.state} during ${reason}${details}`);
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,214 +1,13 @@
import { app } from "electron";
import { setupApplicationMenu } from "./app-menu";
import { loadAppConfig } from "./config";
import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service";
import { deepLinkService } from "./deep-link";
import { gatewayService } from "../server/gateway/service";
import "./ipc";
import { applyProfileConfig } from "./profile-service";
import { proxyService } from "../server/proxy/service";
import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import windowsManager from "./windows";
import { setRuntimeAppPaths } from "./app-paths";
const gotTheLock = app.requestSingleInstanceLock();
const quitProxyRestoreTimeoutMs = 30_000;
let quitPrepared = false;
let stoppingForQuit = false;
let ensureProxyModePromise: Promise<void> | undefined;
let startServicesPromise: Promise<void> | undefined;
let stopForQuitPromise: Promise<void> | undefined;
setRuntimeAppPaths({
appData: app.getPath("appData"),
home: app.getPath("home"),
userData: app.getPath("userData")
});
if (!gotTheLock) {
void import("./main-app.js").catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
app.quit();
} else {
startPrimaryInstance();
}
function startPrimaryInstance(): void {
deepLinkService.register();
deepLinkService.handleArgv(process.argv);
app.on("second-instance", (_event, argv) => {
windowsManager.showMainWindow();
deepLinkService.handleArgv(argv);
queueEnsureConfiguredProxyModeActive("second-instance");
});
app.whenReady().then(() => {
setupApplicationMenu();
windowsManager.createMainWindow();
trayController.start();
appUpdateService.start();
appUpdateService.setInstallPreparation(prepareForUpdateInstall);
void startConfiguredServices("startup");
app.on("activate", () => {
windowsManager.showMainWindow();
queueEnsureConfiguredProxyModeActive("activate");
});
});
app.on("before-quit", (event) => {
if (quitPrepared || appUpdateService.isInstallingUpdate()) {
return;
}
event.preventDefault();
prepareAndQuit();
});
app.on("will-quit", (event) => {
if (quitPrepared || appUpdateService.isInstallingUpdate()) {
return;
}
event.preventDefault();
prepareAndQuit();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
process.once("SIGINT", () => handleTerminationSignal("SIGINT"));
process.once("SIGTERM", () => handleTerminationSignal("SIGTERM"));
}
function prepareAndQuit(): void {
if (stoppingForQuit) {
return;
}
stoppingForQuit = true;
void stopServicesForQuit().finally(() => {
quitPrepared = true;
app.quit();
});
}
async function prepareForUpdateInstall(): Promise<void> {
if (!stoppingForQuit) {
stoppingForQuit = true;
}
await stopServicesForQuit();
quitPrepared = true;
}
function handleTerminationSignal(signal: NodeJS.Signals): void {
if (stoppingForQuit) {
return;
}
stoppingForQuit = true;
void stopServicesForQuit().finally(() => {
quitPrepared = true;
app.exit(signal === "SIGINT" ? 130 : 143);
});
}
function stopServicesForQuit(): Promise<void> {
if (!stopForQuitPromise) {
stopForQuitPromise = gatewayService
.stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs })
.then(() => undefined)
.catch((error) => {
console.error(`Failed to stop services before quit: ${formatError(error)}`);
})
.finally(() => {
try {
restoreClaudeAppGatewayConfig();
} catch (error) {
console.error(`Failed to restore Claude App gateway config before quit: ${formatError(error)}`);
}
trayController.destroy();
});
}
return stopForQuitPromise;
}
function startConfiguredServices(reason: string): Promise<void> {
if (!startServicesPromise) {
startServicesPromise = loadAppConfig()
.then(async (config) => {
try {
config = (await syncClaudeAppGatewayConfig(config)).config;
} catch (error) {
console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
}
const status = await gatewayService.start(config);
if (status.state === "error") {
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
}
if (status.state === "running") {
const profileResult = await applyProfileConfig(config);
for (const client of profileResult.clients) {
if (!client.ok) {
console.error(`Failed to apply ${client.client} profile during ${reason}: ${client.message}`);
}
}
}
if (config.proxy.enabled && config.proxy.systemProxy) {
const proxyStatus = await proxyService.ensureSystemProxyActive();
logProxySystemProxyIssue(reason, proxyStatus);
}
})
.catch((error) => {
console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`);
})
.finally(() => {
trayController.refreshUsageTitle();
startServicesPromise = undefined;
});
}
return startServicesPromise;
}
function queueEnsureConfiguredProxyModeActive(reason: string): void {
void (startServicesPromise ?? Promise.resolve())
.then(() => ensureConfiguredProxyModeActive(reason))
.catch((error) => {
console.error(`Failed to ensure proxy mode during ${reason}: ${formatError(error)}`);
});
}
function ensureConfiguredProxyModeActive(reason: string): Promise<void> {
if (stoppingForQuit) {
return Promise.resolve();
}
if (!ensureProxyModePromise) {
ensureProxyModePromise = loadAppConfig()
.then(async (config) => {
if (!config.proxy.enabled || !config.proxy.systemProxy) {
return;
}
const proxyStatus = proxyService.getStatus();
if (proxyStatus.state !== "running") {
await startConfiguredServices(reason);
return;
}
const ensuredStatus = await proxyService.ensureSystemProxyActive();
logProxySystemProxyIssue(reason, ensuredStatus);
})
.finally(() => {
ensureProxyModePromise = undefined;
});
}
return ensureProxyModePromise;
}
function logProxySystemProxyIssue(reason: string, status: ReturnType<typeof proxyService.getStatus>): void {
if (status.systemProxy.state === "active") {
return;
}
const details = status.systemProxy.lastError ? `: ${status.systemProxy.lastError}` : "";
console.error(`Proxy mode is enabled, but system proxy is ${status.systemProxy.state} during ${reason}${details}`);
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
});

View file

@ -1,10 +1,10 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult, ProfileRuntimeEntry, ProfileRuntimeStatus, ProfileStopResult } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service";
import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "./claude-app-gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "./codex-app-launch";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
@ -156,7 +156,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile),
refreshModelDiscoveryCache: true
});
await ensureGatewayConfigRunning(profileGatewayConfig, "Claude App");
await ensureGatewayConfigRunning(profileGatewayConfig, profile, "Claude App");
const entry = registerProfileApp(profile, "app", await launchClaudeAppProfile(CONFIGDIR, profile, profileGatewayConfig));
const started = await waitForProfileAppStart(entry, 12000);
if (!started) {
@ -179,21 +179,316 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
};
}
async function ensureProfileGateway(
export async function ensureProfileGateway(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
appName: string
appName: string,
options: { reuseExisting?: boolean; startIfMissing?: boolean } = {}
): Promise<AppConfig> {
const profileGatewayConfig = profileGatewayConfigFor(config, profile);
await ensureGatewayConfigRunning(profileGatewayConfig, appName);
return profileGatewayConfig;
const result = await ensureGatewayConfigRunning(profileGatewayConfig, profile, appName, options, config);
return result.acceptedApiKey && result.acceptedApiKey !== result.config.APIKEY
? profileGatewayConfigWithToken(result.config, profile, result.acceptedApiKey)
: result.config;
}
async function ensureGatewayConfigRunning(config: AppConfig, appName: string): Promise<void> {
const startedStatus = await gatewayService.start(config);
if (startedStatus.state !== "running") {
throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`);
type EnsureGatewayConfigRunningResult = {
acceptedApiKey?: string;
config: AppConfig;
};
async function ensureGatewayConfigRunning(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
appName: string,
options: { reuseExisting?: boolean; startIfMissing?: boolean } = {},
candidateConfig: AppConfig = config
): Promise<EnsureGatewayConfigRunningResult> {
const startIfMissing = options.startIfMissing !== false;
if (options.reuseExisting) {
const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig);
if (existingGateway.state === "usable") {
return { acceptedApiKey: existingGateway.apiKey, config };
}
if (existingGateway.state === "unavailable") {
if (!startIfMissing) {
throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`);
}
} else {
throw new Error(existingGatewayConflictMessage(existingGateway, appName));
}
}
if (!startIfMissing) {
throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`);
}
const startedStatus = await gatewayService.start(config);
if (startedStatus.state === "running") {
return { config };
}
if (options.reuseExisting && isAddressInUseError(startedStatus.lastError)) {
const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig);
if (existingGateway.state === "usable") {
return { acceptedApiKey: existingGateway.apiKey, config };
}
throw new Error(existingGatewayConflictMessage(existingGateway, appName));
}
throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`);
}
type ExistingProfileGatewayProbe =
| { endpoint: string; reason?: string; state: "unavailable" }
| { endpoint: string; status?: number; state: "not-ccr" }
| { endpoint: string; message?: string; status: number; state: "unauthorized" }
| { endpoint: string; status: number; state: "unusable" }
| { apiKey?: string; endpoint: string; state: "usable" };
type ExistingGatewayHttpProbe = {
payload?: unknown;
reason?: string;
status?: number;
};
async function probeExistingProfileGateway(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
candidateConfig: AppConfig = config
): Promise<ExistingProfileGatewayProbe> {
const endpoint = profileGatewayEndpoint(config);
const root = await fetchExistingGateway(endpoint, "/");
if (root.status === undefined) {
return { endpoint, reason: root.reason, state: "unavailable" };
}
let ccrGateway = isCcrGatewayRoot(root.payload);
if (!ccrGateway) {
const health = await fetchExistingGateway(endpoint, "/health");
ccrGateway = isCcrGatewayHealth(health.payload);
}
if (!ccrGateway) {
return { endpoint, status: root.status, state: "not-ccr" };
}
let lastUnauthorized: ExistingGatewayHttpProbe | undefined;
for (const apiKey of existingGatewayApiKeyCandidates(config, profile, candidateConfig)) {
const headers: Record<string, string> = {
"user-agent": "Claude Code"
};
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const models = await fetchExistingGateway(endpoint, "/v1/models", { headers });
if (models.status === 200) {
return { apiKey, endpoint, state: "usable" };
}
if (models.status === 401 || models.status === 403) {
lastUnauthorized = models;
continue;
}
return { endpoint, status: models.status ?? 0, state: "unusable" };
}
if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) {
return {
endpoint,
message: readGatewayErrorMessage(lastUnauthorized.payload),
status: lastUnauthorized.status,
state: "unauthorized"
};
}
return { endpoint, status: 0, state: "unusable" };
}
async function fetchExistingGateway(
endpoint: string,
pathname: string,
init: RequestInit = {}
): Promise<ExistingGatewayHttpProbe> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1200);
try {
const response = await fetch(new URL(pathname, endpoint).toString(), {
...init,
signal: controller.signal
});
return {
payload: await readResponseJson(response),
status: response.status
};
} catch (error) {
return { reason: formatError(error) };
} finally {
clearTimeout(timeout);
}
}
async function readResponseJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
function isCcrGatewayRoot(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return value.name === "claude-code-router" || value.plugin === "claude-code-router";
}
function isCcrGatewayHealth(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return typeof value.core === "string" && typeof value.status === "string" && typeof value.timestamp === "string";
}
function readGatewayErrorMessage(value: unknown): string | undefined {
if (!isRecord(value) || !isRecord(value.error)) {
return undefined;
}
return typeof value.error.message === "string" ? value.error.message : undefined;
}
function existingGatewayApiKeyCandidates(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
candidateConfig: AppConfig
): Array<string | undefined> {
const values = [
config.APIKEY,
...(Array.isArray(config.APIKEYS) ? config.APIKEYS.map((apiKey) => apiKey.key) : []),
candidateConfig.APIKEY,
...(Array.isArray(candidateConfig.APIKEYS) ? candidateConfig.APIKEYS.map((apiKey) => apiKey.key) : []),
...readClaudeAppGatewayApiKeyCandidates(),
...readClaudeCodeApiKeyHelperCandidates(profile)
];
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const key = value?.trim();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
result.push(key);
}
return result.length > 0 ? result : [undefined];
}
function readClaudeCodeApiKeyHelperCandidates(profile: ReturnType<typeof findProfileForOpen>): string[] {
const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile));
const files = [
file,
...readBackupFiles(file)
];
return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken));
}
function claudeCodeApiKeyHelperFilename(profile: ReturnType<typeof findProfileForOpen>): string {
const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
return process.platform === "win32"
? `ccr-claude-code-api-key-${slug}.cmd`
: `ccr-claude-code-api-key-${slug}`;
}
function readBackupFiles(file: string): string[] {
const dir = path.dirname(file);
const prefix = `${path.basename(file)}.ccr-backup-`;
try {
return readdirSync(dir)
.filter((entry) => entry.startsWith(prefix))
.sort()
.reverse()
.map((entry) => path.join(dir, entry));
} catch {
return [];
}
}
function readClaudeCodeApiKeyHelperToken(file: string): string {
if (!existsSync(file)) {
return "";
}
try {
const content = readFileSync(file, "utf8");
for (const line of content.split(/\r?\n/g)) {
const token = parseClaudeCodeApiKeyHelperLine(line);
if (token) {
return token;
}
}
} catch {
return "";
}
return "";
}
function parseClaudeCodeApiKeyHelperLine(line: string): string {
const trimmed = line.trim();
const shellPrefix = "printf '%s\\n' ";
if (trimmed.startsWith(shellPrefix)) {
return unquoteShellValue(trimmed.slice(shellPrefix.length).trim());
}
if (/^echo\s+/i.test(trimmed)) {
return trimmed.replace(/^echo\s+/i, "").trim().replace(/^"|"$/g, "");
}
return "";
}
function unquoteShellValue(value: string): string {
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/'\\''/g, "'");
}
return value.replace(/^"|"$/g, "");
}
function existingGatewayConflictMessage(probe: ExistingProfileGatewayProbe, appName: string): string {
if (probe.state === "unauthorized") {
const details = probe.message ? ` ${probe.message}` : "";
return `CCR gateway is already running at ${probe.endpoint}, but it does not accept the API key for ${appName}.${details} Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`;
}
if (probe.state === "unusable") {
return `CCR gateway is already running at ${probe.endpoint}, but it cannot serve ${appName} right now (HTTP ${probe.status}). Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`;
}
if (probe.state === "not-ccr") {
return `Port ${probe.endpoint} is already in use by a non-CCR service. Stop that process or change the CCR gateway port.`;
}
if (probe.state === "unavailable") {
return `CCR gateway is not reachable at ${probe.endpoint}${probe.reason ? `: ${probe.reason}` : ""}.`;
}
return `CCR gateway is already running at ${probe.endpoint}.`;
}
function isAddressInUseError(message: string | undefined): boolean {
return /\bEADDRINUSE\b/i.test(message || "");
}
function profileGatewayEndpoint(config: AppConfig): string {
const host = probeGatewayHost(config.gateway.host);
return `http://${formatEndpointHost(host)}:${config.gateway.port}/`;
}
function probeGatewayHost(host: string): string {
if (!host || host === "0.0.0.0") {
return "127.0.0.1";
}
if (host === "::") {
return "::1";
}
return host;
}
function formatEndpointHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function profileGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): AppConfig {
@ -201,6 +496,10 @@ function profileGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof f
if (!token) {
throw new Error(`No CCR API key was found for profile "${profile.name || profile.id}". Re-save the profile and try again.`);
}
return profileGatewayConfigWithToken(config, profile, token);
}
function profileGatewayConfigWithToken(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>, token: string): AppConfig {
return {
...config,
APIKEY: token,
@ -779,7 +1078,7 @@ function commandProfileRef(config: AppConfig, profile: ReturnType<typeof findPro
return duplicateName ? profile.id : name;
}
function ensureCcrCliLauncher(): string {
export function ensureCcrCliLauncher(): string {
const binDir = path.join(CONFIGDIR, "bin");
mkdirSync(binDir, { recursive: true });
@ -819,38 +1118,73 @@ function findBundledCcrCliSource(): string {
}
function posixCcrLauncher(runtimeFile: string): string {
const nodePath = bundledNodePath();
return [
"#!/bin/sh",
`CCR_CLI_NODE_PATH=${shQuote(nodePath)}`,
'if [ -n "$NODE_PATH" ]; then',
' export NODE_PATH="$CCR_CLI_NODE_PATH:$NODE_PATH"',
"else",
' export NODE_PATH="$CCR_CLI_NODE_PATH"',
"fi",
'if [ -n "$CCR_NODE_BIN" ]; then',
` exec "$CCR_NODE_BIN" ${shQuote(runtimeFile)} "$@"`,
"fi",
"if command -v node >/dev/null 2>&1; then",
` exec node ${shQuote(runtimeFile)} "$@"`,
"fi",
`ELECTRON_RUN_AS_NODE=1 exec ${shQuote(process.execPath)} ${shQuote(runtimeFile)} "$@"`
].join("\n") + "\n";
}
function windowsCcrLauncher(runtimeFile: string): string {
const nodePath = bundledNodePath();
return [
"@echo off",
"setlocal",
`set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`,
`set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`,
"if defined NODE_PATH (",
" set \"NODE_PATH=%CCR_CLI_NODE_PATH%;%NODE_PATH%\"",
") else (",
" set \"NODE_PATH=%CCR_CLI_NODE_PATH%\"",
")",
"if defined CCR_NODE_BIN (",
' "%CCR_NODE_BIN%" "%CCR_CLI_RUNTIME%" %*',
" exit /b %ERRORLEVEL%",
")",
"where node >nul 2>nul",
"if %ERRORLEVEL%==0 (",
' node "%CCR_CLI_RUNTIME%" %*',
" exit /b %ERRORLEVEL%",
")",
"set \"ELECTRON_RUN_AS_NODE=1\"",
`${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`,
"exit /b %ERRORLEVEL%"
].join("\r\n") + "\r\n";
}
function bundledNodePath(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
const candidates = [
path.join(__dirname, "..", "..", "node_modules"),
...(resourcesPath
? [
path.join(resourcesPath, "app.asar", "node_modules"),
path.join(resourcesPath, "app.asar.unpacked", "node_modules"),
path.join(resourcesPath, "app", "node_modules")
]
: []),
path.join(process.cwd(), "node_modules")
];
return uniqueStrings(candidates).join(path.delimiter);
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}
function writeFileIfChanged(file: string, content: string): void {
if (existsSync(file) && readFileSync(file, "utf8") === content) {
return;

View file

@ -44,6 +44,15 @@ export async function applyProfileConfig(config: AppConfig): Promise<ProfileAppl
return result;
}
export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileConfig, token: string): ProfileClientApplyStatus {
const appliedAt = new Date().toISOString();
return profile.agent === "claude-code"
? applyClaudeCodeProfile(config, profile, token, appliedAt)
: profile.agent === "zcode"
? applyZcodeProfile(config, profile, token, appliedAt)
: applyCodexProfile(config, profile, token, appliedAt);
}
function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus {
const settingsFile = resolveClaudeCodeSettingsFile(profile);
if (!profile.enabled) {

View file

@ -39,6 +39,7 @@ import { providerApiKeySafetyIssue } from "../../main/presets";
import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "../../shared/provider-url";
import { backendService } from "../backend-service";
import { RAW_TRACE_SPOOL_DIR } from "../../main/constants";
import { loadPersistedApiKeys } from "../../main/api-key-store";
import { codexDefaultBaseUrl, readCodexAuth } from "../../main/local-agent-provider-service";
import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "../../main/system-proxy-fetch";
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "../mcp/network-capture-mcp";
@ -112,6 +113,7 @@ type ManagedGatewayRuntimeMarker = {
};
type ApiKeyWindowCounter = {
expiresAt: number;
value: number;
windowStart: number;
};
@ -196,6 +198,7 @@ const responseHeaderDenyList = new Set(["connection", "content-encoding", "trans
const maxUsageCaptureBytes = 8 * 1024 * 1024;
const maxPendingRawTraceUpdates = 200;
const pendingRawTraceMaxAgeMs = 5 * 60 * 1000;
const apiKeyLimitCounterRetentionWindows = 2;
const gatewayRuntimeMarkerFile = "gateway-runtime.json";
const rawTraceSyncHeader = "x-ccr-raw-trace-token";
let warnedMissingCursorOpenAICompatContext = false;
@ -216,6 +219,8 @@ const gatewayProviderProtocolFallbackOrder: GatewayProviderProtocol[] = [
];
const privateDirMode = 0o700;
const privateFileMode = 0o600;
const persistedApiKeyCacheTtlMs = 1000;
let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined;
class GatewayService {
private child?: ChildProcess;
@ -430,7 +435,7 @@ class GatewayService {
}
if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) {
const authorization = authorize(request, response, this.config);
const authorization = await authorize(request, response, this.config);
if (!authorization.ok) {
return;
}
@ -450,7 +455,7 @@ class GatewayService {
sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } });
return;
}
const authorization = authorize(request, response, this.config);
const authorization = await authorize(request, response, this.config);
if (!authorization.ok) {
return;
}
@ -461,7 +466,7 @@ class GatewayService {
const pluginRoute = pluginService.matchGatewayRoute(request.method, path);
if (pluginRoute) {
if (pluginRoute.auth !== "none") {
const authorization = authorize(request, response, this.config);
const authorization = await authorize(request, response, this.config);
if (!authorization.ok) {
return;
}
@ -496,7 +501,7 @@ class GatewayService {
return;
}
const authorization = authorize(request, response, this.config);
const authorization = await authorize(request, response, this.config);
if (!authorization.ok) {
return;
}
@ -1681,16 +1686,11 @@ function rewriteModelSelectorForProtocol(
return model;
}
const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized;
const separator = publicModel.indexOf("/");
if (separator <= 0) {
return publicModel;
}
const providerName = publicModel.slice(0, separator).trim();
const targetModel = publicModel.slice(separator + 1).trim();
const provider = findProviderByPublicOrInternalName(config, providerName);
const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined;
return provider && capability ? `${providerCapabilityInternalName(provider, capability.type)}/${targetModel}` : publicModel;
const selector = resolveConfiguredProviderModelSelector(publicModel, config);
const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined;
return selector && capability
? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}`
: publicModel;
}
function providerCapabilityForClientProtocol(
@ -1891,10 +1891,12 @@ function prepareUpstreamCredentialAttempt(input: {
method: string;
path: string;
}): UpstreamAttempt {
const normalizedBody = normalizeConfiguredProviderModelBody(input.attempt.body, input.config);
const target = resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body);
if (!target) {
return {
...input.attempt,
body: normalizedBody?.body ?? input.attempt.body,
headers: input.headers
};
}
@ -1903,6 +1905,7 @@ function prepareUpstreamCredentialAttempt(input: {
if (credentials.length === 0) {
return {
...input.attempt,
body: target.body ?? normalizedBody?.body ?? input.attempt.body,
headers: input.headers
};
}
@ -1912,6 +1915,7 @@ function prepareUpstreamCredentialAttempt(input: {
if (selection.credentials.length === 0) {
return {
...input.attempt,
body: target.body ?? normalizedBody?.body ?? input.attempt.body,
headers: input.headers
};
}
@ -1929,7 +1933,7 @@ function prepareUpstreamCredentialAttempt(input: {
return {
...input.attempt,
body: target.body ?? input.attempt.body,
body: target.body ?? normalizedBody?.body ?? input.attempt.body,
credentialChain: selection.credentials.map((candidate) => candidate.internalName),
credentialIds: selection.credentials.map((candidate) => candidate.credentialId),
credentialProtocol: target.protocol,
@ -1938,6 +1942,22 @@ function prepareUpstreamCredentialAttempt(input: {
};
}
function normalizeConfiguredProviderModelBody(
body: Buffer | undefined,
config: AppConfig
): { body: Buffer; model: string } | undefined {
const parsedBody = parseJsonObjectSafe(body);
const model = stringValue(parsedBody?.model);
const selector = resolveConfiguredProviderModelSelector(model, config);
if (!parsedBody || !selector || selector.model === model) {
return undefined;
}
return {
body: serializeJsonBodyWithModel(parsedBody, selector.model),
model: selector.model
};
}
function resolveProviderCredentialRoutingTarget(
config: AppConfig,
headers: Record<string, string>,
@ -1951,14 +1971,14 @@ function resolveProviderCredentialRoutingTarget(
const parsedBody = parseJsonObjectSafe(body);
const bodyModel = stringValue(parsedBody?.model);
const parsedModel = parseProviderModelSelector(bodyModel);
if (parsedModel) {
const provider = findProviderByPublicOrInternalName(config, parsedModel.provider);
const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config);
if (modelSelector) {
const provider = modelSelector.provider;
const providerProtocol = provider ? providerProtocolForClientProtocol(provider, protocol) : undefined;
if (provider && providerProtocol && activeProviderCredentials(provider).length > 0) {
return {
body: parsedBody ? serializeJsonBodyWithModel(parsedBody, parsedModel.model) : body,
model: parsedModel.model,
body: parsedBody ? serializeJsonBodyWithModel(parsedBody, modelSelector.model) : body,
model: modelSelector.model,
provider,
protocol: providerProtocol
};
@ -2001,6 +2021,44 @@ function parseProviderModelSelector(value: string | undefined): { model: string;
return provider && model ? { model, provider } : undefined;
}
function resolveConfiguredProviderModelSelector(
value: string | undefined,
config: AppConfig
): { model: string; provider: GatewayProviderConfig } | undefined {
let current = normalizeRouteSelector(value);
if (!current) {
return undefined;
}
let selectedProvider: GatewayProviderConfig | undefined;
for (let depth = 0; depth < 4; depth += 1) {
const parsed = parseProviderModelSelector(current);
if (!parsed) {
break;
}
const provider = findProviderByPublicOrInternalName(config, parsed.provider);
if (!provider) {
break;
}
selectedProvider = provider;
current = parsed.model;
const nested = parseProviderModelSelector(current);
if (!nested) {
return current ? { model: current, provider } : undefined;
}
const nestedProvider = findProviderByPublicOrInternalName(config, nested.provider);
if (!nestedProvider || providerRuntimeId(nestedProvider) !== providerRuntimeId(provider)) {
return current ? { model: current, provider } : undefined;
}
}
return selectedProvider && current ? { model: current, provider: selectedProvider } : undefined;
}
function firstTargetProviderHeader(headers: Record<string, string>): string | undefined {
const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"];
if (provider?.trim()) {
@ -2707,6 +2765,15 @@ function normalizeBindHost(host: string): string {
return host.trim().replace(/^\[|\]$/g, "").toLowerCase();
}
function isLoopbackBindHost(host: string): boolean {
const normalized = normalizeBindHost(host).replace(/\.$/, "");
return normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "0:0:0:0:0:0:0:1" ||
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized);
}
function isWildcardBindHost(host: string): boolean {
return host === "" || host === "0.0.0.0" || host === "::" || host === "::0";
}
@ -2945,14 +3012,26 @@ function applyCors(response: ServerResponse, config?: AppConfig): void {
response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
}
function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): ApiKeyAuthorizationResult {
const apiKeys = configuredApiKeys(config);
async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise<ApiKeyAuthorizationResult> {
let apiKeys = await configuredApiKeys(config);
if (apiKeys.length === 0) {
return { ok: true };
if (isLoopbackBindHost(config.gateway.host)) {
return { ok: true };
}
sendJson(response, 403, {
error: {
message: "Configure at least one CCR API key before listening on a non-loopback gateway host."
}
});
return { ok: false };
}
const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request);
const apiKey = token ? apiKeys.find((item) => item.key === token) : undefined;
let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined;
if (!apiKey && token) {
apiKeys = await configuredApiKeys(config, { refresh: true });
apiKey = apiKeys.find((item) => item.key === token);
}
if (apiKey) {
if (isApiKeyExpired(apiKey)) {
sendJson(response, 401, { error: { message: "API key is expired." } });
@ -2965,8 +3044,10 @@ function authorize(request: IncomingMessage, response: ServerResponse, config: A
return { ok: false };
}
function configuredApiKeys(config: AppConfig): ApiKeyConfig[] {
async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise<ApiKeyConfig[]> {
const persistedApiKeys = await loadPersistedApiKeysCached(options);
const values = [
...persistedApiKeys,
...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),
...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : [])
];
@ -2983,6 +3064,24 @@ function configuredApiKeys(config: AppConfig): ApiKeyConfig[] {
return result;
}
async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise<ApiKeyConfig[]> {
const now = Date.now();
if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) {
return persistedApiKeyCache.values;
}
try {
const values = await loadPersistedApiKeys();
persistedApiKeyCache = {
loadedAt: now,
values
};
return values;
} catch (error) {
console.warn(`[gateway] Failed to load persisted API keys: ${formatError(error)}`);
return [];
}
}
function isApiKeyExpired(apiKey: ApiKeyConfig): boolean {
if (!apiKey.expiresAt) {
return false;
@ -3009,7 +3108,7 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming
});
for (const check of checks) {
const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart);
const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now);
if (counter.value + check.rule.requested > check.rule.limit) {
sendJson(response, 429, {
error: {
@ -3030,7 +3129,7 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming
}
for (const check of checks) {
readApiKeyWindowCounter(check.counterKey, check.windowStart).value += check.rule.requested;
readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now).value += check.rule.requested;
}
return true;
}
@ -3076,7 +3175,7 @@ function providerCredentialLimitState(
let utilization = 0;
for (const rule of rules) {
const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs;
const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart);
const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now);
blocked = blocked || counter.value + rule.requested > rule.limit;
utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit);
}
@ -3141,7 +3240,7 @@ function incrementProviderCredentialCounters(
const now = Date.now();
for (const rule of rules) {
const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs;
readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart).value += rule.requested;
readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now).value += rule.requested;
}
}
@ -3202,16 +3301,29 @@ function addApiKeyLimitRule(
});
}
function readApiKeyWindowCounter(key: string, windowStart: number): ApiKeyWindowCounter {
function readApiKeyWindowCounter(key: string, windowStart: number, windowMs: number, now = Date.now()): ApiKeyWindowCounter {
pruneExpiredApiKeyLimitCounters(now);
const existing = apiKeyLimitCounters.get(key);
if (existing && existing.windowStart === windowStart) {
return existing;
}
const fresh = { value: 0, windowStart };
const fresh = {
expiresAt: windowStart + windowMs * apiKeyLimitCounterRetentionWindows,
value: 0,
windowStart
};
apiKeyLimitCounters.set(key, fresh);
return fresh;
}
function pruneExpiredApiKeyLimitCounters(now: number): void {
for (const [key, counter] of apiKeyLimitCounters) {
if (counter.expiresAt <= now) {
apiKeyLimitCounters.delete(key);
}
}
}
function estimateApiKeyLimitUsage(request: IncomingMessage, requestBody: Buffer): ApiKeyLimitUsage {
return estimateLimitUsage(request.method ?? "GET", requestBody);
}

View file

@ -1,4 +1,3 @@
import * as electron from "electron";
import packageJson from "../../../package.json";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ProxyNetworkExchange } from "../../shared/app";
@ -170,7 +169,7 @@ async function handleJsonRpcRequest(payload: unknown): Promise<JsonRpcResponse |
}
function appVersion(): string {
return typeof electron.app?.getVersion === "function" ? electron.app.getVersion() : packageJson.version;
return packageJson.version;
}
async function callTool(params: unknown): Promise<JsonValue> {

View file

@ -1,6 +1,5 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import * as electron from "electron";
import { chmodSync, writeFileSync } from "node:fs";
import http, { type ClientRequest, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import https from "node:https";
@ -9,6 +8,7 @@ import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import tls from "node:tls";
import { pathToFileURL } from "node:url";
import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib";
import type {
AppConfig,
@ -85,6 +85,11 @@ type ActiveProxyNetworkCapture = {
setResponse: (statusCode: number, headers: CapturedHeaders | IncomingHttpHeaders) => void;
};
export type ProxyDesktopIntegration = {
requestMacosCertificateInstallPermission?: () => Promise<boolean>;
revealFile?: (file: string) => Promise<void>;
};
const requestHopByHopHeaders = new Set([
"connection",
"keep-alive",
@ -119,6 +124,7 @@ class ProxyService {
private authority?: CertificateAuthority;
private attachedServer?: AttachedServer;
private config?: AppConfig;
private desktopIntegration: ProxyDesktopIntegration = {};
private networkCaptureEnabled = false;
private networkCaptures: ProxyNetworkCaptureRecord[] = [];
private secureServers = new Map<string, Promise<MitmServer>>();
@ -134,6 +140,10 @@ class ProxyService {
};
private upstreamProxy?: UpstreamProxyConfig;
setDesktopIntegration(integration: ProxyDesktopIntegration): void {
this.desktopIntegration = integration;
}
async start(config: AppConfig): Promise<ProxyStatus> {
await this.stop();
this.config = config;
@ -375,7 +385,7 @@ class ProxyService {
async installCertificate(): Promise<ProxyCertificateInstallResult> {
ensureProxyCertificateAuthority();
if (process.platform === "darwin") {
const approved = await requestMacosCertificateInstallPermission();
const approved = await this.requestMacosCertificateInstallPermission();
if (!approved) {
const status = await this.getCertificateStatus();
return {
@ -398,7 +408,7 @@ class ProxyService {
const installerFile = await openMacosTerminalCertificateInstaller();
terminalMessage = ` Opened Terminal installer: ${installerFile}`;
} catch (terminalError) {
electron.shell?.showItemInFolder?.(PROXY_CA_CERT_FILE);
await this.revealFile(PROXY_CA_CERT_FILE).catch(() => undefined);
terminalMessage = ` Could not open Terminal installer: ${formatError(terminalError)}`;
}
const status = await this.getCertificateStatus();
@ -455,6 +465,18 @@ class ProxyService {
};
}
private async requestMacosCertificateInstallPermission(): Promise<boolean> {
return await (this.desktopIntegration.requestMacosCertificateInstallPermission?.() ?? Promise.resolve(true));
}
private async revealFile(file: string): Promise<void> {
if (this.desktopIntegration.revealFile) {
await this.desktopIntegration.revealFile(file);
return;
}
await revealFileWithSystemCommand(file);
}
async getCertificateStatus(): Promise<ProxyCertificateStatus> {
const base = {
caCertFile: proxyCaCertFile(),
@ -1469,25 +1491,6 @@ function execFilePromise(file: string, args: string[]): Promise<void> {
});
}
async function requestMacosCertificateInstallPermission(): Promise<boolean> {
if (!electron.dialog || !electron.BrowserWindow) {
return true;
}
const window = electron.BrowserWindow.getFocusedWindow() ?? electron.BrowserWindow.getAllWindows()[0];
const options = {
buttons: ["Continue", "Cancel"],
cancelId: 1,
defaultId: 0,
detail:
"macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.",
message: "Install CCR Proxy CA into the macOS System keychain?",
noLink: true,
type: "warning" as const
};
const result = window ? await electron.dialog.showMessageBox(window, options) : await electron.dialog.showMessageBox(options);
return result.response === 0;
}
function execFileText(file: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(file, args, { maxBuffer: 8 * 1024 * 1024, windowsHide: process.platform === "win32" }, (error, stdout, stderr) => {
@ -1555,17 +1558,22 @@ async function openMacosTerminalCertificateInstaller(): Promise<string> {
const installerFile = path.join(os.tmpdir(), `ccr-install-proxy-ca-${randomUUID()}.command`);
writeFileSync(installerFile, `${macosTerminalCertificateInstallScript()}\n`, "utf8");
chmodSync(installerFile, 0o700);
if (electron.shell?.openPath) {
const errorMessage = await electron.shell.openPath(installerFile);
if (errorMessage) {
throw new Error(errorMessage);
}
return installerFile;
}
await execFilePromise("/usr/bin/open", [installerFile]);
return installerFile;
}
async function revealFileWithSystemCommand(file: string): Promise<void> {
if (process.platform === "darwin") {
await execFilePromise("/usr/bin/open", ["-R", file]);
return;
}
if (process.platform === "win32") {
await execFilePromise("explorer.exe", ["/select,", file]);
return;
}
await execFilePromise("xdg-open", [pathToFileURL(path.dirname(file)).toString()]);
}
function macosTerminalCertificateInstallScript(): string {
return [
"#!/bin/zsh",