diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index 2d9ba36c..3a546211 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -2,11 +2,15 @@ import { execCli } from '@main/utils/childProcess'; import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getAppDataPath } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; -import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; +import { + getCachedShellEnv, + getShellPreferredHome, + resolveInteractiveShellEnvBestEffort, +} from '@main/utils/shellEnv'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { createHash, randomUUID } from 'crypto'; -import { existsSync, promises as fsp, readFileSync, statSync } from 'fs'; +import { existsSync, promises as fsp, readdirSync, readFileSync, statSync } from 'fs'; import path from 'path'; import { gunzipSync } from 'zlib'; @@ -136,17 +140,32 @@ function splitPathEnv(pathValue: string | undefined): string[] { .filter(Boolean); } -function resolvePathOpenCodeBinary( - additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [] -): string | null { +function collectPathOpenCodeBinaryCandidates( + additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [], + options: { includeFallbackPathEntries?: boolean } = {} +): string[] { const shellEnv = getCachedShellEnv() ?? {}; - const pathEntries = [ + const directPathEntries = [ ...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)), ...splitPathEnv(shellEnv.PATH), - ...splitPathEnv(buildMergedCliPath()), - ...splitPathEnv(process.env.PATH), ]; + const fallbackPathEntries = + options.includeFallbackPathEntries === false + ? [] + : [...splitPathEnv(buildMergedCliPath()), ...splitPathEnv(process.env.PATH)]; const seen = new Set(); + return [ + ...collectOpenCodeBinariesFromPathEntries(directPathEntries, seen), + ...collectNvmOpenCodeBinaryCandidates().filter(isAbsoluteExistingFile), + ...collectOpenCodeBinariesFromPathEntries(fallbackPathEntries, seen), + ]; +} + +function collectOpenCodeBinariesFromPathEntries( + pathEntries: string[], + seen: Set +): string[] { + const results: string[] = []; for (const entry of pathEntries) { const normalizedEntry = path.resolve(entry); if (seen.has(normalizedEntry)) { @@ -156,17 +175,56 @@ function resolvePathOpenCodeBinary( for (const executableName of getPathExecutableNames()) { const candidate = path.join(normalizedEntry, executableName); if (isAbsoluteExistingFile(candidate)) { - return candidate; + results.push(candidate); } } } - return null; + return results; +} + +function collectNvmOpenCodeBinaryCandidates(): string[] { + if (process.platform === 'win32') { + const appdata = process.env.APPDATA; + if (!appdata) { + return []; + } + return collectVersionedOpenCodeBinaryCandidates(path.join(appdata, 'nvm')); + } + + return collectVersionedOpenCodeBinaryCandidates( + path.join(getShellPreferredHome(), '.nvm', 'versions', 'node'), + 'bin' + ); +} + +function collectVersionedOpenCodeBinaryCandidates(rootPath: string, binSegment = ''): string[] { + let versions: string[]; + try { + versions = readdirSync(rootPath); + } catch { + return []; + } + + return versions + .toSorted((left, right) => right.localeCompare(left, undefined, { numeric: true })) + .flatMap((version) => { + const versionPath = binSegment + ? path.join(rootPath, version, binSegment) + : path.join(rootPath, version); + return getPathExecutableNames().map((executableName) => + path.join(versionPath, executableName) + ); + }); } type OpenCodeBinaryVersionProbe = | { ok: true; version: string | null } | { ok: false; error: string }; +type VerifiedOpenCodeBinaryProbe = + | { ok: true; binaryPath: string; version: string | null } + | { ok: false; firstFailure: { binaryPath: string; error: string } | null }; + async function probeOpenCodeBinaryVersion(binaryPath: string): Promise { try { const { stdout } = await execCli(binaryPath, ['--version'], { @@ -179,30 +237,79 @@ async function probeOpenCodeBinaryVersion(binaryPath: string): Promise { - const cachedCandidate = resolvePathOpenCodeBinary(); - if (cachedCandidate) { - return cachedCandidate; +function normalizeBinaryCandidateForCompare(binaryPath: string): string { + const normalized = path.resolve(binaryPath); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +async function probeFirstWorkingOpenCodeBinaryCandidate( + candidates: string[], + seen: Set, + firstFailure: { binaryPath: string; error: string } | null +): Promise { + let nextFirstFailure = firstFailure; + for (const binaryPath of candidates) { + const normalized = normalizeBinaryCandidateForCompare(binaryPath); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + const version = await probeOpenCodeBinaryVersion(binaryPath); + if (version.ok) { + return { ok: true, binaryPath, version: version.version }; + } + nextFirstFailure ??= { binaryPath, error: version.error }; } + return { ok: false, firstFailure: nextFirstFailure }; +} + +async function probeFirstWorkingPathOpenCodeBinary( + options: { shellEnvTimeoutMs?: number } = {} +): Promise { + const seenCandidates = new Set(); + let firstFailure: { binaryPath: string; error: string } | null = null; + + const cachedProbe = await probeFirstWorkingOpenCodeBinaryCandidate( + collectPathOpenCodeBinaryCandidates([], { + includeFallbackPathEntries: false, + }), + seenCandidates, + firstFailure + ); + if (cachedProbe.ok) { + return cachedProbe; + } + firstFailure = cachedProbe.firstFailure; + const shellEnv = await resolveInteractiveShellEnvBestEffort({ timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS, fallbackEnv: process.env, }); - return resolvePathOpenCodeBinary([shellEnv]); + const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate( + collectPathOpenCodeBinaryCandidates([shellEnv], { + includeFallbackPathEntries: false, + }), + seenCandidates, + firstFailure + ); + if (shellProbe.ok) { + return shellProbe; + } + firstFailure = shellProbe.firstFailure; + + return probeFirstWorkingOpenCodeBinaryCandidate( + collectPathOpenCodeBinaryCandidates([shellEnv]), + seenCandidates, + firstFailure + ); } async function resolveVerifiedPathOpenCodeBinaryPath( options: { shellEnvTimeoutMs?: number } = {} ): Promise { - const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options); - if (!binaryPath) { - return null; - } - - return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null; + const result = await probeFirstWorkingPathOpenCodeBinary(options); + return result.ok ? result.binaryPath : null; } export async function resolveVerifiedOpenCodeRuntimeBinaryPath( @@ -526,26 +633,25 @@ export class OpenCodeRuntimeInstallerService { } private async getPathStatus(): Promise { - const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(); - if (!binaryPath) { - return { installed: false, source: 'missing', state: 'idle' }; - } - const version = await probeOpenCodeBinaryVersion(binaryPath); - if (!version.ok) { + const result = await probeFirstWorkingPathOpenCodeBinary(); + if (result.ok) { return { - installed: false, - binaryPath, + installed: true, + binaryPath: result.binaryPath, + version: result.version ?? undefined, source: 'path', - state: 'failed', - error: version.error, + state: 'ready', }; } + if (!result.firstFailure) { + return { installed: false, source: 'missing', state: 'idle' }; + } return { - installed: true, - binaryPath, - version: version.version ?? undefined, + installed: false, + binaryPath: result.firstFailure.binaryPath, source: 'path', - state: 'ready', + state: 'failed', + error: result.firstFailure.error, }; } diff --git a/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts index d333d859..6ad011ca 100644 --- a/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts +++ b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts @@ -134,6 +134,10 @@ export class OpenCodeBehaviorSourceScanner { 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') }, { @@ -150,6 +154,7 @@ export class OpenCodeBehaviorSourceScanner { 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'), diff --git a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts index 1219ad3a..e822b75c 100644 --- a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts +++ b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts @@ -8,6 +8,7 @@ import { gzipSync } from 'zlib'; const execCliMock = vi.hoisted(() => vi.fn()); const buildMergedCliPathMock = vi.hoisted(() => vi.fn()); const getCachedShellEnvMock = vi.hoisted(() => vi.fn()); +const getShellPreferredHomeMock = vi.hoisted(() => vi.fn()); const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn()); vi.mock('@main/utils/childProcess', () => ({ @@ -20,6 +21,7 @@ vi.mock('@main/utils/cliPathMerge', () => ({ vi.mock('@main/utils/shellEnv', () => ({ getCachedShellEnv: () => getCachedShellEnvMock(), + getShellPreferredHome: () => getShellPreferredHomeMock(), resolveInteractiveShellEnvBestEffort: ( ...args: Parameters ) => resolveInteractiveShellEnvBestEffortMock(...args), @@ -88,6 +90,8 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { buildMergedCliPathMock.mockReturnValue(''); getCachedShellEnvMock.mockReset(); getCachedShellEnvMock.mockReturnValue(null); + getShellPreferredHomeMock.mockReset(); + getShellPreferredHomeMock.mockReturnValue(os.homedir()); resolveInteractiveShellEnvBestEffortMock.mockReset(); resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env); }); @@ -218,12 +222,53 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { }); }); - it('returns a verified OpenCode binary from the merged CLI PATH without interactive shell env resolution', async () => { + it('returns a verified OpenCode binary from the merged CLI PATH after zero-wait shell fallback', async () => { const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode'); await mkdir(path.dirname(binaryPath), { recursive: true }); await writeFile(binaryPath, 'binary', { mode: 0o755 }); buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath)); + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( + binaryPath + ); + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 0, + fallbackEnv: process.env, + }) + ); + expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], { + timeout: 10_000, + windowsHide: true, + }); + }); + + it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => { + const olderBinaryPath = path.join( + tempRoot!, + '.nvm', + 'versions', + 'node', + 'v20.10.0', + 'bin', + 'opencode' + ); + const binaryPath = path.join( + tempRoot!, + '.nvm', + 'versions', + 'node', + 'v22.22.1', + 'bin', + 'opencode' + ); + await mkdir(path.dirname(olderBinaryPath), { recursive: true }); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(olderBinaryPath, 'older binary', { mode: 0o755 }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! }); + getShellPreferredHomeMock.mockReturnValue(tempRoot!); + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( binaryPath ); @@ -234,6 +279,130 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { }); }); + it('returns a verified OpenCode cmd shim from nvm-windows when desktop PATH misses npm globals', async () => { + const originalPlatform = process.platform; + const originalAppData = process.env.APPDATA; + + try { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + writable: true, + }); + process.env.APPDATA = tempRoot!; + + const olderBinaryPath = path.join(tempRoot!, 'nvm', 'v20.10.0', 'opencode.cmd'); + const binaryPath = path.join(tempRoot!, 'nvm', 'v22.22.1', 'opencode.cmd'); + await mkdir(path.dirname(olderBinaryPath), { recursive: true }); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(olderBinaryPath, 'older binary', { mode: 0o755 }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + + await expect( + resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 }) + ).resolves.toBe(binaryPath); + expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled(); + expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], { + timeout: 10_000, + windowsHide: true, + }); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + writable: true, + }); + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + } + }); + + it('skips a broken newer nvm OpenCode binary and reports the next working install', async () => { + const brokenBinaryPath = path.join( + tempRoot!, + '.nvm', + 'versions', + 'node', + 'v23.0.0', + 'bin', + 'opencode' + ); + const workingBinaryPath = path.join( + tempRoot!, + '.nvm', + 'versions', + 'node', + 'v22.22.1', + 'bin', + 'opencode' + ); + await mkdir(path.dirname(brokenBinaryPath), { recursive: true }); + await mkdir(path.dirname(workingBinaryPath), { recursive: true }); + await writeFile(brokenBinaryPath, 'broken binary', { mode: 0o755 }); + await writeFile(workingBinaryPath, 'working binary', { mode: 0o755 }); + getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! }); + getShellPreferredHomeMock.mockReturnValue(tempRoot!); + execCliMock.mockImplementation(async (binaryPath: string) => { + if (binaryPath === brokenBinaryPath) { + throw new Error('broken nvm runtime'); + } + return { stdout: 'opencode 1.15.6\n', stderr: '' }; + }); + + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( + workingBinaryPath + ); + await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({ + installed: true, + source: 'path', + state: 'ready', + binaryPath: workingBinaryPath, + version: 'opencode 1.15.6', + }); + }); + + it('falls through to shell PATH when all fast nvm candidates are broken', async () => { + const brokenBinaryPath = path.join( + tempRoot!, + '.nvm', + 'versions', + 'node', + 'v23.0.0', + 'bin', + 'opencode' + ); + const shellBinaryPath = path.join(tempRoot!, 'custom-npm-prefix', 'bin', 'opencode'); + await mkdir(path.dirname(brokenBinaryPath), { recursive: true }); + await mkdir(path.dirname(shellBinaryPath), { recursive: true }); + await writeFile(brokenBinaryPath, 'broken binary', { mode: 0o755 }); + await writeFile(shellBinaryPath, 'working binary', { mode: 0o755 }); + getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! }); + getShellPreferredHomeMock.mockReturnValue(tempRoot!); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: path.dirname(shellBinaryPath), + HOME: tempRoot!, + }); + execCliMock.mockImplementation(async (binaryPath: string) => { + if (binaryPath === brokenBinaryPath) { + throw new Error('broken nvm runtime'); + } + return { stdout: 'opencode 1.15.6\n', stderr: '' }; + }); + + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( + shellBinaryPath + ); + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 0, + fallbackEnv: process.env, + }) + ); + }); + it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => { const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode'); await mkdir(path.dirname(binaryPath), { recursive: true }); diff --git a/test/main/services/runtime/OpenCodeRuntimeNvmResolution.safe-e2e.test.ts b/test/main/services/runtime/OpenCodeRuntimeNvmResolution.safe-e2e.test.ts new file mode 100644 index 00000000..ebbe6d09 --- /dev/null +++ b/test/main/services/runtime/OpenCodeRuntimeNvmResolution.safe-e2e.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node +/* eslint-disable security/detect-non-literal-fs-filename */ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + OpenCodeRuntimeInstallerService, + resolveVerifiedOpenCodeRuntimeBinaryPath, +} from '../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService'; +import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv'; +import { execCli } from '../../../../src/main/utils/childProcess'; +import { setAppDataBasePath } from '../../../../src/main/utils/pathDecoder'; +import { clearShellEnvCache } from '../../../../src/main/utils/shellEnv'; + +const describePosix = process.platform === 'win32' ? describe.skip : describe; + +describePosix('OpenCode nvm runtime resolution safe e2e', () => { + let tempDir: string | null = null; + let originalHome: string | undefined; + let originalPath: string | undefined; + let originalShell: string | undefined; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-nvm-resolution-e2e-')); + setAppDataBasePath(path.join(tempDir, 'app-data')); + clearShellEnvCache(); + + originalHome = process.env.HOME; + originalPath = process.env.PATH; + originalShell = process.env.SHELL; + process.env.HOME = tempDir; + process.env.PATH = ''; + process.env.SHELL = path.join(tempDir, 'missing-shell'); + }); + + afterEach(async () => { + clearShellEnvCache(); + setAppDataBasePath(null); + + restoreEnvValue('HOME', originalHome); + restoreEnvValue('PATH', originalPath); + restoreEnvValue('SHELL', originalShell); + + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('reports and launches an npm global OpenCode binary installed under nvm when GUI PATH is empty', async () => { + await createFakeNvmOpenCodeBinary('v23.0.0', { broken: true }); + const binaryPath = await createFakeNvmOpenCodeBinary('v22.22.1'); + const binDir = path.dirname(binaryPath); + + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( + binaryPath + ); + + await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({ + installed: true, + source: 'path', + state: 'ready', + binaryPath, + version: 'opencode 1.15.6', + }); + + const bridgeEnv: NodeJS.ProcessEnv = { PATH: '' }; + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: bridgeEnv, + bridgeEnv, + resolveVerifiedOpenCodeRuntimeBinaryPath, + }); + + expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(bridgeEnv.OPENCODE_BIN_PATH).toBe(binaryPath); + expect(bridgeEnv.PATH?.split(path.delimiter)[0]).toBe(binDir); + + const version = await execCli('opencode', ['--version'], { + env: bridgeEnv, + timeout: 2_000, + windowsHide: true, + }); + expect(version.stdout.trim()).toBe('opencode 1.15.6'); + }); + + async function createFakeNvmOpenCodeBinary( + version: string, + options: { broken?: boolean } = {} + ): Promise { + const binDir = path.join(tempDir!, '.nvm', 'versions', 'node', version, 'bin'); + const binaryPath = path.join(binDir, 'opencode'); + await mkdir(binDir, { recursive: true }); + await writeFile( + binaryPath, + options.broken + ? ['#!/bin/sh', 'echo "broken opencode" >&2', 'exit 2'].join('\n') + : [ + '#!/bin/sh', + 'if [ "$1" = "--version" ]; then', + ' echo "opencode 1.15.6"', + ' exit 0', + 'fi', + 'echo "unexpected opencode args: $*" >&2', + 'exit 2', + ].join('\n'), + 'utf8' + ); + await chmod(binaryPath, 0o755); + return binaryPath; + } +}); + +function restoreEnvValue(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/test/main/services/team/OpenCodeManagedOverlay.test.ts b/test/main/services/team/OpenCodeManagedOverlay.test.ts index e11622f7..5ad79577 100644 --- a/test/main/services/team/OpenCodeManagedOverlay.test.ts +++ b/test/main/services/team/OpenCodeManagedOverlay.test.ts @@ -1,7 +1,6 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { @@ -154,6 +153,35 @@ describe('OpenCodeManagedOverlay', () => { ); }); + it('reads JSONC global config as a user-owned behavior source', async () => { + await fs.mkdir(path.join(homePath, '.config/opencode'), { recursive: true }); + await fs.writeFile( + path.join(homePath, '.config/opencode/opencode.jsonc'), + `{ + // global user-owned MCP must be observed, not overwritten + "mcp": { + "agent-teams": { "type": "local", "command": "custom", "enabled": true } + } + }`, + 'utf8' + ); + const scanner = new OpenCodeBehaviorSourceScanner({ homePath }); + + await expect(scanner.readDeclaredMcpNames(projectPath)).resolves.toEqual( + new Set(['agent-teams']) + ); + const sources = await scanner.scan(projectPath); + + expect(sources).toContainEqual( + expect.objectContaining({ + kind: 'global_config', + exists: true, + fileCount: 1, + fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }) + ); + }); + it('rejects managed overlays that would shadow user behavior keys', () => { expect(() => assertManagedOverlayDoesNotShadowUserConfig({