diff --git a/README.md b/README.md index adf1715..51dc2c9 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`: "branch": "main", }, "includeSecrets": false, + "includeMcpSecrets": false, "includeSessions": false, "includePromptStash": false, "extraSecretPaths": [], @@ -100,6 +101,9 @@ Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`: - `~/.local/share/opencode/mcp-auth.json` - Any extra paths in `extraSecretPaths` (allowlist) +MCP API keys stored inside `opencode.json(c)` are **not** committed by default. To allow them +in a private repo, set `"includeMcpSecrets": true` (requires `includeSecrets`). + ### Sessions (private repos only) Sync your OpenCode sessions (conversation history from `/sessions`) across machines by setting `"includeSessions": true`. This requires `includeSecrets` to also be enabled since sessions may contain sensitive data. @@ -146,6 +150,22 @@ Create a local-only overrides file at: Overrides are merged into the runtime config and re-applied to `opencode.json(c)` after pull. +### MCP secret scrubbing + +If your `opencode.json(c)` contains MCP secrets (for example `mcp.*.headers` or `mcp.*.oauth.clientSecret`), opencode-synced will automatically: + +1. Move the secret values into `opencode-synced.overrides.jsonc` (local-only). +2. Replace the values in the synced config with `{env:...}` placeholders. + +This keeps secrets out of the repo while preserving local behavior. On other machines, set the matching environment variables (or add local overrides). +If you want MCP secrets committed (private repos only), set `"includeMcpSecrets": true` alongside `"includeSecrets": true`. + +Env var naming rules: + +- If the header name already looks like an env var (e.g. `CONTEXT7_API_KEY`), it is used directly. +- Otherwise: `OPENCODE_MCP__
` (uppercase, non-alphanumerics become `_`). +- OAuth client secrets use `OPENCODE_MCP__OAUTH_CLIENT_SECRET`. + ## Usage | Command | Description | diff --git a/src/command/sync-enable-secrets.md b/src/command/sync-enable-secrets.md index c36b67d..2a8b228 100644 --- a/src/command/sync-enable-secrets.md +++ b/src/command/sync-enable-secrets.md @@ -4,3 +4,4 @@ description: Enable secrets sync (private repo required) Use the opencode_sync tool with command "enable-secrets". If the user supplies extra secret paths, pass them via extraSecretPaths. +If they want MCP secrets committed in a private repo, pass includeMcpSecrets: true. diff --git a/src/command/sync-init.md b/src/command/sync-init.md index ad83dfe..c6ba00e 100644 --- a/src/command/sync-init.md +++ b/src/command/sync-init.md @@ -9,3 +9,4 @@ If the user wants a custom repo name, pass name="custom-name". If the user wants an org-owned repo, pass owner="org-name". If the user wants a public repo, pass private=false. Include includeSecrets if the user explicitly opts in. +Include includeMcpSecrets only if they want MCP secrets committed to a private repo. diff --git a/src/index.ts b/src/index.ts index de510f5..cd26353 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,6 +125,10 @@ export const OpencodeConfigSync: Plugin = async (ctx) => { url: tool.schema.string().optional().describe('Repo URL'), branch: tool.schema.string().optional().describe('Repo branch'), includeSecrets: tool.schema.boolean().optional().describe('Enable secrets sync'), + includeMcpSecrets: tool.schema + .boolean() + .optional() + .describe('Allow MCP secrets to be committed (requires includeSecrets)'), includeSessions: tool.schema .boolean() .optional() @@ -151,6 +155,7 @@ export const OpencodeConfigSync: Plugin = async (ctx) => { url: args.url, branch: args.branch, includeSecrets: args.includeSecrets, + includeMcpSecrets: args.includeMcpSecrets, includeSessions: args.includeSessions, includePromptStash: args.includePromptStash, create: args.create, @@ -171,7 +176,10 @@ export const OpencodeConfigSync: Plugin = async (ctx) => { return await service.push(); } if (args.command === 'enable-secrets') { - return await service.enableSecrets(args.extraSecretPaths); + return await service.enableSecrets({ + extraSecretPaths: args.extraSecretPaths, + includeMcpSecrets: args.includeMcpSecrets, + }); } if (args.command === 'resolve') { return await service.resolve(); diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 66f4889..6c5cd28 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -2,6 +2,12 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { deepMerge, parseJsonc, pathExists, stripOverrides, writeJsonFile } from './config.js'; +import { + extractMcpSecrets, + hasOverrides, + mergeOverrides, + stripOverrideKeys, +} from './mcp-secrets.js'; import type { SyncItem, SyncPlan } from './paths.js'; import { normalizePath } from './paths.js'; @@ -32,11 +38,44 @@ export async function syncRepoToLocal( export async function syncLocalToRepo( plan: SyncPlan, - overrides: Record | null + overrides: Record | null, + options: { overridesPath?: string; allowMcpSecrets?: boolean } = {} ): Promise { + const configItems = plan.items.filter((item) => item.isConfigFile); + const sanitizedConfigs = new Map>(); + let secretOverrides: Record = {}; + const allowMcpSecrets = Boolean(options.allowMcpSecrets); + + for (const item of configItems) { + if (!(await pathExists(item.localPath))) continue; + + const content = await fs.readFile(item.localPath, 'utf8'); + const parsed = parseJsonc>(content); + const { sanitizedConfig, secretOverrides: extracted } = extractMcpSecrets(parsed); + if (!allowMcpSecrets) { + sanitizedConfigs.set(item.localPath, sanitizedConfig); + } + if (hasOverrides(extracted)) { + secretOverrides = mergeOverrides(secretOverrides, extracted); + } + } + + let overridesForStrip = overrides; + if (hasOverrides(secretOverrides)) { + if (!allowMcpSecrets) { + const baseOverrides = overrides ?? {}; + const mergedOverrides = mergeOverrides(baseOverrides, secretOverrides); + if (options.overridesPath && !isDeepEqual(baseOverrides, mergedOverrides)) { + await writeJsonFile(options.overridesPath, mergedOverrides, { jsonc: true }); + } + } + overridesForStrip = overrides ? stripOverrideKeys(overrides, secretOverrides) : overrides; + } + for (const item of plan.items) { - if (item.isConfigFile && overrides && Object.keys(overrides).length > 0) { - await copyConfigForRepo(item, overrides, plan.repoRoot); + if (item.isConfigFile) { + const sanitized = sanitizedConfigs.get(item.localPath); + await copyConfigForRepo(item, overridesForStrip, plan.repoRoot, sanitized); continue; } @@ -70,24 +109,27 @@ async function copyItem( async function copyConfigForRepo( item: SyncItem, - overrides: Record, - repoRoot: string + overrides: Record | null, + repoRoot: string, + configOverride?: Record ): Promise { if (!(await pathExists(item.localPath))) { await removePath(item.repoPath); return; } - const localContent = await fs.readFile(item.localPath, 'utf8'); - const localConfig = parseJsonc>(localContent); + const localConfig = + configOverride ?? + parseJsonc>(await fs.readFile(item.localPath, 'utf8')); const baseConfig = await readRepoConfig(item, repoRoot); + const effectiveOverrides = overrides ?? {}; if (baseConfig) { - const expectedLocal = deepMerge(baseConfig, overrides) as Record; + const expectedLocal = deepMerge(baseConfig, effectiveOverrides) as Record; if (isDeepEqual(localConfig, expectedLocal)) { return; } } - const stripped = stripOverrides(localConfig, overrides, baseConfig); + const stripped = stripOverrides(localConfig, effectiveOverrides, baseConfig); const stat = await fs.stat(item.localPath); await fs.mkdir(path.dirname(item.repoPath), { recursive: true }); await writeJsonFile(item.repoPath, stripped, { diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index bc95b44..be3679e 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { deepMerge, stripOverrides } from './config.js'; +import { canCommitMcpSecrets, deepMerge, normalizeSyncConfig, stripOverrides } from './config.js'; describe('deepMerge', () => { it('merges nested objects and replaces arrays', () => { @@ -44,3 +44,29 @@ describe('stripOverrides', () => { expect(stripped).toEqual({ theme: 'opencode', other: true }); }); }); + +describe('normalizeSyncConfig', () => { + it('disables MCP secrets when secrets are disabled', () => { + const normalized = normalizeSyncConfig({ + includeSecrets: false, + includeMcpSecrets: true, + }); + expect(normalized.includeMcpSecrets).toBe(false); + }); + + it('allows MCP secrets when secrets are enabled', () => { + const normalized = normalizeSyncConfig({ + includeSecrets: true, + includeMcpSecrets: true, + }); + expect(normalized.includeMcpSecrets).toBe(true); + }); +}); + +describe('canCommitMcpSecrets', () => { + it('requires includeSecrets and includeMcpSecrets', () => { + expect(canCommitMcpSecrets({ includeSecrets: false, includeMcpSecrets: true })).toBe(false); + expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: false })).toBe(false); + expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: true })).toBe(true); + }); +}); diff --git a/src/sync/config.ts b/src/sync/config.ts index 126f33c..4ab9b0f 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -14,6 +14,7 @@ export interface SyncConfig { repo?: SyncRepoConfig; localRepoPath?: string; includeSecrets?: boolean; + includeMcpSecrets?: boolean; includeSessions?: boolean; includePromptStash?: boolean; extraSecretPaths?: string[]; @@ -35,8 +36,10 @@ export async function pathExists(filePath: string): Promise { } export function normalizeSyncConfig(config: SyncConfig): SyncConfig { + const includeSecrets = Boolean(config.includeSecrets); return { - includeSecrets: Boolean(config.includeSecrets), + includeSecrets, + includeMcpSecrets: includeSecrets ? Boolean(config.includeMcpSecrets) : false, includeSessions: Boolean(config.includeSessions), includePromptStash: Boolean(config.includePromptStash), extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [], @@ -45,6 +48,10 @@ export function normalizeSyncConfig(config: SyncConfig): SyncConfig { }; } +export function canCommitMcpSecrets(config: SyncConfig): boolean { + return Boolean(config.includeSecrets) && Boolean(config.includeMcpSecrets); +} + export async function loadSyncConfig(locations: SyncLocations): Promise { if (!(await pathExists(locations.syncConfigPath))) { return null; diff --git a/src/sync/mcp-secrets.test.ts b/src/sync/mcp-secrets.test.ts new file mode 100644 index 0000000..f4d8bfa --- /dev/null +++ b/src/sync/mcp-secrets.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { extractMcpSecrets } from './mcp-secrets.js'; + +describe('extractMcpSecrets', () => { + it('moves MCP header secrets into overrides and adds env placeholders', () => { + const input = { + mcp: { + context7: { + type: 'remote', + url: 'https://mcp.context7.com/mcp', + headers: { + CONTEXT7_API_KEY: 'ctx7-secret', + }, + }, + }, + }; + + const { sanitizedConfig, secretOverrides } = extractMcpSecrets(input); + + expect(secretOverrides).toEqual({ + mcp: { + context7: { + headers: { + CONTEXT7_API_KEY: 'ctx7-secret', + }, + }, + }, + }); + + expect(sanitizedConfig).toEqual({ + mcp: { + context7: { + type: 'remote', + url: 'https://mcp.context7.com/mcp', + headers: { + CONTEXT7_API_KEY: '{env:CONTEXT7_API_KEY}', + }, + }, + }, + }); + }); + + it('leaves env placeholders intact and skips overrides', () => { + const input = { + mcp: { + context7: { + headers: { + CONTEXT7_API_KEY: '{env:CONTEXT7_API_KEY}', + }, + }, + }, + }; + + const { sanitizedConfig, secretOverrides } = extractMcpSecrets(input); + + expect(secretOverrides).toEqual({}); + expect(sanitizedConfig).toEqual(input); + }); + + it('handles bearer authorization and oauth client secrets', () => { + const input = { + mcp: { + github: { + headers: { + Authorization: 'Bearer ghp_example', + }, + oauth: { + clientId: 'public', + clientSecret: 'super-secret', + }, + }, + }, + }; + + const { sanitizedConfig, secretOverrides } = extractMcpSecrets(input); + + expect(secretOverrides).toEqual({ + mcp: { + github: { + headers: { + Authorization: 'Bearer ghp_example', + }, + oauth: { + clientSecret: 'super-secret', + }, + }, + }, + }); + + expect(sanitizedConfig).toEqual({ + mcp: { + github: { + headers: { + Authorization: 'Bearer {env:OPENCODE_MCP_GITHUB_AUTHORIZATION}', + }, + oauth: { + clientId: 'public', + clientSecret: '{env:OPENCODE_MCP_GITHUB_OAUTH_CLIENT_SECRET}', + }, + }, + }, + }); + }); + + it('preserves other authorization schemes', () => { + const input = { + mcp: { + gitlab: { + headers: { + Authorization: 'Token glpat-secret', + }, + }, + }, + }; + + const { sanitizedConfig } = extractMcpSecrets(input); + + expect(sanitizedConfig).toEqual({ + mcp: { + gitlab: { + headers: { + Authorization: 'Token {env:OPENCODE_MCP_GITLAB_AUTHORIZATION}', + }, + }, + }, + }); + }); +}); diff --git a/src/sync/mcp-secrets.ts b/src/sync/mcp-secrets.ts new file mode 100644 index 0000000..0c15b25 --- /dev/null +++ b/src/sync/mcp-secrets.ts @@ -0,0 +1,162 @@ +import { deepMerge } from './config.js'; + +export interface McpSecretExtraction { + sanitizedConfig: Record; + secretOverrides: Record; +} + +const ENV_PLACEHOLDER_PATTERN = /\{env:[^}]+\}/i; + +export function extractMcpSecrets(config: Record): McpSecretExtraction { + const sanitizedConfig = cloneConfig(config); + const secretOverrides: Record = {}; + + const mcp = getPlainObject(sanitizedConfig.mcp); + if (!mcp) { + return { sanitizedConfig, secretOverrides }; + } + + for (const [serverName, serverConfigValue] of Object.entries(mcp)) { + const serverConfig = getPlainObject(serverConfigValue); + if (!serverConfig) continue; + + const headers = getPlainObject(serverConfig.headers); + if (headers) { + for (const [headerName, headerValue] of Object.entries(headers)) { + if (!isSecretString(headerValue)) continue; + const envVar = buildHeaderEnvVar(serverName, headerName); + const placeholder = buildHeaderPlaceholder(String(headerValue), envVar, headerName); + headers[headerName] = placeholder; + setNestedValue(secretOverrides, ['mcp', serverName, 'headers', headerName], headerValue); + } + } + + const oauth = getPlainObject(serverConfig.oauth); + if (oauth) { + const clientSecret = oauth.clientSecret; + if (isSecretString(clientSecret)) { + const envVar = buildEnvVar(serverName, 'OAUTH_CLIENT_SECRET'); + oauth.clientSecret = `{env:${envVar}}`; + setNestedValue(secretOverrides, ['mcp', serverName, 'oauth', 'clientSecret'], clientSecret); + } + } + } + + return { sanitizedConfig, secretOverrides }; +} + +function isSecretString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !ENV_PLACEHOLDER_PATTERN.test(value); +} + +function buildHeaderEnvVar(serverName: string, headerName: string): string { + if (/^[A-Z0-9_]+$/.test(headerName)) { + return headerName; + } + return buildEnvVar(serverName, headerName); +} + +function buildEnvVar(serverName: string, key: string): string { + const serverToken = toEnvToken(serverName, 'SERVER'); + const keyToken = toEnvToken(key, 'VALUE'); + return `OPENCODE_MCP_${serverToken}_${keyToken}`; +} + +function toEnvToken(input: string, fallback: string): string { + const cleaned = String(input) + .trim() + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + if (!cleaned) return fallback; + return cleaned.toUpperCase(); +} + +function buildHeaderPlaceholder(value: string, envVar: string, headerName?: string): string { + if (!isAuthorizationHeader(headerName)) { + return `{env:${envVar}}`; + } + + const schemeMatch = value.match(/^([A-Za-z][A-Za-z0-9+.-]*)\s+/); + if (schemeMatch) { + return `${schemeMatch[0]}{env:${envVar}}`; + } + return `{env:${envVar}}`; +} + +function isAuthorizationHeader(headerName?: string): boolean { + if (!headerName) return false; + const normalized = headerName.toLowerCase(); + return normalized === 'authorization' || normalized === 'proxy-authorization'; +} + +function setNestedValue(target: Record, path: string[], value: unknown): void { + let current = target; + for (let i = 0; i < path.length - 1; i += 1) { + const key = path[i]; + const next = current[key]; + if (!isPlainObject(next)) { + current[key] = {}; + } + current = current[key] as Record; + } + current[path[path.length - 1]] = value; +} + +function getPlainObject(value: unknown): Record | null { + return isPlainObject(value) ? (value as Record) : null; +} + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + +function cloneConfig(config: Record): Record { + if (typeof structuredClone === 'function') { + return structuredClone(config); + } + return JSON.parse(JSON.stringify(config)) as Record; +} + +export function mergeOverrides( + base: Record, + extra: Record +): Record { + return deepMerge(base, extra) as Record; +} + +export function stripOverrideKeys( + base: Record, + toRemove: Record +): Record { + if (!isPlainObject(base) || !isPlainObject(toRemove)) { + return base; + } + + const result: Record = { ...base }; + + for (const [key, removeValue] of Object.entries(toRemove)) { + if (!Object.hasOwn(result, key)) continue; + const currentValue = result[key]; + if (isPlainObject(removeValue) && isPlainObject(currentValue)) { + const stripped = stripOverrideKeys( + currentValue as Record, + removeValue as Record + ); + if (Object.keys(stripped).length === 0) { + delete result[key]; + } else { + result[key] = stripped; + } + continue; + } + + delete result[key]; + } + + return result; +} + +export function hasOverrides(value: Record): boolean { + return Object.keys(value).length > 0; +} diff --git a/src/sync/service.ts b/src/sync/service.ts index 7a1db93..2a97593 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -2,6 +2,7 @@ import type { PluginInput } from '@opencode-ai/plugin'; import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; import { generateCommitMessage } from './commit.js'; import { + canCommitMcpSecrets, loadOverrides, loadState, loadSyncConfig, @@ -45,6 +46,7 @@ interface InitOptions { url?: string; branch?: string; includeSecrets?: boolean; + includeMcpSecrets?: boolean; includeSessions?: boolean; includePromptStash?: boolean; create?: boolean; @@ -64,7 +66,10 @@ export interface SyncService { link: (_options: LinkOptions) => Promise; pull: () => Promise; push: () => Promise; - enableSecrets: (_extraSecretPaths?: string[]) => Promise; + enableSecrets: (_options?: { + extraSecretPaths?: string[]; + includeMcpSecrets?: boolean; + }) => Promise; resolve: () => Promise; } @@ -76,7 +81,11 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { startupSync: async () => { const config = await loadSyncConfig(locations); if (!config) { - await showToast(ctx.client, 'Configure opencode-synced with /sync-init.', 'info'); + await showToast( + ctx.client, + 'Configure opencode-synced with /sync-init or link to an existing repo with /sync-link', + 'info' + ); return; } try { @@ -112,6 +121,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const repoIdentifier = resolveRepoIdentifier(config); const includeSecrets = config.includeSecrets ? 'enabled' : 'disabled'; + const includeMcpSecrets = config.includeMcpSecrets ? 'enabled' : 'disabled'; const includeSessions = config.includeSessions ? 'enabled' : 'disabled'; const includePromptStash = config.includePromptStash ? 'enabled' : 'disabled'; const lastPull = state.lastPull ?? 'never'; @@ -131,6 +141,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { `Repo: ${repoIdentifier}`, `Branch: ${branch}`, `Secrets: ${includeSecrets}`, + `MCP secrets: ${includeMcpSecrets}`, `Sessions: ${includeSessions}`, `Prompt stash: ${includePromptStash}`, `Last pull: ${lastPull}`, @@ -161,7 +172,10 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { if (created) { const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); - await syncLocalToRepo(plan, overrides); + await syncLocalToRepo(plan, overrides, { + overridesPath: locations.overridesPath, + allowMcpSecrets: canCommitMcpSecrets(config), + }); const dirty = await hasLocalChanges(ctx.$, repoRoot); if (dirty) { @@ -204,6 +218,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const config = normalizeSyncConfig({ repo: { owner: found.owner, name: found.name }, includeSecrets: false, + includeMcpSecrets: false, includeSessions: false, includePromptStash: false, extraSecretPaths: [], @@ -290,7 +305,10 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); - await syncLocalToRepo(plan, overrides); + await syncLocalToRepo(plan, overrides, { + overridesPath: locations.overridesPath, + allowMcpSecrets: canCommitMcpSecrets(config), + }); const dirty = await hasLocalChanges(ctx.$, repoRoot); if (!dirty) { @@ -307,11 +325,17 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { return `Pushed changes: ${message}`; }, - enableSecrets: async (extraSecretPaths?: string[]) => { + enableSecrets: async (options?: { + extraSecretPaths?: string[]; + includeMcpSecrets?: boolean; + }) => { const config = await getConfigOrThrow(locations); config.includeSecrets = true; - if (extraSecretPaths) { - config.extraSecretPaths = extraSecretPaths; + if (options?.extraSecretPaths) { + config.extraSecretPaths = options.extraSecretPaths; + } + if (options?.includeMcpSecrets !== undefined) { + config.includeMcpSecrets = options.includeMcpSecrets; } await ensureRepoPrivate(ctx.$, config); @@ -398,7 +422,10 @@ async function runStartup( const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); - await syncLocalToRepo(plan, overrides); + await syncLocalToRepo(plan, overrides, { + overridesPath: locations.overridesPath, + allowMcpSecrets: canCommitMcpSecrets(config), + }); const changes = await hasLocalChanges(ctx.$, repoRoot); if (!changes) { log.debug('No local changes to push'); @@ -454,6 +481,7 @@ async function buildConfigFromInit($: Shell, options: InitOptions) { return normalizeSyncConfig({ repo, includeSecrets: options.includeSecrets ?? false, + includeMcpSecrets: options.includeMcpSecrets ?? false, includeSessions: options.includeSessions ?? false, includePromptStash: options.includePromptStash ?? false, extraSecretPaths: options.extraSecretPaths ?? [],