mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
feat(skills): discover Native Chat skills on direct SSH hosts
Native Chat's / and $ pickers now list skills stored on the pane's SSH
execution host. Discovery runs as one skills.discover request inside the
deployed relay; the desktop never walks remote skill directories per-file.
- relay: new skills.discover handler runs the existing Node scanner on the
remote host with an 8s abort budget; old relays answer -32601
- desktop: connection-scoped SshSkillDiscoveryProvider (9s timeout) validates
relay frames against a bounded zod schema before renderer state
- runtime: new skills.discoverForPane({worktreeId, terminalTabId}) derives the
scan cwd from persisted tab state; a renderer path can never reach a remote
scan. Old paired runtimes answer method_not_found instead of silently
scanning their own disk
- renderer: SSH panes use the pane method with a connection-generation cache
key; old relay/runtime map to an upgrade-required picker state with Retry
Closes #11466
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import type { SshSkillDiscoveryProvider } from './ssh-skill-discovery-provider'
|
||||
|
||||
const sshProviders = new Map<string, SshSkillDiscoveryProvider>()
|
||||
|
||||
export const SSH_SKILL_DISCOVERY_PROVIDER_UNAVAILABLE_MESSAGE =
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
|
||||
export function registerSshSkillDiscoveryProvider(
|
||||
connectionId: string,
|
||||
provider: SshSkillDiscoveryProvider
|
||||
): void {
|
||||
sshProviders.set(connectionId, provider)
|
||||
}
|
||||
|
||||
export function unregisterSshSkillDiscoveryProvider(connectionId: string): void {
|
||||
sshProviders.delete(connectionId)
|
||||
}
|
||||
|
||||
export function getSshSkillDiscoveryProvider(
|
||||
connectionId: string
|
||||
): SshSkillDiscoveryProvider | undefined {
|
||||
return sshProviders.get(connectionId)
|
||||
}
|
||||
|
||||
export function requireSshSkillDiscoveryProvider(connectionId: string): SshSkillDiscoveryProvider {
|
||||
const provider = getSshSkillDiscoveryProvider(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_SKILL_DISCOVERY_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import {
|
||||
SshSkillDiscoveryProvider,
|
||||
SshSkillDiscoveryUnsupportedError
|
||||
} from './ssh-skill-discovery-provider'
|
||||
|
||||
const VALID_RESULT = {
|
||||
skills: [
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'docs',
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'repo',
|
||||
sourceLabel: 'Repo skills',
|
||||
rootPath: '/remote/repo/.agents/skills',
|
||||
directoryPath: '/remote/repo/.agents/skills/docs',
|
||||
skillFilePath: '/remote/repo/.agents/skills/docs/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: 'repo-agents',
|
||||
label: 'Repo skills',
|
||||
path: '/remote/repo/.agents/skills',
|
||||
sourceKind: 'repo',
|
||||
providers: ['agent-skills'],
|
||||
owner: null,
|
||||
exists: true
|
||||
}
|
||||
],
|
||||
scannedAt: 12345
|
||||
}
|
||||
|
||||
function providerWith(request: ReturnType<typeof vi.fn>): SshSkillDiscoveryProvider {
|
||||
return new SshSkillDiscoveryProvider('conn-1', { request } as unknown as SshChannelMultiplexer)
|
||||
}
|
||||
|
||||
describe('SshSkillDiscoveryProvider', () => {
|
||||
it('sends one skills.discover request with cwd, signal, and a bounded timeout', async () => {
|
||||
const request = vi.fn().mockResolvedValue(VALID_RESULT)
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await providerWith(request).discover('/remote/repo', {
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'skills.discover',
|
||||
{ cwd: '/remote/repo' },
|
||||
{ signal: controller.signal, timeoutMs: 9_000 }
|
||||
)
|
||||
expect(result.skills.map((skill) => skill.name)).toEqual(['docs'])
|
||||
})
|
||||
|
||||
it('maps JSON-RPC method-not-found to the relay upgrade error', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
Object.assign(new Error('Method not found: skills.discover'), { code: -32601 })
|
||||
)
|
||||
|
||||
await expect(providerWith(request).discover('/remote/repo')).rejects.toBeInstanceOf(
|
||||
SshSkillDiscoveryUnsupportedError
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed relay responses instead of trusting the frame', async () => {
|
||||
const malformed = {
|
||||
...VALID_RESULT,
|
||||
skills: [{ ...VALID_RESULT.skills[0], fileCount: Number.POSITIVE_INFINITY }]
|
||||
}
|
||||
const request = vi.fn().mockResolvedValue(malformed)
|
||||
|
||||
await expect(providerWith(request).discover('/remote/repo')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects oversized strings from the relay', async () => {
|
||||
const malformed = {
|
||||
...VALID_RESULT,
|
||||
skills: [{ ...VALID_RESULT.skills[0], name: 'x'.repeat(10_000) }]
|
||||
}
|
||||
const request = vi.fn().mockResolvedValue(malformed)
|
||||
|
||||
await expect(providerWith(request).discover('/remote/repo')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('passes through other transport errors unchanged', async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error('Request "skills.discover" timed out'))
|
||||
|
||||
await expect(providerWith(request).discover('/remote/repo')).rejects.toThrow(
|
||||
'Request "skills.discover" timed out'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SkillDiscoveryResult } from '../../shared/skills'
|
||||
import { parseSkillDiscoveryResult } from '../../shared/skills'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
|
||||
/** Below the renderer's 10s runtime-RPC timeout and above the relay's 8s scan
|
||||
* budget so each layer's classified error outlives the outer deadline. */
|
||||
const SKILL_DISCOVERY_REQUEST_TIMEOUT_MS = 9_000
|
||||
|
||||
/** Old relay without skills.discover; the runtime maps this to the typed
|
||||
* relay-upgrade-required pane response instead of a generic host error. */
|
||||
export class SshSkillDiscoveryUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'This SSH host is running an older Orca relay without skill discovery. Reconnect to deploy the latest relay, then try again.'
|
||||
)
|
||||
this.name = 'SshSkillDiscoveryUnsupportedError'
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonRpcMethodNotFoundError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound
|
||||
}
|
||||
|
||||
export class SshSkillDiscoveryProvider {
|
||||
constructor(
|
||||
private readonly connectionId: string,
|
||||
private readonly mux: SshChannelMultiplexer
|
||||
) {}
|
||||
|
||||
getConnectionId(): string {
|
||||
return this.connectionId
|
||||
}
|
||||
|
||||
async discover(cwd: string, options?: { signal?: AbortSignal }): Promise<SkillDiscoveryResult> {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = await this.mux.request(
|
||||
'skills.discover',
|
||||
{ cwd },
|
||||
{ signal: options?.signal, timeoutMs: SKILL_DISCOVERY_REQUEST_TIMEOUT_MS }
|
||||
)
|
||||
} catch (error) {
|
||||
if (isJsonRpcMethodNotFoundError(error)) {
|
||||
throw new SshSkillDiscoveryUnsupportedError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Why: the relay frame is remote input; validate before it reaches renderer state.
|
||||
return parseSkillDiscoveryResult(raw)
|
||||
}
|
||||
}
|
||||
@@ -666,6 +666,8 @@ import {
|
||||
} from '../project-runtime-git-options'
|
||||
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
|
||||
import type { SkillDiscoveryForPaneResponse } from '../../shared/skills'
|
||||
import { discoverPaneSkills } from '../skills/pane-skill-discovery'
|
||||
import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command'
|
||||
import {
|
||||
getLocalWorktreePathAccess,
|
||||
@@ -12272,6 +12274,37 @@ export class OrcaRuntimeService {
|
||||
: undefined
|
||||
}
|
||||
|
||||
// Why: the pane request carries identity only; this runtime derives the scan
|
||||
// directory and execution host from its own state, so a renderer-claimed path
|
||||
// can never reach a remote scan.
|
||||
async discoverSkillsForPane(
|
||||
args: { worktreeId: string; terminalTabId?: string },
|
||||
signal?: AbortSignal
|
||||
): Promise<SkillDiscoveryForPaneResponse> {
|
||||
const target = await this.resolveRuntimeFileTarget(args.worktreeId)
|
||||
const session = this.getWorkspaceSessionForWorktree(args.worktreeId)
|
||||
const sessionWorktreeId = session
|
||||
? resolveTerminalSessionWorktreeId(session, args.worktreeId)
|
||||
: null
|
||||
const tab =
|
||||
args.terminalTabId && session && sessionWorktreeId
|
||||
? session.tabsByWorktree[sessionWorktreeId]?.find(
|
||||
(candidate) => candidate.id === args.terminalTabId
|
||||
)
|
||||
: undefined
|
||||
return discoverPaneSkills({
|
||||
worktreeId: args.worktreeId,
|
||||
// A missing/unpersisted tab still scans a runtime-owned path: the workspace root.
|
||||
cwd: tab?.startupCwd?.trim() || target.worktree.path,
|
||||
connectionId: target.connectionId,
|
||||
projectRuntime: target.connectionId
|
||||
? undefined
|
||||
: this.resolveProjectRuntimeForWorktree(args.worktreeId),
|
||||
repos: this.listRepos(),
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' {
|
||||
let pty: RuntimePtyWorktreeRecord | null = null
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { SkillDiscoveryTargetSchema } from '../../../../shared/skills'
|
||||
import {
|
||||
PaneSkillDiscoveryTargetSchema,
|
||||
SkillDiscoveryTargetSchema
|
||||
} from '../../../../shared/skills'
|
||||
import {
|
||||
discoverSkillsOnTarget,
|
||||
resolveSkillDiscoveryTarget
|
||||
@@ -21,5 +24,13 @@ export const SKILL_METHODS: RpcMethod[] = [
|
||||
}
|
||||
return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos())
|
||||
}
|
||||
}),
|
||||
// Why: a separate method, not a field on skills.discover — old runtimes strip
|
||||
// unknown fields and would answer with a wrong-host native scan; method_not_found
|
||||
// lets clients classify the skew instead.
|
||||
defineMethod({
|
||||
name: 'skills.discoverForPane',
|
||||
params: PaneSkillDiscoveryTargetSchema,
|
||||
handler: async (params, { runtime, signal }) => runtime.discoverSkillsForPane(params, signal)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -389,3 +389,55 @@ describe('skill discovery', () => {
|
||||
expect(result.skills.map((skill) => skill.name)).not.toContain('Too Deep')
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill discovery cancellation', () => {
|
||||
async function makeScanFixture(): Promise<{ home: string; repo: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-abort-'))
|
||||
const home = join(root, 'home')
|
||||
const repo = join(root, 'repo')
|
||||
const repoSkill = join(repo, '.agents', 'skills', 'docs')
|
||||
await mkdir(join(home, '.codex', 'skills', 'review'), { recursive: true })
|
||||
await mkdir(repoSkill, { recursive: true })
|
||||
await writeFile(join(home, '.codex', 'skills', 'review', 'SKILL.md'), '# Review\n')
|
||||
await writeFile(join(repoSkill, 'SKILL.md'), '# Docs\n')
|
||||
return { home, repo }
|
||||
}
|
||||
|
||||
it('rejects an already-aborted scan instead of resolving with empty results', async () => {
|
||||
const { home, repo } = await makeScanFixture()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(
|
||||
discoverSkills({ homeDir: home, cwd: repo, repos: [], signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('rejects when aborted mid-scan and never publishes partial data', async () => {
|
||||
const { home, repo } = await makeScanFixture()
|
||||
const controller = new AbortController()
|
||||
const pending = discoverSkills({
|
||||
homeDir: home,
|
||||
cwd: repo,
|
||||
repos: [],
|
||||
signal: controller.signal
|
||||
})
|
||||
controller.abort()
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('returns identical successful results when a live signal is provided', async () => {
|
||||
const { home, repo } = await makeScanFixture()
|
||||
const controller = new AbortController()
|
||||
const withSignal = await discoverSkills({
|
||||
homeDir: home,
|
||||
cwd: repo,
|
||||
repos: [],
|
||||
signal: controller.signal
|
||||
})
|
||||
const withoutSignal = await discoverSkills({ homeDir: home, cwd: repo, repos: [] })
|
||||
expect(withSignal.skills.map((skill) => skill.name).sort()).toEqual(
|
||||
withoutSignal.skills.map((skill) => skill.name).sort()
|
||||
)
|
||||
expect(withSignal.skills.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,16 @@ const SKILL_FILE_NAME = 'SKILL.md'
|
||||
const MAX_MARKDOWN_BYTES = 256 * 1024
|
||||
const MAX_SKILL_FILES = 200
|
||||
|
||||
/** Node-version-safe alternative to signal.throwIfAborted(); an aborted scan
|
||||
* must reject rather than resolve with partial results. */
|
||||
function throwIfDiscoveryAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
const error = new Error('Skill discovery aborted')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(pathValue: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(pathValue)
|
||||
@@ -46,10 +56,15 @@ function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): b
|
||||
return rel.split(sep).length <= maxDepth
|
||||
}
|
||||
|
||||
async function findSkillFiles(rootPath: string, maxDepth: number): Promise<string[]> {
|
||||
async function findSkillFiles(
|
||||
rootPath: string,
|
||||
maxDepth: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
const visitedDirectoryPaths = new Set<string>()
|
||||
async function visit(dirPath: string): Promise<void> {
|
||||
throwIfDiscoveryAborted(signal)
|
||||
if (!isWithinDepth(rootPath, dirPath, maxDepth)) {
|
||||
return
|
||||
}
|
||||
@@ -71,6 +86,7 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise<strin
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
throwIfDiscoveryAborted(signal)
|
||||
const entryPath = join(dirPath, entry.name)
|
||||
if (entry.name === SKILL_FILE_NAME) {
|
||||
if (entry.isFile()) {
|
||||
@@ -109,10 +125,11 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise<strin
|
||||
return out
|
||||
}
|
||||
|
||||
async function countFiles(dirPath: string): Promise<number> {
|
||||
async function countFiles(dirPath: string, signal?: AbortSignal): Promise<number> {
|
||||
let count = 0
|
||||
const visitedDirectoryPaths = new Set<string>()
|
||||
async function visit(currentPath: string): Promise<void> {
|
||||
throwIfDiscoveryAborted(signal)
|
||||
if (count >= MAX_SKILL_FILES) {
|
||||
return
|
||||
}
|
||||
@@ -184,11 +201,12 @@ async function readSkillSummary(skillFilePath: string): Promise<{
|
||||
|
||||
type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string }
|
||||
|
||||
async function scanRoot(root: SkillScanRoot): Promise<ScannedSkill[]> {
|
||||
async function scanRoot(root: SkillScanRoot, signal?: AbortSignal): Promise<ScannedSkill[]> {
|
||||
const maxDepth = root.sourceKind === 'plugin' ? 9 : 4
|
||||
const skillFiles = await findSkillFiles(root.path, maxDepth)
|
||||
const skillFiles = await findSkillFiles(root.path, maxDepth, signal)
|
||||
const skills = await Promise.all(
|
||||
skillFiles.map(async (skillFilePath): Promise<ScannedSkill | null> => {
|
||||
throwIfDiscoveryAborted(signal)
|
||||
// Why: path identity belongs to the scanning host; canonicalizing before
|
||||
// returning prevents symlinked roots from becoming duplicate picker rows.
|
||||
const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath)
|
||||
@@ -211,7 +229,7 @@ async function scanRoot(root: SkillScanRoot): Promise<ScannedSkill[]> {
|
||||
directoryPath,
|
||||
skillFilePath,
|
||||
installed: true,
|
||||
fileCount: await countFiles(directoryPath),
|
||||
fileCount: await countFiles(directoryPath, signal),
|
||||
updatedAt: summary.updatedAt,
|
||||
canonicalSkillFilePath
|
||||
} satisfies ScannedSkill
|
||||
@@ -225,7 +243,10 @@ export async function discoverSkills(args: {
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
includeCwd?: boolean
|
||||
signal?: AbortSignal
|
||||
}): Promise<SkillDiscoveryResult> {
|
||||
const signal = args.signal
|
||||
throwIfDiscoveryAborted(signal)
|
||||
const homeDir = args.homeDir ?? homedir()
|
||||
const roots = [
|
||||
...buildSkillDiscoverySources({ ...args, homeDir }),
|
||||
@@ -238,6 +259,7 @@ export async function discoverSkills(args: {
|
||||
const sources: SkillDiscoverySource[] = []
|
||||
const skillGroups = await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
throwIfDiscoveryAborted(signal)
|
||||
const exists = await pathExists(root.path)
|
||||
sources.push({
|
||||
...root,
|
||||
@@ -248,9 +270,10 @@ export async function discoverSkills(args: {
|
||||
if (!exists) {
|
||||
return []
|
||||
}
|
||||
return scanRoot(root)
|
||||
return scanRoot(root, signal)
|
||||
})
|
||||
)
|
||||
throwIfDiscoveryAborted(signal)
|
||||
const seen = new Map<string, DiscoveredSkill>()
|
||||
for (const skill of skillGroups.flat()) {
|
||||
// Why: overlapping repo/cwd roots and symlinked provider homes can reach
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillDiscoveryResult } from '../../shared/skills'
|
||||
import type { SshSkillDiscoveryProvider } from '../providers/ssh-skill-discovery-provider'
|
||||
import { SshSkillDiscoveryUnsupportedError } from '../providers/ssh-skill-discovery-provider'
|
||||
import {
|
||||
registerSshSkillDiscoveryProvider,
|
||||
unregisterSshSkillDiscoveryProvider
|
||||
} from '../providers/ssh-skill-discovery-dispatch'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
discoverSkillsOnTarget: vi.fn(),
|
||||
resolveSkillDiscoveryTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./skill-discovery-target', () => ({
|
||||
discoverSkillsOnTarget: mocks.discoverSkillsOnTarget,
|
||||
resolveSkillDiscoveryTarget: mocks.resolveSkillDiscoveryTarget
|
||||
}))
|
||||
|
||||
import { discoverPaneSkills } from './pane-skill-discovery'
|
||||
|
||||
const EMPTY_RESULT: SkillDiscoveryResult = { skills: [], sources: [], scannedAt: 1 }
|
||||
|
||||
function fakeProvider(discover: ReturnType<typeof vi.fn>): SshSkillDiscoveryProvider {
|
||||
return { getConnectionId: () => 'conn-1', discover } as unknown as SshSkillDiscoveryProvider
|
||||
}
|
||||
|
||||
describe('discoverPaneSkills', () => {
|
||||
afterEach(() => {
|
||||
unregisterSshSkillDiscoveryProvider('conn-1')
|
||||
mocks.discoverSkillsOnTarget.mockReset()
|
||||
mocks.resolveSkillDiscoveryTarget.mockReset()
|
||||
})
|
||||
|
||||
it('routes SSH panes to the connection-scoped provider with the derived cwd', async () => {
|
||||
const discover = vi.fn().mockResolvedValue(EMPTY_RESULT)
|
||||
registerSshSkillDiscoveryProvider('conn-1', fakeProvider(discover))
|
||||
const signal = new AbortController().signal
|
||||
|
||||
const response = await discoverPaneSkills({
|
||||
worktreeId: 'wt-1',
|
||||
cwd: '/remote/repo',
|
||||
connectionId: 'conn-1',
|
||||
repos: [],
|
||||
signal
|
||||
})
|
||||
|
||||
expect(response).toEqual({ status: 'ok', result: EMPTY_RESULT })
|
||||
expect(discover).toHaveBeenCalledWith('/remote/repo', { signal })
|
||||
expect(mocks.discoverSkillsOnTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an old relay as relay-upgrade-required, never an empty success', async () => {
|
||||
const discover = vi.fn().mockRejectedValue(new SshSkillDiscoveryUnsupportedError())
|
||||
registerSshSkillDiscoveryProvider('conn-1', fakeProvider(discover))
|
||||
|
||||
await expect(
|
||||
discoverPaneSkills({
|
||||
worktreeId: 'wt-1',
|
||||
cwd: '/remote/repo',
|
||||
connectionId: 'conn-1',
|
||||
repos: []
|
||||
})
|
||||
).resolves.toEqual({ status: 'relay-upgrade-required' })
|
||||
})
|
||||
|
||||
it('fails a disconnected SSH pane instead of scanning local disk', async () => {
|
||||
await expect(
|
||||
discoverPaneSkills({
|
||||
worktreeId: 'wt-1',
|
||||
cwd: '/remote/repo',
|
||||
connectionId: 'conn-1',
|
||||
repos: []
|
||||
})
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
expect(mocks.discoverSkillsOnTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps non-SSH panes on the existing local/WSL target resolution', async () => {
|
||||
const resolved = { kind: 'native-host', cwd: '/local/repo' }
|
||||
mocks.resolveSkillDiscoveryTarget.mockReturnValue(resolved)
|
||||
mocks.discoverSkillsOnTarget.mockResolvedValue(EMPTY_RESULT)
|
||||
const projectRuntime = { status: 'resolved' } as never
|
||||
|
||||
const response = await discoverPaneSkills({
|
||||
worktreeId: 'wt-1',
|
||||
cwd: '/local/repo',
|
||||
projectRuntime,
|
||||
repos: []
|
||||
})
|
||||
|
||||
expect(response).toEqual({ status: 'ok', result: EMPTY_RESULT })
|
||||
expect(mocks.resolveSkillDiscoveryTarget).toHaveBeenCalledWith({
|
||||
cwd: '/local/repo',
|
||||
worktreeId: 'wt-1',
|
||||
projectRuntime
|
||||
})
|
||||
expect(mocks.discoverSkillsOnTarget).toHaveBeenCalledWith(resolved, [])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Repo } from '../../shared/types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
|
||||
import type { SkillDiscoveryForPaneResponse } from '../../shared/skills'
|
||||
import {
|
||||
getSshSkillDiscoveryProvider,
|
||||
SSH_SKILL_DISCOVERY_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-skill-discovery-dispatch'
|
||||
import { SshSkillDiscoveryUnsupportedError } from '../providers/ssh-skill-discovery-provider'
|
||||
import { discoverSkillsOnTarget, resolveSkillDiscoveryTarget } from './skill-discovery-target'
|
||||
|
||||
/** Executes a pane-scoped scan on the workspace's execution host. `cwd` and
|
||||
* `connectionId` are runtime-resolved, never renderer input. */
|
||||
export async function discoverPaneSkills(args: {
|
||||
worktreeId: string
|
||||
cwd: string
|
||||
connectionId?: string
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
repos: readonly Repo[]
|
||||
signal?: AbortSignal
|
||||
}): Promise<SkillDiscoveryForPaneResponse> {
|
||||
if (args.connectionId) {
|
||||
const provider = getSshSkillDiscoveryProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_SKILL_DISCOVERY_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
try {
|
||||
return { status: 'ok', result: await provider.discover(args.cwd, { signal: args.signal }) }
|
||||
} catch (error) {
|
||||
if (error instanceof SshSkillDiscoveryUnsupportedError) {
|
||||
return { status: 'relay-upgrade-required' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const target = resolveSkillDiscoveryTarget({
|
||||
cwd: args.cwd,
|
||||
worktreeId: args.worktreeId,
|
||||
...(args.projectRuntime ? { projectRuntime: args.projectRuntime } : {})
|
||||
})
|
||||
return { status: 'ok', result: await discoverSkillsOnTarget(target, args.repos) }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/** The relay bundle imports this scanner directly; an Electron import anywhere
|
||||
* in its transitive graph would break every deployed SSH relay. */
|
||||
const RELAY_SCANNER_ENTRY = 'src/main/skills/discovery.ts'
|
||||
|
||||
describe('relay-bundled skill scanner boundary', () => {
|
||||
it('keeps the scanner import graph free of Electron', () => {
|
||||
const visited = new Set<string>()
|
||||
const offenders: string[] = []
|
||||
const queue = [resolve(RELAY_SCANNER_ENTRY)]
|
||||
while (queue.length > 0) {
|
||||
const file = queue.pop() as string
|
||||
if (visited.has(file)) {
|
||||
continue
|
||||
}
|
||||
visited.add(file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
for (const specifier of collectModuleSpecifiers(file, source)) {
|
||||
if (specifier === 'electron' || specifier.startsWith('electron/')) {
|
||||
offenders.push(`${file} -> ${specifier}`)
|
||||
continue
|
||||
}
|
||||
if (!specifier.startsWith('.')) {
|
||||
continue
|
||||
}
|
||||
const resolved = resolveLocalModule(dirname(file), specifier)
|
||||
if (resolved) {
|
||||
queue.push(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([])
|
||||
expect(visited.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
function resolveLocalModule(fromDir: string, specifier: string): string | null {
|
||||
const base = resolve(fromDir, specifier)
|
||||
for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) {
|
||||
if (candidate.endsWith('.ts') || candidate.endsWith('.tsx')) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function collectModuleSpecifiers(fileName: string, source: string): string[] {
|
||||
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true)
|
||||
const specifiers: string[] = []
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (
|
||||
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
specifiers.push(node.moduleSpecifier.text)
|
||||
}
|
||||
if (
|
||||
ts.isImportTypeNode(node) &&
|
||||
ts.isLiteralTypeNode(node.argument) &&
|
||||
ts.isStringLiteral(node.argument.literal)
|
||||
) {
|
||||
specifiers.push(node.argument.literal.text)
|
||||
}
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
isModuleLoader(node.expression) &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
specifiers.push(node.arguments[0].text)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(file)
|
||||
return specifiers
|
||||
}
|
||||
|
||||
function isModuleLoader(expression: ts.Expression): boolean {
|
||||
return (
|
||||
expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(expression) && expression.text === 'require')
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
|
||||
import { isMethodNotFoundError } from './ssh-filesystem-stream-reader'
|
||||
import { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { SshSkillDiscoveryProvider } from '../providers/ssh-skill-discovery-provider'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
||||
import {
|
||||
@@ -61,6 +62,10 @@ import {
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
registerSshSkillDiscoveryProvider,
|
||||
unregisterSshSkillDiscoveryProvider
|
||||
} from '../providers/ssh-skill-discovery-dispatch'
|
||||
import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events'
|
||||
import { PortScanner } from './ssh-port-scanner'
|
||||
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
|
||||
@@ -1065,6 +1070,11 @@ export class SshRelaySession {
|
||||
)
|
||||
registerSshGitProvider(this.targetId, gitProvider)
|
||||
|
||||
registerSshSkillDiscoveryProvider(
|
||||
this.targetId,
|
||||
new SshSkillDiscoveryProvider(this.targetId, mux)
|
||||
)
|
||||
|
||||
this.wireUpPtyEvents(ptyProvider, mux, providerGeneration)
|
||||
this.wireUpAgentHookEvents(mux)
|
||||
this.wireUpRemoteWorkspaceEvents(mux)
|
||||
@@ -1572,6 +1582,7 @@ export class SshRelaySession {
|
||||
unregisterSshPtyProvider(this.targetId)
|
||||
unregisterSshFilesystemProvider(this.targetId)
|
||||
unregisterSshGitProvider(this.targetId)
|
||||
unregisterSshSkillDiscoveryProvider(this.targetId)
|
||||
this.sourceIdentityByRelayPtyId.clear()
|
||||
this.retiredSourceDeliveries.clear()
|
||||
this.rejectedPtyRecoveryAttempts.clear()
|
||||
|
||||
@@ -41,6 +41,7 @@ import { PortScanHandler } from './port-scan-handler'
|
||||
import { AgentExecHandler } from './agent-exec-handler'
|
||||
import { WorkspaceSessionHandler } from './workspace-session-handler'
|
||||
import { AiVaultHandler } from './ai-vault-handler'
|
||||
import { SkillDiscoveryHandler } from './skill-discovery-handler'
|
||||
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
|
||||
import { PluginOverlayManager } from './plugin-overlay'
|
||||
import {
|
||||
@@ -700,6 +701,9 @@ async function main(): Promise<void> {
|
||||
const _aiVaultHandler = new AiVaultHandler(dispatcher)
|
||||
void _aiVaultHandler
|
||||
|
||||
const _skillDiscoveryHandler = new SkillDiscoveryHandler(dispatcher)
|
||||
void _skillDiscoveryHandler
|
||||
|
||||
// Why: relay-hosted plugin provisioning is a later phase. Register the
|
||||
// enforcement boundary now with no consented identities or runtime services.
|
||||
registerRelayPluginHostCallHandlers(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import type { MethodHandler, RequestContext } from './dispatcher'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ homedir: vi.fn(() => '/nonexistent-home') }))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeOs>()
|
||||
return { ...actual, homedir: () => mocks.homedir() }
|
||||
})
|
||||
|
||||
import { SkillDiscoveryHandler } from './skill-discovery-handler'
|
||||
|
||||
function createHandler(): MethodHandler {
|
||||
const handlers = new Map<string, MethodHandler>()
|
||||
new SkillDiscoveryHandler({
|
||||
onRequest: (method: string, handler: MethodHandler): void => {
|
||||
handlers.set(method, handler)
|
||||
}
|
||||
} as never)
|
||||
const handler = handlers.get('skills.discover')
|
||||
if (!handler) {
|
||||
throw new Error('skills.discover handler not registered')
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
function requestContext(signal?: AbortSignal): RequestContext {
|
||||
return { clientId: 1, isStale: () => false, signal }
|
||||
}
|
||||
|
||||
describe('SkillDiscoveryHandler', () => {
|
||||
beforeEach(() => {
|
||||
mocks.homedir.mockReturnValue('/nonexistent-home')
|
||||
})
|
||||
|
||||
it('discovers remote home and repository skills from the relay host', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-relay-skills-'))
|
||||
const home = join(root, 'home')
|
||||
const repo = join(root, 'repo')
|
||||
await mkdir(join(home, '.codex', 'skills', 'review'), { recursive: true })
|
||||
await mkdir(join(repo, '.agents', 'skills', 'docs'), { recursive: true })
|
||||
await mkdir(join(repo, '.claude', 'skills', 'notes'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.codex', 'skills', 'review', 'SKILL.md'),
|
||||
['---', 'name: code-review', 'description: Review changes.', '---', ''].join('\n')
|
||||
)
|
||||
await writeFile(join(repo, '.agents', 'skills', 'docs', 'SKILL.md'), '# Docs\n')
|
||||
await writeFile(join(repo, '.claude', 'skills', 'notes', 'SKILL.md'), '# Notes\n')
|
||||
mocks.homedir.mockReturnValue(home)
|
||||
|
||||
const result = (await createHandler()({ cwd: repo }, requestContext())) as {
|
||||
skills: { name: string; providers: string[] }[]
|
||||
sources: { exists: boolean }[]
|
||||
}
|
||||
|
||||
expect(result.skills.map((skill) => skill.name).sort()).toEqual([
|
||||
'Docs',
|
||||
'Notes',
|
||||
'code-review'
|
||||
])
|
||||
expect(result.skills.find((skill) => skill.name === 'Docs')?.providers).toEqual([
|
||||
'agent-skills'
|
||||
])
|
||||
expect(result.skills.find((skill) => skill.name === 'Notes')?.providers).toEqual(['claude'])
|
||||
// Missing roots are reported exactly as local targeted discovery reports them.
|
||||
expect(result.sources.some((source) => !source.exists)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a missing, empty, or oversized cwd', async () => {
|
||||
const handler = createHandler()
|
||||
await expect(handler({}, requestContext())).rejects.toThrow('Invalid skill discovery cwd')
|
||||
await expect(handler({ cwd: ' ' }, requestContext())).rejects.toThrow(
|
||||
'Invalid skill discovery cwd'
|
||||
)
|
||||
await expect(handler({ cwd: 'x'.repeat(5000) }, requestContext())).rejects.toThrow(
|
||||
'Invalid skill discovery cwd'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a cancelled request instead of publishing an empty success', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(
|
||||
createHandler()({ cwd: '/tmp/anywhere' }, requestContext(controller.signal))
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('never includes full SKILL.md contents in the response', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-relay-skills-'))
|
||||
const repo = join(root, 'repo')
|
||||
await mkdir(join(repo, '.agents', 'skills', 'docs'), { recursive: true })
|
||||
// Name/description metadata may cross; body text past them must not.
|
||||
const body = [
|
||||
'---',
|
||||
'name: docs',
|
||||
'description: Write docs.',
|
||||
'---',
|
||||
'',
|
||||
'SECRET-BODY-MARKER should never cross the relay boundary.'
|
||||
].join('\n')
|
||||
await writeFile(join(repo, '.agents', 'skills', 'docs', 'SKILL.md'), body)
|
||||
|
||||
const result = await createHandler()({ cwd: repo }, requestContext())
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain('SECRET-BODY-MARKER')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { discoverSkills } from '../main/skills/discovery'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import type { SkillDiscoveryResult } from '../shared/skills'
|
||||
|
||||
/** Below the desktop provider's 9s request timeout so the classified timeout
|
||||
* error, not a generic transport timeout, is what reaches the picker. */
|
||||
const SCAN_BUDGET_MS = 8_000
|
||||
const MAX_CWD_LENGTH = 4096
|
||||
|
||||
export class SkillDiscoveryHandler {
|
||||
constructor(dispatcher: RelayDispatcher) {
|
||||
// Why: new method name — pre-skill relays answer -32601 so the desktop can
|
||||
// prompt a reconnect instead of showing an empty skill list.
|
||||
dispatcher.onRequest('skills.discover', (params, context) => this.discover(params, context))
|
||||
}
|
||||
|
||||
private async discover(
|
||||
params: Record<string, unknown>,
|
||||
context: RequestContext
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const cwd = typeof params.cwd === 'string' ? params.cwd.trim() : ''
|
||||
if (!cwd || cwd.length > MAX_CWD_LENGTH) {
|
||||
throw new Error('Invalid skill discovery cwd')
|
||||
}
|
||||
const budget = new AbortController()
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
budget.abort()
|
||||
}, SCAN_BUDGET_MS)
|
||||
const onClientAbort = (): void => budget.abort()
|
||||
if (context.signal?.aborted) {
|
||||
budget.abort()
|
||||
} else {
|
||||
context.signal?.addEventListener('abort', onClientAbort, { once: true })
|
||||
}
|
||||
try {
|
||||
// Why: repos [] + relay-derived home keeps the scan targeted to this cwd's
|
||||
// known skill roots; the client never supplies remote path semantics.
|
||||
return await discoverSkills({
|
||||
repos: [],
|
||||
cwd,
|
||||
homeDir: homedir(),
|
||||
signal: budget.signal
|
||||
})
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
throw new Error('Skill discovery timed out on the SSH host')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
context.signal?.removeEventListener('abort', onClientAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SkillSourceKind } from '../../../../shared/skills'
|
||||
import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state'
|
||||
import type { NativeChatSkillDiscoveryErrorKind } from './native-chat-picker-items'
|
||||
|
||||
export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
autocomplete,
|
||||
@@ -72,28 +73,16 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
) : null}
|
||||
{autocomplete.skillStatus === 'error' ? (
|
||||
<PickerStatus>
|
||||
<span className="min-w-0 flex-1">
|
||||
{autocomplete.skillErrorKind === 'unavailable'
|
||||
? translate(
|
||||
'components.native-chat.composer.skillsUnavailableHost',
|
||||
'Skills are unavailable for this host'
|
||||
)
|
||||
: translate(
|
||||
'components.native-chat.composer.skillsLoadFailed',
|
||||
'Could not load skills from this host'
|
||||
)}
|
||||
</span>
|
||||
{autocomplete.skillErrorKind !== 'unavailable' ? (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={onRetry}
|
||||
className="flex shrink-0 items-center gap-1 rounded-sm px-1.5 py-0.5 text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{translate('components.native-chat.composer.retrySkills', 'Retry')}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">{getSkillErrorText(autocomplete.skillErrorKind)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={onRetry}
|
||||
className="flex shrink-0 items-center gap-1 rounded-sm px-1.5 py-0.5 text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{translate('components.native-chat.composer.retrySkills', 'Retry')}
|
||||
</button>
|
||||
</PickerStatus>
|
||||
) : null}
|
||||
{skills.map((item) => {
|
||||
@@ -116,10 +105,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
{autocomplete.skillStatus === 'loading'
|
||||
? translate('components.native-chat.composer.loadingSkills', 'Loading skills...')
|
||||
: autocomplete.skillStatus === 'error'
|
||||
? translate(
|
||||
'components.native-chat.composer.skillsLoadFailed',
|
||||
'Could not load skills from this host'
|
||||
)
|
||||
? getSkillErrorText(autocomplete.skillErrorKind)
|
||||
: emptyText
|
||||
? emptyText
|
||||
: autocomplete.skillsEnabled
|
||||
@@ -139,6 +125,25 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
)
|
||||
})
|
||||
|
||||
function getSkillErrorText(errorKind: NativeChatSkillDiscoveryErrorKind | undefined): string {
|
||||
if (errorKind === 'relay-upgrade-required') {
|
||||
return translate(
|
||||
'components.native-chat.composer.skillsSshRelayUpgrade',
|
||||
'Reconnect this SSH host to enable skills.'
|
||||
)
|
||||
}
|
||||
if (errorKind === 'runtime-upgrade-required') {
|
||||
return translate(
|
||||
'components.native-chat.composer.skillsRuntimeUpgrade',
|
||||
'Update the remote runtime to enable skills.'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'components.native-chat.composer.skillsLoadFailed',
|
||||
'Could not load skills from this host'
|
||||
)
|
||||
}
|
||||
|
||||
function getPickerEmptyText(
|
||||
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' | 'skill' }>
|
||||
): string {
|
||||
|
||||
@@ -28,10 +28,17 @@ export type NativeChatPickerItem =
|
||||
sources: { sourceKind: SkillSourceKind; skillFilePath: string }[]
|
||||
}
|
||||
|
||||
export type NativeChatSkillDiscoveryErrorKind =
|
||||
| 'timeout'
|
||||
| 'host'
|
||||
| 'unknown'
|
||||
| 'relay-upgrade-required'
|
||||
| 'runtime-upgrade-required'
|
||||
|
||||
export type NativeChatSkillDiscoverySnapshot = {
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
skills: readonly DiscoveredSkill[]
|
||||
errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown'
|
||||
errorKind?: NativeChatSkillDiscoveryErrorKind
|
||||
}
|
||||
|
||||
const PICKER_RESULT_LIMIT = 50
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AppState } from '../../store/types'
|
||||
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { PaneSkillDiscoveryTarget, SkillDiscoveryTarget } from '../../../../shared/skills'
|
||||
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
getExplicitRuntimeEnvironmentIdForWorktree,
|
||||
@@ -19,6 +19,8 @@ export type NativeChatSkillStateInputs = Pick<
|
||||
| 'repos'
|
||||
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
|
||||
| 'settings'
|
||||
| 'sshConnectionStates'
|
||||
| 'sshStateByEnvironment'
|
||||
| 'tabsByWorktree'
|
||||
| 'worktreesByRepo'
|
||||
>
|
||||
@@ -30,13 +32,24 @@ type NativeChatSkillWorktreeState = {
|
||||
worktreesByRepo: Record<string, readonly { id: string; path: string }[]>
|
||||
}
|
||||
|
||||
export type NativeChatSkillDiscoveryContext = {
|
||||
key: string
|
||||
cwd: string
|
||||
executionHostKind: 'local' | 'runtime' | 'ssh'
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
discoveryTarget: SkillDiscoveryTarget
|
||||
}
|
||||
export type NativeChatSkillDiscoveryContext =
|
||||
| {
|
||||
key: string
|
||||
cwd: string
|
||||
executionHostKind: 'local' | 'runtime'
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
discoveryTarget: SkillDiscoveryTarget
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
cwd: string
|
||||
executionHostKind: 'ssh'
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
/** Identity only; the owning runtime derives the scanned directory. */
|
||||
paneTarget: PaneSkillDiscoveryTarget
|
||||
/** Known-disconnected host: skip the doomed RPC and show the host error. */
|
||||
sshDisconnected: boolean
|
||||
}
|
||||
|
||||
export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSkillStateInputs {
|
||||
return {
|
||||
@@ -48,6 +61,8 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki
|
||||
repos: state.repos,
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey,
|
||||
settings: state.settings,
|
||||
sshConnectionStates: state.sshConnectionStates,
|
||||
sshStateByEnvironment: state.sshStateByEnvironment,
|
||||
tabsByWorktree: state.tabsByWorktree,
|
||||
worktreesByRepo: state.worktreesByRepo
|
||||
}
|
||||
@@ -99,12 +114,35 @@ export function resolveNativeChatSkillDiscoveryContext(
|
||||
const hostId = getExecutionHostIdForWorktree(state, worktreeId)
|
||||
const parsedHost = parseExecutionHostId(hostId)
|
||||
if (parsedHost?.kind === 'ssh') {
|
||||
const runtimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId)
|
||||
// Why: a runtime-owned SSH target without an explicit owner stamp is
|
||||
// ambiguous; failing closed beats resolving it against local providers.
|
||||
if (isRuntimeOwnedSshTargetId(parsedHost.targetId) && !runtimeEnvironmentId) {
|
||||
return null
|
||||
}
|
||||
const connectionState = runtimeEnvironmentId
|
||||
? state.sshStateByEnvironment
|
||||
.get(runtimeEnvironmentId)
|
||||
?.connectionStates.get(parsedHost.targetId)
|
||||
: state.sshConnectionStates.get(parsedHost.targetId)
|
||||
// Why: an unhydrated environment bucket is unknown, not disconnected — the
|
||||
// owning runtime answers authoritatively. A missing local entry is offline.
|
||||
const status = connectionState?.status ?? (runtimeEnvironmentId ? null : 'disconnected')
|
||||
return {
|
||||
key: JSON.stringify(['ssh', hostId, cwd]),
|
||||
key: JSON.stringify([
|
||||
'ssh',
|
||||
runtimeEnvironmentId ?? null,
|
||||
hostId,
|
||||
connectionState?.connectionGeneration ?? 0,
|
||||
cwd
|
||||
]),
|
||||
cwd,
|
||||
executionHostKind: 'ssh',
|
||||
runtimeTarget: { kind: 'local' },
|
||||
discoveryTarget: { cwd, worktreeId }
|
||||
runtimeTarget: runtimeEnvironmentId
|
||||
? { kind: 'environment', environmentId: runtimeEnvironmentId }
|
||||
: { kind: 'local' },
|
||||
paneTarget: { worktreeId, terminalTabId },
|
||||
sshDisconnected: status !== null && status !== 'connected'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resetNativeChatSkillDiscoveryCacheForTests,
|
||||
useNativeChatSkills
|
||||
} from './use-native-chat-skills'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
|
||||
function stateForHost(hostId: string) {
|
||||
return {
|
||||
@@ -43,6 +44,8 @@ function stateForHost(hostId: string) {
|
||||
],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
sshConnectionStates: new Map(),
|
||||
sshStateByEnvironment: new Map(),
|
||||
tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] },
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId }]
|
||||
@@ -50,46 +53,66 @@ function stateForHost(hostId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function connectedSshState() {
|
||||
return {
|
||||
...stateForHost('ssh:target-1'),
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'target-1',
|
||||
{
|
||||
targetId: 'target-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 3
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function Probe({ enabled }: { enabled: boolean }): null {
|
||||
mocks.snapshots.push(useNativeChatSkills('codex', 'tab-1', enabled))
|
||||
return null
|
||||
}
|
||||
|
||||
const DISCOVERY_RESULT = {
|
||||
skills: [
|
||||
{
|
||||
id: 'browser',
|
||||
name: 'browser',
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
rootPath: '/home/test/.agents/skills',
|
||||
directoryPath: '/home/test/.agents/skills/browser',
|
||||
skillFilePath: '/home/test/.agents/skills/browser/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: 'home-agents',
|
||||
label: 'Agent skills home',
|
||||
path: '/home/test/.agents/skills',
|
||||
sourceKind: 'home',
|
||||
providers: ['agent-skills'],
|
||||
owner: null,
|
||||
exists: true
|
||||
}
|
||||
],
|
||||
scannedAt: 1
|
||||
}
|
||||
|
||||
describe('useNativeChatSkills', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state = stateForHost('local')
|
||||
mocks.snapshots = []
|
||||
mocks.callRuntimeRpc.mockReset()
|
||||
mocks.callRuntimeRpc.mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
id: 'browser',
|
||||
name: 'browser',
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
rootPath: '/home/test/.agents/skills',
|
||||
directoryPath: '/home/test/.agents/skills/browser',
|
||||
skillFilePath: '/home/test/.agents/skills/browser/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: 'home-agents',
|
||||
label: 'Agent skills home',
|
||||
path: '/home/test/.agents/skills',
|
||||
sourceKind: 'home',
|
||||
providers: ['agent-skills'],
|
||||
owner: null,
|
||||
exists: true
|
||||
}
|
||||
],
|
||||
scannedAt: 1
|
||||
})
|
||||
mocks.callRuntimeRpc.mockResolvedValue(DISCOVERY_RESULT)
|
||||
resetNativeChatSkillDiscoveryCacheForTests()
|
||||
})
|
||||
|
||||
@@ -123,10 +146,44 @@ describe('useNativeChatSkills', () => {
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks SSH discovery unavailable without scanning another host', async () => {
|
||||
mocks.state = stateForHost('ssh:connection-1')
|
||||
it('discovers SSH pane skills through the identity-only pane method', async () => {
|
||||
mocks.state = connectedSshState()
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', result: DISCOVERY_RESULT })
|
||||
render(<Probe enabled />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.errorKind).toBe('unavailable'))
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready'))
|
||||
expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser'])
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'skills.discoverForPane',
|
||||
{ worktreeId: 'worktree-1', terminalTabId: 'tab-1' },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('maps an old relay to relay-upgrade-required with Retry intact', async () => {
|
||||
mocks.state = connectedSshState()
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'relay-upgrade-required' })
|
||||
render(<Probe enabled />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.errorKind).toBe('relay-upgrade-required'))
|
||||
expect(mocks.snapshots.at(-1)?.status).toBe('error')
|
||||
expect(typeof mocks.snapshots.at(-1)?.retry).toBe('function')
|
||||
})
|
||||
|
||||
it('maps an old paired runtime (method_not_found) to runtime-upgrade-required', async () => {
|
||||
mocks.state = connectedSshState()
|
||||
mocks.callRuntimeRpc.mockRejectedValue(
|
||||
new RuntimeRpcCallError({
|
||||
error: { code: 'method_not_found', message: 'Unknown method: skills.discoverForPane' }
|
||||
} as never)
|
||||
)
|
||||
render(<Probe enabled />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.errorKind).toBe('runtime-upgrade-required'))
|
||||
})
|
||||
|
||||
it('shows a host error for a disconnected SSH host without issuing the RPC', async () => {
|
||||
mocks.state = stateForHost('ssh:target-1')
|
||||
render(<Probe enabled />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.errorKind).toBe('host'))
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
|
||||
import {
|
||||
isNativeChatSkillForAgent,
|
||||
resolveNativeChatSkillDiscoveryContext,
|
||||
resolveNativeChatSkillDiscoveryCwd
|
||||
} from './use-native-chat-skills'
|
||||
import type { NativeChatSkillStateInputs } from './native-chat-skill-discovery-context'
|
||||
|
||||
function skill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
|
||||
return {
|
||||
@@ -158,3 +160,185 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => {
|
||||
).toBe('/repo/worktree/packages/app')
|
||||
})
|
||||
})
|
||||
|
||||
function connectionState(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
targetId: 'target-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 7,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function sshInputs(overrides: Record<string, unknown> = {}): NativeChatSkillStateInputs {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'worktree-1',
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
projects: [],
|
||||
repos: [{ id: 'repo-1', path: '/repo', connectionId: null, executionHostId: 'ssh:target-1' }],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
sshConnectionStates: new Map([['target-1', connectionState()]]),
|
||||
sshStateByEnvironment: new Map(),
|
||||
tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] },
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId: 'ssh:target-1' }
|
||||
]
|
||||
},
|
||||
...overrides
|
||||
} as unknown as NativeChatSkillStateInputs
|
||||
}
|
||||
|
||||
describe('resolveNativeChatSkillDiscoveryContext for SSH panes', () => {
|
||||
it('builds a pane-bound SSH context keyed on the connection generation', () => {
|
||||
const context = resolveNativeChatSkillDiscoveryContext(sshInputs(), 'tab-1')
|
||||
expect(context?.executionHostKind).toBe('ssh')
|
||||
if (context?.executionHostKind !== 'ssh') {
|
||||
throw new Error('expected ssh context')
|
||||
}
|
||||
expect(context.runtimeTarget).toEqual({ kind: 'local' })
|
||||
expect(context.paneTarget).toEqual({ worktreeId: 'worktree-1', terminalTabId: 'tab-1' })
|
||||
expect(context.sshDisconnected).toBe(false)
|
||||
expect(context.key).toContain('7')
|
||||
|
||||
const regenerated = resolveNativeChatSkillDiscoveryContext(
|
||||
sshInputs({
|
||||
sshConnectionStates: new Map([['target-1', connectionState({ connectionGeneration: 8 })]])
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
// Reconnect bumps the generation, which must invalidate the pane cache key.
|
||||
expect(regenerated?.key).not.toBe(context.key)
|
||||
})
|
||||
|
||||
it('marks a missing or non-connected local SSH state as disconnected', () => {
|
||||
const missing = resolveNativeChatSkillDiscoveryContext(
|
||||
sshInputs({ sshConnectionStates: new Map() }),
|
||||
'tab-1'
|
||||
)
|
||||
const reconnecting = resolveNativeChatSkillDiscoveryContext(
|
||||
sshInputs({
|
||||
sshConnectionStates: new Map([['target-1', connectionState({ status: 'reconnecting' })]])
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(missing?.executionHostKind === 'ssh' && missing.sshDisconnected).toBe(true)
|
||||
expect(reconnecting?.executionHostKind === 'ssh' && reconnecting.sshDisconnected).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed for a runtime-owned SSH target without an explicit owner', () => {
|
||||
const context = resolveNativeChatSkillDiscoveryContext(
|
||||
sshInputs({
|
||||
repos: [
|
||||
{ id: 'repo-1', path: '/repo', connectionId: null, executionHostId: 'ssh:runtime-ssh-t1' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'worktree-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
hostId: 'ssh:runtime-ssh-t1'
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(context).toBeNull()
|
||||
})
|
||||
|
||||
it('routes an owner-stamped runtime SSH target through its environment', () => {
|
||||
const context = resolveNativeChatSkillDiscoveryContext(
|
||||
sshInputs({
|
||||
repos: [
|
||||
{ id: 'repo-1', path: '/repo', connectionId: null, executionHostId: 'ssh:runtime-ssh-t1' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'worktree-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
hostId: 'ssh:runtime-ssh-t1',
|
||||
runtimeOwnerEnvironmentId: 'env-1'
|
||||
}
|
||||
]
|
||||
},
|
||||
sshStateByEnvironment: new Map([
|
||||
[
|
||||
'env-1',
|
||||
{
|
||||
connectionStates: new Map([
|
||||
[
|
||||
'runtime-ssh-t1',
|
||||
connectionState({ targetId: 'runtime-ssh-t1', connectionGeneration: 4 })
|
||||
]
|
||||
]),
|
||||
targetLabels: new Map(),
|
||||
removedTargetLabels: new Map(),
|
||||
targetsHydrated: true
|
||||
}
|
||||
]
|
||||
])
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(context?.executionHostKind).toBe('ssh')
|
||||
if (context?.executionHostKind !== 'ssh') {
|
||||
throw new Error('expected ssh context')
|
||||
}
|
||||
expect(context.runtimeTarget).toEqual({ kind: 'environment', environmentId: 'env-1' })
|
||||
expect(context.key).toContain('env-1')
|
||||
expect(context.key).toContain('4')
|
||||
expect(context.sshDisconnected).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unhydrated environment bucket as unknown rather than disconnected', () => {
|
||||
const context = resolveNativeChatSkillDiscoveryContext(
|
||||
sshInputs({
|
||||
repos: [
|
||||
{ id: 'repo-1', path: '/repo', connectionId: null, executionHostId: 'ssh:runtime-ssh-t1' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'worktree-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
hostId: 'ssh:runtime-ssh-t1',
|
||||
runtimeOwnerEnvironmentId: 'env-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(context?.executionHostKind === 'ssh' && context.sshDisconnected).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the same remote path on two SSH hosts cache-isolated', () => {
|
||||
const onTargetTwo = resolveNativeChatSkillDiscoveryContext(
|
||||
sshInputs({
|
||||
repos: [
|
||||
{ id: 'repo-1', path: '/repo', connectionId: null, executionHostId: 'ssh:target-2' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId: 'ssh:target-2' }
|
||||
]
|
||||
},
|
||||
sshConnectionStates: new Map([['target-2', connectionState({ targetId: 'target-2' })]])
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
const onTargetOne = resolveNativeChatSkillDiscoveryContext(sshInputs(), 'tab-1')
|
||||
expect(onTargetOne?.cwd).toBe(onTargetTwo?.cwd)
|
||||
expect(onTargetOne?.key).not.toBe(onTargetTwo?.key)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,15 +2,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../store'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
SkillDiscoveryForPaneResponse,
|
||||
SkillDiscoveryResult
|
||||
} from '../../../../shared/skills'
|
||||
import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat'
|
||||
import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
resolveNativeChatSkillDiscoveryContext,
|
||||
selectNativeChatSkillStateInputs,
|
||||
type NativeChatSkillDiscoveryContext
|
||||
} from './native-chat-skill-discovery-context'
|
||||
import type { NativeChatSkillDiscoveryErrorKind } from './native-chat-picker-items'
|
||||
|
||||
export {
|
||||
resolveNativeChatSkillDiscoveryContext,
|
||||
@@ -29,10 +36,18 @@ export type NativeChatSkillDiscovery = {
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
skills: DiscoveredSkill[]
|
||||
error: Error | null
|
||||
errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown'
|
||||
errorKind?: NativeChatSkillDiscoveryErrorKind
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
/** Old relay behind a current runtime; reconnecting the SSH host deploys it. */
|
||||
class SshRelaySkillUpgradeRequiredError extends Error {
|
||||
constructor() {
|
||||
super('This SSH host is running an older Orca relay without skill discovery.')
|
||||
this.name = 'SshRelaySkillUpgradeRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
type StoredDiscoveryState = Omit<NativeChatSkillDiscovery, 'retry'> & {
|
||||
contextKey: string | null
|
||||
}
|
||||
@@ -91,17 +106,17 @@ export function useNativeChatSkills(
|
||||
setState(IDLE_STATE)
|
||||
return
|
||||
}
|
||||
if (context.executionHostKind === 'ssh') {
|
||||
if (context.executionHostKind === 'ssh' && context.sshDisconnected) {
|
||||
emitNativeChatSkillDiscovery({
|
||||
agent,
|
||||
outcome: 'unavailable',
|
||||
outcome: 'error',
|
||||
executionHostKind: 'ssh'
|
||||
})
|
||||
setState({
|
||||
status: 'error',
|
||||
skills: [],
|
||||
error: new Error('Skill discovery is unavailable for SSH hosts.'),
|
||||
errorKind: 'unavailable',
|
||||
error: new Error('The SSH host is disconnected.'),
|
||||
errorKind: 'host',
|
||||
contextKey: context.key
|
||||
})
|
||||
return
|
||||
@@ -138,21 +153,24 @@ export function useNativeChatSkills(
|
||||
return
|
||||
}
|
||||
const error = reason instanceof Error ? reason : new Error(String(reason))
|
||||
const timedOut = /timed?\s*out|timeout/i.test(error.message)
|
||||
const upgradeKind = classifyUpgradeRequired(context, error)
|
||||
const timedOut = !upgradeKind && /timed?\s*out|timeout/i.test(error.message)
|
||||
emitNativeChatSkillDiscovery({
|
||||
agent,
|
||||
outcome: timedOut ? 'timeout' : 'error',
|
||||
outcome: upgradeKind ? 'upgrade-required' : timedOut ? 'timeout' : 'error',
|
||||
executionHostKind: context.executionHostKind
|
||||
})
|
||||
setState({
|
||||
status: 'error',
|
||||
skills: [],
|
||||
error,
|
||||
errorKind: timedOut
|
||||
? 'timeout'
|
||||
: context.executionHostKind === 'runtime'
|
||||
? 'host'
|
||||
: 'unknown',
|
||||
errorKind:
|
||||
upgradeKind ??
|
||||
(timedOut
|
||||
? 'timeout'
|
||||
: context.executionHostKind === 'runtime' || context.executionHostKind === 'ssh'
|
||||
? 'host'
|
||||
: 'unknown'),
|
||||
contextKey: paneCacheKey
|
||||
})
|
||||
}
|
||||
@@ -210,14 +228,7 @@ function getOrStartDiscovery(
|
||||
// Why: the local runtime.call branch ignores timeoutMs, so the renderer must
|
||||
// enforce the design's scan timeout itself or a stalled local scan loads forever.
|
||||
const request = withDiscoveryTimeout(
|
||||
callRuntimeRpc<SkillDiscoveryResult>(
|
||||
context.runtimeTarget,
|
||||
'skills.discover',
|
||||
context.discoveryTarget,
|
||||
{
|
||||
timeoutMs: DISCOVERY_TIMEOUT_MS
|
||||
}
|
||||
),
|
||||
startDiscoveryRequest(context),
|
||||
DISCOVERY_BACKSTOP_TIMEOUT_MS
|
||||
).finally(() => {
|
||||
if (inFlightDiscovery.get(context.key) === request) {
|
||||
@@ -228,6 +239,54 @@ function getOrStartDiscovery(
|
||||
return request
|
||||
}
|
||||
|
||||
async function startDiscoveryRequest(
|
||||
context: NativeChatSkillDiscoveryContext
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
if (context.executionHostKind === 'ssh') {
|
||||
const response = await callRuntimeRpc<SkillDiscoveryForPaneResponse>(
|
||||
context.runtimeTarget,
|
||||
'skills.discoverForPane',
|
||||
context.paneTarget,
|
||||
{ timeoutMs: DISCOVERY_TIMEOUT_MS }
|
||||
)
|
||||
if (response.status === 'relay-upgrade-required') {
|
||||
throw new SshRelaySkillUpgradeRequiredError()
|
||||
}
|
||||
return response.result
|
||||
}
|
||||
return callRuntimeRpc<SkillDiscoveryResult>(
|
||||
context.runtimeTarget,
|
||||
'skills.discover',
|
||||
context.discoveryTarget,
|
||||
{ timeoutMs: DISCOVERY_TIMEOUT_MS }
|
||||
)
|
||||
}
|
||||
|
||||
function classifyUpgradeRequired(
|
||||
context: NativeChatSkillDiscoveryContext,
|
||||
error: Error
|
||||
): Extract<
|
||||
NativeChatSkillDiscoveryErrorKind,
|
||||
'relay-upgrade-required' | 'runtime-upgrade-required'
|
||||
> | null {
|
||||
if (context.executionHostKind !== 'ssh') {
|
||||
return null
|
||||
}
|
||||
if (error instanceof SshRelaySkillUpgradeRequiredError) {
|
||||
return 'relay-upgrade-required'
|
||||
}
|
||||
// Why: a runtime predating skills.discoverForPane would strip pane identity
|
||||
// from the legacy method and scan its own disk; the missing method is the
|
||||
// detectable version-skew signal (same mapping as native chat history).
|
||||
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
|
||||
return 'runtime-upgrade-required'
|
||||
}
|
||||
if (isRuntimeCompatBlockError(error)) {
|
||||
return 'runtime-upgrade-required'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function withDiscoveryTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Skill discovery timed out.')), timeoutMs)
|
||||
|
||||
@@ -14773,7 +14773,8 @@
|
||||
"skills": "Skills",
|
||||
"loadingSkills": "Loading skills...",
|
||||
"skillsLoaded": "Skills loaded",
|
||||
"skillsUnavailableHost": "Skills are unavailable for this host",
|
||||
"skillsSshRelayUpgrade": "Reconnect this SSH host to enable skills.",
|
||||
"skillsRuntimeUpgrade": "Update the remote runtime to enable skills.",
|
||||
"skillsLoadFailed": "Could not load skills from this host",
|
||||
"retrySkills": "Retry",
|
||||
"skillCommandCollision": "Also a skill name - agent decides",
|
||||
|
||||
@@ -14458,7 +14458,8 @@
|
||||
"commands": "Comandos",
|
||||
"skills": "Habilidades",
|
||||
"loadingSkills": "Cargando habilidades…",
|
||||
"skillsUnavailableHost": "Las habilidades no están disponibles para este host",
|
||||
"skillsSshRelayUpgrade": "Reconecta este host SSH para habilitar las habilidades.",
|
||||
"skillsRuntimeUpgrade": "Actualiza el runtime remoto para habilitar las habilidades.",
|
||||
"skillsLoadFailed": "No se pudieron cargar las habilidades de este host",
|
||||
"retrySkills": "Reintentar",
|
||||
"skillCommandCollision": "También es un nombre de habilidad - el agente decide",
|
||||
|
||||
@@ -14458,7 +14458,8 @@
|
||||
"commands": "コマンド",
|
||||
"skills": "スキル",
|
||||
"loadingSkills": "スキルを読み込み中…",
|
||||
"skillsUnavailableHost": "このホストではスキルが利用できません",
|
||||
"skillsSshRelayUpgrade": "スキルを有効にするには、このSSHホストに再接続してください。",
|
||||
"skillsRuntimeUpgrade": "スキルを有効にするには、リモートランタイムを更新してください。",
|
||||
"skillsLoadFailed": "このホストからスキルを読み込めませんでした",
|
||||
"retrySkills": "再試行",
|
||||
"skillCommandCollision": "スキル名でもあります - エージェントが判断します",
|
||||
|
||||
@@ -14469,7 +14469,8 @@
|
||||
"commands": "명령어",
|
||||
"skills": "스킬",
|
||||
"loadingSkills": "스킬 로딩 중…",
|
||||
"skillsUnavailableHost": "이 호스트에서는 스킬을 사용할 수 없습니다",
|
||||
"skillsSshRelayUpgrade": "스킬을 사용하려면 이 SSH 호스트를 다시 연결하세요.",
|
||||
"skillsRuntimeUpgrade": "스킬을 사용하려면 원격 런타임을 업데이트하세요.",
|
||||
"skillsLoadFailed": "이 호스트에서 스킬을 불러올 수 없습니다",
|
||||
"retrySkills": "재시도",
|
||||
"skillCommandCollision": "스킬 이름이기도 합니다 - 에이전트가 결정합니다",
|
||||
|
||||
@@ -14478,7 +14478,8 @@
|
||||
"commands": "命令",
|
||||
"skills": "技能",
|
||||
"loadingSkills": "正在加载技能…",
|
||||
"skillsUnavailableHost": "此主机不可用技能",
|
||||
"skillsSshRelayUpgrade": "重新连接此 SSH 主机以启用技能。",
|
||||
"skillsRuntimeUpgrade": "更新远程运行时以启用技能。",
|
||||
"skillsLoadFailed": "无法从此主机加载技能",
|
||||
"retrySkills": "重试",
|
||||
"skillCommandCollision": "也是技能名称 - 由代理决定",
|
||||
|
||||
@@ -91,7 +91,7 @@ export function emitNativeChatSendClassified(args: {
|
||||
|
||||
export function emitNativeChatSkillDiscovery(args: {
|
||||
agent: NativeChatTelemetryAgent
|
||||
outcome: 'ready' | 'error' | 'timeout' | 'unavailable'
|
||||
outcome: 'ready' | 'error' | 'timeout' | 'upgrade-required'
|
||||
executionHostKind: 'local' | 'runtime' | 'ssh'
|
||||
}): void {
|
||||
track('native_chat_skill_discovery', {
|
||||
|
||||
@@ -103,6 +103,77 @@ export const SkillDiscoveryTargetSchema: z.ZodType<SkillDiscoveryTarget> = z.obj
|
||||
.optional()
|
||||
})
|
||||
|
||||
export type PaneSkillDiscoveryTarget = {
|
||||
worktreeId: string
|
||||
/** Pane whose runtime-persisted startup directory should be scanned. */
|
||||
terminalTabId?: string
|
||||
}
|
||||
|
||||
/** Pane discovery carries workspace/pane identity only — never a path or SSH
|
||||
* connection id. The owning runtime derives the scan directory from its own
|
||||
* persisted state. */
|
||||
export const PaneSkillDiscoveryTargetSchema: z.ZodType<PaneSkillDiscoveryTarget> = z.object({
|
||||
worktreeId: z.string().min(1).max(512),
|
||||
terminalTabId: z.string().min(1).max(512).optional()
|
||||
})
|
||||
|
||||
/** Relay upgrade is a typed payload, not an error: old runtimes strip unknown
|
||||
* request fields and old relays answer -32601, so clients must never have to
|
||||
* string-match error messages to detect version skew. */
|
||||
export type SkillDiscoveryForPaneResponse =
|
||||
| { status: 'ok'; result: SkillDiscoveryResult }
|
||||
| { status: 'relay-upgrade-required' }
|
||||
|
||||
const SKILL_PROVIDER_VALUES = ['codex', 'claude', 'agent-skills'] as const
|
||||
const SKILL_SOURCE_KIND_VALUES = ['home', 'repo', 'bundled', 'plugin'] as const
|
||||
|
||||
const boundedString = (max: number): z.ZodString => z.string().max(max)
|
||||
|
||||
/** Validates untrusted skill metadata at the SSH relay boundary before it can
|
||||
* enter renderer state: bounded strings, known enums, finite numbers. */
|
||||
export const SkillDiscoveryResultSchema = z.object({
|
||||
skills: z
|
||||
.array(
|
||||
z.object({
|
||||
id: boundedString(512),
|
||||
name: boundedString(512),
|
||||
description: boundedString(8192).nullable(),
|
||||
providers: z.array(z.enum(SKILL_PROVIDER_VALUES)).max(8),
|
||||
sourceKind: z.enum(SKILL_SOURCE_KIND_VALUES),
|
||||
sourceLabel: boundedString(1024),
|
||||
rootPath: boundedString(4096),
|
||||
rootPaths: z.array(boundedString(4096)).max(64).optional(),
|
||||
directoryPath: boundedString(4096),
|
||||
skillFilePath: boundedString(4096),
|
||||
installed: z.boolean(),
|
||||
fileCount: z.number().int().min(0).max(1_000_000),
|
||||
updatedAt: z.number().finite().nullable()
|
||||
})
|
||||
)
|
||||
.max(5000),
|
||||
sources: z
|
||||
.array(
|
||||
z.object({
|
||||
id: boundedString(512),
|
||||
label: boundedString(1024),
|
||||
path: boundedString(4096),
|
||||
sourceKind: z.enum(SKILL_SOURCE_KIND_VALUES),
|
||||
providers: z.array(z.enum(SKILL_PROVIDER_VALUES)).max(8),
|
||||
owner: boundedString(64).nullable(),
|
||||
exists: z.boolean(),
|
||||
skippedReason: z.enum(['missing', 'remote-repo']).optional()
|
||||
})
|
||||
)
|
||||
.max(1000),
|
||||
scannedAt: z.number().finite()
|
||||
})
|
||||
|
||||
export function parseSkillDiscoveryResult(value: unknown): SkillDiscoveryResult {
|
||||
// Why: an unrecognized source owner only fails provider filtering, so the
|
||||
// schema keeps owner as a bounded string rather than pinning AgentType.
|
||||
return SkillDiscoveryResultSchema.parse(value) as SkillDiscoveryResult
|
||||
}
|
||||
|
||||
export type SkillFrontmatterSummary = {
|
||||
name: string | null
|
||||
description: string | null
|
||||
|
||||
@@ -529,7 +529,7 @@ const nativeChatSendClassifiedSchema = z
|
||||
const nativeChatSkillDiscoverySchema = z
|
||||
.object({
|
||||
agent_kind: agentKindSchema,
|
||||
outcome: z.enum(['ready', 'error', 'timeout', 'unavailable']),
|
||||
outcome: z.enum(['ready', 'error', 'timeout', 'upgrade-required']),
|
||||
execution_host_kind: z.enum(['local', 'runtime', 'ssh'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
Reference in New Issue
Block a user