import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { createHash } from 'crypto'; import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; export interface OpenCodeMcpServerConfig { type: 'local' | 'remote'; command?: string; args?: string[]; url?: string; enabled: boolean; environment?: Record; timeout?: number; } export type OpenCodeBehaviorSourceKind = | 'global_config' | 'project_config' | 'global_plugin_dir' | 'project_plugin_dir' | 'project_opencode_dir'; export interface OpenCodeBehaviorSource { kind: OpenCodeBehaviorSourceKind; pathHash: string; exists: boolean; fingerprint: string | null; fileCount: number; } export interface OpenCodeManagedOverlay { launchMode: 'project_root_with_inline_overlay'; projectPath: string; env: { OPENCODE_CONFIG_CONTENT: string; OPENCODE_DISABLE_AUTOUPDATE?: '1'; }; appMcpServerName: string; appMcpConfig: OpenCodeMcpServerConfig; preservedSources: OpenCodeBehaviorSource[]; diagnostics: string[]; } export interface OpenCodeManagedOverlayBuilderInput { projectPath: string; preferredMcpName: string; appMcpCommand: string; appMcpArgs: string[]; appMcpEnv: Record; mcpTimeoutMs?: number; env?: NodeJS.ProcessEnv; } export interface OpenCodeBehaviorSourceScannerOptions { homePath?: string; maxDirectoryFiles?: number; } const FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS = [ 'plugin', 'plugins', 'agent', 'command', 'instructions', 'formatter', 'lsp', 'theme', 'keybinds', 'model', 'mode', 'provider', 'tools', 'skills', ] as const; export class OpenCodeManagedOverlayBuilder { constructor( private readonly behaviorSourceScanner = new OpenCodeBehaviorSourceScanner(), private readonly clock: () => Date = () => new Date() ) {} async build(input: OpenCodeManagedOverlayBuilderInput): Promise { const preservedSources = await this.behaviorSourceScanner.scan(input.projectPath); const existingMcpNames = await this.behaviorSourceScanner.readDeclaredMcpNames( input.projectPath ); const appMcpServerName = pickAppOwnedMcpServerName(input.preferredMcpName, existingMcpNames); const overlayConfig = buildManagedOverlayConfig({ serverName: appMcpServerName, command: input.appMcpCommand, args: input.appMcpArgs, environment: input.appMcpEnv, timeout: input.mcpTimeoutMs ?? 10_000, }); assertManagedOverlayDoesNotShadowUserConfig(overlayConfig); return { launchMode: 'project_root_with_inline_overlay', projectPath: input.projectPath, env: applyOpenCodeAutoUpdatePolicy( { OPENCODE_CONFIG_CONTENT: JSON.stringify(overlayConfig), }, input.env ?? process.env ), appMcpServerName, appMcpConfig: overlayConfig.mcp[appMcpServerName], preservedSources, diagnostics: buildOverlayDiagnostics({ preferredMcpName: input.preferredMcpName, appMcpServerName, existingMcpNames, preservedSources, checkedAt: this.clock().toISOString(), }), }; } } export class OpenCodeBehaviorSourceScanner { private readonly homePath: string; private readonly maxDirectoryFiles: number; constructor(options: OpenCodeBehaviorSourceScannerOptions = {}) { this.homePath = options.homePath ?? os.homedir(); this.maxDirectoryFiles = options.maxDirectoryFiles ?? 200; } async scan(projectPath: string): Promise { const sourceSpecs: { kind: OpenCodeBehaviorSourceKind; targetPath: string }[] = [ { kind: 'global_config', targetPath: path.join(this.homePath, '.config/opencode/opencode.json'), }, { kind: 'global_config', targetPath: path.join(this.homePath, '.config/opencode/opencode.jsonc'), }, { kind: 'project_config', targetPath: path.join(projectPath, 'opencode.json') }, { kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') }, { kind: 'global_plugin_dir', targetPath: path.join(this.homePath, '.config/opencode/plugins'), }, { kind: 'project_plugin_dir', targetPath: path.join(projectPath, '.opencode/plugins') }, { kind: 'project_opencode_dir', targetPath: path.join(projectPath, '.opencode') }, ]; return Promise.all(sourceSpecs.map((source) => this.fingerprintSource(source))); } async readDeclaredMcpNames(projectPath: string): Promise> { const configPaths = [ path.join(this.homePath, '.config/opencode/opencode.json'), path.join(this.homePath, '.config/opencode/opencode.jsonc'), path.join(projectPath, 'opencode.json'), path.join(projectPath, 'opencode.jsonc'), path.join(projectPath, '.opencode/opencode.json'), path.join(projectPath, '.opencode/opencode.jsonc'), ]; const names = new Set(); for (const configPath of configPaths) { const config = await this.readConfig(configPath); const mcp = asRecord(config?.mcp); for (const name of Object.keys(mcp ?? {})) { names.add(name); } } return names; } private async fingerprintSource(input: { kind: OpenCodeBehaviorSourceKind; targetPath: string; }): Promise { const pathHash = hashText(input.targetPath); let stat; try { stat = await fs.stat(input.targetPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { kind: input.kind, pathHash, exists: false, fingerprint: null, fileCount: 0, }; } throw error; } if (stat.isFile()) { const content = await fs.readFile(input.targetPath); return { kind: input.kind, pathHash, exists: true, fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:${hashBuffer(content)}`), fileCount: 1, }; } if (stat.isDirectory()) { const entries = await this.listDirectoryFiles(input.targetPath); return { kind: input.kind, pathHash, exists: true, fingerprint: hashJson( entries.map((entry) => ({ relativePath: entry.relativePath, size: entry.size, mtimeMs: entry.mtimeMs, contentHash: entry.contentHash, })) ), fileCount: entries.length, }; } return { kind: input.kind, pathHash, exists: true, fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:unsupported`), fileCount: 0, }; } private async listDirectoryFiles(rootPath: string): Promise< { relativePath: string; size: number; mtimeMs: number; contentHash: string; }[] > { const results: { relativePath: string; size: number; mtimeMs: number; contentHash: string; }[] = []; const visit = async (directoryPath: string): Promise => { if (results.length >= this.maxDirectoryFiles) { return; } const entries = await fs.readdir(directoryPath, { withFileTypes: true }); for (const entry of entries) { if (results.length >= this.maxDirectoryFiles) { return; } const absolutePath = path.join(directoryPath, entry.name); if (entry.isDirectory()) { await visit(absolutePath); continue; } if (!entry.isFile()) { continue; } const stat = await fs.stat(absolutePath); const content = await fs.readFile(absolutePath); results.push({ relativePath: path.relative(rootPath, absolutePath), size: stat.size, mtimeMs: stat.mtimeMs, contentHash: hashBuffer(content), }); } }; await visit(rootPath); return results.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); } private async readConfig(configPath: string): Promise | null> { let text: string; try { text = await fs.readFile(configPath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null; } throw error; } try { const parsed = JSON.parse(stripJsonComments(text)) as unknown; return asRecord(parsed); } catch { return null; } } } export function buildManagedOverlayConfig(input: { serverName: string; command: string; args: string[]; environment: Record; timeout: number; }): { mcp: Record } { return { mcp: { [input.serverName]: { type: 'local', command: input.command, args: input.args, enabled: true, environment: input.environment, timeout: input.timeout, }, }, }; } export function assertManagedOverlayDoesNotShadowUserConfig(config: Record): void { const usedForbiddenKeys = FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS.filter((key) => key in config); if (usedForbiddenKeys.length > 0) { throw new Error( `Managed OpenCode overlay must not set user behavior keys: ${usedForbiddenKeys.join(', ')}` ); } } export function pickAppOwnedMcpServerName(preferred: string, existingNames: Set): string { if (!existingNames.has(preferred)) { return preferred; } let index = 1; while (existingNames.has(`${preferred}-runtime-${index}`)) { index += 1; } return `${preferred}-runtime-${index}`; } function buildOverlayDiagnostics(input: { preferredMcpName: string; appMcpServerName: string; existingMcpNames: Set; preservedSources: OpenCodeBehaviorSource[]; checkedAt: string; }): string[] { const diagnostics = [ `OpenCode managed overlay checked at ${input.checkedAt}`, `OpenCode preserved behavior sources: ${input.preservedSources.filter((source) => source.exists).length}`, ]; if (input.appMcpServerName !== input.preferredMcpName) { diagnostics.push( `User OpenCode config already declares MCP server "${input.preferredMcpName}"; managed runtime will use "${input.appMcpServerName}"` ); } if (input.existingMcpNames.size > 0) { diagnostics.push( `OpenCode existing MCP server names observed: ${[...input.existingMcpNames].sort().join(', ')}` ); } return diagnostics; } function stripJsonComments(text: string): string { return text .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/.*$/gm, (_match, prefix: string) => prefix); } function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null; } function hashJson(value: unknown): string { return hashText(stableJsonStringify(value)); } function hashText(value: string): string { return createHash('sha256').update(value).digest('hex'); } function hashBuffer(value: Buffer): string { return createHash('sha256').update(value).digest('hex'); } function stableJsonStringify(value: unknown): string { if (value === null || typeof value !== 'object') { return JSON.stringify(value); } if (Array.isArray(value)) { return `[${value.map(stableJsonStringify).join(',')}]`; } return `{${Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`) .join(',')}}`; }