diff --git a/README.md b/README.md index 2a14fea..703f61c 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,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`. `~/.agents/` is enabled by default and may contain instructions or skills you consider private. Review it before syncing, keep the sync repository private when needed, or set @@ -144,7 +144,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 3a56321..43ef6bd 100644 --- a/src/sync/apply.test.ts +++ b/src/sync/apply.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; +import type { SyncConfig } from './config.js'; import { loadOverrides, normalizeSyncConfig, parseJsonc } from './config.js'; import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js'; import { buildSyncPlan, resolveSyncLocations } from './paths.js'; @@ -283,6 +284,137 @@ 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, 'custom-configs'); + 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', 'custom-configs'], + 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' }); + }); + }); +}); + describe('chunked Git session items', () => { const chunkOptions = { thresholdBytes: 8, diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 00d5985..47e0527 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -1,3 +1,6 @@ +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'; @@ -6,6 +9,7 @@ import { buildSyncPlan, expandHome, normalizePath, + resolveExtraPath, resolveHomeDir, resolveRepoRoot, resolveSyncLocations, @@ -235,6 +239,85 @@ 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('includes canonical plural and legacy singular config directories exactly once', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); @@ -267,6 +350,25 @@ describe('buildSyncPlan', () => { expect(new Set(plan.items.map((item) => item.repoPath)).size).toBe(plan.items.length); }); + 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\\.config\\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('filters plural defaults from extra config paths case-insensitively on Windows', () => { const env = { USERPROFILE: 'C:\\Users\\Test', @@ -286,6 +388,24 @@ describe('buildSyncPlan', () => { expect(plan.extraConfigs.allowlist).toEqual([]); }); + 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 cff9f69..ccb74dc 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -152,17 +152,28 @@ export function expandHome( export function normalizePath( inputPath: string, homeDir: string, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + baseDir?: string ): string { const pathApi = pathApiFor(platform); const expanded = expandHome(inputPath, homeDir, platform); - const resolved = pathApi.resolve(expanded); + 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, @@ -330,7 +341,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) => @@ -341,20 +354,20 @@ export function buildSyncPlan( 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 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, - locations, repoConfigExtraDir, configManifestPath, platform @@ -371,23 +384,18 @@ export function buildSyncPlan( } function buildExtraPathPlan( - inputPaths: string[] | undefined, - locations: SyncLocations, + sourcePaths: string[], repoExtraDir: string, manifestPath: string, platform: NodeJS.Platform ): ExtraPathPlan { - const allowlist = (inputPaths ?? []).map((entry) => - normalizePath(entry, locations.xdg.homeDir, platform) - ); - - const entries = allowlist.map((sourcePath) => ({ + const entries = sourcePaths.map((sourcePath) => ({ sourcePath, repoPath: pathApiFor(platform).join(repoExtraDir, encodeExtraPath(sourcePath)), })); return { - allowlist, + allowlist: sourcePaths, manifestPath, entries, }; 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;