fix skills visibility by provider registry

This commit is contained in:
Brennan Benson
2026-08-24 10:57:48 -07:00
parent afd76a4df9
commit 64b20bdf7a
5 changed files with 75 additions and 20 deletions
@@ -29,7 +29,7 @@ function discovery(owner: string | null, rootPath = '/Users/test/.agents/skills'
id: 'source',
label: 'Source',
path: rootPath,
sourceKind: 'home' as const,
sourceKind: 'home' as 'home' | 'repo',
providers: ['agent-skills' as const],
owner,
exists: true
@@ -52,11 +52,30 @@ describe('isNativeChatSkillForAgent', () => {
expect(isNativeChatSkillForAgent('claude', skill({ providers: ['agent-skills'] }))).toBe(false)
})
it('uses explicit source ownership and keeps shared roots visible', () => {
it('uses explicit source ownership and limits global shared roots to Codex', () => {
const shared = discovery(null)
expect(isNativeChatSkillForAgent('codex', skill({}), shared)).toBe(true)
expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(true)
expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(true)
expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(false)
expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(false)
})
it('allows canonical workspace roots only for providers that read them', () => {
const workspace = discovery(null, '/workspace/.agents/skills')
workspace.sources[0]!.sourceKind = 'repo'
expect(
isNativeChatSkillForAgent(
'codex',
skill({ rootPath: '/workspace/.agents/skills', sourceKind: 'repo' }),
workspace
)
).toBe(true)
expect(
isNativeChatSkillForAgent(
'claude',
skill({ rootPath: '/workspace/.agents/skills', sourceKind: 'repo' }),
workspace
)
).toBe(false)
})
it('aliases OpenClaude to Claude roots without exposing them to other agents', () => {
@@ -91,12 +110,12 @@ describe('isNativeChatSkillForAgent', () => {
]
} satisfies Pick<SkillDiscoveryResult, 'sources'>
// A symlinked skill deduped under the Codex root but also reachable
// through the shared root stays visible to every agent.
// through the shared root stays visible to Codex only at global scope.
const merged = skill({
rootPath: '/Users/test/.codex/skills',
rootPaths: ['/Users/test/.codex/skills', '/Users/test/.agents/skills']
})
expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(true)
expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(false)
expect(isNativeChatSkillForAgent('codex', merged, result)).toBe(true)
const codexOnly = skill({
rootPath: '/Users/test/.codex/skills',
@@ -11,6 +11,7 @@ import {
selectNativeChatSkillStateInputs,
type NativeChatSkillDiscoveryContext
} from './native-chat-skill-discovery-context'
import { isSkillSourceVisibleToAgent } from '../../../../shared/skill-install-providers'
export {
resolveNativeChatSkillDiscoveryContext,
@@ -66,7 +67,7 @@ export function isNativeChatSkillForAgent(
const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath]
return rootPaths.some((rootPath) => {
const source = result.sources.find((entry) => entry.path === rootPath)
return source?.owner === null || source?.owner === profile.skillSourceOwner
return source ? isSkillSourceVisibleToAgent(profile.skillSourceOwner, source) : false
})
}
@@ -40,7 +40,7 @@ function source(
}
describe('orchestration skill agent coverage', () => {
it('marks shared-path agents from the global ~/.agents/skills install', () => {
it('marks only Codex from the global ~/.agents/skills install', () => {
const skills = [
skill({
providers: ['agent-skills'],
@@ -58,8 +58,8 @@ describe('orchestration skill agent coverage', () => {
)
).toEqual([
{ agent: 'codex', label: 'Codex', installed: true },
{ agent: 'gemini', label: 'Gemini', installed: true },
{ agent: 'droid', label: 'Droid', installed: true }
{ agent: 'gemini', label: 'Gemini', installed: false },
{ agent: 'droid', label: 'Droid', installed: false }
])
})
@@ -1,9 +1,9 @@
import type { AgentType } from '../../../shared/agent-status-types'
import type { DiscoveredSkill, SkillDiscoverySource } from '../../../shared/skills'
import type { TuiAgent } from '../../../shared/tui-agent'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { getAgentLabel } from '@/lib/agent-catalog'
import { TUI_AGENT_AUTO_PICK_ORDER } from '../../../shared/tui-agent-selection'
import { isSkillSourceVisibleToAgent } from '../../../shared/skill-install-providers'
export type OrchestrationSkillAgentStatus = {
agent: TuiAgent
@@ -30,20 +30,12 @@ function isOrchestrationSkill(skill: DiscoveredSkill): boolean {
)
}
// Why: `native-chat-agent-profiles.ts` carries the same rule as `skillSourceOwner`;
// keep the two in step when an agent starts reading another agent's roots.
function getSkillSourceOwnerForAgent(agent: TuiAgent): AgentType {
// Why: both launch Claude Code and therefore consume Claude-owned skill roots.
return agent === 'claude-agent-teams' || agent === 'openclaude' ? 'claude' : agent
}
/** `skills` and `sources` must come from the same discovery scan. */
export function agentHasOrchestrationSkill(
agent: TuiAgent,
skills: readonly DiscoveredSkill[],
sources: readonly SkillDiscoverySource[]
): boolean {
const owner = getSkillSourceOwnerForAgent(agent)
return skills.some((skill) => {
if (!isOrchestrationSkill(skill)) {
return false
@@ -58,7 +50,7 @@ export function agentHasOrchestrationSkill(
(source) =>
source.path === rootPath &&
source.sourceKind !== 'repo' &&
(source.owner === null || source.owner === owner)
isSkillSourceVisibleToAgent(agent, source)
)
)
})
+43
View File
@@ -1,3 +1,5 @@
import type { SkillDiscoverySource } from './skills'
/**
* Agents Orca can place an installed skill for.
*
@@ -95,6 +97,47 @@ export function skillInstallProvider(
return PROVIDERS_BY_ID.get(id)
}
const SKILL_INSTALL_PROVIDER_ALIASES: Readonly<Record<string, SkillInstallProviderId>> = {
'claude-agent-teams': 'claude',
openclaude: 'claude'
}
/** Resolve a detected agent to the provider registry entry that owns its roots. */
export function skillInstallProviderIdForAgent(value: string): SkillInstallProviderId | null {
if (isSkillInstallProviderId(value)) {
return value
}
return SKILL_INSTALL_PROVIDER_ALIASES[value] ?? null
}
/**
* Whether a discovered source is readable by an agent at its scan scope.
* Shared canonical roots are only visible where the registry says the provider
* reads that scope; owned roots remain limited to their owner (and aliases).
*/
export function isSkillSourceVisibleToAgent(
agent: string,
source: Pick<SkillDiscoverySource, 'owner' | 'sourceKind'>
): boolean {
const providerId = skillInstallProviderIdForAgent(agent)
const sourceOwner = source.owner
if (sourceOwner !== null) {
return sourceOwner === agent || sourceOwner === providerId
}
const provider = providerId ? skillInstallProvider(providerId) : undefined
if (!provider) {
return false
}
if (source.sourceKind === 'home') {
return provider.globalSegments === null
}
if (source.sourceKind === 'repo') {
return provider.workspaceSegments === null
}
return false
}
/** Detected agents Orca can actually place skills for, in registry order. */
export function installableSkillProviders(
detectedProviders: readonly string[]