diff --git a/src/main/runtime/runtime-skill-install-authority.ts b/src/main/runtime/runtime-skill-install-authority.ts index 60cba2b22c9..3c040b4079b 100644 --- a/src/main/runtime/runtime-skill-install-authority.ts +++ b/src/main/runtime/runtime-skill-install-authority.ts @@ -13,7 +13,7 @@ import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations' import { resolveEnvironmentSkillProviderRoots, - resolveWslGrokSkillProviderRoot, + resolveWslSkillProviderRoots, withClaudeSkillProviderRoot } from '../skills/skill-provider-runtime-roots' import type { SkillInstallRequest, SkillProviderRootOverrides } from './runtime-skill-types' @@ -84,13 +84,8 @@ export async function resolveSkillProviderRoots( if (destination.scope !== 'global') { return {} } - const grok = destination.wslDistro - ? await resolveWslGrokSkillProviderRoot(destination.wslDistro) - : null const roots = destination.wslDistro - ? grok - ? { grok } - : {} + ? await resolveWslSkillProviderRoots(destination.wslDistro) : resolveEnvironmentSkillProviderRoots() const config = host.getClaudeConfigDirectory?.( destination.wslDistro diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts index 7cca77d3fd4..2194eb01ba3 100644 --- a/src/main/skills/skill-discovery-concurrency.test.ts +++ b/src/main/skills/skill-discovery-concurrency.test.ts @@ -185,7 +185,7 @@ describe('bounded concurrent skill discovery', () => { const line = String(info.mock.calls.at(0)?.at(0)) // `present` is the signal that separates "big tree" from "big root set", and // is not derivable from the other counts. - expect(line).toContain('[skills] scan roots=26 present=3 walked=26 skills=3') + expect(line).toContain('[skills] scan roots=32 present=3 walked=32 skills=3') expect(line).toContain('home-claude') expect(line).not.toContain(home) expect(line).not.toContain(tmpdir()) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 1e490413a20..89881a310cc 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -140,6 +140,7 @@ export function buildSkillDiscoverySources( // Why: `npx skills add --global` writes into each agent's own home skills // directory, so coverage misses them unless we scan every provider root. ...providerHomeSources(home, pathApi, providerRootOverrides), + // Why: discovery-only agent folders; installs for these agents go to the canonical root. source( 'home-opencode', 'OpenCode home', @@ -173,14 +174,6 @@ export function buildSkillDiscoverySources( ['agent-skills'], 'prime-agent' ), - source( - 'home-antigravity', - 'Antigravity home', - pathApi.join(home, '.gemini', 'config', 'skills'), - 'home', - ['agent-skills'], - 'antigravity' - ), // Why: Muse installs to `.agents/skills` but also reads XDG config home. source( 'home-muse', diff --git a/src/main/skills/skill-provider-destinations.test.ts b/src/main/skills/skill-provider-destinations.test.ts index b755c4068e8..4254a6bd07f 100644 --- a/src/main/skills/skill-provider-destinations.test.ts +++ b/src/main/skills/skill-provider-destinations.test.ts @@ -128,7 +128,7 @@ describe('resolveSkillProviderDestinations', () => { resolveSkillProviderDestinations({ scope: 'global', homeDirectory: HOME, - detectedProviders: ['opencode', 'goose', 'not-an-agent'] + detectedProviders: ['aider', 'ante', 'not-an-agent'] }) ).toEqual([]) }) diff --git a/src/main/skills/skill-provider-discovery-coverage.test.ts b/src/main/skills/skill-provider-discovery-coverage.test.ts index ab5dce46e40..ed6181a5be1 100644 --- a/src/main/skills/skill-provider-discovery-coverage.test.ts +++ b/src/main/skills/skill-provider-discovery-coverage.test.ts @@ -55,7 +55,10 @@ describe('skill install provider table drives discovery', () => { continue } const relocated = resolve('/srv', provider.id) - const overrides = resolveEnvironmentSkillProviderRoots({ [provider.homeEnv]: relocated }) + const overrides = resolveEnvironmentSkillProviderRoots( + { [provider.homeEnv]: relocated }, + resolve(HOME) + ) expect(overrides).toEqual({ [provider.id]: join(relocated, 'skills') }) expect( discover(overrides).find( @@ -64,4 +67,12 @@ describe('skill install provider table drives discovery', () => { ).toBe(overrides[provider.id]) } }) + + it('names each provider home with a plain env var the WSL probe can expand', () => { + for (const provider of SKILL_INSTALL_PROVIDERS) { + if (provider.homeEnv) { + expect(provider.homeEnv).toMatch(/^[A-Z_][A-Z0-9_]*$/u) + } + } + }) }) diff --git a/src/main/skills/skill-provider-runtime-roots.test.ts b/src/main/skills/skill-provider-runtime-roots.test.ts index 43298e0a738..e7b7e50933e 100644 --- a/src/main/skills/skill-provider-runtime-roots.test.ts +++ b/src/main/skills/skill-provider-runtime-roots.test.ts @@ -1,13 +1,26 @@ import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers' import { resolveDefaultHermesSkillsRoot, resolveEnvironmentHermesSkillsRoot, resolveEnvironmentSkillProviderRoots, - resolveWslGrokSkillProviderRoot, + resolveWslSkillProviderRoots, withClaudeSkillProviderRoot } from './skill-provider-runtime-roots' +// The probe prints `$HOME` then each table `homeEnv` in table order, each NUL-terminated. +function wslHomes(values: Record, home = '/home/dev'): string { + return [ + home, + ...SKILL_INSTALL_PROVIDERS.flatMap((provider) => + provider.homeEnv ? [values[provider.homeEnv] ?? ''] : [] + ) + ] + .map((value) => `${value}\0`) + .join('') +} + describe('skill provider runtime roots', () => { it('maps Claude and Grok config homes to their global skill roots', () => { const claudeRoot = resolve('/srv/claude') @@ -35,6 +48,23 @@ describe('skill provider runtime roots', () => { }) }) + it('falls back to the default folder for a home at the root, the user home or above it', () => { + const userHome = resolve('/home/dev') + for (const unusable of [resolve('/'), resolve('/home'), userHome]) { + expect( + resolveEnvironmentSkillProviderRoots( + { GROK_HOME: unusable, KIRO_HOME: unusable }, + userHome + ), + unusable + ).toEqual({}) + } + const inside = join(userHome, 'profiles', 'kiro') + expect(resolveEnvironmentSkillProviderRoots({ KIRO_HOME: inside }, userHome)).toEqual({ + kiro: join(inside, 'skills') + }) + }) + it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => { const hermesRoot = resolve('/srv/hermes') expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: hermesRoot })).toBe( @@ -103,23 +133,49 @@ describe('skill provider runtime roots', () => { expect(seen).toEqual([]) }) - it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => { + it('maps a WSL login shell home var to a host-readable skill root', async () => { await expect( - resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n') - ).resolves.toBe('\\\\wsl.localhost\\Ubuntu-24.04\\srv\\grok\\skills') + resolveWslSkillProviderRoots('Ubuntu-24.04', async () => + wslHomes({ GROK_HOME: '/srv/grok\n' }) + ) + ).resolves.toEqual({ grok: '\\\\wsl.localhost\\Ubuntu-24.04\\srv\\grok\\skills' }) }) - it('ignores unsafe or missing WSL GROK_HOME values', async () => { + it('ignores unsafe, unusable, incomplete or missing WSL home values', async () => { + for (const value of ['../grok', '/srv/a\tb', '/', '/home', '/home/dev', '/home/dev/']) { + await expect( + resolveWslSkillProviderRoots('Ubuntu', async () => wslHomes({ GROK_HOME: value })), + value + ).resolves.toEqual({}) + } await expect( - resolveWslGrokSkillProviderRoot('Ubuntu', async () => '../grok') - ).resolves.toBeNull() + resolveWslSkillProviderRoots('Ubuntu', async () => + wslHomes({ GROK_HOME: '/home/dev/profiles/grok' }) + ) + ).resolves.toEqual({ + grok: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\profiles\\grok\\skills' + }) + // No final terminator: the payload is incomplete, so nothing in it is trusted. await expect( - resolveWslGrokSkillProviderRoot('Ubuntu', async () => '/srv/grok\0other') - ).resolves.toBeNull() + resolveWslSkillProviderRoots('Ubuntu', async () => + wslHomes({ GROK_HOME: '/srv/grok' }).slice(0, -1) + ) + ).resolves.toEqual({}) + // An empty or relative guest $HOME disables the home guard, so nothing is trusted. + for (const home of ['', 'home/dev']) { + await expect( + resolveWslSkillProviderRoots('Ubuntu', async () => + wslHomes({ GROK_HOME: '/srv/grok' }, home) + ), + home + ).resolves.toEqual({}) + } + // A lost fence. + await expect(resolveWslSkillProviderRoots('Ubuntu', async () => null)).resolves.toEqual({}) await expect( - resolveWslGrokSkillProviderRoot('Ubuntu', async () => { + resolveWslSkillProviderRoots('Ubuntu', async () => { throw new Error('probe failed') }) - ).resolves.toBeNull() + ).resolves.toEqual({}) }) }) diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts index 7d93b404cb9..065f6e9cca7 100644 --- a/src/main/skills/skill-provider-runtime-roots.ts +++ b/src/main/skills/skill-provider-runtime-roots.ts @@ -1,19 +1,43 @@ import { statSync } from 'node:fs' -import { isAbsolute, join, resolve } from 'node:path' +import { homedir } from 'node:os' +import path, { isAbsolute, join, posix, resolve } from 'node:path' import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers' +import { buildWslCapturedLoginShellCommand } from '../../shared/wsl-login-shell-command' import { toWindowsWslPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' import type { SkillProviderRootOverrides } from './skill-provider-destinations' const PROVIDER_ROOT_MAX_LENGTH = 32_768 const WSL_ENV_PROBE_TIMEOUT_MS = 8_000 -const WSL_ENV_PROBE_MAX_BYTES = 4_097 -const WSL_GROK_HOME_SCRIPT = [ - 'entry=$(getent passwd "$(id -u)" 2>/dev/null || true)', - 'login_shell=${entry##*:}', - 'case "$login_shell" in /*) ;; *) login_shell=/bin/sh ;; esac', - `exec "$login_shell" -lc 'printf %s "\${GROK_HOME:-}" | head -c ${WSL_ENV_PROBE_MAX_BYTES}'` -].join('\n') +const WSL_ENV_PROBE_VALUE_MAX_LENGTH = 4_096 +// Room for rc output ahead of the fence; a clipped fence reads as a failed probe. +const WSL_ENV_PROBE_MAX_BYTES = 65_536 +const PROVIDER_HOME_ENVS = SKILL_INSTALL_PROVIDERS.flatMap((provider) => + provider.homeEnv ? [{ id: provider.id, name: provider.homeEnv }] : [] +) +// Why $HOME first: unusable homes are judged against the guest's own home. +const WSL_PROVIDER_HOMES_PROBE = buildWslCapturedLoginShellCommand( + `printf '%s\\0' "$HOME" ${PROVIDER_HOME_ENVS.map(({ name }) => `"\${${name}:-}"`).join(' ')}` +) + +type HomePathApi = Pick + +/** + * Kiro documents ignoring a `KIRO_HOME` at the filesystem root, the user home or + * an ancestor of it; Orca applies that rule to every `homeEnv` on purpose, since + * `/skills` there would be an unrelated folder. + */ +function isUsableProviderHome(candidate: string, userHome: string, pathApi: HomePathApi): boolean { + if (pathApi.dirname(candidate) === candidate) { + return false + } + const toUserHome = pathApi.relative(candidate, userHome) + return ( + toUserHome === '..' || + toUserHome.startsWith(`..${pathApi.sep}`) || + pathApi.isAbsolute(toUserHome) + ) +} function normalizedRoot(value: string | undefined): string | null { const candidate = value?.trim() @@ -29,12 +53,13 @@ function normalizedRoot(value: string | undefined): string | null { } export function resolveEnvironmentSkillProviderRoots( - env: NodeJS.ProcessEnv = process.env + env: NodeJS.ProcessEnv = process.env, + userHome: string = homedir() ): SkillProviderRootOverrides { const roots: SkillProviderRootOverrides = {} for (const provider of SKILL_INSTALL_PROVIDERS) { const home = provider.homeEnv ? normalizedRoot(env[provider.homeEnv]) : null - if (home) { + if (home && isUsableProviderHome(home, resolve(userHome), path)) { roots[provider.id] = join(home, 'skills') } } @@ -91,50 +116,70 @@ export function withClaudeSkillProviderRoot( return configDirectory ? { ...roots, claude: join(configDirectory, 'skills') } : roots } -type WslEnvironmentProbe = (distro: string) => Promise +/** The fenced payload, or null when the probe failed or the fence was lost. */ +type WslEnvironmentProbe = (distro: string) => Promise -async function probeWslGrokHome(distro: string): Promise { +async function probeWslProviderHomes(distro: string): Promise { const result = await runWslProcess({ distro, - // 'none': the script runs its own `"$login_shell" -lc`, so asking the - // runner to probe first buys a second login shell and spends up to half - // the 8s budget before the -lc that actually reads GROK_HOME starts. + // 'none': the fenced command runs its own login shell, so a runner probe + // would buy a second one out of the same 8s budget. loginPath: 'none', - script: WSL_GROK_HOME_SCRIPT, - // POSIX (`case`, `exec`); declared because the payload is opaque here. + script: WSL_PROVIDER_HOMES_PROBE.command, shell: 'sh', timeoutMs: WSL_ENV_PROBE_TIMEOUT_MS, maxOutputBytes: WSL_ENV_PROBE_MAX_BYTES }) - // A timeout mid-write can leave a truncated but shape-valid absolute path. if (result.code !== 0 || result.timedOut) { - return '' - } - return result.stdout -} - -export async function resolveWslGrokSkillProviderRoot( - distro: string, - probe: WslEnvironmentProbe = probeWslGrokHome -): Promise { - try { - const value = await probe(distro) - const candidate = value.trim() - if ( - !candidate || - candidate.length >= WSL_ENV_PROBE_MAX_BYTES || - !candidate.startsWith('/') || - candidate.includes('\\') || - Array.from(candidate).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) - ) { - return null - } - const grokHome = candidate.replace(/\/+$/u, '') || '/' - return toWindowsWslPath(`${grokHome === '/' ? '' : grokHome}/skills`, distro) - } catch { return null } + return WSL_PROVIDER_HOMES_PROBE.readStdout(result.stdout) +} + +/** A clean absolute guest path, or null. */ +function cleanWslPath(value: string): string | null { + const candidate = value.trim() + if ( + !candidate || + candidate.length > WSL_ENV_PROBE_VALUE_MAX_LENGTH || + !candidate.startsWith('/') || + candidate.includes('\\') || + Array.from(candidate).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) + ) { + return null + } + return posix.resolve(candidate) +} + +/** Every table `homeEnv` as the WSL login shell sees it, read in one probe. */ +export async function resolveWslSkillProviderRoots( + distro: string, + probe: WslEnvironmentProbe = probeWslProviderHomes +): Promise { + try { + // `$HOME` then each value, all NUL-terminated; any other shape is a broken probe. + const fields = (await probe(distro))?.split('\0') ?? [] + if (fields.length !== PROVIDER_HOME_ENVS.length + 2) { + return {} + } + const [rawUserHome, ...values] = fields + // Without the guest home the root/home/ancestor guard cannot run. + const userHome = cleanWslPath(rawUserHome) + if (!userHome) { + return {} + } + const roots: SkillProviderRootOverrides = {} + PROVIDER_HOME_ENVS.forEach(({ id }, index) => { + const home = cleanWslPath(values[index]) + if (home && isUsableProviderHome(home, userHome, posix)) { + roots[id] = toWindowsWslPath(`${home}/skills`, distro) + } + }) + return roots + } catch { + return {} + } } diff --git a/src/main/skills/skill-wsl-provider-detection.test.ts b/src/main/skills/skill-wsl-provider-detection.test.ts index 677d0b6fdf9..6e57179e1ca 100644 --- a/src/main/skills/skill-wsl-provider-detection.test.ts +++ b/src/main/skills/skill-wsl-provider-detection.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runWslProcessMock = vi.hoisted(() => vi.fn()) vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) +import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers' +import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' type RunWslProcessSpec = { distro: string; loginPath: string; script: string } @@ -48,7 +50,27 @@ describe('detectSkillProvidersInWsl', () => { const found = await detectSkillProvidersInWsl('Ubuntu') - expect(found).toEqual(['codex', 'claude']) + expect(found.toSorted()).toEqual(['claude', 'codex']) + }) + + it('probes every install provider by the commands Orca detects it with', async () => { + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: 'cursor-agent\n', + stderr: '', + timedOut: false + }) + + const found = await detectSkillProvidersInWsl('Ubuntu') + + const [spec] = runWslProcessMock.mock.calls.at(-1) as [RunWslProcessSpec] + for (const provider of SKILL_INSTALL_PROVIDERS) { + for (const command of getTuiAgentDetectCommands(TUI_AGENT_CONFIG[provider.id])) { + expect(spec.script, provider.id).toContain(`'${command}'`) + } + } + expect(found).toEqual(['cursor']) }) it('ignores stray output that is not a recognized provider name', async () => { diff --git a/src/main/skills/skill-wsl-provider-detection.ts b/src/main/skills/skill-wsl-provider-detection.ts index 7b81d1d341c..164529971c7 100644 --- a/src/main/skills/skill-wsl-provider-detection.ts +++ b/src/main/skills/skill-wsl-provider-detection.ts @@ -1,16 +1,30 @@ +import { isSkillInstallProviderId } from '../../shared/skill-install-providers' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from '../../shared/tui-agent-detection-commands' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { runWslProcess } from '../wsl/wsl-runner' +const PROVIDER_DETECTION_COMMANDS = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter((command) => + isSkillInstallProviderId(command.id) +) + const DETECTION_SCRIPT = [ - 'set -eu', - 'command -v codex >/dev/null 2>&1 && printf "codex\\n" || true', - 'command -v claude >/dev/null 2>&1 && printf "claude\\n" || true' + `for cmd in ${getTuiAgentDetectionProbeCommands(PROVIDER_DETECTION_COMMANDS, 'wsl') + .map(quotePosixShell) + .join(' ')}; do`, + 'command -v "$cmd" >/dev/null 2>&1 && printf "%s\\n" "$cmd"', + 'done', + 'true' ].join('\n') export async function detectSkillProvidersInWsl(distro: string): Promise { let result try { // Why probe: a bare `sh -c` has no login shell, so a PATH built by nvm/mise - // rc files never applies and an installed codex/claude reads as absent. + // rc files never applies and an installed agent reads as absent. result = await runWslProcess({ distro, loginPath: 'preferred', @@ -25,7 +39,7 @@ export async function detectSkillProvidersInWsl(distro: string): Promise provider === 'codex' || provider === 'claude') + return resolveDetectedTuiAgentIds( + PROVIDER_DETECTION_COMMANDS, + new Set(result.stdout.split(/\r?\n/u)), + 'wsl' + ) } diff --git a/src/shared/skill-install-providers.ts b/src/shared/skill-install-providers.ts index 9d130a43433..64a544da788 100644 --- a/src/shared/skill-install-providers.ts +++ b/src/shared/skill-install-providers.ts @@ -12,7 +12,7 @@ type SkillInstallProviderEntry = { displayName: string globalSegments: readonly string[] | null workspaceSegments: readonly string[] | null - /** Env var relocating the agent's home (skills in `/skills`); the WSL probe is still Grok-only. */ + /** Env var relocating the agent's home (skills in `/skills`). */ homeEnv?: string } @@ -90,6 +90,130 @@ export const SKILL_INSTALL_PROVIDERS = Object.freeze([ displayName: 'ZCode', globalSegments: ['.zcode', 'skills'], workspaceSegments: null + }, + { + id: 'codebuddy', + displayName: 'CodeBuddy', + globalSegments: ['.codebuddy', 'skills'], + workspaceSegments: ['.codebuddy', 'skills'] + }, + { + id: 'qoder', + displayName: 'Qoder', + globalSegments: ['.qoder', 'skills'], + workspaceSegments: ['.qoder', 'skills'] + }, + { + id: 'kiro', + displayName: 'Kiro', + globalSegments: ['.kiro', 'skills'], + workspaceSegments: ['.kiro', 'skills'], + homeEnv: 'KIRO_HOME' + }, + // Why: the Antigravity CLI keeps user skills under its config home and + // project skills in the canonical root. + { + id: 'antigravity', + displayName: 'Antigravity', + globalSegments: ['.gemini', 'config', 'skills'], + workspaceSegments: null + }, + // Why: every agent below reads the canonical .agents/skills root at both scopes. + { + id: 'autohand', + displayName: 'Autohand Code', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'goose', + displayName: 'Goose', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'crush', + displayName: 'Crush', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'command-code', + displayName: 'Command Code', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'mistral-vibe', + displayName: 'Mistral Vibe', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'qwen-code', + displayName: 'Qwen Code', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'rovo', + displayName: 'Rovo Dev', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'openclaw', + displayName: 'OpenClaw', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'devin', + displayName: 'Devin', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'opencode', + displayName: 'OpenCode', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'pi', + displayName: 'Pi', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'amp', + displayName: 'Amp', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'kilo', + displayName: 'Kilo Code', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'cline', + displayName: 'Cline', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'kimi', + displayName: 'Kimi', + globalSegments: null, + workspaceSegments: null + }, + { + id: 'copilot', + displayName: 'GitHub Copilot', + globalSegments: null, + workspaceSegments: null } ] satisfies SkillInstallProviderEntry[])