Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
"branch": "main",
},
"includeSecrets": false,
"includeMcpSecrets": false,
"includeSessions": false,
"includePromptStash": false,
"extraSecretPaths": [],
Expand All @@ -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.
Expand Down Expand Up @@ -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_<SERVER>_<HEADER>` (uppercase, non-alphanumerics become `_`).
- OAuth client secrets use `OPENCODE_MCP_<SERVER>_OAUTH_CLIENT_SECRET`.

## Usage

| Command | Description |
Expand Down
1 change: 1 addition & 0 deletions src/command/sync-enable-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/command/sync-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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();
Expand Down
60 changes: 51 additions & 9 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -32,11 +38,44 @@ export async function syncRepoToLocal(

export async function syncLocalToRepo(
plan: SyncPlan,
overrides: Record<string, unknown> | null
overrides: Record<string, unknown> | null,
options: { overridesPath?: string; allowMcpSecrets?: boolean } = {}
): Promise<void> {
const configItems = plan.items.filter((item) => item.isConfigFile);
const sanitizedConfigs = new Map<string, Record<string, unknown>>();
let secretOverrides: Record<string, unknown> = {};
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<Record<string, unknown>>(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;
}

Expand Down Expand Up @@ -70,24 +109,27 @@ async function copyItem(

async function copyConfigForRepo(
item: SyncItem,
overrides: Record<string, unknown>,
repoRoot: string
overrides: Record<string, unknown> | null,
repoRoot: string,
configOverride?: Record<string, unknown>
): Promise<void> {
if (!(await pathExists(item.localPath))) {
await removePath(item.repoPath);
return;
}

const localContent = await fs.readFile(item.localPath, 'utf8');
const localConfig = parseJsonc<Record<string, unknown>>(localContent);
const localConfig =
configOverride ??
parseJsonc<Record<string, unknown>>(await fs.readFile(item.localPath, 'utf8'));
const baseConfig = await readRepoConfig(item, repoRoot);
const effectiveOverrides = overrides ?? {};
if (baseConfig) {
const expectedLocal = deepMerge(baseConfig, overrides) as Record<string, unknown>;
const expectedLocal = deepMerge(baseConfig, effectiveOverrides) as Record<string, unknown>;
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, {
Expand Down
28 changes: 27 additions & 1 deletion src/sync/config.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
9 changes: 8 additions & 1 deletion src/sync/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface SyncConfig {
repo?: SyncRepoConfig;
localRepoPath?: string;
includeSecrets?: boolean;
includeMcpSecrets?: boolean;
includeSessions?: boolean;
includePromptStash?: boolean;
extraSecretPaths?: string[];
Expand All @@ -35,8 +36,10 @@ export async function pathExists(filePath: string): Promise<boolean> {
}

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 : [],
Expand All @@ -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<SyncConfig | null> {
if (!(await pathExists(locations.syncConfigPath))) {
return null;
Expand Down
129 changes: 129 additions & 0 deletions src/sync/mcp-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -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}',
},
},
},
});
});
});
Loading
Loading