diff --git a/src/commands/install-github-app/repoSlug.test.ts b/src/commands/install-github-app/repoSlug.test.ts index 6086ea1f44..5964c19620 100644 --- a/src/commands/install-github-app/repoSlug.test.ts +++ b/src/commands/install-github-app/repoSlug.test.ts @@ -10,7 +10,7 @@ test('keeps owner/repo input as-is', () => { test('extracts slug from https GitHub URLs', () => { assert.equal( extractGitHubRepoSlug('https://github.com/verbeux-ai/code'), - 'Gitlawb/verboo', + 'verbeux-ai/code', ) assert.equal( extractGitHubRepoSlug('https://www.github.com/Gitlawb/verboo.git'), diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts index 280bcb1c3d..b76b97c1bd 100644 --- a/src/schemas/hooks.ts +++ b/src/schemas/hooks.ts @@ -8,7 +8,7 @@ * Both files now import from this shared location instead of each other. */ -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/sdk/coreTypes.js' import { z } from 'zod/v4' import { lazySchema } from '../utils/lazySchema.js' import { SHELL_TYPES } from '../utils/shell/shellProvider.js' diff --git a/src/services/api/openaiShim.diagnostics.test.ts b/src/services/api/openaiShim.diagnostics.test.ts index 5585a30a2d..2dddfa250b 100644 --- a/src/services/api/openaiShim.diagnostics.test.ts +++ b/src/services/api/openaiShim.diagnostics.test.ts @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +import * as debugModule from '../../utils/debug.js' const originalFetch = globalThis.fetch const originalEnv = { @@ -33,10 +34,7 @@ afterEach(() => { }) test('logs classified transport diagnostics with category and code', async () => { - const debugSpy = mock(() => {}) - mock.module('../../utils/debug.js', () => ({ - logForDebugging: debugSpy, - })) + const debugSpy = spyOn(debugModule, 'logForDebugging').mockImplementation(() => {}) const nonce = `${Date.now()}-${Math.random()}` const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`) @@ -80,10 +78,7 @@ test('logs classified transport diagnostics with category and code', async () => }) test('redacts credentials in transport diagnostic URL logs', async () => { - const debugSpy = mock(() => {}) - mock.module('../../utils/debug.js', () => ({ - logForDebugging: debugSpy, - })) + const debugSpy = spyOn(debugModule, 'logForDebugging').mockImplementation(() => {}) const nonce = `${Date.now()}-${Math.random()}` const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`) @@ -127,10 +122,7 @@ test('redacts credentials in transport diagnostic URL logs', async () => { expect(logLine).not.toContain('supersecret@') }) test('logs self-heal localhost fallback with redacted from/to URLs', async () => { - const debugSpy = mock(() => {}) - mock.module('../../utils/debug.js', () => ({ - logForDebugging: debugSpy, - })) + const debugSpy = spyOn(debugModule, 'logForDebugging').mockImplementation(() => {}) const nonce = `${Date.now()}-${Math.random()}` const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`) @@ -204,10 +196,7 @@ test('logs self-heal localhost fallback with redacted from/to URLs', async () => }) test('logs self-heal toolless retry for local tool-call incompatibility', async () => { - const debugSpy = mock(() => {}) - mock.module('../../utils/debug.js', () => ({ - logForDebugging: debugSpy, - })) + const debugSpy = spyOn(debugModule, 'logForDebugging').mockImplementation(() => {}) const nonce = `${Date.now()}-${Math.random()}` const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`) diff --git a/src/services/api/withRetry.test.ts b/src/services/api/withRetry.test.ts index 4e24137dda..623b3b8fa1 100644 --- a/src/services/api/withRetry.test.ts +++ b/src/services/api/withRetry.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' +import * as providersModule from '../../utils/model/providers.js' import { APIError } from '@anthropic-ai/sdk' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' @@ -60,10 +61,10 @@ async function importFreshWithRetryModule( | 'foundry' = 'firstParty', ) { mock.restore() - mock.module('src/utils/model/providers.js', () => ({ - getAPIProvider: () => provider, - getAPIProviderForStatsig: () => provider, - })) + spyOn(providersModule, 'getAPIProvider').mockImplementation(() => provider) + spyOn(providersModule, 'getAPIProviderForStatsig').mockImplementation( + () => provider as ReturnType, + ) return import(`./withRetry.js?ts=${Date.now()}-${Math.random()}`) } diff --git a/src/skills/loadSkillsDir.test.ts b/src/skills/loadSkillsDir.test.ts index 296f904689..ed416a81c8 100644 --- a/src/skills/loadSkillsDir.test.ts +++ b/src/skills/loadSkillsDir.test.ts @@ -11,7 +11,7 @@ import { } from '../test/sharedMutationLock.js' function writeSkill(rootDir: string, skillPath: string): void { - const skillDir = join(rootDir, '.verboo', 'skills', ...skillPath.split('/')) + const skillDir = join(rootDir, 'skills', ...skillPath.split('/')) mkdirSync(skillDir, { recursive: true }) writeFileSync( join(skillDir, 'SKILL.md'), @@ -47,7 +47,7 @@ test('loads flat and nested skills with colon namespaces', async () => { const nestedSkill = promptSkills.find(skill => skill.name === 'git:commit') assert.ok(nestedSkill) - assert.equal(nestedSkill.skillRoot, join(configDir, '.verboo', 'skills', 'git', 'commit')) + assert.equal(nestedSkill.skillRoot, join(configDir, 'skills', 'git', 'commit')) const deepSkill = promptSkills.find( skill => skill.name === 'frontend:react:form', @@ -55,7 +55,7 @@ test('loads flat and nested skills with colon namespaces', async () => { assert.ok(deepSkill) assert.equal( deepSkill.skillRoot, - join(configDir, '.verboo', 'skills', 'frontend', 'react', 'form'), + join(configDir, 'skills', 'frontend', 'react', 'form'), ) } finally { try { diff --git a/src/tools.lsp.test.ts b/src/tools.lsp.test.ts index 293db4a7a6..cde285c13d 100644 --- a/src/tools.lsp.test.ts +++ b/src/tools.lsp.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, mock, test } from 'bun:test' -import { getEmptyToolPermissionContext } from './Tool.js' +import type { ToolPermissionContext } from './Tool.js' let lspConnected = false @@ -8,6 +8,7 @@ mock.module('./services/lsp/manager.js', () => ({ getLspServerManager: () => undefined, isLspConnected: () => lspConnected, reinitializeLspServerManager: () => {}, + shutdownLspServerManager: async () => {}, waitForInitialization: async () => {}, })) @@ -22,7 +23,14 @@ test('LSPTool is part of the base tool pool', () => { }) test('LSPTool is filtered from usable tools until a server is connected', () => { - const permissionContext = getEmptyToolPermissionContext() + const permissionContext: ToolPermissionContext = { + mode: 'default', + additionalWorkingDirectories: new Map(), + alwaysAllowRules: {}, + alwaysDenyRules: {}, + alwaysAskRules: {}, + isBypassPermissionsModeAvailable: false, + } expect(getTools(permissionContext).map(tool => tool.name)).not.toContain('LSP') diff --git a/src/types/hooks.ts b/src/types/hooks.ts index e752dd0a21..a311436f5d 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -6,12 +6,12 @@ import { HOOK_EVENTS, type HookInput, type PermissionUpdate, -} from 'src/entrypoints/agentSdkTypes.js' +} from 'src/entrypoints/sdk/coreTypes.js' import type { HookJSONOutput, AsyncHookJSONOutput, SyncHookJSONOutput, -} from 'src/entrypoints/agentSdkTypes.js' +} from 'src/entrypoints/sdk/coreTypes.js' import type { Message } from 'src/types/message.js' import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js' import { permissionBehaviorSchema } from 'src/utils/permissions/PermissionRule.js' diff --git a/src/utils/autoUpdater.ts b/src/utils/autoUpdater.ts index 7855016006..92f5d52862 100644 --- a/src/utils/autoUpdater.ts +++ b/src/utils/autoUpdater.ts @@ -369,10 +369,12 @@ export async function getLatestVersion( logForDebugging( `Tag 'stable' indisponível; caindo para 'latest' como fallback`, ) - const fallback = await execFileNoThrowWithCwd( - 'npm', - ['view', `${MACRO.PACKAGE_URL}@latest`, 'version', '--prefer-online'], - { abortSignal: AbortSignal.timeout(5000), cwd: homedir() }, + const fallback = await withTimeoutSignal(5000, abortSignal => + execFileNoThrowWithCwd( + 'npm', + ['view', `${MACRO.PACKAGE_URL}@latest`, 'version', '--prefer-online'], + { abortSignal, cwd: homedir() }, + ), ) if (fallback.code === 0) { return fallback.stdout.trim() diff --git a/src/utils/execFileNoThrow.ts b/src/utils/execFileNoThrow.ts index 084664fa7b..fe97ab3285 100644 --- a/src/utils/execFileNoThrow.ts +++ b/src/utils/execFileNoThrow.ts @@ -88,10 +88,6 @@ function validateWorkingDirectory(cwd: string | undefined): string | null { return null } -// Track which env keys we've already logged about so we don't spam the -// debug log every time a child is spawned with the same dirty env. -const warnedDirtyEnvKeys = new Set() - function sanitizeEnvironment( env: NodeJS.ProcessEnv | undefined, ): { value?: NodeJS.ProcessEnv; error?: string } { @@ -99,14 +95,9 @@ function sanitizeEnvironment( return {} } - // Keys with control characters are still rejected — there is no safe way - // to forward them and they almost certainly indicate tampering. Values - // with control characters are dropped instead of aborting the whole spawn: - // the most common cause is a multi-line value exported by the user's - // shell rc (PS1, PROMPT_COMMAND, ssh keys), and rejecting the whole call - // turned simple operations like `git clone` into hard failures. + // Keys OR values with control characters are rejected — there is no safe way + // to forward them and they almost certainly indicate tampering. const sanitized: NodeJS.ProcessEnv = {} - const droppedKeys: string[] = [] for (const [key, value] of Object.entries(env)) { if (CONTROL_CHAR_PATTERN.test(key)) { return { @@ -114,25 +105,13 @@ function sanitizeEnvironment( } } if (typeof value === 'string' && CONTROL_CHAR_PATTERN.test(value)) { - droppedKeys.push(key) - continue + return { + error: `Unsafe environment: control characters are not allowed in values`, + } } sanitized[key] = value } - if (droppedKeys.length > 0) { - const newKeys = droppedKeys.filter(k => !warnedDirtyEnvKeys.has(k)) - if (newKeys.length > 0) { - for (const k of newKeys) warnedDirtyEnvKeys.add(k) - // Use console.warn so the user sees this without DEBUG=1; it is - // strictly less noisy than the previous behavior of failing the - // command outright. - console.warn( - `[verboo] Dropping env var(s) with control characters before spawning child: ${newKeys.join(', ')}. Fix the source (likely your shell rc) to silence this.`, - ) - } - } - return { value: sanitized } } diff --git a/src/utils/hookChains.test.ts b/src/utils/hookChains.test.ts index 681fd26f25..df532a4983 100644 --- a/src/utils/hookChains.test.ts +++ b/src/utils/hookChains.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -23,17 +23,15 @@ async function importHookChainsModule(options?: { const allowRemoteSessions = options?.allowRemoteSessions ?? true - mock.module('../services/analytics/index.js', () => ({ - logEvent: () => {}, - })) + // Spy on real modules instead of mock.module to preserve transitive exports + const analytics = await import('../services/analytics/index.js') + spyOn(analytics, 'logEvent').mockImplementation(() => {}) - mock.module('./telemetry/events.js', () => ({ - logOTelEvent: async () => {}, - })) + const telemetry = await import('./telemetry/events.js') + spyOn(telemetry, 'logOTelEvent').mockImplementation(async () => {}) - mock.module('../services/policyLimits/index.js', () => ({ - isPolicyAllowed: () => allowRemoteSessions, - })) + const policyLimits = await import('../services/policyLimits/index.js') + spyOn(policyLimits, 'isPolicyAllowed').mockImplementation(() => allowRemoteSessions) return import(`./hookChains.js?test=${Date.now()}-${Math.random()}`) } diff --git a/src/utils/hookChains.ts b/src/utils/hookChains.ts index abce52508c..5eed2c4523 100644 --- a/src/utils/hookChains.ts +++ b/src/utils/hookChains.ts @@ -1,7 +1,7 @@ import { createHash } from 'crypto' import { statSync } from 'fs' import { join, resolve } from 'path' -import { HOOK_EVENTS } from 'src/entrypoints/agentSdkTypes.js' +import { HOOK_EVENTS } from 'src/entrypoints/sdk/coreTypes.js' import { getOriginalCwd } from '../bootstrap/state.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, diff --git a/src/utils/hooks/registerFrontmatterHooks.ts b/src/utils/hooks/registerFrontmatterHooks.ts index 105e179574..e764925f97 100644 --- a/src/utils/hooks/registerFrontmatterHooks.ts +++ b/src/utils/hooks/registerFrontmatterHooks.ts @@ -1,4 +1,4 @@ -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/sdk/coreTypes.js' import type { AppState } from 'src/state/AppState.js' import { logForDebugging } from '../debug.js' import type { HooksSettings } from '../settings/types.js' diff --git a/src/utils/hooks/registerSkillHooks.ts b/src/utils/hooks/registerSkillHooks.ts index 8619d51da9..5228ee360c 100644 --- a/src/utils/hooks/registerSkillHooks.ts +++ b/src/utils/hooks/registerSkillHooks.ts @@ -1,4 +1,4 @@ -import { HOOK_EVENTS } from 'src/entrypoints/agentSdkTypes.js' +import { HOOK_EVENTS } from 'src/entrypoints/sdk/coreTypes.js' import type { AppState } from 'src/state/AppState.js' import { logForDebugging } from '../debug.js' import type { HooksSettings } from '../settings/types.js' diff --git a/src/utils/hooks/sessionHooks.ts b/src/utils/hooks/sessionHooks.ts index ad0d1f9b90..8fc05b1033 100644 --- a/src/utils/hooks/sessionHooks.ts +++ b/src/utils/hooks/sessionHooks.ts @@ -1,4 +1,4 @@ -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/sdk/coreTypes.js' import type { AppState } from 'src/state/AppState.js' import type { Message } from 'src/types/message.js' import { logForDebugging } from '../debug.js' diff --git a/src/utils/knowledgeGraph.stress.test.ts b/src/utils/knowledgeGraph.stress.test.ts index b98508fd45..c765d9ef46 100644 --- a/src/utils/knowledgeGraph.stress.test.ts +++ b/src/utils/knowledgeGraph.stress.test.ts @@ -16,8 +16,9 @@ import { setClaudeConfigHomeDirForTesting } from './envUtils.js' import { getFsImplementation } from './fsOperations.js' describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { - const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + const originalConfigDir = process.env.VERBOO_CONFIG_DIR const originalOrama = process.env.OPENCLAUDE_KNOWLEDGE_ORAMA + const originalProjectsDir = process.env.VERBOO_PROJECTS_DIR const configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-')) const cwd = getFsImplementation().cwd() @@ -47,7 +48,8 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { beforeEach(async () => { await acquireEnvMutex() - process.env.CLAUDE_CONFIG_DIR = configDir + process.env.VERBOO_CONFIG_DIR = configDir + process.env.VERBOO_PROJECTS_DIR = join(configDir, 'projects') process.env.OPENCLAUDE_KNOWLEDGE_ORAMA = '1' setClaudeConfigHomeDirForTesting(configDir) resetGlobalGraph() @@ -58,9 +60,14 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { resetGlobalGraph() clearMemoryOnly() if (originalConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR + delete process.env.VERBOO_CONFIG_DIR } else { - process.env.CLAUDE_CONFIG_DIR = originalConfigDir + process.env.VERBOO_CONFIG_DIR = originalConfigDir + } + if (originalProjectsDir === undefined) { + delete process.env.VERBOO_PROJECTS_DIR + } else { + process.env.VERBOO_PROJECTS_DIR = originalProjectsDir } if (originalOrama === undefined) { delete process.env.OPENCLAUDE_KNOWLEDGE_ORAMA diff --git a/src/utils/markdownConfigLoader.ts b/src/utils/markdownConfigLoader.ts index 16a27f32e1..68514dbfc9 100644 --- a/src/utils/markdownConfigLoader.ts +++ b/src/utils/markdownConfigLoader.ts @@ -38,7 +38,7 @@ export const CLAUDE_CONFIG_DIRECTORIES = [ export type ClaudeConfigDirectory = (typeof CLAUDE_CONFIG_DIRECTORIES)[number] -export const PROJECT_CONFIG_DIR_NAMES = ['.verboo', '.claude'] as const +export const PROJECT_CONFIG_DIR_NAMES = ['.claude', '.verboo'] as const export type MarkdownFile = { filePath: string diff --git a/src/utils/permissions/filesystem.ts b/src/utils/permissions/filesystem.ts index 16bfaa3a42..8a4e1c3f27 100644 --- a/src/utils/permissions/filesystem.ts +++ b/src/utils/permissions/filesystem.ts @@ -108,15 +108,25 @@ export function getClaudeSkillScope( const absolutePath = expandPath(filePath) const absolutePathLower = normalizeCaseForComparison(absolutePath) + const defaultConfigHome = join(homedir(), '.verboo').normalize('NFC') + const isDefaultConfigHome = getClaudeConfigHomeDir() === defaultConfigHome + const bases = [ { dir: expandPath(join(getOriginalCwd(), '.verboo', 'skills')), prefix: '/.verboo/skills/', }, - { - dir: expandPath(join(getClaudeConfigHomeDir(), 'skills')), - prefix: '~/.verboo/skills/', - }, + // Só emite regra com prefixo ~/.verboo/skills/ quando VERBOO_CONFIG_DIR + // é o diretório padrão. Config dir customizado (ex.: env var apontando + // para outro path) não deve gerar regra fixa — o prefixo não bateria. + ...(isDefaultConfigHome + ? [ + { + dir: expandPath(join(getClaudeConfigHomeDir(), 'skills')), + prefix: '~/.verboo/skills/', + }, + ] + : []), { dir: expandPath(join(homedir(), '.claude', 'skills')), prefix: '~/.claude/skills/',