Install shared skills for 20 more agents and read every agent home in WSL (#29122)

* Add skill install entries for more agents; read every provider home env in WSL

Adds table entries for CodeBuddy, Qoder, Kiro (KIRO_HOME), Antigravity and
16 agents that read the shared .agents/skills root. The Antigravity entry
replaces its hand-written discovery source. The WSL env probe now reads
every table homeEnv in one NUL-terminated login-shell probe, and WSL
provider detection probes every table provider's detection commands.

* Fence the WSL provider-home probe and reject unusable relocated homes

The WSL probe now runs through buildWslCapturedLoginShellCommand, so rc
output cannot be read as a home value, bash reads .bashrc exports, and fish
falls back to sh. A relocated home at the filesystem root, the user home or
an ancestor of it falls back to the default folder on host and WSL.

* Treat an unusable WSL guest home as a failed provider-home probe
This commit is contained in:
Jinwoo Hong
2026-10-15 14:21:28 -04:00
committed by GitHub
parent 027ac77710
commit fd21909f15
10 changed files with 346 additions and 84 deletions
@@ -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
@@ -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())
+1 -8
View File
@@ -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',
@@ -128,7 +128,7 @@ describe('resolveSkillProviderDestinations', () => {
resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['opencode', 'goose', 'not-an-agent']
detectedProviders: ['aider', 'ante', 'not-an-agent']
})
).toEqual([])
})
@@ -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)
}
}
})
})
@@ -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<string, string>, 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({})
})
})
+90 -45
View File
@@ -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<typeof posix, 'dirname' | 'isAbsolute' | 'relative' | 'sep'>
/**
* 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
* `<home>/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<string>
/** The fenced payload, or null when the probe failed or the fence was lost. */
type WslEnvironmentProbe = (distro: string) => Promise<string | null>
async function probeWslGrokHome(distro: string): Promise<string> {
async function probeWslProviderHomes(distro: string): Promise<string | null> {
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<string | null> {
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<SkillProviderRootOverrides> {
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 {}
}
}
@@ -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 () => {
@@ -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<string[]> {
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<string[
if (result.code !== 0) {
throw new Error('skill-install-wsl-provider-detection-failed')
}
// Unconditional, not just on an empty list: the script ends in `|| true`, so
// Unconditional, not just on an empty list: the script always exits 0, so
// without the login PATH each lookup independently reads absent. A `claude`
// on the default PATH via Windows interop plus an nvm-only `codex` returns a
// plausible-looking `['claude']`, and the caller then skips the ~/.codex
@@ -33,7 +47,9 @@ export async function detectSkillProvidersInWsl(distro: string): Promise<string[
if (!result.environmentResolved) {
throw new Error('skill-install-wsl-provider-detection-failed')
}
return result.stdout
.split(/\r?\n/u)
.filter((provider) => provider === 'codex' || provider === 'claude')
return resolveDetectedTuiAgentIds(
PROVIDER_DETECTION_COMMANDS,
new Set(result.stdout.split(/\r?\n/u)),
'wsl'
)
}
+125 -1
View File
@@ -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 `<home>/skills`); the WSL probe is still Grok-only. */
/** Env var relocating the agent's home (skills in `<home>/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[])