From 8ae15aa886a02c03bd696dac64287f01edad1a2c Mon Sep 17 00:00:00 2001 From: iHildy Date: Sat, 29 Aug 2026 16:06:00 -0700 Subject: [PATCH 1/2] fix: resolve relative extra paths from config root --- README.md | 4 +- src/sync/apply.test.ts | 134 +++++++++++++++++++++++++++++++++++++++++ src/sync/paths.test.ts | 121 ++++++++++++++++++++++++++++++++++++- src/sync/paths.ts | 72 +++++++++++----------- 4 files changed, 294 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index b470996..8e86bf4 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`: - `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`, `skills/` - `~/.agents/` - `~/.local/state/opencode/model.json` (model favorites) -- Any additional paths in `extraConfigPaths` (allowlist, files or folders). You do not need to include default paths like `~/.config/opencode/skills` or `~/.agents`. +- Any additional paths in `extraConfigPaths` (allowlist, files or folders). Relative paths resolve from `$XDG_CONFIG_HOME/opencode` (normally `~/.config/opencode`). You do not need to include default paths like `~/.config/opencode/skills` or `~/.agents`. Disable default directory sync by setting: - `"includeOpencodeSkills": false` to skip `~/.config/opencode/skills/` @@ -112,7 +112,7 @@ Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`: - `~/.local/share/opencode/auth.json` - `~/.local/share/opencode/mcp-auth.json` -- Any extra paths in `extraSecretPaths` (allowlist, files or folders) +- Any extra paths in `extraSecretPaths` (allowlist, files or folders). Relative paths resolve from `$XDG_CONFIG_HOME/opencode` (normally `~/.config/opencode`). 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`). diff --git a/src/sync/apply.test.ts b/src/sync/apply.test.ts index 97fd212..abcd0d6 100644 --- a/src/sync/apply.test.ts +++ b/src/sync/apply.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; +import type { SyncConfig } from './config.js'; +import { normalizeSyncConfig } from './config.js'; import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js'; +import { buildSyncPlan, resolveSyncLocations } from './paths.js'; const EMPTY_EXTRA_PLAN: ExtraPathPlan = { allowlist: [], @@ -276,3 +279,134 @@ describe('syncRepoToLocal for session database', () => { }); }); }); + +describe('relative extra paths', () => { + it('syncs config and secret files and directories from an unrelated cwd', async () => { + await withTempDir(async (root) => { + const homeDir = path.join(root, 'home'); + const configRoot = path.join(homeDir, '.config', 'opencode'); + const repoRoot = path.join(root, 'repo'); + const unrelatedCwd = path.join(root, 'unrelated-cwd'); + const configFile = path.join(configRoot, 'SOUL.md'); + const configDirectory = path.join(configRoot, 'commands'); + const secretFile = path.join(configRoot, 'credentials', 'token.json'); + const secretDirectory = path.join(configRoot, 'private-agents'); + await fs.mkdir(configDirectory, { recursive: true }); + await fs.mkdir(path.dirname(secretFile), { recursive: true }); + await fs.mkdir(secretDirectory, { recursive: true }); + await fs.mkdir(unrelatedCwd, { recursive: true }); + await fs.writeFile(configFile, 'config-file', 'utf8'); + await fs.writeFile(path.join(configDirectory, 'custom.md'), 'config-directory', 'utf8'); + await fs.writeFile(secretFile, 'secret-file', 'utf8'); + await fs.writeFile(path.join(secretDirectory, 'private.md'), 'secret-directory', 'utf8'); + + const locations = resolveSyncLocations({ HOME: homeDir }, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: true, + extraConfigPaths: ['SOUL.md', 'commands'], + extraSecretPaths: ['credentials/token.json', 'private-agents'], + }; + const originalCwd = process.cwd(); + + try { + process.chdir(unrelatedCwd); + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, repoRoot, 'linux'); + await syncLocalToRepo(plan, null); + + const configManifest = JSON.parse( + await fs.readFile(plan.extraConfigs.manifestPath, 'utf8') + ) as { + entries: Array<{ sourcePath: string; repoPath: string; type: 'file' | 'dir' }>; + }; + const secretManifest = JSON.parse( + await fs.readFile(plan.extraSecrets.manifestPath, 'utf8') + ) as { + entries: Array<{ sourcePath: string; repoPath: string; type: 'file' | 'dir' }>; + }; + + expect(configManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([ + [configFile, 'file'], + [configDirectory, 'dir'], + ]); + expect(secretManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([ + [secretFile, 'file'], + [secretDirectory, 'dir'], + ]); + + const configRepoPaths = new Map( + configManifest.entries.map((entry) => [ + entry.sourcePath, + path.join(repoRoot, entry.repoPath), + ]) + ); + const secretRepoPaths = new Map( + secretManifest.entries.map((entry) => [ + entry.sourcePath, + path.join(repoRoot, entry.repoPath), + ]) + ); + await expect(fs.readFile(configRepoPaths.get(configFile) ?? '', 'utf8')).resolves.toBe( + 'config-file' + ); + await expect( + fs.readFile(path.join(configRepoPaths.get(configDirectory) ?? '', 'custom.md'), 'utf8') + ).resolves.toBe('config-directory'); + await expect(fs.readFile(secretRepoPaths.get(secretFile) ?? '', 'utf8')).resolves.toBe( + 'secret-file' + ); + await expect( + fs.readFile(path.join(secretRepoPaths.get(secretDirectory) ?? '', 'private.md'), 'utf8') + ).resolves.toBe('secret-directory'); + } finally { + process.chdir(originalCwd); + } + }); + }); + + it('does not apply a manifest source outside the resolved allowlist', async () => { + await withTempDir(async (root) => { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const locations = resolveSyncLocations({ HOME: homeDir }, 'linux'); + const allowedPath = path.join(locations.configRoot, 'allowed.json'); + const blockedPath = path.join(locations.configRoot, 'blocked.json'); + const allowedRepoPath = path.join(repoRoot, 'config', 'extra', 'allowed.json'); + const rogueRepoPath = path.join(repoRoot, 'config', 'extra', 'rogue.json'); + await fs.mkdir(path.dirname(rogueRepoPath), { recursive: true }); + await fs.writeFile(allowedRepoPath, 'allowed-copy', 'utf8'); + await fs.writeFile(rogueRepoPath, 'must-not-copy', 'utf8'); + + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: ['allowed.json'], + }; + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, repoRoot, 'linux'); + await fs.mkdir(path.dirname(plan.extraConfigs.manifestPath), { recursive: true }); + await fs.writeFile( + plan.extraConfigs.manifestPath, + JSON.stringify({ + entries: [ + { + sourcePath: allowedPath, + repoPath: path.relative(repoRoot, allowedRepoPath), + type: 'file', + }, + { + sourcePath: blockedPath, + repoPath: path.relative(repoRoot, rogueRepoPath), + type: 'file', + }, + ], + }), + 'utf8' + ); + + await syncRepoToLocal(plan, null); + + await expect(fs.readFile(allowedPath, 'utf8')).resolves.toBe('allowed-copy'); + await expect(fs.stat(blockedPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); +}); diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 57b60d8..4dc7a81 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -1,8 +1,11 @@ +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { describe, expect, it } from 'vitest'; import type { SyncConfig } from './config.js'; import { normalizeSyncConfig } from './config.js'; -import { buildSyncPlan, resolveSyncLocations, resolveXdgPaths } from './paths.js'; +import { buildSyncPlan, resolveExtraPath, resolveSyncLocations, resolveXdgPaths } from './paths.js'; describe('resolveXdgPaths', () => { it('resolves linux defaults', () => { @@ -106,6 +109,122 @@ describe('buildSyncPlan', () => { expect(plan.extraConfigs.allowlist).toEqual([customConfigPath]); }); + it('resolves relative extra config and secret paths from the opencode config root', async () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const unrelatedCwd = await fs.mkdtemp(path.join(tmpdir(), 'opencode-sync-cwd-')); + const originalCwd = process.cwd(); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: true, + extraConfigPaths: ['SOUL.md', 'commands/custom'], + extraSecretPaths: ['credentials/token.json', 'private-agents'], + }; + + try { + process.chdir(unrelatedCwd); + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + + expect(plan.extraConfigs.allowlist).toEqual([ + '/home/test/.config/opencode/SOUL.md', + '/home/test/.config/opencode/commands/custom', + ]); + expect(plan.extraSecrets.allowlist).toEqual([ + '/home/test/.config/opencode/credentials/token.json', + '/home/test/.config/opencode/private-agents', + ]); + } finally { + process.chdir(originalCwd); + await fs.rm(unrelatedCwd, { recursive: true, force: true }); + } + }); + + it('keeps home-relative and absolute extra path behavior', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: true, + extraConfigPaths: ['~/shared/config.json', '/opt/opencode/config.json'], + extraSecretPaths: ['~/.ssh/id_rsa', '/run/secrets/opencode'], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + + expect(plan.extraConfigs.allowlist).toEqual([ + '/home/test/shared/config.json', + '/opt/opencode/config.json', + ]); + expect(plan.extraSecrets.allowlist).toEqual([ + '/home/test/.ssh/id_rsa', + '/run/secrets/opencode', + ]); + }); + + it('uses the XDG opencode root for relative paths when a custom config dir is set', () => { + const env = { + HOME: '/home/test', + XDG_CONFIG_HOME: '/srv/xdg-config', + opencode_config_dir: '/custom/opencode', + } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + + expect(resolveExtraPath('custom.json', locations, 'linux')).toBe( + '/srv/xdg-config/opencode/custom.json' + ); + }); + + it('deduplicates relative paths that are already default sync items', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: ['agent', 'opencode.json', 'skills', '~/.agents', 'custom.json'], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + + expect(plan.extraConfigs.allowlist).toEqual(['/home/test/.config/opencode/custom.json']); + }); + + it('uses Windows path semantics for relative, home-relative, and absolute entries', () => { + const env = { + USERPROFILE: 'C:\\Users\\Test', + APPDATA: 'C:\\Users\\Test\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\Test\\AppData\\Local', + } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'win32'); + + expect(resolveExtraPath('commands\\custom.md', locations, 'win32')).toBe( + 'c:\\users\\test\\appdata\\roaming\\opencode\\commands\\custom.md' + ); + expect(resolveExtraPath('~/shared/config.json', locations, 'win32')).toBe( + 'c:\\users\\test\\shared\\config.json' + ); + expect(resolveExtraPath('D:\\opencode\\config.json', locations, 'win32')).toBe( + 'd:\\opencode\\config.json' + ); + }); + + it('keeps traversing relative paths exact and their repository paths contained', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: ['../shared/config.json'], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + const entry = plan.extraConfigs.entries[0]; + + expect(plan.extraConfigs.allowlist).toEqual(['/home/test/.config/shared/config.json']); + expect(entry?.sourcePath).toBe('/home/test/.config/shared/config.json'); + expect(entry?.repoPath.startsWith('/repo/config/extra/')).toBe(true); + expect(path.relative('/repo/config/extra', entry?.repoPath ?? '').startsWith('..')).toBe(false); + }); + it('includes skills directory in default sync items', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 54bd147..fbd8f07 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -133,16 +133,34 @@ export function expandHome(inputPath: string, homeDir: string): string { export function normalizePath( inputPath: string, homeDir: string, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + baseDir?: string ): string { - const expanded = expandHome(inputPath, homeDir); - const resolved = path.resolve(expanded); + const pathApi = platform === 'win32' ? path.win32 : path.posix; + let expanded = inputPath; + if (homeDir && inputPath === '~') { + expanded = homeDir; + } else if (homeDir && inputPath.startsWith('~/')) { + expanded = pathApi.join(homeDir, inputPath.slice(2)); + } + + const resolved = baseDir ? pathApi.resolve(baseDir, expanded) : pathApi.resolve(expanded); if (platform === 'win32') { return resolved.toLowerCase(); } return resolved; } +export function resolveExtraPath( + inputPath: string, + locations: SyncLocations, + platform: NodeJS.Platform = process.platform +): string { + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const configRoot = pathApi.join(locations.xdg.configDir, 'opencode'); + return normalizePath(inputPath, locations.xdg.homeDir, platform, configRoot); +} + export function isSamePath( left: string, right: string, @@ -303,7 +321,9 @@ export function buildSyncPlan( } } - const extraSecretPaths = config.includeSecrets ? config.extraSecretPaths : []; + const extraSecretPaths = config.includeSecrets + ? config.extraSecretPaths.map((entry) => resolveExtraPath(entry, locations, platform)) + : []; const filteredExtraSecrets = usingSecretsBackend ? extraSecretPaths.filter( (entry) => @@ -312,26 +332,16 @@ export function buildSyncPlan( ) : extraSecretPaths; - const extraSecrets = buildExtraPathPlan( - filteredExtraSecrets, - locations, - repoExtraDir, - manifestPath, - platform - ); - - const extraConfigPaths = (config.extraConfigPaths ?? []).filter( - (entry) => - !items.some((item) => isSamePath(entry, item.localPath, locations.xdg.homeDir, platform)) - ); - - const extraConfigs = buildExtraPathPlan( - extraConfigPaths, - locations, - repoConfigExtraDir, - configManifestPath, - platform - ); + const extraSecrets = buildExtraPathPlan(filteredExtraSecrets, repoExtraDir, manifestPath); + + const extraConfigPaths = (config.extraConfigPaths ?? []) + .map((entry) => resolveExtraPath(entry, locations, platform)) + .filter( + (entry) => + !items.some((item) => isSamePath(entry, item.localPath, locations.xdg.homeDir, platform)) + ); + + const extraConfigs = buildExtraPathPlan(extraConfigPaths, repoConfigExtraDir, configManifestPath); return { items, @@ -344,23 +354,17 @@ export function buildSyncPlan( } function buildExtraPathPlan( - inputPaths: string[] | undefined, - locations: SyncLocations, + sourcePaths: string[], repoExtraDir: string, - manifestPath: string, - platform: NodeJS.Platform + manifestPath: string ): ExtraPathPlan { - const allowlist = (inputPaths ?? []).map((entry) => - normalizePath(entry, locations.xdg.homeDir, platform) - ); - - const entries = allowlist.map((sourcePath) => ({ + const entries = sourcePaths.map((sourcePath) => ({ sourcePath, repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)), })); return { - allowlist, + allowlist: sourcePaths, manifestPath, entries, }; From ad6a2927f522f99f21b91fa76cef03fa1abc9426 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 30 Aug 2026 18:58:03 -0700 Subject: [PATCH 2/2] test: isolate nested Git fixtures from hooks --- src/sync/repo-history.test.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/sync/repo-history.test.ts b/src/sync/repo-history.test.ts index 212e686..1a0ec31 100644 --- a/src/sync/repo-history.test.ts +++ b/src/sync/repo-history.test.ts @@ -9,6 +9,23 @@ import { afterEach, describe, expect, it } from 'vitest'; import { inspectOversizedUnpushedHistory } from './repo.js'; const roots: string[] = []; +const GIT_LOCAL_ENVIRONMENT_VARIABLES = [ + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_CONFIG', + 'GIT_CONFIG_PARAMETERS', + 'GIT_CONFIG_COUNT', + 'GIT_OBJECT_DIRECTORY', + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_IMPLICIT_WORK_TREE', + 'GIT_GRAFT_FILE', + 'GIT_INDEX_FILE', + 'GIT_NO_REPLACE_OBJECTS', + 'GIT_REPLACE_REF_BASE', + 'GIT_PREFIX', + 'GIT_SHALLOW_FILE', + 'GIT_COMMON_DIR', +]; const execAsync = promisify(exec); const shell = createTestShell(); @@ -99,10 +116,19 @@ async function createGitFixture(): Promise<{ root: string; local: string; remote function git(cwd: string, ...args: string[]): void { const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', + env: createIsolatedGitEnvironment(), }); if (result.status !== 0) throw new Error(result.stderr); } +function createIsolatedGitEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const variable of GIT_LOCAL_ENVIRONMENT_VARIABLES) { + delete env[variable]; + } + return env; +} + interface TestShellCommand extends Promise<{ stdout: string; stderr: string }> { quiet: () => TestShellCommand; text: () => Promise; @@ -115,7 +141,9 @@ function createTestShell(): PluginInput['$'] { result + segment + (index < values.length ? shellQuote(values[index]) : ''), '' ); - const execution = execAsync(command) as unknown as TestShellCommand; + const execution = execAsync(command, { + env: createIsolatedGitEnvironment(), + }) as unknown as TestShellCommand; execution.quiet = () => execution; execution.text = async () => (await execution).stdout; return execution;