diff --git a/.changeset/calm-trains-wash.md b/.changeset/calm-trains-wash.md new file mode 100644 index 00000000..bf938989 --- /dev/null +++ b/.changeset/calm-trains-wash.md @@ -0,0 +1,9 @@ +--- +'@calycode/cli': patch +--- + +chore: Improved OpenCode native host stability and setup across Chromium browsers. + +- Added safer OpenCode version control with a pinned default and optional override. +- Improved native host setup and extension discovery behavior for Chromium-based browsers. +- Added support for passing OpenCode version from native host message payloads. diff --git a/docs/commands/oc-init.md b/docs/commands/oc-init.md index f72c090f..b52ab2aa 100644 --- a/docs/commands/oc-init.md +++ b/docs/commands/oc-init.md @@ -26,4 +26,25 @@ Options: Run 'caly-xano --help' for detailed usage. https://github.com/calycode/xano-tools | https://links.calycode.com/discord -``` \ No newline at end of file +``` + +### Build-time and runtime configuration + +The native host extension discovery uses this precedence order: +1. runtime `CALY_OC_*` variables +2. build-time pinned `CALY_BUILD_OC_*` variables (embedded at build) +3. repository defaults + +Set these **before build** to bake defaults into the CLI/binary: +- `CALY_BUILD_OC_EXT_DISCOVERY_MODE` +- `CALY_BUILD_OC_EXT_NAME` +- `CALY_BUILD_OC_EXT_TRUSTED_AUTHORS` +- `CALY_BUILD_OC_EXT_TRUSTED_HOMEPAGES` +- `CALY_BUILD_OC_EXT_TRUSTED_UPDATE_URLS` +- `CALY_BUILD_OC_EXT_REQUIRE_NATIVE_MESSAGING` +- `CALY_BUILD_OC_EXT_PUBLIC_KEY_B64` +- `CALY_BUILD_OC_EXT_DISCOVERY_ENABLED` +- `CALY_BUILD_OC_EXT_INCLUDE_KNOWN_IDS` +- `CALY_BUILD_OC_WRITE_ALL_BROWSER_MANIFESTS` + +At runtime, use the same variable names with `CALY_OC_` prefix to override baked defaults. diff --git a/docs/commands/oc-native-host.md b/docs/commands/oc-native-host.md index e2942664..bc1f8a78 100644 --- a/docs/commands/oc-native-host.md +++ b/docs/commands/oc-native-host.md @@ -17,8 +17,39 @@ Options: └─ -h, --help display help for command Commands: - └─ status Show native host manifest, wrapper, and extension allowli... + ├─ status Show native host manifest, wrapper, and extension allowlist status. + └─ help display help for command +``` + +### oc native-host status +```term +$ caly-xano oc native-host status +Native Host Status: + - Platform: win32 + - Wrapper Path: C:\Users\\.calycode\bin\calycode-host.bat + - Wrapper Exists: Yes + - App ID: com.calycode.cli + - Extension ID Source: discovery:balanced+known + - Chrome Manifest: C:\Users\\.calycode\com.calycode.cli.json + - Chrome Manifest Exists: Yes + - Brave Manifest: C:\Users\\.calycode\com.calycode.cli.json + - Brave Manifest Exists: Yes + - Edge Manifest: C:\Users\\.calycode\com.calycode.cli.json + - Edge Manifest Exists: Yes + - Chromium Manifest: C:\Users\\.calycode\com.calycode.cli.json + - Chromium Manifest Exists: Yes + - Chrome Registry Key: HKEY_CURRENT_USER\Software\Google\Chrome\NativeMessagingHosts\com.calycode.cli + - Chrome Registry Configured: Yes + - Brave Registry Key: HKEY_CURRENT_USER\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\com.calycode.cli + - Brave Registry Configured: Yes + - Edge Registry Key: HKEY_CURRENT_USER\Software\Microsoft\Edge\NativeMessagingHosts\com.calycode.cli + - Edge Registry Configured: Yes + - Chromium Registry Key: HKEY_CURRENT_USER\Software\Chromium\NativeMessagingHosts\com.calycode.cli + - Chromium Registry Configured: Yes + - Expected Extension IDs: hadkkdmpcmllbkfopioopcmeapjchpbm, lnhipaeaeiegnlokhokfokndgadkohfe + - Expected Origins: chrome-extension://hadkkdmpcmllbkfopioopcmeapjchpbm/, chrome-extension://lnhipaeaeiegnlokhokfokndgadkohfe/ + - Manifest Allowed Origins: chrome-extension://hadkkdmpcmllbkfopioopcmeapjchpbm/, chrome-extension://lnhipaeaeiegnlokhokfokndgadkohfe/ +``` Run 'caly-xano --help' for detailed usage. https://github.com/calycode/xano-tools | https://links.calycode.com/discord -``` \ No newline at end of file diff --git a/packages/cli/esbuild.config.ts b/packages/cli/esbuild.config.ts index 799624a0..679e988a 100644 --- a/packages/cli/esbuild.config.ts +++ b/packages/cli/esbuild.config.ts @@ -7,6 +7,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = resolve(__dirname); const distDir = resolve(__dirname, 'dist'); +function defineEnvValue(name: string): string { + const value = process.env[name]; + return value === undefined ? JSON.stringify('') : JSON.stringify(value); +} + +const buildEnvDefines: Record = { + 'process.env.CALY_BUILD_OC_EXT_DISCOVERY_MODE': defineEnvValue('CALY_BUILD_OC_EXT_DISCOVERY_MODE'), + 'process.env.CALY_BUILD_OC_EXT_NAME': defineEnvValue('CALY_BUILD_OC_EXT_NAME'), + 'process.env.CALY_BUILD_OC_EXT_TRUSTED_AUTHORS': defineEnvValue('CALY_BUILD_OC_EXT_TRUSTED_AUTHORS'), + 'process.env.CALY_BUILD_OC_EXT_TRUSTED_HOMEPAGES': defineEnvValue('CALY_BUILD_OC_EXT_TRUSTED_HOMEPAGES'), + 'process.env.CALY_BUILD_OC_EXT_TRUSTED_UPDATE_URLS': defineEnvValue('CALY_BUILD_OC_EXT_TRUSTED_UPDATE_URLS'), + 'process.env.CALY_BUILD_OC_EXT_REQUIRE_NATIVE_MESSAGING': defineEnvValue('CALY_BUILD_OC_EXT_REQUIRE_NATIVE_MESSAGING'), + 'process.env.CALY_BUILD_OC_EXT_PUBLIC_KEY_B64': defineEnvValue('CALY_BUILD_OC_EXT_PUBLIC_KEY_B64'), + 'process.env.CALY_BUILD_OC_EXT_DISCOVERY_ENABLED': defineEnvValue('CALY_BUILD_OC_EXT_DISCOVERY_ENABLED'), + 'process.env.CALY_BUILD_OC_EXT_INCLUDE_KNOWN_IDS': defineEnvValue('CALY_BUILD_OC_EXT_INCLUDE_KNOWN_IDS'), + 'process.env.CALY_BUILD_OC_WRITE_ALL_BROWSER_MANIFESTS': defineEnvValue('CALY_BUILD_OC_WRITE_ALL_BROWSER_MANIFESTS'), +}; + (async () => { try { // Copy github actions @@ -22,6 +40,7 @@ const distDir = resolve(__dirname, 'dist'); }, bundle: true, platform: 'node', + define: buildEnvDefines, plugins: [], target: 'node20', format: 'cjs', diff --git a/packages/cli/src/commands/opencode/implementation.ts b/packages/cli/src/commands/opencode/implementation.ts index 1eb13b61..67f7d139 100644 --- a/packages/cli/src/commands/opencode/implementation.ts +++ b/packages/cli/src/commands/opencode/implementation.ts @@ -6,40 +6,75 @@ import os from 'node:os'; import { spawn, execSync } from 'node:child_process'; import { HOST_APP_INFO } from '../../utils/host-constants'; import { GitHubContentFetcher } from '../../utils/github-content-fetcher'; +import { resolveAllowedExtensionIds } from './native-host/discovery'; +import { + setupNativeHostRegistration, + showNativeHostStatus as showNativeHostStatusImpl, +} from './native-host/setup'; -const OPENCODE_PKG = 'opencode-ai@latest'; +const DEFAULT_OPENCODE_VERSION = '1.14.41'; +const OC_VERSION_REGEX = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; interface LaunchOpencodeServerOptions { port: number; extraOrigins?: string[]; stdio?: 'inherit' | 'pipe' | 'ignore'; detach?: boolean; + ocVersion?: string; } -function getNativeHostManifestPath(platform: NodeJS.Platform, homeDir: string): string { - if (platform === 'darwin') { - return path.join( - homeDir, - `Library/Application Support/Google/Chrome/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, - ); +function normalizeOcVersion(rawVersion?: string): string | undefined { + const value = rawVersion?.trim(); + return value ? value : undefined; +} + +function parseOcVersionFromArgv(argv: string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--oc-version') { + return normalizeOcVersion(argv[i + 1]); + } + if (arg.startsWith('--oc-version=')) { + return normalizeOcVersion(arg.slice('--oc-version='.length)); + } } + return undefined; +} - if (platform === 'linux') { - return path.join( - homeDir, - `.config/google-chrome/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, - ); +function resolveOcVersion(explicitVersion?: string): string { + const explicit = normalizeOcVersion(explicitVersion); + if (explicit) { + if (!OC_VERSION_REGEX.test(explicit)) { + throw new Error( + `Invalid OpenCode version "${explicit}". Use semantic version format like "1.14.41".`, + ); + } + return explicit; } - if (platform === 'win32') { - return path.join(homeDir, '.calycode', `${HOST_APP_INFO.reverseAppId}.json`); + const fromEnv = normalizeOcVersion(process.env.CALY_OC_OPENCODE_VERSION); + if (fromEnv) { + if (!OC_VERSION_REGEX.test(fromEnv)) { + throw new Error( + `Invalid CALY_OC_OPENCODE_VERSION "${fromEnv}". Use semantic version format like "1.14.41".`, + ); + } + return fromEnv; } - throw new Error(`Unsupported platform: ${platform}`); + return DEFAULT_OPENCODE_VERSION; } -function getNativeHostRegistryKey(): string { - return `HKEY_CURRENT_USER\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_APP_INFO.reverseAppId}`; +function getOpencodePackageSpecifier(version: string): string { + return `opencode-ai@${version}`; +} + +function warnIfUsingNonDefaultOcVersion(version: string): void { + if (version !== DEFAULT_OPENCODE_VERSION) { + log.warn( + `Using OpenCode ${version} (override). Our currently validated default is ${DEFAULT_OPENCODE_VERSION}.`, + ); + } } function launchOpencodeServer({ @@ -47,10 +82,19 @@ function launchOpencodeServer({ extraOrigins = [], stdio = 'inherit', detach = false, + ocVersion, }: LaunchOpencodeServerOptions) { validatePort(port); - const args = ['-y', OPENCODE_PKG, 'serve', '--port', String(port), ...getCorsArgs(extraOrigins)]; + const resolvedVersion = resolveOcVersion(ocVersion); + const args = [ + '-y', + getOpencodePackageSpecifier(resolvedVersion), + 'serve', + '--port', + String(port), + ...getCorsArgs(extraOrigins), + ]; const configDir = getCalycodeOpencodeConfigDir(); const workingDir = getOpencodeWorkingDir('server'); @@ -310,11 +354,12 @@ function getOpencodeWorkingDir( * Environment variable: CALY_EXTRA_CORS_ORIGINS (comma-separated list of additional origins) */ function getAllowedCorsOrigins(): string[] { + const resolvedExtensions = resolveAllowedExtensionIds(); const defaultOrigins = [ // The main Xano application 'https://app.xano.com', // Chrome extension origins for extension-to-server communication - ...HOST_APP_INFO.allowedExtensionIds.map((id) => `chrome-extension://${id}`), + ...resolvedExtensions.ids.map((id) => `chrome-extension://${id}`), ]; // Allow additional CORS origins via environment variable (for development/testing) @@ -337,7 +382,11 @@ function getCorsArgs(extraOrigins: string[] = []) { * This allows exposing the full capability of the OpenCode agent. * Sets OPENCODE_CONFIG_DIR to use CalyCode-specific configuration. */ -async function proxyOpencode(args: string[], workdirOverrides?: OpencodeWorkingDirOverrides) { +async function proxyOpencode( + args: string[], + workdirOverrides?: OpencodeWorkingDirOverrides, + ocVersion?: string, +) { log.info( '🤖 Powered by OpenCode - The open source AI coding agent\n' + ' https://github.com/anomalyco/opencode (MIT License)', @@ -349,10 +398,13 @@ async function proxyOpencode(args: string[], workdirOverrides?: OpencodeWorkingD const workingDir = getOpencodeWorkingDir('proxy', workdirOverrides); log.info(`OpenCode working directory: ${workingDir}`); + const resolvedVersion = resolveOcVersion(ocVersion); + warnIfUsingNonDefaultOcVersion(resolvedVersion); + return new Promise((resolve, reject) => { // Use 'npx' to execute the opencode-ai CLI with the provided arguments // Set OPENCODE_CONFIG_DIR to use our custom config without polluting user's global config - const proc = spawn('npx', ['-y', OPENCODE_PKG, ...args], { + const proc = spawn('npx', ['-y', getOpencodePackageSpecifier(resolvedVersion), ...args], { ...getSpawnOptions('inherit', { OPENCODE_CONFIG_DIR: configDir }, workingDir), }); @@ -530,7 +582,11 @@ async function startNativeHost() { return false; }; - const startServer = async (port: number = 4096, extraOrigins: string[] = []) => { + const startServer = async ( + port: number = 4096, + extraOrigins: string[] = [], + requestedOcVersion?: string, + ) => { // Validate port to prevent injection via invalid values try { validatePort(port); @@ -563,16 +619,26 @@ async function startNativeHost() { } try { - const args = ['-y', OPENCODE_PKG, 'serve', '--port', String(port), ...getCorsArgs(extraOrigins)]; - logger.log(`Spawning npx ${args.join(' ')}`); - logger.log(`Using OpenCode config directory: ${getCalycodeOpencodeConfigDir()}`); - logger.log(`Using OpenCode working directory: ${getOpencodeWorkingDir('server')}`); + const resolvedVersion = resolveOcVersion( + requestedOcVersion || parseOcVersionFromArgv(process.argv), + ); + const args = ['-y', getOpencodePackageSpecifier(resolvedVersion), 'serve', '--port', String(port), ...getCorsArgs(extraOrigins)]; + logger.log(`Spawning npx ${args.join(' ')}`); + logger.log(`Using OpenCode version: ${resolvedVersion}`); + logger.log(`Using OpenCode config directory: ${getCalycodeOpencodeConfigDir()}`); + logger.log(`Using OpenCode working directory: ${getOpencodeWorkingDir('server')}`); + if (resolvedVersion !== DEFAULT_OPENCODE_VERSION) { + logger.log( + `Using overridden OpenCode ${resolvedVersion}. Current validated default is ${DEFAULT_OPENCODE_VERSION}.`, + ); + } - serverProc = launchOpencodeServer({ - port, - extraOrigins, - stdio: 'ignore', - }); + serverProc = launchOpencodeServer({ + port, + extraOrigins, + stdio: 'ignore', + ocVersion: resolvedVersion, + }); serverProc.on('error', (err) => { logger.error('Failed to spawn server process', err); @@ -605,14 +671,21 @@ async function startNativeHost() { message: 'Server spawned but failed to become ready in time', }); } - } catch (err) { + } catch (err: any) { logger.error('Unexpected error starting server', err); - sendMessage({ status: 'error', message: 'Unexpected error starting server' }); + sendMessage({ + status: 'error', + message: err?.message || 'Unexpected error starting server', + }); } }; - const restartServer = async (port: number = 4096, extraOrigins: string[] = []) => { - logger.log('Restart requested', { port, extraOrigins }); + const restartServer = async ( + port: number = 4096, + extraOrigins: string[] = [], + requestedOcVersion?: string, + ) => { + logger.log('Restart requested', { port, extraOrigins, requestedOcVersion }); // Kill existing server process if we have a reference if (serverProc) { @@ -649,7 +722,7 @@ async function startNativeHost() { } // Start fresh with new config - await startServer(port, extraOrigins); + await startServer(port, extraOrigins, requestedOcVersion); }; const handleMessage = (msg: any) => { @@ -661,12 +734,14 @@ async function startNativeHost() { } else if (msg.type === 'start') { const port = msg.port ? parseInt(msg.port, 10) : 4096; const origins = Array.isArray(msg.origins) ? msg.origins : []; - startServer(port, origins); + const requestedOcVersion = typeof msg.ocVersion === 'string' ? msg.ocVersion : undefined; + startServer(port, origins, requestedOcVersion); } else if (msg.type === 'restart') { // Restart the server with new origins - used when CORS configuration needs updating const port = msg.port ? parseInt(msg.port, 10) : 4096; const origins = Array.isArray(msg.origins) ? msg.origins : []; - restartServer(port, origins); + const requestedOcVersion = typeof msg.ocVersion === 'string' ? msg.ocVersion : undefined; + restartServer(port, origins, requestedOcVersion); } else if (msg.type === 'stop') { const port = msg.port ? parseInt(msg.port, 10) : 4096; logger.log('Stop requested', { port, hasServerProc: !!serverProc }); @@ -1308,16 +1383,28 @@ async function clearSkillsCache(): Promise { log.success('Skills cache cleared.'); } -async function serveOpencode({ port = 4096, detach = false }: { port?: number; detach?: boolean }) { +async function serveOpencode({ + port = 4096, + detach = false, + ocVersion, +}: { + port?: number; + detach?: boolean; + ocVersion?: string; +}) { // Validate port validatePort(port); + const resolvedVersion = resolveOcVersion(ocVersion); + warnIfUsingNonDefaultOcVersion(resolvedVersion); + if (detach) { log.info(`Starting OpenCode server on port ${port} in background...`); const proc = launchOpencodeServer({ port, stdio: 'ignore', detach: true, + ocVersion: resolvedVersion, }); proc.unref(); log.success('OpenCode server started in background.'); @@ -1330,6 +1417,7 @@ async function serveOpencode({ port = 4096, detach = false }: { port?: number; d const proc = launchOpencodeServer({ port, stdio: 'inherit', + ocVersion: resolvedVersion, }); proc.on('close', (code) => { @@ -1350,178 +1438,16 @@ async function setupOpencode({ extensionIds, force = false, skipConfig = false, + ocVersion, }: { extensionIds?: string[]; force?: boolean; skipConfig?: boolean; + ocVersion?: string; } = {}) { - const platform = os.platform(); - const homeDir = os.homedir(); - let manifestPath = ''; - - // Use provided extension IDs or fall back to the ones in HOST_APP_INFO - const allowedExtensionIds = extensionIds?.length - ? extensionIds - : HOST_APP_INFO.allowedExtensionIds; - - log.info(`Setting up native host for ${allowedExtensionIds.length} extension(s)...`); - - // We need to point to the executable. - // If we are running from source (dev), it's `node .../cli/dist/index.cjs opencode native-host`. - // If bundled, it's `/path/to/calycode-exe opencode native-host`. - // Chrome Native Hosts usually want a direct path to an executable or a bat/sh script. - // They don't natively support arguments in the "path" field of the manifest (except on Linux sometimes, but it's flaky). - // Best practice: Create a wrapper script (bat/sh) that calls our CLI with the `native-host` argument. - - const isWin = platform === 'win32'; - const executablePath = process.execPath; // Path to node or the bundled binary - - // Determine how to call the CLI - // If we are in pkg (bundled), process.execPath is the binary. - // If we are in node, process.execPath is node, and we need the script path. - - let manifestExePath: string; - - if (isWin) { - // Development mode on Windows: - // Batch files have known issues with binary stdin/stdout for Native Messaging. - // - // The recommended solution for Windows is to use a VBScript (.vbs) or - // Windows Script Host (.wsf) wrapper that properly handles stdin/stdout. - // However, these also have limitations with binary data. - // - // The most reliable approach is to use a small compiled launcher or - // directly reference node.exe with the script in a way Chrome accepts. - // - // For development, we'll try a batch file with minimal commands. - // If this doesn't work, users can run `xano opencode native-host` directly - // from a terminal for testing, or build the bundled exe. - - const wrapperDir = path.join(homeDir, '.calycode', 'bin'); - if (!fs.existsSync(wrapperDir)) { - fs.mkdirSync(wrapperDir, { recursive: true }); - } - - const wrapperPath = path.join(wrapperDir, 'calycode-host.bat'); - let wrapperContent = ''; - - // Detect if running from the bundled executable or regular node - // SEA apps usually have the executable as process.execPath - const isBundled = process.execPath.toLowerCase().endsWith('caly.exe') || (process as any).pkg; - - if (isBundled) { - // Bundled: simpler, just call the exe - // @echo off prevents the command itself from being printed - wrapperContent = `@echo off\r\n`; - wrapperContent += `"${process.execPath}" opencode native-host %*\r\n`; - } else { - // Node/NPM/NPX: - // We need to find the entry point. - // process.argv[1] is reliable for the current session. - // To support the global install scenario, we use that path. - - wrapperContent = `@echo off\r\n`; - wrapperContent += `"${process.execPath}" "${process.argv[1]}" opencode native-host %*\r\n`; - } - - fs.writeFileSync(wrapperPath, wrapperContent); - manifestExePath = wrapperPath; - - log.info(`Created wrapper script: ${wrapperPath}`); - // log.info(`Script path: ${scriptPath}`); // Removed logging of scriptPath as it is no longer defined separately - log.info(`Wrapper content: ${wrapperContent.trim()}`); - log.warn('Note: Development mode on Windows uses a batch file wrapper.'); - log.warn('If Native Messaging fails, try building the bundled exe instead.'); - } else { - // Unix-like systems - shell scripts work fine - const wrapperDir = path.join(homeDir, '.calycode', 'bin'); - if (!fs.existsSync(wrapperDir)) { - fs.mkdirSync(wrapperDir, { recursive: true }); - } - - const wrapperPath = path.join(wrapperDir, 'calycode-host.sh'); - let wrapperContent: string; - - wrapperContent = `#!/bin/sh\nexec "${executablePath}" "${process.argv[1]}" opencode native-host\n`; - - fs.writeFileSync(wrapperPath, wrapperContent); - fs.chmodSync(wrapperPath, '755'); - manifestExePath = wrapperPath; - } - - let manifestContent: any = { - name: HOST_APP_INFO.reverseAppId, - description: HOST_APP_INFO.description, - path: manifestExePath, - type: 'stdio', - // Allow all configured extension IDs - allowed_origins: allowedExtensionIds.map((id) => `chrome-extension://${id}/`), - }; - - // Adjust manifest path based on OS - if (platform === 'win32') { - // Windows requires registry key - manifestPath = getNativeHostManifestPath(platform, homeDir); - - try { - // Use full HKEY_CURRENT_USER instead of HKCU for clarity/safety - const regKey = getNativeHostRegistryKey(); - // Use reg.exe to add the key. - // /ve adds the default value. /t REG_SZ specifies type. /d specifies data. /f forces overwrite. - const regArgs = ['add', regKey, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f']; - - log.info(`Executing registry command: reg ${regArgs.join(' ')}`); - - await new Promise((resolve, reject) => { - const proc = spawn('reg', regArgs, { stdio: 'ignore' }); - - proc.on('close', (code) => { - if (code === 0) { - log.success(`Registry key added: ${regKey}`); - - // Verify it immediately - try { - const verifyArgs = ['query', regKey, '/ve']; - const verifyProc = spawn('reg', verifyArgs, { stdio: 'pipe' }); - verifyProc.stdout.on('data', (d) => - log.info(`Registry Verification: ${d.toString().trim()}`), - ); - } catch (e) { - /* ignore verify error */ - } - resolve(); - } else { - log.error(`Failed to add registry key. Exit code: ${code}`); - log.warn('You may need to add it manually:'); - log.info(`Key: ${regKey}`); - log.info(`Value: ${manifestPath}`); - reject(new Error(`Failed to add registry key. Exit code: ${code}`)); - } - }); - - proc.on('error', (err) => { - log.error(`Failed to spawn registry command: ${err.message}`); - reject(new Error(`Failed to spawn registry command: ${err.message}`)); - }); - }); - } catch (error: any) { - log.error(`Error adding registry key: ${error.message}`); - } - } else { - manifestPath = getNativeHostManifestPath(platform, homeDir); - } - - // Ensure directory exists - const manifestDir = path.dirname(manifestPath); - if (!fs.existsSync(manifestDir)) { - fs.mkdirSync(manifestDir, { recursive: true }); - } - - // Write manifest - fs.writeFileSync(manifestPath, JSON.stringify(manifestContent, null, 2)); - log.success(`Native messaging host manifest created at: ${manifestPath}`); - log.success(`Executable path in manifest: ${manifestExePath}`); - + const resolvedVersion = resolveOcVersion(ocVersion); + warnIfUsingNonDefaultOcVersion(resolvedVersion); + await setupNativeHostRegistration(extensionIds, resolvedVersion); log.info('Native host setup complete.'); // Setup OpenCode configuration (agents, commands, instructions) @@ -1537,64 +1463,7 @@ async function setupOpencode({ } function showNativeHostStatus(): void { - const platform = os.platform(); - const homeDir = os.homedir(); - const manifestPath = getNativeHostManifestPath(platform, homeDir); - const wrapperPath = path.join(homeDir, '.calycode', 'bin', platform === 'win32' ? 'calycode-host.bat' : 'calycode-host.sh'); - const expectedOrigins = HOST_APP_INFO.allowedExtensionIds.map((id) => `chrome-extension://${id}/`); - - const lines: string[] = []; - lines.push('Native Host Status:'); - lines.push(` - Platform: ${platform}`); - lines.push(` - Manifest Path: ${manifestPath}`); - lines.push(` - Manifest Exists: ${fs.existsSync(manifestPath) ? 'Yes' : 'No'}`); - lines.push(` - Wrapper Path: ${wrapperPath}`); - lines.push(` - Wrapper Exists: ${fs.existsSync(wrapperPath) ? 'Yes' : 'No'}`); - lines.push(` - App ID: ${HOST_APP_INFO.reverseAppId}`); - - if (platform === 'win32') { - const regKey = getNativeHostRegistryKey(); - let registryConfigured = false; - try { - execSync(`reg query "${regKey}" /ve`, { stdio: 'ignore', windowsHide: true }); - registryConfigured = true; - } catch { - registryConfigured = false; - } - lines.push(` - Registry Key: ${regKey}`); - lines.push(` - Registry Configured: ${registryConfigured ? 'Yes' : 'No'}`); - } - - let manifestAllowedOrigins: string[] = []; - if (fs.existsSync(manifestPath)) { - try { - const manifestRaw = fs.readFileSync(manifestPath, 'utf8'); - const manifest = JSON.parse(manifestRaw) as { allowed_origins?: string[] }; - manifestAllowedOrigins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : []; - } catch { - lines.push(' - Manifest Parse: Failed'); - } - } - - lines.push( - ` - Expected Extension IDs: ${HOST_APP_INFO.allowedExtensionIds.join(', ')}`, - ); - lines.push( - ` - Expected Origins: ${expectedOrigins.join(', ')}`, - ); - lines.push( - ` - Manifest Allowed Origins: ${manifestAllowedOrigins.length ? manifestAllowedOrigins.join(', ') : '(none)'}`, - ); - - const missingOrigins = expectedOrigins.filter((origin) => !manifestAllowedOrigins.includes(origin)); - if (missingOrigins.length > 0) { - lines.push(` - Missing Expected Origins: ${missingOrigins.join(', ')}`); - lines.push(' - Recommendation: run `caly-xano oc init --force` to refresh native host setup.'); - log.warn(lines.join('\n')); - return; - } - - log.success(lines.join('\n')); + showNativeHostStatusImpl(); } export { diff --git a/packages/cli/src/commands/opencode/index.ts b/packages/cli/src/commands/opencode/index.ts index f943546d..e5c45e16 100644 --- a/packages/cli/src/commands/opencode/index.ts +++ b/packages/cli/src/commands/opencode/index.ts @@ -21,14 +21,15 @@ async function registerOpencodeCommands(program) { .command('oc') .alias('opencode') .description( - 'Manage OpenCode AI integration and tools.\n' + - ' Powered by OpenCode - The open source AI coding agent.\n' + - ' GitHub: https://github.com/anomalyco/opencode\n' + - ' License: MIT (see LICENSES/opencode-ai.txt)', + 'Manage OpenCode AI integration and tools.\n' + + ' Powered by OpenCode - The open source AI coding agent.\n' + + ' GitHub: https://github.com/anomalyco/opencode\n' + + ' License: MIT (see LICENSES/opencode-ai.txt)', ) .allowUnknownOption() // Allow passing through unknown flags to the underlying CLI .option('--cwd', 'Run OpenCode proxy commands from the current shell directory') - .option('--workdir ', 'Run OpenCode proxy commands from a specific working directory'); + .option('--workdir ', 'Run OpenCode proxy commands from a specific working directory') + .option('--oc-version ', 'Override OpenCode package version for this command'); opencodeNamespace .command('init') @@ -37,12 +38,13 @@ async function registerOpencodeCommands(program) { ) .option('-f, --force', 'Force overwrite existing configuration files') .option('--skip-config', 'Skip installing OpenCode configuration templates') - .action(async (options) => { - await setupOpencode({ - force: options.force, - skipConfig: options.skipConfig, - }); - }); + .action(async (options, command) => { + await setupOpencode({ + force: options.force, + skipConfig: options.skipConfig, + ocVersion: command.parent?.opts()?.ocVersion, + }); + }); // Template management subcommands const templatesNamespace = opencodeNamespace @@ -169,12 +171,13 @@ async function registerOpencodeCommands(program) { .description('Serve the OpenCode AI server locally.') .option('--port ', 'Port to run the OpenCode server on (default: 4096)') .option('-d, --detach', 'Run the server in the background (detached mode)') - .action(async (options) => { - await serveOpencode({ - port: options.port ? parseInt(options.port, 10) : undefined, - detach: options.detach, - }); - }); + .action(async (options, command) => { + await serveOpencode({ + port: options.port ? parseInt(options.port, 10) : undefined, + detach: options.detach, + ocVersion: command.parent?.opts()?.ocVersion, + }); + }); const nativeHostCommand = opencodeNamespace .command('native-host') @@ -225,9 +228,10 @@ async function registerOpencodeCommands(program) { const passThroughArgs = rawArgs.slice(opencodeIndex + 1); - let forceCwd = !!command.parent?.opts()?.cwd; - let explicitWorkdir = command.parent?.opts()?.workdir as string | undefined; - const sanitizedPassThroughArgs: string[] = []; + let forceCwd = !!command.parent?.opts()?.cwd; + let explicitWorkdir = command.parent?.opts()?.workdir as string | undefined; + let ocVersion = command.parent?.opts()?.ocVersion as string | undefined; + const sanitizedPassThroughArgs: string[] = []; for (let i = 0; i < passThroughArgs.length; i++) { const arg = passThroughArgs[i]; @@ -257,6 +261,20 @@ async function registerOpencodeCommands(program) { continue; } + if (arg === '--oc-version') { + const next = passThroughArgs[i + 1]; + if (next) { + ocVersion = next; + i++; + } + continue; + } + + if (arg.startsWith('--oc-version=')) { + ocVersion = arg.slice('--oc-version='.length); + continue; + } + sanitizedPassThroughArgs.push(arg); } @@ -264,11 +282,11 @@ async function registerOpencodeCommands(program) { // No, if we are here, it's because it wasn't init/serve/native-host (mostly). // BUT 'run' is default, so 'caly-xano opencode' (no args) also lands here. - await proxyOpencode(sanitizedPassThroughArgs, { - forceCwd, - explicitWorkdir, - }); - }); + await proxyOpencode(sanitizedPassThroughArgs, { + forceCwd, + explicitWorkdir, + }, ocVersion); + }); } export { registerOpencodeCommands }; diff --git a/packages/cli/src/commands/opencode/native-host/discovery.ts b/packages/cli/src/commands/opencode/native-host/discovery.ts new file mode 100644 index 00000000..ee57fbff --- /dev/null +++ b/packages/cli/src/commands/opencode/native-host/discovery.ts @@ -0,0 +1,632 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; +import { HOST_APP_INFO } from '../../../utils/host-constants'; + +const CHROME_EXTENSION_ID_REGEX = /^[a-p]{32}$/; + +const BUILD_OC_DEFAULTS = { + extDiscoveryMode: process.env.CALY_BUILD_OC_EXT_DISCOVERY_MODE, + extName: process.env.CALY_BUILD_OC_EXT_NAME, + extTrustedAuthors: process.env.CALY_BUILD_OC_EXT_TRUSTED_AUTHORS, + extTrustedHomepages: process.env.CALY_BUILD_OC_EXT_TRUSTED_HOMEPAGES, + extTrustedUpdateUrls: process.env.CALY_BUILD_OC_EXT_TRUSTED_UPDATE_URLS, + extRequireNativeMessaging: process.env.CALY_BUILD_OC_EXT_REQUIRE_NATIVE_MESSAGING, + extPublicKeyB64: process.env.CALY_BUILD_OC_EXT_PUBLIC_KEY_B64, + extDiscoveryEnabled: process.env.CALY_BUILD_OC_EXT_DISCOVERY_ENABLED, + extIncludeKnownIds: process.env.CALY_BUILD_OC_EXT_INCLUDE_KNOWN_IDS, + writeAllBrowserManifests: process.env.CALY_BUILD_OC_WRITE_ALL_BROWSER_MANIFESTS, +}; + +interface ChromiumUserDataRoot { + browser: string; + userDataPath: string; +} + +interface ExtensionManifest { + name?: string; + author?: string; + key?: string; + homepage_url?: string; + update_url?: string; + permissions?: string[]; + optional_permissions?: string[]; + default_locale?: string; +} + +interface ExtensionDiscoveryConfig { + mode: 'strict' | 'balanced' | 'name-only'; + extensionName: string; + trustedAuthorPatterns: string[]; + trustedHomepagePrefixes: string[]; + trustedUpdateUrlPrefixes: string[]; + requireNativeMessagingPermission: boolean; + expectedPublicKeyBase64?: string; +} + +interface ExtensionCandidateMatch { + id: string; + browser: string; + profile: string; + name: string; + confidence: 'high' | 'medium' | 'low'; + reasons: string[]; +} + +interface ResolveExtensionIdsResult { + ids: string[]; + matched: ExtensionCandidateMatch[]; + source: string; +} + +function parseBooleanEnv(envValue: string | undefined, defaultValue: boolean): boolean { + if (!envValue) { + return defaultValue; + } + + const normalized = envValue.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return defaultValue; +} + +function parseListEnv(envValue: string | undefined): string[] { + if (!envValue) { + return []; + } + return envValue + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +} + +function resolveEnvWithBuildFallback(runtimeEnv: string | undefined, buildEnv: string | undefined): string | undefined { + const runtimeValue = runtimeEnv?.trim(); + if (runtimeValue) { + return runtimeValue; + } + + const buildValue = buildEnv?.trim(); + if (buildValue) { + return buildValue; + } + + return undefined; +} + +function parseBooleanEnvWithBuildFallback( + runtimeEnv: string | undefined, + buildEnv: string | undefined, + defaultValue: boolean, +): boolean { + const runtimeValue = runtimeEnv?.trim(); + if (runtimeValue) { + return parseBooleanEnv(runtimeValue, defaultValue); + } + + const buildValue = buildEnv?.trim(); + if (buildValue) { + return parseBooleanEnv(buildValue, defaultValue); + } + + return defaultValue; +} + +function parseListEnvWithBuildFallback(runtimeEnv: string | undefined, buildEnv: string | undefined): string[] { + const runtimeList = parseListEnv(runtimeEnv); + if (runtimeList.length > 0) { + return runtimeList; + } + + return parseListEnv(buildEnv); +} + +function normalizeLower(value: string | undefined): string { + return (value || '').trim().toLowerCase(); +} + +function isValidExtensionId(id: string): boolean { + return CHROME_EXTENSION_ID_REGEX.test(id); +} + +function resolveExtensionIdFromPublicKeyBase64(base64Key: string): string | null { + try { + const keyBuffer = Buffer.from(base64Key, 'base64'); + if (keyBuffer.length === 0) { + return null; + } + + const digest = createHash('sha256').update(keyBuffer).digest(); + const chars = 'abcdefghijklmnop'; + let id = ''; + for (const byte of digest.subarray(0, 16)) { + id += chars[(byte >> 4) & 0x0f] + chars[byte & 0x0f]; + } + return id; + } catch { + return null; + } +} + +function getExtensionDiscoveryConfig(): ExtensionDiscoveryConfig { + const envMode = normalizeLower( + resolveEnvWithBuildFallback( + process.env.CALY_OC_EXT_DISCOVERY_MODE, + BUILD_OC_DEFAULTS.extDiscoveryMode, + ), + ); + const mode: 'strict' | 'balanced' | 'name-only' = + envMode === 'strict' || envMode === 'name-only' || envMode === 'balanced' + ? envMode + : HOST_APP_INFO.extensionDiscovery.mode; + + const trustedAuthorPatterns = parseListEnvWithBuildFallback( + process.env.CALY_OC_EXT_TRUSTED_AUTHORS, + BUILD_OC_DEFAULTS.extTrustedAuthors, + ); + const trustedHomepagePrefixes = parseListEnvWithBuildFallback( + process.env.CALY_OC_EXT_TRUSTED_HOMEPAGES, + BUILD_OC_DEFAULTS.extTrustedHomepages, + ); + const trustedUpdateUrlPrefixes = parseListEnvWithBuildFallback( + process.env.CALY_OC_EXT_TRUSTED_UPDATE_URLS, + BUILD_OC_DEFAULTS.extTrustedUpdateUrls, + ); + + return { + mode, + extensionName: + resolveEnvWithBuildFallback(process.env.CALY_OC_EXT_NAME, BUILD_OC_DEFAULTS.extName) || + HOST_APP_INFO.extensionDiscovery.extensionName, + trustedAuthorPatterns: + trustedAuthorPatterns.length > 0 + ? trustedAuthorPatterns + : HOST_APP_INFO.extensionDiscovery.trustedAuthorPatterns, + trustedHomepagePrefixes: + trustedHomepagePrefixes.length > 0 + ? trustedHomepagePrefixes + : HOST_APP_INFO.extensionDiscovery.trustedHomepagePrefixes, + trustedUpdateUrlPrefixes: + trustedUpdateUrlPrefixes.length > 0 + ? trustedUpdateUrlPrefixes + : ['https://clients2.google.com/service/update2/crx'], + requireNativeMessagingPermission: parseBooleanEnv( + resolveEnvWithBuildFallback( + process.env.CALY_OC_EXT_REQUIRE_NATIVE_MESSAGING, + BUILD_OC_DEFAULTS.extRequireNativeMessaging, + ), + HOST_APP_INFO.extensionDiscovery.requireNativeMessagingPermission, + ), + expectedPublicKeyBase64: resolveEnvWithBuildFallback( + process.env.CALY_OC_EXT_PUBLIC_KEY_B64, + BUILD_OC_DEFAULTS.extPublicKeyB64, + ), + }; +} + +function getChromiumUserDataRoots(platform: NodeJS.Platform, homeDir: string): ChromiumUserDataRoot[] { + if (platform === 'darwin') { + return [ + { + browser: 'Chrome', + userDataPath: path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome'), + }, + { + browser: 'Brave', + userDataPath: path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'), + }, + { + browser: 'Edge', + userDataPath: path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge'), + }, + { + browser: 'Chromium', + userDataPath: path.join(homeDir, 'Library', 'Application Support', 'Chromium'), + }, + ]; + } + + if (platform === 'linux') { + return [ + { browser: 'Chrome', userDataPath: path.join(homeDir, '.config', 'google-chrome') }, + { + browser: 'Brave', + userDataPath: path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'), + }, + { browser: 'Edge', userDataPath: path.join(homeDir, '.config', 'microsoft-edge') }, + { browser: 'Chromium', userDataPath: path.join(homeDir, '.config', 'chromium') }, + ]; + } + + if (platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + return []; + } + + return [ + { + browser: 'Chrome', + userDataPath: path.join(localAppData, 'Google', 'Chrome', 'User Data'), + }, + { + browser: 'Brave', + userDataPath: path.join(localAppData, 'BraveSoftware', 'Brave-Browser', 'User Data'), + }, + { + browser: 'Edge', + userDataPath: path.join(localAppData, 'Microsoft', 'Edge', 'User Data'), + }, + { + browser: 'Chromium', + userDataPath: path.join(localAppData, 'Chromium', 'User Data'), + }, + ]; + } + + return []; +} + +function readJsonFile(filePath: string): T | null { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function getProfileDirectories(userDataPath: string): string[] { + if (!fs.existsSync(userDataPath)) { + return []; + } + + const entries = fs.readdirSync(userDataPath, { withFileTypes: true }); + const profiles: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const dirName = entry.name; + const isLikelyProfile = + dirName === 'Default' || dirName.startsWith('Profile ') || dirName === 'Guest Profile'; + if (!isLikelyProfile) { + continue; + } + + const profilePath = path.join(userDataPath, dirName); + if (fs.existsSync(path.join(profilePath, 'Preferences'))) { + profiles.push(profilePath); + } + } + + return profiles; +} + +function resolveLocalizedMessage( + extensionVersionPath: string, + locale: string | undefined, + messageKey: string, +): string | null { + if (!locale) { + return null; + } + + const localeCandidates = [locale, 'en', 'en_US']; + for (const candidate of localeCandidates) { + const messagesPath = path.join(extensionVersionPath, '_locales', candidate, 'messages.json'); + const messages = readJsonFile>(messagesPath); + if (messages && messages[messageKey]?.message) { + return messages[messageKey].message || null; + } + } + + return null; +} + +function resolveManifestName(manifest: ExtensionManifest, extensionVersionPath: string): string { + if (!manifest.name) { + return ''; + } + + const localizedMatch = manifest.name.match(/^__MSG_([^_]+)__$/); + if (!localizedMatch) { + return manifest.name; + } + + const localized = resolveLocalizedMessage( + extensionVersionPath, + manifest.default_locale, + localizedMatch[1], + ); + return localized || manifest.name; +} + +function parseExtensionVersion(dirName: string): number[] | null { + const coreVersion = dirName.split('_')[0]?.trim(); + if (!coreVersion || !/^\d+(?:\.\d+)*$/.test(coreVersion)) { + return null; + } + return coreVersion.split('.').map((part) => Number.parseInt(part, 10)); +} + +function compareVersionParts(aParts: number[], bParts: number[]): number { + const maxLen = Math.max(aParts.length, bParts.length); + for (let i = 0; i < maxLen; i++) { + const a = aParts[i] ?? 0; + const b = bParts[i] ?? 0; + if (a !== b) { + return a - b; + } + } + return 0; +} + +function getLatestExtensionVersionPath(extensionRootPath: string): string | null { + if (!fs.existsSync(extensionRootPath)) { + return null; + } + + const versionDirs = fs + .readdirSync(extensionRootPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + fullPath: path.join(extensionRootPath, entry.name), + parsedVersion: parseExtensionVersion(entry.name), + })); + + if (versionDirs.length === 0) { + return null; + } + + const semverCandidates = versionDirs.filter((entry) => entry.parsedVersion !== null); + if (semverCandidates.length > 0) { + semverCandidates.sort((a, b) => compareVersionParts(b.parsedVersion!, a.parsedVersion!)); + return semverCandidates[0].fullPath; + } + + versionDirs.sort((a, b) => { + const aTime = fs.statSync(a.fullPath).mtimeMs; + const bTime = fs.statSync(b.fullPath).mtimeMs; + return bTime - aTime; + }); + + return versionDirs[0].fullPath; +} + +function discoverExtensionIds(): ResolveExtensionIdsResult { + const platform = os.platform(); + const homeDir = os.homedir(); + const config = getExtensionDiscoveryConfig(); + const expectedIdFromPublicKey = config.expectedPublicKeyBase64 + ? resolveExtensionIdFromPublicKeyBase64(config.expectedPublicKeyBase64) + : null; + + const targetName = normalizeLower(config.extensionName); + const trustedAuthors = config.trustedAuthorPatterns.map((pattern) => normalizeLower(pattern)); + const trustedHomepages = config.trustedHomepagePrefixes.map((prefix) => normalizeLower(prefix)); + const trustedUpdateUrls = config.trustedUpdateUrlPrefixes.map((prefix) => normalizeLower(prefix)); + + const matches: ExtensionCandidateMatch[] = []; + + for (const root of getChromiumUserDataRoots(platform, homeDir)) { + for (const profilePath of getProfileDirectories(root.userDataPath)) { + const extensionsPath = path.join(profilePath, 'Extensions'); + if (!fs.existsSync(extensionsPath)) { + continue; + } + + const extensionEntries = fs.readdirSync(extensionsPath, { withFileTypes: true }); + for (const extensionEntry of extensionEntries) { + if (!extensionEntry.isDirectory()) { + continue; + } + + const extensionId = extensionEntry.name; + if (!isValidExtensionId(extensionId)) { + continue; + } + + const extensionRootPath = path.join(extensionsPath, extensionId); + const latestVersionPath = getLatestExtensionVersionPath(extensionRootPath); + if (!latestVersionPath) { + continue; + } + + const manifestPath = path.join(latestVersionPath, 'manifest.json'); + const manifest = readJsonFile(manifestPath); + if (!manifest) { + continue; + } + + const name = resolveManifestName(manifest, latestVersionPath); + const normalizedName = normalizeLower(name); + if (!normalizedName || normalizedName !== targetName) { + continue; + } + + const permissions = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])].map((perm) => + normalizeLower(perm), + ); + const hasNativeMessagingPermission = permissions.includes('nativemessaging'); + if (config.requireNativeMessagingPermission && !hasNativeMessagingPermission) { + continue; + } + + if (expectedIdFromPublicKey && extensionId !== expectedIdFromPublicKey) { + continue; + } + + const author = normalizeLower(manifest.author); + const homepage = normalizeLower(manifest.homepage_url); + const updateUrl = normalizeLower(manifest.update_url); + const manifestKeyDerivedId = manifest.key + ? resolveExtensionIdFromPublicKeyBase64(manifest.key) + : null; + + const authorMatch = trustedAuthors.some((pattern) => author.includes(pattern)); + const homepageMatch = trustedHomepages.some((prefix) => homepage.startsWith(prefix)); + const updateUrlMatch = trustedUpdateUrls.some((prefix) => updateUrl.startsWith(prefix)); + const keyMatchesId = manifestKeyDerivedId ? manifestKeyDerivedId === extensionId : false; + + const trustSignals = [authorMatch, homepageMatch, updateUrlMatch, keyMatchesId].filter( + Boolean, + ).length; + + let accepted = false; + let confidence: 'high' | 'medium' | 'low' = 'low'; + + if (config.mode === 'name-only') { + accepted = true; + confidence = 'low'; + } else if (config.mode === 'strict') { + accepted = trustSignals >= 2; + confidence = accepted ? 'high' : 'low'; + } else { + const effectiveSignals = trustSignals + (hasNativeMessagingPermission ? 1 : 0); + accepted = effectiveSignals >= 1; + confidence = effectiveSignals >= 2 ? 'high' : effectiveSignals >= 1 ? 'medium' : 'low'; + } + + if (!accepted) { + continue; + } + + const profileName = path.basename(profilePath); + const reasons: string[] = []; + reasons.push(`name=${name}`); + if (hasNativeMessagingPermission) reasons.push('nativeMessaging'); + if (authorMatch) reasons.push('author'); + if (homepageMatch) reasons.push('homepage'); + if (updateUrlMatch) reasons.push('update_url'); + if (keyMatchesId) reasons.push('manifest_key'); + if (expectedIdFromPublicKey) reasons.push('expected_public_key'); + + matches.push({ + id: extensionId, + browser: root.browser, + profile: profileName, + name, + confidence, + reasons, + }); + } + } + } + + const deduped = new Map(); + for (const match of matches) { + const existing = deduped.get(match.id); + if (!existing) { + deduped.set(match.id, match); + continue; + } + + const confidenceRank = { high: 3, medium: 2, low: 1 }; + if (confidenceRank[match.confidence] > confidenceRank[existing.confidence]) { + deduped.set(match.id, match); + } + } + + const ids = Array.from(deduped.keys()).sort(); + return { + ids, + matched: Array.from(deduped.values()).sort((a, b) => a.id.localeCompare(b.id)), + source: `discovery:${config.mode}`, + }; +} + +/** + * Resolve allowed browser extension IDs used in native-host manifests. + * + * If `providedIds` is supplied, it validates and returns those IDs. + * Otherwise, it runs discovery (when enabled) and merges discovered IDs + * with known IDs based on configuration. + * + * @param providedIds Optional explicit extension IDs to trust and use. + * @returns Resolved extension IDs, match metadata, and source label. + */ +function resolveAllowedExtensionIds(providedIds?: string[]): ResolveExtensionIdsResult { + if (providedIds?.length) { + const uniqueIds = Array.from(new Set(providedIds.map((id) => id.trim()).filter(Boolean))); + const invalid = uniqueIds.filter((id) => !isValidExtensionId(id)); + if (invalid.length > 0) { + throw new Error( + `Invalid extension id(s): ${invalid.join(', ')}. IDs must match ${CHROME_EXTENSION_ID_REGEX.source}.`, + ); + } + return { + ids: uniqueIds.sort(), + matched: uniqueIds.map((id) => ({ + id, + browser: 'manual', + profile: 'manual', + name: HOST_APP_INFO.extensionDiscovery.extensionName, + confidence: 'high', + reasons: ['manual'], + })), + source: 'manual', + }; + } + + const discoveryEnabled = parseBooleanEnvWithBuildFallback( + process.env.CALY_OC_EXT_DISCOVERY_ENABLED, + BUILD_OC_DEFAULTS.extDiscoveryEnabled, + true, + ); + const includeKnownIds = parseBooleanEnvWithBuildFallback( + process.env.CALY_OC_EXT_INCLUDE_KNOWN_IDS, + BUILD_OC_DEFAULTS.extIncludeKnownIds, + true, + ); + const knownIds = HOST_APP_INFO.allowedExtensionIds.filter(isValidExtensionId); + + let discovered: ResolveExtensionIdsResult = { ids: [], matched: [], source: 'discovery:disabled' }; + if (discoveryEnabled) { + discovered = discoverExtensionIds(); + } + + const idSet = new Set(); + for (const id of discovered.ids) { + idSet.add(id); + } + if (includeKnownIds) { + for (const id of knownIds) { + idSet.add(id); + } + } + + return { + ids: Array.from(idSet).sort(), + matched: discovered.matched, + source: `${discovered.source}${includeKnownIds ? '+known' : ''}`, + }; +} + +/** + * Resolve whether native-host manifests should be written for all browser targets. + * + * Used during native-host setup to decide if manifests should be created only for + * detected browser roots or force-written for all known targets. + * + * @returns {boolean} True when all browser manifests should be written. + */ +function resolveWriteAllBrowserManifests(): boolean { + return parseBooleanEnv( + resolveEnvWithBuildFallback( + process.env.CALY_OC_WRITE_ALL_BROWSER_MANIFESTS, + BUILD_OC_DEFAULTS.writeAllBrowserManifests, + ), + false, + ); +} + +export { resolveAllowedExtensionIds, resolveWriteAllBrowserManifests }; +export type { ResolveExtensionIdsResult, ExtensionCandidateMatch }; diff --git a/packages/cli/src/commands/opencode/native-host/setup.ts b/packages/cli/src/commands/opencode/native-host/setup.ts new file mode 100644 index 00000000..579a1265 --- /dev/null +++ b/packages/cli/src/commands/opencode/native-host/setup.ts @@ -0,0 +1,311 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn, spawnSync } from 'node:child_process'; +import { log } from '@clack/prompts'; +import { HOST_APP_INFO } from '../../../utils/host-constants'; +import { getNativeHostTargets, type NativeHostBrowserTarget } from './targets'; +import { + resolveAllowedExtensionIds, + resolveWriteAllBrowserManifests, + type ResolveExtensionIdsResult, +} from './discovery'; + +interface SetupNativeHostResult { + manifestExePath: string; + extensionResolution: ResolveExtensionIdsResult; +} + +interface NativeHostManifest { + name: string; + description: string; + path: string; + type: 'stdio'; + allowed_origins: string[]; +} + +function createNativeHostWrapper(platform: NodeJS.Platform, homeDir: string, ocVersion?: string): string { + const isWin = platform === 'win32'; + const executablePath = process.execPath; + const ocVersionArg = ocVersion ? ` --oc-version ${ocVersion}` : ''; + + if (isWin) { + const wrapperDir = path.join(homeDir, '.calycode', 'bin'); + if (!fs.existsSync(wrapperDir)) { + fs.mkdirSync(wrapperDir, { recursive: true }); + } + + const wrapperPath = path.join(wrapperDir, 'calycode-host.bat'); + let wrapperContent = ''; + const isBundled = process.execPath.toLowerCase().endsWith('caly.exe') || (process as any).pkg; + + if (isBundled) { + wrapperContent = `@echo off\r\n`; + wrapperContent += `"${process.execPath}" opencode native-host${ocVersionArg} %*\r\n`; + } else { + wrapperContent = `@echo off\r\n`; + wrapperContent += `"${process.execPath}" "${process.argv[1]}" opencode native-host${ocVersionArg} %*\r\n`; + log.warn('Note: Development mode on Windows uses a batch file wrapper.'); + log.warn('If Native Messaging fails, try building the bundled exe instead.'); + } + + fs.writeFileSync(wrapperPath, wrapperContent); + log.info(`Created wrapper script: ${wrapperPath}`); + log.info(`Wrapper content: ${wrapperContent.trim()}`); + return wrapperPath; + } + + const wrapperDir = path.join(homeDir, '.calycode', 'bin'); + if (!fs.existsSync(wrapperDir)) { + fs.mkdirSync(wrapperDir, { recursive: true }); + } + + const wrapperPath = path.join(wrapperDir, 'calycode-host.sh'); + let wrapperContent: string; + const isBundled = process.execPath.toLowerCase().endsWith('caly') || (process as any).pkg; + if (isBundled || !process.argv[1]) { + wrapperContent = `#!/bin/sh\nexec "${executablePath}" opencode native-host${ocVersionArg} "$@"\n`; + } else { + wrapperContent = `#!/bin/sh\nexec "${executablePath}" "${process.argv[1]}" opencode native-host${ocVersionArg} "$@"\n`; + } + + fs.writeFileSync(wrapperPath, wrapperContent); + fs.chmodSync(wrapperPath, '755'); + return wrapperPath; +} + +function createNativeHostManifest(manifestExePath: string, allowedExtensionIds: string[]): NativeHostManifest { + return { + name: HOST_APP_INFO.reverseAppId, + description: HOST_APP_INFO.description, + path: manifestExePath, + type: 'stdio', + allowed_origins: allowedExtensionIds.map((id) => `chrome-extension://${id}/`), + }; +} + +function writeNativeHostManifests( + platform: NodeJS.Platform, + targets: NativeHostBrowserTarget[], + manifestContent: NativeHostManifest, +): NativeHostBrowserTarget[] { + const writeAllBrowserManifests = resolveWriteAllBrowserManifests(); + const writtenTargets: NativeHostBrowserTarget[] = []; + + for (const target of targets) { + if (platform !== 'win32' && !writeAllBrowserManifests) { + const browserRootDir = path.dirname(path.dirname(target.manifestPath)); + if (!fs.existsSync(browserRootDir)) { + continue; + } + } + + const manifestDir = path.dirname(target.manifestPath); + if (!fs.existsSync(manifestDir)) { + fs.mkdirSync(manifestDir, { recursive: true }); + } + + fs.writeFileSync(target.manifestPath, JSON.stringify(manifestContent, null, 2)); + writtenTargets.push(target); + log.success(`Native messaging host manifest created for ${target.browser}: ${target.manifestPath}`); + } + + return writtenTargets; +} + +async function registerWindowsNativeHosts(targets: NativeHostBrowserTarget[]): Promise { + const registryFailures: string[] = []; + + for (const target of targets) { + if (!target.registryKey) { + continue; + } + + const regArgs = ['add', target.registryKey, '/ve', '/t', 'REG_SZ', '/d', target.manifestPath, '/f']; + try { + await new Promise((resolve, reject) => { + const proc = spawn('reg', regArgs, { stdio: 'ignore' }); + proc.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`Exit code ${code}`)); + }); + proc.on('error', reject); + }); + + log.success(`Registry key added for ${target.browser}: ${target.registryKey}`); + } catch (error: any) { + const message = `${target.browser}: ${error?.message || 'unknown error'}`; + registryFailures.push(message); + log.warn(`Failed to add registry key for ${target.browser}. ${message}`); + } + } + + if (registryFailures.length === targets.length) { + throw new Error(`Failed to register native host for all Windows browsers: ${registryFailures.join('; ')}`); + } +} + +/** + * Set up native-host wrapper, manifests, and platform registration for browser integration. + * + * @param extensionIds Optional explicit extension IDs. When omitted, discovery/default + * IDs are resolved automatically. + * @param ocVersion Optional OpenCode version to persist in wrapper invocation. + * @returns Promise resolving to wrapper path and extension resolution details. + */ +async function setupNativeHostRegistration( + extensionIds?: string[], + ocVersion?: string, +): Promise { + const platform = os.platform(); + const homeDir = os.homedir(); + const nativeHostTargets = getNativeHostTargets(platform, homeDir); + const extensionResolution = resolveAllowedExtensionIds(extensionIds); + const allowedExtensionIds = extensionResolution.ids; + + if (allowedExtensionIds.length === 0) { + throw new Error( + 'No extension IDs were discovered. Install the extension first, or set CALY_OC_EXT_INCLUDE_KNOWN_IDS=true.', + ); + } + + log.info( + `Setting up native host for ${allowedExtensionIds.length} extension(s) [source=${extensionResolution.source}]...`, + ); + + const manifestExePath = createNativeHostWrapper(platform, homeDir, ocVersion); + const manifestContent = createNativeHostManifest(manifestExePath, allowedExtensionIds); + const writtenTargets = writeNativeHostManifests(platform, nativeHostTargets, manifestContent); + + if (platform === 'win32') { + await registerWindowsNativeHosts(nativeHostTargets); + } + + if (writtenTargets.length === 0) { + throw new Error( + 'No browser manifest targets were written. Install a supported Chromium browser or set CALY_OC_WRITE_ALL_BROWSER_MANIFESTS=true.', + ); + } + + log.success(`Executable path in manifest: ${manifestExePath}`); + + if (extensionResolution.matched.length > 0) { + for (const match of extensionResolution.matched) { + log.info( + `Extension match: ${match.id} [${match.browser}/${match.profile}] confidence=${match.confidence} (${match.reasons.join(', ')})`, + ); + } + } + + return { + manifestExePath, + extensionResolution, + }; +} + +/** + * Show current native-host status, including wrapper, manifest, registry, and origin drift checks. + * + * @returns Void. Writes a formatted status report to CLI output. + */ +function showNativeHostStatus(): void { + const platform = os.platform(); + const homeDir = os.homedir(); + const wrapperPath = path.join(homeDir, '.calycode', 'bin', platform === 'win32' ? 'calycode-host.bat' : 'calycode-host.sh'); + const nativeHostTargets = getNativeHostTargets(platform, homeDir); + const extensionResolution = resolveAllowedExtensionIds(); + const expectedOrigins = extensionResolution.ids.map((id) => `chrome-extension://${id}/`); + + const lines: string[] = []; + lines.push('Native Host Status:'); + lines.push(` - Platform: ${platform}`); + lines.push(` - Wrapper Path: ${wrapperPath}`); + lines.push(` - Wrapper Exists: ${fs.existsSync(wrapperPath) ? 'Yes' : 'No'}`); + lines.push(` - App ID: ${HOST_APP_INFO.reverseAppId}`); + lines.push(` - Extension ID Source: ${extensionResolution.source}`); + + for (const target of nativeHostTargets) { + const exists = fs.existsSync(target.manifestPath); + lines.push(` - ${target.browser} Manifest: ${target.manifestPath}`); + lines.push(` - ${target.browser} Manifest Exists: ${exists ? 'Yes' : 'No'}`); + } + + if (platform === 'win32') { + for (const target of nativeHostTargets) { + if (!target.registryKey) { + continue; + } + + const probe = spawnSync('reg', ['query', target.registryKey, '/ve'], { + stdio: 'ignore', + windowsHide: true, + }); + const registryConfigured = probe.status === 0; + + lines.push(` - ${target.browser} Registry Key: ${target.registryKey}`); + lines.push(` - ${target.browser} Registry Configured: ${registryConfigured ? 'Yes' : 'No'}`); + } + } + + const manifestDrifts: Array<{ browser: string; manifestPath: string; missingOrigins: string[] }> = []; + const manifestOriginsByBrowser: Array<{ browser: string; origins: string[] }> = []; + + for (const target of nativeHostTargets) { + if (!fs.existsSync(target.manifestPath)) { + continue; + } + + try { + const manifestRaw = fs.readFileSync(target.manifestPath, 'utf8'); + const manifest = JSON.parse(manifestRaw) as { allowed_origins?: string[] }; + const allowedOrigins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : []; + manifestOriginsByBrowser.push({ browser: target.browser, origins: allowedOrigins }); + + const missingOrigins = expectedOrigins.filter((origin) => !allowedOrigins.includes(origin)); + if (missingOrigins.length > 0) { + manifestDrifts.push({ + browser: target.browser, + manifestPath: target.manifestPath, + missingOrigins, + }); + } + } catch { + lines.push(` - Manifest Parse (${target.browser}): Failed`); + } + } + + lines.push( + ` - Expected Extension IDs: ${extensionResolution.ids.length ? extensionResolution.ids.join(', ') : '(none)'}`, + ); + lines.push( + ` - Expected Origins: ${expectedOrigins.join(', ')}`, + ); + if (manifestOriginsByBrowser.length === 0) { + lines.push(' - Manifest Allowed Origins: (none)'); + } else { + for (const entry of manifestOriginsByBrowser) { + lines.push( + ` - ${entry.browser} Manifest Allowed Origins: ${entry.origins.length ? entry.origins.join(', ') : '(none)'}`, + ); + } + } + + if (manifestDrifts.length > 0) { + for (const drift of manifestDrifts) { + lines.push( + ` - Missing Expected Origins (${drift.browser}): ${drift.missingOrigins.join(', ')}`, + ); + lines.push(` - Drift Manifest Path (${drift.browser}): ${drift.manifestPath}`); + } + lines.push(' - Recommendation: run `caly-xano oc init --force` to refresh native host setup.'); + log.warn(lines.join('\n')); + return; + } + + log.success(lines.join('\n')); +} + +export { setupNativeHostRegistration, showNativeHostStatus }; diff --git a/packages/cli/src/commands/opencode/native-host/targets.ts b/packages/cli/src/commands/opencode/native-host/targets.ts new file mode 100644 index 00000000..3cc6e993 --- /dev/null +++ b/packages/cli/src/commands/opencode/native-host/targets.ts @@ -0,0 +1,107 @@ +import path from 'node:path'; +import { HOST_APP_INFO } from '../../../utils/host-constants'; + +interface NativeHostBrowserTarget { + browser: string; + manifestPath: string; + registryKey?: string; +} + +function getNativeHostTargets(platform: NodeJS.Platform, homeDir: string): NativeHostBrowserTarget[] { + if (platform === 'darwin') { + return [ + { + browser: 'Chrome', + manifestPath: path.join( + homeDir, + `Library/Application Support/Google/Chrome/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + { + browser: 'Brave', + manifestPath: path.join( + homeDir, + `Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + { + browser: 'Edge', + manifestPath: path.join( + homeDir, + `Library/Application Support/Microsoft Edge/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + { + browser: 'Chromium', + manifestPath: path.join( + homeDir, + `Library/Application Support/Chromium/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + ]; + } + + if (platform === 'linux') { + return [ + { + browser: 'Chrome', + manifestPath: path.join( + homeDir, + `.config/google-chrome/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + { + browser: 'Brave', + manifestPath: path.join( + homeDir, + `.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + { + browser: 'Edge', + manifestPath: path.join( + homeDir, + `.config/microsoft-edge/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + { + browser: 'Chromium', + manifestPath: path.join( + homeDir, + `.config/chromium/NativeMessagingHosts/${HOST_APP_INFO.reverseAppId}.json`, + ), + }, + ]; + } + + if (platform === 'win32') { + const manifestPath = path.join(homeDir, '.calycode', `${HOST_APP_INFO.reverseAppId}.json`); + return [ + { + browser: 'Chrome', + manifestPath, + registryKey: `HKEY_CURRENT_USER\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_APP_INFO.reverseAppId}`, + }, + { + browser: 'Brave', + manifestPath, + registryKey: `HKEY_CURRENT_USER\\Software\\BraveSoftware\\Brave-Browser\\NativeMessagingHosts\\${HOST_APP_INFO.reverseAppId}`, + }, + { + browser: 'Edge', + manifestPath, + registryKey: `HKEY_CURRENT_USER\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_APP_INFO.reverseAppId}`, + }, + { + browser: 'Chromium', + manifestPath, + registryKey: `HKEY_CURRENT_USER\\Software\\Chromium\\NativeMessagingHosts\\${HOST_APP_INFO.reverseAppId}`, + }, + ]; + } + + throw new Error(`Unsupported platform: ${platform}`); +} + +export { getNativeHostTargets }; +export type { NativeHostBrowserTarget }; diff --git a/packages/cli/src/index-bundled.ts b/packages/cli/src/index-bundled.ts index 20917430..e1dd4ce2 100644 --- a/packages/cli/src/index-bundled.ts +++ b/packages/cli/src/index-bundled.ts @@ -2,7 +2,6 @@ import { spawnSync } from 'node:child_process'; import { isSea } from 'node:sea'; import { program } from './program'; import { setupOpencode, startNativeHost } from './commands/opencode/implementation'; -import { HOST_APP_INFO } from './utils/host-constants'; import { exitIfLegacyXanoInvocation } from './utils/legacy-command-guard'; /** @@ -82,7 +81,7 @@ function escapeAppleScript(str: string): string { async function runSetup() { console.log('@calycode Native Host Installer'); console.log('------------------------------'); - console.log(`Setting up native host for ${HOST_APP_INFO.allowedExtensionIds.length} extension(s)...`); + console.log('Setting up native host using deterministic extension discovery...'); console.log(`Executable: ${process.argv[0]}`); try { diff --git a/packages/cli/src/utils/host-constants.ts b/packages/cli/src/utils/host-constants.ts index 7e2abf19..5626cc1a 100644 --- a/packages/cli/src/utils/host-constants.ts +++ b/packages/cli/src/utils/host-constants.ts @@ -1,15 +1,44 @@ -export const HOST_APP_INFO = { +export type ExtensionDiscoveryMode = 'strict' | 'balanced' | 'name-only'; + +interface HostAppInfo { + name: string; + description: string; + reverseAppId: string; + appId: string; + version: string; + url: string; + extensionId: string; + allowedExtensionIds: string[]; + extensionDiscovery: { + extensionName: string; + trustedAuthorPatterns: string[]; + trustedHomepagePrefixes: string[]; + requireNativeMessagingPermission: boolean; + mode: ExtensionDiscoveryMode; + }; +} + +export const HOST_APP_INFO: HostAppInfo = { name: 'CalyCode Xano CLI', description: 'CalyCode Xano CLI Native Host', reverseAppId: 'com.calycode.cli', appId: 'cli.calycode.com', version: '1.0.0', url: 'https://calycode.com/xano', - // Production extension ID (Chrome Web Store) + // Known extension IDs (fast-path allowlist) extensionId: 'hadkkdmpcmllbkfopioopcmeapjchpbm', - // All extension IDs that should be allowed to connect (production + development) allowedExtensionIds: [ 'hadkkdmpcmllbkfopioopcmeapjchpbm', // Production (Chrome Web Store) 'lnhipaeaeiegnlokhokfokndgadkohfe', // Development (unpacked) ], + extensionDiscovery: { + extensionName: '@calycode | Extension', + trustedAuthorPatterns: ['calycode', '@calycode', 'Mihály @calycode'], + trustedHomepagePrefixes: [ + 'https://extension.calycode.com', + 'https://www.extension.calycode.com', + ], + requireNativeMessagingPermission: true, + mode: 'balanced', + }, };