diff --git a/openspec/changes/add-dsh-native-plugin/interface-design.md b/openspec/changes/add-dsh-native-plugin/interface-design.md index 0bae6aee6..ae88ebeb7 100644 --- a/openspec/changes/add-dsh-native-plugin/interface-design.md +++ b/openspec/changes/add-dsh-native-plugin/interface-design.md @@ -406,3 +406,17 @@ Client 使用原生 `InputActions.captureInsertion()` 和 `insertText(text, span Client 的 `beginConversationManagement(sourceRevision,itemKey,action,version,requestKey,signal)` 只准备计划并返回确认卡,requestKey 必须是 UUIDv4;`confirmConversationManagement(confirmationRef,confirmed,signal)` 绑定当前 Agent、来源及原计划。二者均不是模型工具。接受后沿用 getOperation 查询,不重复提交。 多个位置时通过原生问题(可用时)要求用户选定一个位置,随后再由 Host 确认操作。模型引导禁止推荐同时处理所有位置;本期不提供批量工具。Host 的确定性保证是精确引用、单位置计划和真实人工确认,不宣称能直接证明模型理解了用户自然语言。 + + +## 可用性诊断扩展 + +正式行为见 [技能可用性诊断](skill-availability-design.md)。仅新增插件内部 RPC 与模型只读工具,不新增 Registry HTTP API。 + +| 入口 | 输入 | 输出 | +| --- | --- | --- | +| Agent RPC `checkAvailability` | `sourceRevision`、`itemKey`、`AbortSignal` | `SkillAvailability` | +| 模型工具 `astron_skillhub_check_availability` | 严格二选一:`{sourceRevision,itemKey}` 或 `{name}`;拒绝混用、未知字段和路径 | `schemaVersion:1`、`kind:skillhub-availability`、`diagnosis` | + +`SkillAvailability` 用独立字段表达 `integrity`(verified/changed/missing/unknown)、`discovery`(winner/shadowed/not-discovered/unknown)、`association`(managed-reference/not-associated)与可选 `invocation`(userInvocable/modelInvocable)。`checkedAt` 为检查时刻,`reason` 为受控原因。`name` 仅在实际技能名可核对时返回,不能用市场 slug 冒充调用名。托管目标的 `installation` 仅包含精确记录中的 namespace、slug、version、scope;顶层可选 scope 与该目标一致。即使缺少文件、无法解析调用名,也可据此显示被检查目标。按 name 查询不返回 installation 或 scope,不能从名称猜测来源。 + +`managed-reference` 仅证明选中了当前来源管理记录,不代表文件完整或当前可调用;`not-associated` 表示此次按名观察没有建立管理归属,不能解释为已确认属于其他市场。未知状态不得转成未安装。结果不携带路径、原始宿主对象、文档正文或异常文本。所有只读结果均不授予修改权限。 diff --git a/openspec/changes/add-dsh-native-plugin/skill-availability-design.md b/openspec/changes/add-dsh-native-plugin/skill-availability-design.md new file mode 100644 index 000000000..2e62a5fe5 --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/skill-availability-design.md @@ -0,0 +1,54 @@ +# 技能可用性诊断 + +## 范围与产品交互 + +首期提供当前会话的只读诊断,回答“已安装的技能为什么不能用”。不新增安装来源,不修改 SKILL.md、安装记录、技能启停状态或用户草稿;不自动修复、覆盖、卸载或发送消息。不涉及 SaaS、CLI 命令或 Registry API 变化。 + +已安装技能卡片的“更多”提供“检查可用性”,按当前选定安装位置检查。结果在卡片下方紧凑显示结论及一条建议,正常结果不追加操作按钮;不显示路径、内部引用、原始错误、来源 URL 或重复安装信息。切换位置清除旧诊断。旧卡片引用失效时提示重新查询,不能重新绑定另一版本。 + +对话可调用只读工具 `astron_skillhub_check_availability`。精确托管安装使用当前查询的 sourceRevision + itemKey;按实际技能名查询仅反映宿主当前生效技能,不能据此认领来源。模型简述工具结论,不重复完整列表,也不能把诊断当安装/卸载授权。 + +## 状态契约 + +文件完整性、宿主发现与调用策略是独立事实: + +| 场景 | 必须表达 | 不能推断 | +|---|---|---| +| 托管文件与指纹匹配,宿主加载相同资源 | 当前检查可用;保留用户/模型调用策略 | 后续任意调用一定成功 | +| 不同资源赢得同名查找 | 所选安装被同名技能覆盖;可证实才说明赢家范围 | 默认项目一定优先,自动切换技能 | +| 同一资源内容或策略与磁盘不一致 | 内容变化或宿主状态待核验 | 同名覆盖 | +| 本地文件指纹变化 | 本地内容已修改 | 文件损坏或恶意 | +| 明确文件不存在 | 文件缺失 | 未安装过 | +| 权限错误、元数据异常、服务不可用、目录未完整读取 | 无法核验,并给出受控原因 | 未安装、确定不可用 | +| 完整宿主快照未发现目标 | 当前会话未发现该技能 | 全机器没有此技能 | +| 外部/无法关联的宿主赢家 | 来源未关联,仅展示已观察事实 | 任意 Registry 归属、版本、未修改 | +| 调用策略 | 分开说明用户调用与模型调用是否允许 | 一项禁用代表全部禁用 | + +同名赢家通过官方宿主结果判定,不自行重建优先级。外部技能只读,不能出现更新、卸载或接管动作。首期不提供全机器扫描;宿主赢家视图不代表所有被覆盖的外部技能。 + +## 实现边界 + +复用宿主可信 agent/session/cwd、当前来源 revision、opaque itemKey 和精确安装路径校验。Host 内部读取 inventory、只读基线与官方 skills snapshot/get;不调用 CLI 进程。诊断放入独立模块,现有管理工具负责注册和传输。 + +官方 snapshot 的 complete 必须保留;缺少 snapshot 能力或不完整返回 unknown,不使用 list 空数组证明不存在。兼容仍以现有两个 RC 基线为准,逐一实测能力,不扩大 peer 范围。 + +所有异步边界检查来源、会话、cwd、fs/skills 原始服务身份和取消信号。诊断只报告时点观察;不改变已有使用前复核与变更确认。结果仅构造白名单字段,不透传宿主 content、metadata、resourceBase、provider 或异常。 + +```mermaid +flowchart TD + A[卡片检查或模型只读工具] --> B[校验当前会话与选定目标] + B --> C{托管安装引用?} + C -->|是| D[读取精确目录与指纹基线] + C -->|否| E[按技能名读取当前宿主赢家] + D --> F[读取官方完整性快照与赢家] + E --> G[构造不含路径的诊断] + F --> G + G --> H[复核上下文未变化] + H --> I[简短结论与建议] +``` + +## 验收 + +覆盖:可用、同名跨范围覆盖、外部同名赢家、同路径内容变化、本地修改、目录/入口缺失、权限错误、无效元数据、无 snapshot、不完整快照、用户与模型调用策略四种组合、过期引用、来源/会话/服务切换、取消与并发。断言诊断无文件写入,无原始路径和敏感内容输出。 + +浏览器验证两列卡片、长技能名、诊断展开、切换位置、重复点击、请求取消及历史卡片,不让诊断增加大面积布局或破坏已有按钮。双 RC 的官方 Host 验证与真实模型对话分开记录,未调用模型不能称为真实对话验收。 diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md index a3a323e0d..7e048d480 100644 --- a/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md @@ -227,3 +227,35 @@ The plugin SHALL display instance-provided public filter labels and apply an exp #### Scenario: Change the source - **WHEN** the configured source revision changes - **THEN** the previous source's selected label and late responses cannot become the new source's active filter + + +### Requirement: Read-only current-session availability diagnosis +The plugin SHALL diagnose a selected managed installation or a named current-session skill without changing files, inventory, invocation policy or composer drafts. Diagnosis SHALL distinguish integrity, Host discovery and user/model invocation policy. See `../../skill-availability-design.md` for the interaction and bounded implementation scope. + +#### Scenario: A different installation wins the same name +- **WHEN** the official Host resolves the selected skill name to a different resource identity +- **THEN** the selected installation is reported as shadowed, and any winner scope is shown only when independently established; the plugin SHALL NOT switch the target or invoke the winner automatically + +#### Scenario: The same resource changed +- **WHEN** the resource identity is unchanged but its content or policy differs from the checked local package +- **THEN** the diagnosis reports changed or unverified state rather than claiming same-name shadowing + +#### Scenario: Incomplete Host or filesystem evidence +- **WHEN** snapshot support is absent, a provider is incomplete, an approved root is unreadable, or context changes while checking +- **THEN** the plugin reports unknown or a stale-context failure, never inferring uninstalled from an empty list + +#### Scenario: Skill has no current-source ownership +- **WHEN** a named Host winner cannot be exactly associated with a current-source managed installation +- **THEN** its source is described as unassociated and only observed invocation facts are exposed; the diagnosis SHALL NOT infer a registry, version or clean fingerprint, or offer mutation actions + +#### Scenario: Invocation policies differ +- **WHEN** user invocation and model invocation have different values +- **THEN** the result preserves both values without treating one disabled policy as total unavailability + +#### Scenario: Compact historical-card diagnosis +- **WHEN** a user checks availability from an installed card +- **THEN** the result belongs to that exact installation reference and selected scope; changing scope clears it, and a stale reference requires a fresh query rather than automatic rebinding + +#### Scenario: Path-free result +- **WHEN** Host resources or errors contain paths, content, metadata or secrets +- **THEN** only explicitly allowed diagnosis fields reach the model and UI, and no raw resource or error is serialized diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index 3d0160b9c..5d649ffdf 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -366,3 +366,10 @@ - [x] 42.11 统一单份/双份安装卡片三行布局与正常等高,列表按6条展开;真实混合多卡验证深浅/窄屏/菜单及目标标识。 - [x] 42.12 修复同名技能使用阻断提示挤乱操作行;保留精确安装目标保护,验证生效安装使用、取消及草稿/文件不变。 + +## 43. 当前会话技能可用性诊断 + +- [x] 43.1 明确只读诊断、来源边界、未知状态和紧凑交互(skill-availability-design.md)。 +- [x] 43.2 实现 Host 诊断与只读对话工具,保留完整性、发现和调用策略的独立事实。 +- [x] 43.3 管理卡片提供按选定安装位置检查的紧凑入口。 +- [x] 43.4 边界测试、双 RC 宿主与浏览器组件验证;真实模型与完整 DSH 浏览器链路未执行,边界见 verification.md。 diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md index 753fb678d..944039f9b 100644 --- a/openspec/changes/add-dsh-native-plugin/verification.md +++ b/openspec/changes/add-dsh-native-plugin/verification.md @@ -407,3 +407,13 @@ CLI 全量结果与后续专项分开记录,不声称最终新增用例都来 ### 提交前主线集成验证 无冲突合入最新主线,保留 Registry 子路径下载修复。集成后使用 Bun 1.3.13:CLI lint/typecheck/build 与 783 项测试、3429 次断言通过;DSH build/typecheck/lint 与 523 项测试、11693 次断言通过。OpenSpec 严格校验和 diff 检查通过。最终只读评审未发现新增阻断问题;公开改动为 105 份源码、回归测试和正式文档,无本地截图、日志、配置或构建包。远端 CI、合并和 npm 发布独立记录,不以本地结果替代。 + + +## 技能可用性诊断验证 + +- 完整插件检查:构建、类型、lint通过;538项测试通过,无失败。 +- 独立评审已闭环:首次异步等待期间服务替换、provider清理、独立诊断身份、时点文案与结果白名单。关键失效测试通过内存恢复旧实现证实可检出回归。 +- 官方0.2.0-rc.2和0.2.1-rc.2各通过runtime-baseline、plugin-lifecycle和skill-availability;每版14次真实工具诊断、1次Agent RPC。覆盖同名覆盖、缺失、修改、四种调用策略、不完整/失败provider、脱敏及文件/记录不变。 +- 浏览器组件层:真实组件与显式模拟服务,明暗主题、900/360宽度共80项通过;包含范围切换取消、失败重试、历史引用、两列/单列、缺失身份展示。 +- 新版Host全新联网依赖安装超时;后续使用官方依赖的隔离副本通过真实Host验证,不能据此宣称全新联网安装成功。 +- 未调用真实模型,未执行完整DSH浏览器至Host链路或原生草稿验收;未使用生产Registry。本轮不改现有用户预览,不提交测试过程、截图、模型配置或临时环境。 diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index 7a5eb05ef..d1ff76f75 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -208,3 +208,10 @@ Version selection is read-only. A verifiable newer selected version offers an ex `astron_skillhub_list_installed` lists current-source managed records in the current user/project roots, not every skill on the machine. `astron_skillhub_check_update` checks the latest or an explicitly requested newer version. `astron_skillhub_upgrade` and `astron_skillhub_uninstall` require an exact installed reference and real human confirmation, including explicitly selected scopes. Without a native question provider they return a confirmation card. Models cannot authorize operations using paths, plan IDs or confirmation flags. `astron_skillhub_operation` reads the original session-owned receipt without resubmitting a mutation. New conversational installs default to user scope after an explicit install request. Project installation is explicit/advanced. Updating one location preserves other locations' versions. Local edits and stale references block mutations. CLI installations sharing this inventory must be upgraded together; old CLI/new inventory mixed writes are unsupported. + + +### Skill availability diagnosis (unreleased) + +Choose “更多 → 检查可用性” on a conversational installed-skill card to inspect its selected location. A conversation can also use the read-only `astron_skillhub_check_availability` tool to explain why a skill cannot be used. + +The result distinguishes local integrity, current-session discovery, and user/model invocation policies. Shadowing, local modifications and unknown Host state are separate outcomes. A name-only lookup reports Host observations without proving installation ownership or integrity. Diagnosis never modifies, removes or enables/disables a skill, edits a draft, or sends a message. A historical result is not a guarantee about a later invocation. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index cd5d2aae4..1118a0b56 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -211,3 +211,10 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh - `astron_skillhub_operation`:以原回执查询本会话操作结果,不重复提交操作;历史成功不代表当前仍安装。 多份安装先选择目标;更新不改变其他位置版本。本地修改、来源/会话变化和失效引用阻止操作。启用逐位置库存后,同机共同管理技能的 CLI 必须同步升级;旧版 CLI 与新库存混用不受支持。 + + +### 技能可用性诊断(待发布) + +在对话安装列表的技能卡片中选择“更多 → 检查可用性”,检查当前选定位置。也可以在对话中询问“这个技能为什么不能用”,由 `astron_skillhub_check_availability` 返回只读结果。 + +诊断分别核验本地文件、当前会话加载结果,以及用户/模型调用策略。同名技能生效、本地修改和宿主无法核验会给出不同结论。按技能名查询只说明当前宿主观察到的事实,不证明安装来源或内容完整性。检查不会修改、卸载、启停技能或发送消息;历史结果不保证下一次调用仍然可用。 diff --git a/plugins/dsh/scripts/probe-host.mjs b/plugins/dsh/scripts/probe-host.mjs index 0ce56aadc..808cab4f5 100644 --- a/plugins/dsh/scripts/probe-host.mjs +++ b/plugins/dsh/scripts/probe-host.mjs @@ -16,6 +16,7 @@ const probes = { 'runtime-baseline': 'runtime-baseline-result.json', 'runtime-unsupported': 'runtime-unsupported-result.json', 'installed-state': 'installed-state-result.json', + 'skill-availability': 'skill-availability-result.json', 'upgrade-candidate': 'upgrade-candidate-result.json', 'upgrade-drift': 'upgrade-drift-result.json', 'plan-context': 'plan-context-result.json', diff --git a/plugins/dsh/scripts/smoke-native-host.mjs b/plugins/dsh/scripts/smoke-native-host.mjs index 4d28af635..ff9b5a832 100644 --- a/plugins/dsh/scripts/smoke-native-host.mjs +++ b/plugins/dsh/scripts/smoke-native-host.mjs @@ -217,7 +217,7 @@ async function main() { const manifest = JSON.parse(await readFile(join(root, 'home/profiles/skillhub-m0/node_modules/@astron-team/skillhub-dsh/package.json'), 'utf8')); if (manifest.name !== '@astron-team/skillhub-dsh') throw new Error('Unexpected installed candidate identity.'); const probeScript = fileURLToPath(new URL('./probe-host.mjs', import.meta.url)); - for (const probe of ['runtime-baseline', 'plugin-lifecycle']) { + for (const probe of ['runtime-baseline', 'plugin-lifecycle', 'skill-availability']) { summary.stage = probe; await run(probe, [probeScript, '--root', root, '--probe', probe], 150000); const lines = (await readFile(join(root, 'logs', probe + '.log'), 'utf8')).trim().split('\n'); @@ -238,7 +238,7 @@ async function main() { } catch (error) { summary.failure = ['SPAWN_FAILED', 'DEADLINE_OR_CANCELLED', 'PROCESS_FAILED', 'CLEANUP_DEADLINE'].includes(error.code) ? error.code : 'INVALID_RESULT'; - if (['runtime-baseline', 'plugin-lifecycle'].includes(summary.stage)) { + if (['runtime-baseline', 'plugin-lifecycle', 'skill-availability'].includes(summary.stage)) { const log = await readFile(join(root, 'logs', summary.stage + '.log'), 'utf8').catch(() => ''); summary.probeFailure = safeProbeFailure(log, summary.stage); } diff --git a/plugins/dsh/src/client/index.tsx b/plugins/dsh/src/client/index.tsx index 9727de591..ecefa3278 100644 --- a/plugins/dsh/src/client/index.tsx +++ b/plugins/dsh/src/client/index.tsx @@ -138,7 +138,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { installed: (revision, signal) => { assertCurrent(); return withSessionRemote(scope!, remote => remote.skillhub.listInstalled(revision, signal)); }, }; } - for (const name of ['astron_skillhub_list_installed', 'astron_skillhub_check_update', 'astron_skillhub_upgrade', 'astron_skillhub_uninstall', 'astron_skillhub_operation']) { + for (const name of ['astron_skillhub_check_availability', 'astron_skillhub_list_installed', 'astron_skillhub_check_update', 'astron_skillhub_upgrade', 'astron_skillhub_uninstall', 'astron_skillhub_operation']) { ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name, inject: sessionId => { const receipt = receiptReadsFor(sessionId); @@ -159,6 +159,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { receipt.assertCurrent(); return { sourceRevision: source.value.sourceRevision, list: installed.value }; }), + availability: (revision, itemKey, signal) => call(remote => remote.skillhub.checkAvailability(revision, itemKey, signal)), check: (revision, itemKey, signal) => call(remote => remote.skillhub.checkUpdate(revision, itemKey, signal)), begin: (revision, itemKey, action, version, requestKey, signal) => call(remote => remote.skillhub.beginConversationManagement(revision, itemKey, action, version, requestKey, signal)), confirm: (reference, confirmed, signal) => call(remote => remote.skillhub.confirmConversationManagement(reference, confirmed, signal)), diff --git a/plugins/dsh/src/client/management-result.ts b/plugins/dsh/src/client/management-result.ts index 10a09f946..cd7942284 100644 --- a/plugins/dsh/src/client/management-result.ts +++ b/plugins/dsh/src/client/management-result.ts @@ -14,7 +14,14 @@ const management = z.object({ ...base, kind: z.literal('skillhub-management'), i action: z.enum(['upgrade', 'uninstall']), status: z.enum(['awaiting-confirmation', 'cancelled', 'blocked', 'accepted']), candidateVersion: text.nullable(), reason: z.string(), confirmationRef: text.nullable(), operation: operation.nullable() }); const operationResult = z.object({ schemaVersion: z.literal(1), kind: z.literal('skillhub-operation'), toolCallId: text, operation: operation.extend({ kind: z.string(), namespace: text.optional(), slug: text, version: text, targetLabel: text, phase: z.string(), warningCount: z.number(), discovery: z.enum(['unconfirmed', 'confirmed', 'failed']).optional(), recovery: z.enum(['not-needed', 'restored', 'incomplete', 'unknown']).optional() }).nullable() }); -const schema = z.discriminatedUnion('kind', [installed, update, management, operationResult]); +export const availabilitySchema = z.object({ installation: z.object({ namespace: text, slug: text, version: text, scope: z.enum(['user', 'project']) }).optional(), scope: z.enum(['user', 'project']).optional(), checkedAt: text, name: z.string().max(128).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(), + association: z.enum(['managed-reference', 'not-associated']), integrity: z.enum(['verified', 'changed', 'missing', 'unknown']), + discovery: z.enum(['winner', 'shadowed', 'not-discovered', 'unknown']), + invocation: z.object({ userInvocable: z.boolean(), modelInvocable: z.boolean() }).optional(), + reason: z.enum(['observed', 'host-unavailable', 'files-missing', 'files-unreadable', 'local-changes', 'baseline-unverified', + 'definition-unverified', 'snapshot-unavailable', 'snapshot-incomplete', 'observation-changed', 'not-discovered', 'same-name-winner', 'definition-changed', 'read-failed']) }); +const availability = z.object({ schemaVersion: z.literal(1), kind: z.literal('skillhub-availability'), toolCallId: text, diagnosis: availabilitySchema }); +const schema = z.discriminatedUnion('kind', [installed, update, management, operationResult, availability]); export type ManagementViewResult = z.infer; export type ManagementReceipt = z.infer; /** Transcript metadata is display data, never authority to mutate. Host rechecks every reference. */ diff --git a/plugins/dsh/src/client/management-tool-view.tsx b/plugins/dsh/src/client/management-tool-view.tsx index 0dba4e1ba..a2c190227 100644 --- a/plugins/dsh/src/client/management-tool-view.tsx +++ b/plugins/dsh/src/client/management-tool-view.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useId, useMemo, useRef, useState, type React import { QueryClientProvider, useQuery, type QueryClient } from '@tanstack/react-query'; import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'; import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'; -import type { ConversationManagementResult, InstalledSkillList, InstalledUpdateCheck } from '../host/types.js'; +import type { ConversationManagementResult, InstalledSkillList, InstalledUpdateCheck, SkillAvailability } from '../host/types.js'; import type { InstallationServices } from './install-control.js'; import { parseManagementResult, managementReason, type ManagementViewResult, type ManagementReceipt } from './management-result.js'; import { NativeControlStyles } from './native-control-styles.js'; @@ -15,6 +15,7 @@ export interface ManagementViewServices { client: QueryClient; installation: InstallationServices; refreshInstalled(signal: AbortSignal): Promise<{ sourceRevision: string; list: InstalledSkillList }>; + availability?(revision: string, itemKey: string, signal: AbortSignal): Promise>; check(revision: string, itemKey: string, signal: AbortSignal): Promise>; begin(revision: string, itemKey: string, action: 'upgrade' | 'uninstall', version: string | null, requestKey: string, signal: AbortSignal): Promise>; confirm(ref: string, confirmed: boolean, signal: AbortSignal): Promise>; @@ -71,11 +72,35 @@ export function ManagementContent({ result, services }: { result: ManagementView return ; } function ManagementResultContent({ result, services }: { result: ManagementViewResult; services: ManagementViewServices }) { + if (result.kind === 'skillhub-availability') return ; if (result.kind === 'skillhub-operation') return ; if (result.kind === 'skillhub-management') return ; if (result.kind === 'skillhub-update-check') return ; return ; } +export function AvailabilityResult({ value, standalone = false }: { value: SkillAvailability; standalone?: boolean }) { + const messages: Record = { + observed: [value.association === 'not-associated' ? '当前会话已发现,来源未关联。' : '当前检查:文件与宿主加载一致。', '仅反映本次检查结果。'], + 'host-unavailable': ['宿主状态无法核验。', '请稍后重新检查。'], + 'files-missing': ['所选安装文件缺失。', '请重新查询安装记录。'], + 'files-unreadable': ['文件无法读取。', '请检查本地访问权限后重试。'], + 'local-changes': ['本地内容已修改。', '请核对修改是否符合预期。'], + 'baseline-unverified': ['安装内容基线无法核验。', '请重新查询安装记录。'], + 'definition-unverified': ['技能定义或宿主加载无法核验。', '请核对技能定义后重新检查。'], + 'snapshot-unavailable': ['宿主未提供完整性快照。', '请确认宿主技能服务状态。'], + 'snapshot-incomplete': ['宿主技能列表不完整。', '请稍后重新检查。'], + 'observation-changed': ['检查期间状态发生变化。', '请重新检查。'], + 'not-discovered': ['当前会话未发现此技能。', '请核对技能定义与当前会话。'], + 'same-name-winner': ['所选安装被同名技能覆盖。', '请核对当前会话的同名技能。'], + 'definition-changed': ['文件与宿主加载内容不一致。', '请等待宿主刷新后重新检查。'], + 'read-failed': ['暂时无法核验。', '请稍后重新检查。'], + }; + const [message, advice] = messages[value.reason]; + return
+ {standalone &&

{value.installation ? `@${value.installation.namespace}/${value.installation.slug} · v${value.installation.version}` : value.name ?? '技能可用性'}{value.scope && ` · ${scopeLabel(value.scope)}`}

} +

{message}

{value.invocation &&

用户调用{value.invocation.userInvocable ? '允许' : '禁用'} · 模型调用{value.invocation.modelInvocable ? '允许' : '禁用'}

}{advice} +
; +} function ManagementOperation({ result, services }: { result: Extract; services: ManagementViewServices }) { const original = result.operation; const query = useQuery({ queryKey: ['skillhub-management-operation', services.installation.sessionId, original?.context.hostInstanceId, original?.operationId], @@ -170,6 +195,15 @@ function ManagementRow({ item, revision, services, initialCheck, checkOnly = fal onPendingMutation?(pending: boolean): void; onManagement?(result: ConversationManagementResult): void; }) { const [check, setCheck] = useState(initialCheck); + const availabilityId = useId(); + const availability = useQuery({ queryKey: ['skillhub-availability', services.installation.sessionId, revision, item.itemKey, availabilityId], + enabled: false, retry: false, gcTime: 0, queryFn: async ({ signal }) => { + await verifyManagementReference(services, revision, item.itemKey, signal); + if (!services.availability) throw new Error('Availability service unavailable'); + const value = read(await services.availability(revision, item.itemKey, signal)); + services.assertCurrent(); signal.throwIfAborted(); return value; + } }); + const [moreOpen, setMoreOpen] = useState(false); const [useError, setUseError] = useState(''); const moreTrigger = useRef(null); @@ -209,13 +243,15 @@ function ManagementRow({ item, revision, services, initialCheck, checkOnly = fal {check?.state === 'upgradable' && } {check?.state === 'unavailable' && } {!checkOnly && !check && item.state === 'present' && } - {!checkOnly && item.state === 'present' && (!check || check.state === 'upgradable') && } + {!checkOnly && (services.availability || (item.state === 'present' && (!check || check.state === 'upgradable'))) && } {!checkOnly && item.state === 'missing' && } {useError &&

{useError}

} + {availability.isFetching ?

正在检查可用性…

: availability.isError ? <>

{stale(availability.error) ? '安装记录已变化,请重新查询。' : '可用性暂时无法核验。'}

{stale(availability.error) && } : availability.data && } {moreOpen &&
- {!check && } - + {services.availability && } + {!check && item.state === 'present' && } + {item.state === 'present' && }
} } ; @@ -295,6 +331,7 @@ function ManagementStyles() { .skillhub-controls .astron-sh-management-target-content>.astron-sh-management-row{padding:0;min-height:28px} .skillhub-controls .astron-sh-management-view .astron-sh-management-more-trigger{width:28px;min-width:28px;padding:3px 0;border:1px solid var(--astron-sh-border)} .skillhub-controls .astron-sh-management-more-content{display:flex;justify-content:flex-end;align-items:center;gap:4px;flex-basis:100%;min-width:0;padding-top:3px;border-top:1px solid var(--astron-sh-border)} +.skillhub-controls .astron-sh-management-availability{flex-basis:100%;min-width:0;overflow-wrap:anywhere} .skillhub-controls .astron-sh-management-use-error{flex-basis:100%;min-width:0;overflow-wrap:anywhere;color:var(--astron-sh-muted)} .skillhub-controls .astron-sh-management-actions:empty{display:none} @container(max-width:440px){.skillhub-controls .astron-sh-management-row>.astron-sh-management-identity{flex-basis:100%}.skillhub-controls .astron-sh-management-actions{margin-left:auto}} diff --git a/plugins/dsh/src/host/index.ts b/plugins/dsh/src/host/index.ts index 53eec0cb7..ab2ac7c8b 100644 --- a/plugins/dsh/src/host/index.ts +++ b/plugins/dsh/src/host/index.ts @@ -31,6 +31,8 @@ import type { UpgradePlanView, UninstallPlanView, HostExecutePlanRequest, HostRe import type { ResolvedInstallationTarget } from './installation-targets.js'; import { assertInstallationMutation, assertRemovalMutation, resolveInstallationPath, resolveInstalledPath } from './installation-paths.js'; import { inspectDshSkill, observeDshInstallation, sameDshSkill } from './skill-discovery.js'; +import { inspectSkillAvailability } from './skill-availability.js'; +import type { SkillAvailability } from './types.js'; import { validateDshSkill } from './skill-format.js'; import { installRequest, operationQuery, executePlanRequest } from './install-request.js'; import { conversationInstallKey, conversationInstallRequest } from './conversation-install-request.js'; @@ -332,6 +334,51 @@ export class SkillHubService extends TypertRemoteService { return { items, complete: result.complete, issues: result.issues, unavailable }; } + @RemoteScope('agent') + async checkAvailability(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise { + return this.checkAvailabilityForAgent({ sourceRevision, itemKey }, signal); + } + + private async checkAvailabilityForAgent(request: ManagementRequest, signal: AbortSignal, agentId?: string): Promise { + const parsed = managementRequests.availability.parse(request); + const combined = AbortSignal.any([signal, this.lifetime.signal]); + const id = agentId ?? this.ctx.invocation?.request.args.agentId; + const agent = typeof id === 'string' ? this.ctx.get('agents')?.get(SessionId(id)) : undefined; + const cwd = agent?.session.header.cwd; + const sessionId = agent?.session.id; + const fs = agent?.ctx.get('fs'); + const skills = this.ctx.get('skills'); + const revision = this.getContext().sourceRevision; + const runtime = this.localRuntime; + const selected = 'itemKey' in parsed ? await this.readInstalledItem(parsed.sourceRevision, parsed.itemKey, combined, agentId) : undefined; + const valid = () => { + combined.throwIfAborted(); selected?.valid(); this.assertSource(revision); + if (!agent || !cwd || agent.session.id !== sessionId || agent.session.header.cwd !== cwd + || this.ctx.get('agents')?.get(SessionId(sessionId!)) !== agent || this.localRuntime !== runtime + || serviceIdentity(agent.ctx.get('fs')) !== serviceIdentity(fs) + || serviceIdentity(this.ctx.get('skills')) !== serviceIdentity(skills)) { + throw new RemoteError('skillhub/context-stale', 'Session changed.', { retryable: false }); + } + }; + valid(); + const options = { scope: agent!, cwd: cwd!, signal: combined }; + const diagnosis = await inspectSkillAvailability({ ctx: agent!.ctx, signal: combined, valid, + name: 'name' in parsed ? parsed.name : undefined, + reader: skills ? { get: name => skills.get(name, options), + ...(typeof skills.snapshot === 'function' ? { snapshot: () => skills.snapshot(options) } : {}) } : undefined, + managed: selected ? { directory: selected.item.targetPath, fingerprint: selected.item.fingerprint, + baseline: async () => { + const target = await resolveInstalledPath({ scope: selected.item.scope, cwd: cwd!, fs: fs!, signal: combined }); + valid(); + if (join(target.root, selected.item.slug) !== selected.item.targetPath) throw new RemoteError('skillhub/context-stale', 'Skill root changed.', { retryable: false }); + return selected.runtime!.inspectRemoval({ registry: this.getContext().registryUrl, + namespace: selected.item.namespace, slug: selected.item.slug, version: selected.item.version }, target); + } } : undefined }); + valid(); + return { ...diagnosis, ...(selected ? { scope: selected.item.scope, installation: { namespace: selected.item.namespace, + slug: selected.item.slug, version: selected.item.version, scope: selected.item.scope } } : {}) }; + } + /** Read-only handoff: installed snapshots and market names never authorize an invocation. */ @RemoteScope('agent') async prepareSkillUse(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise { @@ -1190,6 +1237,8 @@ export class SkillHubService extends TypertRemoteService { throw new RemoteError('skillhub/context-stale', 'Conversation unavailable.', { retryable: false }); } signal.throwIfAborted(); + if (action === 'availability') return { schemaVersion: 1, kind: 'skillhub-availability', toolCallId: context.callId, + diagnosis: await this.checkAvailabilityForAgent(request, signal, context.sessionId) }; if (action === 'operation') { const { operationId, hostInstanceId } = managementRequests.operation.parse(request); if (runtime.getStatus().hostInstanceId !== hostInstanceId) throw new RemoteError('skillhub/context-stale', 'Host changed.', { retryable: false }); diff --git a/plugins/dsh/src/host/management-tool.ts b/plugins/dsh/src/host/management-tool.ts index 03131a34a..74a1c74fe 100644 --- a/plugins/dsh/src/host/management-tool.ts +++ b/plugins/dsh/src/host/management-tool.ts @@ -5,23 +5,37 @@ import type { ConversationSearchContext } from './search-tool.js'; import { installationFailureCode } from './installation-failure.js'; export const MANAGEMENT_TOOLS = { + availability: 'astron_skillhub_check_availability', list: 'astron_skillhub_list_installed', check: 'astron_skillhub_check_update', upgrade: 'astron_skillhub_upgrade', uninstall: 'astron_skillhub_uninstall', operation: 'astron_skillhub_operation', } as const; export type ManagementAction = keyof typeof MANAGEMENT_TOOLS; const reference = z.string().min(1).max(2048); export const managementRequests = { + availability: z.union([z.object({ sourceRevision: reference, itemKey: reference }).strict(), + z.object({ name: z.string().min(1).max(128).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) }).strict()]), list: z.object({}).strict(), check: z.object({ sourceRevision: reference, itemKey: reference, version: reference.optional() }).strict(), upgrade: z.object({ sourceRevision: reference, itemKey: reference, version: reference.optional() }).strict(), uninstall: z.object({ sourceRevision: reference, itemKey: reference }).strict(), operation: z.object({ operationId: z.uuid(), hostInstanceId: z.uuid() }).strict(), }; -export type ManagementRequest = { sourceRevision?: string; itemKey?: string; version?: string; operationId?: string; hostInstanceId?: string }; +export type ManagementRequest = { name?: string; sourceRevision?: string; itemKey?: string; version?: string; operationId?: string; hostInstanceId?: string }; /** The model needs outcomes, not local paths, diagnostics or executable confirmation references. */ function modelView(result: Record): object { + if (result.kind === 'skillhub-availability') { + const value = result.diagnosis as Record | undefined; + const installation = value?.installation as Record | undefined; + const invocation = value?.invocation as Record | undefined; + return { schemaVersion: 1, kind: 'skillhub-availability', diagnosis: value && { + checkedAt: value.checkedAt, name: value.name, scope: value.scope, association: value.association, integrity: value.integrity, + discovery: value.discovery, reason: value.reason, + installation: installation && { namespace: installation.namespace, slug: installation.slug, version: installation.version, scope: installation.scope }, + invocation: invocation && { userInvocable: invocation.userInvocable, modelInvocable: invocation.modelInvocable }, + } }; + } const operation = result.operation; if (!operation || typeof operation !== 'object') return { ...result, sourceLabel: undefined, confirmationRef: undefined }; const value = operation as Record; @@ -51,12 +65,12 @@ export function registerManagementTools(tools: Pick [{ type: 'text', text: JSON.stringify(modelView(result)) + modelInstructions(result) }], diff --git a/plugins/dsh/src/host/skill-availability.ts b/plugins/dsh/src/host/skill-availability.ts new file mode 100644 index 000000000..d52a5c9f8 --- /dev/null +++ b/plugins/dsh/src/host/skill-availability.ts @@ -0,0 +1,100 @@ +import type { Context } from '@deepseek-ai/cordis'; +import { FileSystemSkillProvider } from '@deepseek-ai/dsh-skill-filesystem'; +import { lstat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { sameDshSkill, type LoadedSkill } from './skill-discovery.js'; +import type { RemovalBaseline } from './generated/native-contract.js'; +import type { SkillAvailability } from './types.js'; + +export interface AvailabilityReader { + snapshot?(): Promise<{ complete: boolean; skills: readonly { name: string }[] }>; + get(name: string): Promise; +} +export const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Whitelisted observations only. Neither a successful check nor a name lookup authorizes changes. */ +export async function inspectSkillAvailability(input: { + ctx: Context; reader?: AvailabilityReader; signal: AbortSignal; valid(): void; name?: string; + managed?: { directory: string; fingerprint?: string; baseline(): Promise }; +}): Promise { + const { reader, managed, signal } = input; + const valid = () => { signal.throwIfAborted(); input.valid(); }; + const read = async (work: () => Promise): Promise => { + valid(); try { return await work(); } finally { valid(); } + }; + const result: SkillAvailability = { checkedAt: new Date().toISOString(), + association: managed ? 'managed-reference' : 'not-associated', integrity: 'unknown', discovery: 'unknown', reason: 'host-unavailable' }; + let expected: LoadedSkill | undefined; + let baseline: RemovalBaseline | undefined; + let name = input.name; + valid(); + try { + if (managed) { + // Only the Host-validated exact installation is read. ENOENT is the only missing proof. + try { + for (const path of [managed.directory, join(managed.directory, 'SKILL.md')]) { + const stat = await read(() => lstat(path)); + if (stat.isSymbolicLink() || (path === managed.directory ? !stat.isDirectory() : !stat.isFile())) { result.reason = 'files-unreadable'; return result; } + } + } catch (error) { + valid(); + result.integrity = (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unknown'; + result.reason = result.integrity === 'missing' ? 'files-missing' : 'files-unreadable'; + return result; + } + baseline = await read(managed.baseline); + if (baseline.status === 'blocked') { + result.integrity = baseline.reason === 'local-changes' ? 'changed' : 'unknown'; + result.reason = baseline.reason === 'local-changes' ? 'local-changes' : 'baseline-unverified'; + } else if (baseline.kind === 'uninstall' && baseline.target.path === managed.directory && managed.fingerprint + && baseline.inventoryItem.targets.some(target => target.installDir === managed.directory && target.fingerprint === managed.fingerprint)) { + result.integrity = 'verified'; + } else { result.reason = 'baseline-unverified'; } + const provider = new FileSystemSkillProvider(input.ctx, { signal, invalidate: () => {} }, { + providerName: 'skillhub-availability', includeDefaultRoots: false, customSkillDirs: [managed.directory], watch: false, + }); + try { + const listed = await read(() => provider.list({ signal })); + if (!Array.isArray(listed) && !listed.complete) { result.reason = 'files-unreadable'; return result; } + const candidate = (Array.isArray(listed) ? listed : listed.candidates).find(value => value.path === join(managed.directory, 'SKILL.md')); + if (candidate) expected = await read(() => provider.get(candidate, { signal })); + } finally { + try { await provider.dispose(); } finally { valid(); } + } + if (!expected) { result.reason = 'definition-unverified'; return result; } + name = expected.name; + } + if (!name || name.length > 128 || !skillNamePattern.test(name)) { result.reason = 'definition-unverified'; return result; } + result.name = name; + if (!reader?.snapshot) { result.reason = 'snapshot-unavailable'; return result; } + const snapshot = await read(() => reader.snapshot!()); + if (!snapshot.complete) { result.reason = 'snapshot-incomplete'; return result; } + const winner = await read(() => reader.get(name!)); + const finalSnapshot = await read(() => reader.snapshot!()); + const finalWinner = await read(() => reader.get(name!)); + if (!finalSnapshot.complete || !isDeepStrictEqual(snapshot, finalSnapshot) || !isDeepStrictEqual(winner, finalWinner)) { + result.reason = 'observation-changed'; return result; + } + if (managed && !isDeepStrictEqual(baseline, await read(managed.baseline))) { + result.integrity = 'unknown'; result.reason = 'observation-changed'; return result; + } + if (!winner) { + if (snapshot.skills.some(skill => skill.name === name)) { result.reason = 'definition-unverified'; return result; } + result.discovery = 'not-discovered'; result.reason = 'not-discovered'; return result; + } + if (winner.name !== name || !snapshot.skills.some(skill => skill.name === name)) { result.reason = 'observation-changed'; return result; } + if (managed && expected) { + if (winner.path !== expected.path || winner.resourceBase?.kind !== 'directory' || winner.resourceBase.path !== managed.directory) { + result.discovery = 'shadowed'; result.reason = 'same-name-winner'; return result; + } + if (!sameDshSkill(winner, expected, managed.directory)) { result.reason = 'definition-changed'; return result; } + } + result.discovery = 'winner'; + result.invocation = { userInvocable: winner.invocation.userInvocable, modelInvocable: winner.invocation.modelInvocable }; + if (!managed || result.integrity === 'verified') result.reason = 'observed'; + return result; + } catch { + valid(); result.discovery = 'unknown'; result.reason = 'read-failed'; return result; + } +} diff --git a/plugins/dsh/src/host/types.ts b/plugins/dsh/src/host/types.ts index 4463db3fc..f4dfbf7a2 100644 --- a/plugins/dsh/src/host/types.ts +++ b/plugins/dsh/src/host/types.ts @@ -183,3 +183,18 @@ export interface ConversationManagementResult { candidateVersion: string | null; reason: string; confirmationRef: string | null; operation: NativeOperation | null; } + +/** Path-free, read-only observations at checkedAt; never execution authorization. */ +export interface SkillAvailability { + installation?: { namespace: string; slug: string; version: string; scope: 'user' | 'project' }; + scope?: 'user' | 'project'; + checkedAt: string; + name?: string; + association: 'managed-reference' | 'not-associated'; + integrity: 'verified' | 'changed' | 'missing' | 'unknown'; + discovery: 'winner' | 'shadowed' | 'not-discovered' | 'unknown'; + invocation?: { userInvocable: boolean; modelInvocable: boolean }; + reason: 'observed' | 'host-unavailable' | 'files-missing' | 'files-unreadable' | 'local-changes' + | 'baseline-unverified' | 'definition-unverified' | 'snapshot-unavailable' | 'snapshot-incomplete' + | 'observation-changed' | 'not-discovered' | 'same-name-winner' | 'definition-changed' | 'read-failed'; +} diff --git a/plugins/dsh/test/client-entry-lifecycle.test.ts b/plugins/dsh/test/client-entry-lifecycle.test.ts index 7b6140e69..8f0c31dc1 100644 --- a/plugins/dsh/test/client-entry-lifecycle.test.ts +++ b/plugins/dsh/test/client-entry-lifecycle.test.ts @@ -26,7 +26,7 @@ test('layout loss removes only catalog entries and preserves conversation regist const flush = async () => { await new Promise(resolve => setTimeout(resolve, 25)); }; try { await plugin; await flush(); - const tools = ['conversation.input.dock/skillhub-case-choices', 'conversation.input.left/skillhub-use-focus', 'tool.call.toolview/astron_skillhub_check_update', 'tool.call.toolview/astron_skillhub_install', 'tool.call.toolview/astron_skillhub_list_installed', 'tool.call.toolview/astron_skillhub_operation', 'tool.call.toolview/astron_skillhub_search', 'tool.call.toolview/astron_skillhub_uninstall', 'tool.call.toolview/astron_skillhub_upgrade']; + const tools = ['conversation.input.dock/skillhub-case-choices', 'conversation.input.left/skillhub-use-focus', 'tool.call.toolview/astron_skillhub_check_availability', 'tool.call.toolview/astron_skillhub_check_update', 'tool.call.toolview/astron_skillhub_install', 'tool.call.toolview/astron_skillhub_list_installed', 'tool.call.toolview/astron_skillhub_operation', 'tool.call.toolview/astron_skillhub_search', 'tool.call.toolview/astron_skillhub_uninstall', 'tool.call.toolview/astron_skillhub_upgrade']; expect([...entries.keys()].sort()).toEqual(tools); const original = tools.map(key => entries.get(key)); for (let cycle = 0; cycle < 2; cycle++) { diff --git a/plugins/dsh/test/client-slot-lifecycle.test.ts b/plugins/dsh/test/client-slot-lifecycle.test.ts index 2094f6f4e..999d8bce3 100644 --- a/plugins/dsh/test/client-slot-lifecycle.test.ts +++ b/plugins/dsh/test/client-slot-lifecycle.test.ts @@ -58,7 +58,7 @@ test('official slot declaration loss preserves catalog and independently removes const tools = slots.register({ name: 'test.tools', children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, } } as never, empty); - expect(slots.entriesOfSlot('tool.call.toolview')).toHaveLength(7); + expect(slots.entriesOfSlot('tool.call.toolview')).toHaveLength(8); tools(); await flush(); expect(slots.entriesOfSlot('tool.call.toolview')).toHaveLength(0); expect(slots.entriesOfSlot('main')[0]?.component).toBe(catalog); diff --git a/plugins/dsh/test/integration/skill-availability-fixture.mjs b/plugins/dsh/test/integration/skill-availability-fixture.mjs new file mode 100644 index 000000000..07a40e188 --- /dev/null +++ b/plugins/dsh/test/integration/skill-availability-fixture.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +export const inject = ['sessionController', 'workspaceRegistry', 'tools', 'sessions', 'skills', 'skillhub', 'typertGateway']; +export function apply(ctx, config) { + const root = resolve(config.root); + assert(root.startsWith(resolve(tmpdir()) + sep)); + assert(fileURLToPath(import.meta.url).startsWith(resolve(tmpdir()) + sep)); + assert.equal(process.env.DSH_HOME, join(root, 'home')); + const abort = new AbortController(); + const timer = setTimeout(() => run(ctx, root, AbortSignal.any([abort.signal, AbortSignal.timeout(70000)])) + .catch(error => writeFile(join(root, 'skill-availability-result.json'), JSON.stringify({ passed: false, error: error.message }))), 1000); + return () => { clearTimeout(timer); abort.abort(); }; +} + +async function digest(directory) { + const hash = createHash('sha256'); + async function visit(path, relative = '') { + let entries; + try { entries = await readdir(path, { withFileTypes: true }); } + catch (error) { if (error.code === 'ENOENT') { hash.update('missing:' + relative); return; } throw error; } + const observed = await stat(path); + hash.update(JSON.stringify([relative, observed.mtimeMs, observed.ctimeMs, observed.mode])); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const name = relative + '/' + entry.name; + hash.update(name); + if (entry.isDirectory()) await visit(join(path, entry.name), name); + else if (entry.isFile()) { + const observed = await stat(join(path, entry.name)); + hash.update(JSON.stringify([observed.mtimeMs, observed.ctimeMs, observed.mode])); + hash.update(await readFile(join(path, entry.name))); + } + else throw new Error('Unexpected fixture filesystem entry'); + } + } + await visit(directory); + return hash.digest('hex'); +} + +async function run(ctx, root, signal) { + const project = join(root, 'availability-' + randomUUID()); + await mkdir(project); + const workspace = await ctx.workspaceRegistry.create(project); + const created = await ctx.sessionController.create({ workspaceId: workspace.id, agentPreset: 'standard' }); + const resolved = await ctx.sessionController.resolveAgent(created.sessionId); + if ('error' in resolved) throw resolved.error; + const agent = resolved.agent; + const execute = (name, args) => ctx.tools.execute({ callId: randomUUID(), name, arguments: args, agent, signal }); + const namespace = process.env.DSH_FIXTURE_NAMESPACE; + const paths = { project: join(project, '.dsh/skills/document-helper'), user: join(root, 'home/skills/document-helper') }; + const records = []; + const protectedPaths = [project, join(root, 'home/skills'), join(root, 'home/skillhub'), join(root, 'native-install-home/.skillhub')]; + async function check(args, expected) { + const before = await Promise.all(protectedPaths.map(digest)); + const result = await execute('astron_skillhub_check_availability', args); + assert.equal(result.isError, false); + assert.equal(result.value.kind, 'skillhub-availability'); + assert.equal(result.value.schemaVersion, 1); + for (const [key, value] of Object.entries(expected)) assert.deepEqual(result.value.diagnosis[key], value, key); + if (args.name) assert.equal(result.value.diagnosis.installation, undefined); + else { + assert.equal(result.value.diagnosis.installation.namespace, namespace); + assert.equal(result.value.diagnosis.installation.slug, 'document-helper'); + assert.equal(result.value.diagnosis.installation.version, '1.2.0'); + } + assert.equal(result.value.diagnosis.version, undefined); + assert.equal(result.value.diagnosis.fingerprint, undefined); + const rendered = JSON.stringify(result.content); + assert(rendered.includes('skillhub-availability'), 'Real tools.execute rendered the diagnosis'); + for (const forbidden of [root, 'AVAILABILITY_PRIVATE_SENTINEL', 'resourceBase', 'metadata', 'provider']) assert(!rendered.includes(forbidden), 'Model render leaked ' + forbidden); + assert.deepEqual(await Promise.all(protectedPaths.map(digest)), before, 'Diagnosis changed installation/inventory/operation files'); + records.push({ query: args.name ? 'name' : 'managed', ...expected }); + return result.value.diagnosis; + } + async function install(scope) { + const result = await execute('astron_skillhub_install', { namespace, slug: 'document-helper', scope }); + assert.equal(result.isError, false); + assert.equal(result.value.status, 'accepted'); + const deadline = Date.now() + 15000; + let operation; + while (Date.now() < deadline) { + operation = await ctx.skillhub.getInstallationOperation(created.sessionId, result.value.operationId); + if (['completed', 'failed', 'unknown', 'recovery-required'].includes(operation?.phase)) break; + await delay(100, undefined, { signal }); + } + assert.equal(operation?.phase, 'completed'); + } + async function reference(scope) { + const sourceRevision = ctx.skillhub.getContext().sourceRevision; + const list = await ctx.typertGateway.invoke({ peer: ctx.get('connection')?.browserPeer ?? ctx.get('connection')?.operator, + namespace: 'skillhub', method: 'listInstalled', args: { agentId: created.sessionId, sourceRevision }, signal }); + const item = list.items.find(value => value.namespace === namespace && value.scope === scope); + assert(item); + return { sourceRevision, itemKey: item.itemKey }; + } + await install('user'); + await check(await reference('user'), { association: 'managed-reference', scope: 'user', integrity: 'verified', discovery: 'winner', name: 'document-helper' }); + await install('project'); + await check(await reference('project'), { scope: 'project', integrity: 'verified', discovery: 'winner' }); + await check(await reference('user'), { integrity: 'verified', discovery: 'shadowed', reason: 'same-name-winner' }); + const remote = await ctx.typertGateway.invoke({ peer: ctx.get('connection')?.browserPeer ?? ctx.get('connection')?.operator, + namespace: 'skillhub', method: 'checkAvailability', args: { agentId: created.sessionId, ...await reference('project') }, signal }); + assert.equal(remote.discovery, 'winner'); + + await check({ name: 'document-helper' }, { association: 'not-associated', integrity: 'unknown', discovery: 'winner' }); + const target = paths.project; + const skillPath = join(target, 'SKILL.md'); + const original = await readFile(skillPath, 'utf8'); + for (const mode of ['content-modified', 'skill-missing', 'directory-missing']) { + const backup = join(root, 'availability-backup-' + randomUUID()); + try { + if (mode === 'content-modified') await writeFile(skillPath, original + '\nAVAILABILITY_PRIVATE_SENTINEL'); + else await rename(mode === 'skill-missing' ? skillPath : target, backup); + await check(await reference('project'), { integrity: mode === 'content-modified' ? 'changed' : 'missing' }); + } finally { + if (mode === 'content-modified') await writeFile(skillPath, original); + else await rename(backup, mode === 'skill-missing' ? skillPath : target); + } + } + for (const userInvocable of [true, false]) for (const modelInvocable of [true, false]) { + const name = `availability-policy-${Number(userInvocable)}-${Number(modelInvocable)}`; + const dispose = ctx.skills.register({ name, description: 'Fixture', content: 'AVAILABILITY_PRIVATE_SENTINEL', + source: 'runtime', provider: 'AVAILABILITY_PRIVATE_SENTINEL', metadata: { private: 'AVAILABILITY_PRIVATE_SENTINEL' }, + resourceBase: { kind: 'directory', path: join(root, 'AVAILABILITY_PRIVATE_SENTINEL') }, invocation: { userInvocable, modelInvocable } }); + try { await check({ name }, { association: 'not-associated', integrity: 'unknown', discovery: 'winner', invocation: { userInvocable, modelInvocable } }); } + finally { dispose(); } + } + await check({ name: 'availability-absent' }, { discovery: 'not-discovered', integrity: 'unknown' }); + const dispose = ctx.skills.registerProvider(() => ({ name: 'availability-incomplete', + list: async () => ({ candidates: [], complete: false }), get: async () => undefined })); + try { await check({ name: 'document-helper' }, { discovery: 'unknown', reason: 'snapshot-incomplete' }); } + finally { dispose(); } + const failedProvider = ctx.skills.registerProvider(() => ({ name: 'availability-failed', + list: async () => { throw new Error('AVAILABILITY_PRIVATE_SENTINEL'); }, get: async () => undefined })); + try { await check({ name: 'document-helper' }, { discovery: 'unknown', reason: 'snapshot-incomplete' }); } + finally { failedProvider(); } + await writeFile(join(root, 'skill-availability-result.json'), JSON.stringify({ passed: true, records, + realAgentScope: true, remoteAgentMethod: true, modelRenderChecked: true, protectedFilesUnchanged: true, + registry: 'isolated-fixture', modelInvoked: false }, null, 2)); +} diff --git a/plugins/dsh/test/management-tool.test.ts b/plugins/dsh/test/management-tool.test.ts index b9ce109cb..6ef9511f1 100644 --- a/plugins/dsh/test/management-tool.test.ts +++ b/plugins/dsh/test/management-tool.test.ts @@ -16,7 +16,7 @@ test('management registration preserves occupied tools and strict parameters rej const dispose = registerManagementTools(tools, async () => { calls++; return {}; }); const tool = tools.get(MANAGEMENT_TOOLS.upgrade)!; expect(() => registerManagementTools(tools, async () => ({}))).toThrow('already registered'); - expect(tools.entries.size).toBe(5); + expect(tools.entries.size).toBe(6); for (const extra of [{ confirmed: true }, { planId: 'plan' }, { path: '/forged' }]) { await expect(tool.execute({ sourceRevision: 'source', itemKey: 'item', ...extra }, context)).rejects.toThrow(); } @@ -77,6 +77,7 @@ test('every registered management tool teaches fresh-state reads and concise pub expect(guidance).toContain('skill name, installation scope, version and state'); expect(guidance).toContain('short conclusion instead of duplicating it'); } + if (action === 'availability') continue; // Routing references remain usable by subsequent tools; only prose guidance changes. expect(rendered).toContain(item.itemKey); if (action === 'list' || action === 'check') expect(rendered).toContain('private-source-revision'); @@ -84,3 +85,22 @@ test('every registered management tool teaches fresh-state reads and concise pub expect(tool.output.presentationMeta!(args, result)).toEqual(result); } }); + + +test('availability tool enforces exclusive selectors and strips unexpected diagnostic fields', async () => { + const tools = registry(); let calls = 0; + registerManagementTools(tools, async () => { calls++; return { kind: 'skillhub-availability', diagnosis: { + checkedAt: 'now', name: 'writer', association: 'not-associated', integrity: 'unknown', discovery: 'winner', reason: 'observed', + invocation: { userInvocable: false, modelInvocable: true, raw: 'secret-policy' }, + installation: { namespace: 'team', slug: 'writer', version: '1', scope: 'user', path: '/secret/installation' }, + content: 'secret-content', path: '/secret/path', metadata: { token: 'secret-token' }, provider: 'secret-provider', + } }; }); + const tool = tools.get(MANAGEMENT_TOOLS.availability)!; + for (const args of [{}, { name: '../path' }, { name: 'writer', itemKey: 'key', sourceRevision: 'source' }, { itemKey: 'key' }, { name: 'writer', confirmed: true }]) { + await expect(tool.execute(args, context)).rejects.toThrow(); + } + expect(calls).toBe(0); + const value = await tool.execute({ name: 'writer' }, context); + const rendered = JSON.stringify(tool.output.render({ name: 'writer' }, JSON.parse(JSON.stringify(value)))); + expect(rendered).toContain('not-associated'); expect(rendered).not.toContain('secret'); +}); diff --git a/plugins/dsh/test/management-view.test.tsx b/plugins/dsh/test/management-view.test.tsx index bcb8ac05f..461c61245 100644 --- a/plugins/dsh/test/management-view.test.tsx +++ b/plugins/dsh/test/management-view.test.tsx @@ -191,3 +191,28 @@ test('two-column catalog initially shows six cards and offers more for larger to expect(html).toContain('batch-5'); expect(html).not.toContain('batch-6'); } }); + + +test('availability transcript is compact and does not offer ownership or mutation actions', () => { + const value = { schemaVersion: 1 as const, kind: 'skillhub-availability' as const, toolCallId: 'call-1', diagnosis: { + checkedAt: 'now', name: 'writer', association: 'not-associated' as const, integrity: 'unknown' as const, + discovery: 'winner' as const, reason: 'observed' as const, invocation: { userInvocable: false, modelInvocable: true }, + } }; + const parsed = parseManagementResult({ ...value, diagnosis: { ...value.diagnosis, path: '/secret/path' } }, 'call-1'); + expect(parsed).toEqual(value); + const html = render(value); + expect(html).toContain('writer'); expect(html).toContain('仅反映本次检查结果'); expect(html).not.toContain('仍会重新核验'); + expect(html).toContain('来源未关联'); expect(html).toContain('用户调用禁用'); expect(html).toContain('模型调用允许'); + expect(html).not.toContain(' { + const html = render({ schemaVersion: 1, kind: 'skillhub-availability', toolCallId: 'call-1', diagnosis: { + checkedAt: 'now', association: 'managed-reference', scope: 'user', integrity: 'missing', discovery: 'unknown', reason: 'files-missing', + installation: { namespace: 'team', slug: 'selected-package', version: '1.2.0', scope: 'user' }, + } }); + expect(html).toContain('@team/selected-package'); expect(html).toContain('v1.2.0'); + expect(html).toContain('当前用户'); expect(html).toContain('文件缺失'); expect(html).not.toContain(' { }); test('official generator publishes the declared endpoints with scoped writes and strict codec', () => { - expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'beginConversationManagement', 'checkUpdate', 'checkVersionUpdate', 'confirmConversationManagement', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareSkillUse', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'startInstall', 'testRegistryConnection']); - for (const method of ['beginConversationManagement', 'confirmConversationManagement', 'openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareSkillUse', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) { + expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'beginConversationManagement', 'checkAvailability', 'checkUpdate', 'checkVersionUpdate', 'confirmConversationManagement', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareSkillUse', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'startInstall', 'testRegistryConnection']); + for (const method of ['checkAvailability', 'beginConversationManagement', 'confirmConversationManagement', 'openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareSkillUse', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) { const scoped = TYPERT_REMOTE.descriptors.find(item => item.method === method)!; expect(scoped.invocation).toMatchObject({ kind: 'context', context: 'agent', wire: 'agentId' }); } diff --git a/plugins/dsh/test/prepare-skill-use.test.ts b/plugins/dsh/test/prepare-skill-use.test.ts index e4013a017..a3abe4f6e 100644 --- a/plugins/dsh/test/prepare-skill-use.test.ts +++ b/plugins/dsh/test/prepare-skill-use.test.ts @@ -1,17 +1,19 @@ -import { expect, test } from 'bun:test'; +import { expect, spyOn, test } from 'bun:test'; import { Context, symbols } from '@deepseek-ai/cordis'; import { FileSystemSkillProvider } from '@deepseek-ai/dsh-skill-filesystem'; import { readSkillExamples } from '../../../cli/src/services/skill-examples.js'; import { randomBytes, createHash } from 'node:crypto'; -import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import SkillHubService from '../src/host/index.js'; import type { LoadedSkill } from '../src/host/skill-discovery.js'; import type { InstalledProjectionItem, RemovalBaseline } from '../src/host/generated/native-contract.js'; -import type { SkillUsePreparation } from '../src/host/types.js'; +import type { SkillAvailability, SkillUsePreparation } from '../src/host/types.js'; async function fixture(run: (f: { + availability(signal?: AbortSignal, key?: string): Promise; + named(signal?: AbortSignal): Promise<{ diagnosis: SkillAvailability }>; use(signal?: AbortSignal, key?: string): Promise; key: string; directory: string; setLookup(callback: (loaded: LoadedSkill) => Promise): void; change(kind: string): void; observations(): number; @@ -35,21 +37,25 @@ async function fixture(run: (f: { const loaded = await provider.get(candidates[0]!, { signal: controller.signal }); if (!loaded) throw new Error('Missing fixture skill'); let lookup = async (skill: LoadedSkill): Promise => skill; - let revision = 'source'; let observations = 0; let pendingOperations = 0; let shared = false; + let revision = 'source'; let observations = 0; let pendingOperations = 0; let shared = false; let invalidMetadata = false; let replaceDuringInventory = false; + let replaceDuringProviderRead = false; const fs = { processPathFromHostPath: (path: string) => path, resolve: async (path: string) => ({ displayPath: path }), processPath: (target: { displayPath: string }) => target.displayPath, listDir: async (target: { displayPath: string }) => (await readdir(target.displayPath, { withFileTypes: true })).map(entry => ({ name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', target: { displayPath: join(target.displayPath, entry.name) }, })), stat: async (target: { displayPath: string }) => ({ type: (await lstat(target.displayPath)).isDirectory() ? 'directory' : 'file' }), - readText: (target: { displayPath: string }) => readFile(target.displayPath, 'utf8'), + readText: (target: { displayPath: string }) => { + if (replaceDuringProviderRead) currentSkills = { ...skills }; + return readFile(target.displayPath, 'utf8'); + }, }; let currentFs = fs; const session = { id: 'session-a', header: { cwd } }; const agent = { session, ctx }; const nativeGet = ctx.get.bind(ctx); ctx.get = ((name: string) => name === 'fs' ? currentFs : nativeGet(name as never)) as typeof ctx.get; - const skills = { get: async (name: string, options: { cwd: string; scope: unknown; signal: AbortSignal }) => { + const skills = { snapshot: async () => ({ complete: true, skills: [{ name: 'actual-name' }] }), get: async (name: string, options: { cwd: string; scope: unknown; signal: AbortSignal }) => { expect(name).toBe('actual-name'); expect(options.cwd).toBe(cwd); expect(options.scope).toBe(agent); options.signal.throwIfAborted(); observations++; return lookup(loaded); } }; @@ -66,8 +72,9 @@ async function fixture(run: (f: { } } }; const runtime = { getStatus: () => ({ state: 'ready', pendingOperations }), readSkillExamples, - listInstalled: async () => ({ items, complete: true, issues: [] }), + listInstalled: async () => { if (replaceDuringInventory) currentSkills = { ...skills }; return { items, complete: true, issues: [] }; }, inspectRemoval: async (): Promise => { + if (invalidMetadata) return { status: 'blocked', reason: 'metadata-invalid' }; if (shared) return { status: 'blocked', reason: 'shared-target' }; // Model the existing baseline's all-resource checks; real bundled core is covered by native acceptance. if (await readFile(join(directory, 'SKILL.md'), 'utf8') !== body @@ -82,11 +89,15 @@ async function fixture(run: (f: { ctx: { invocation: { request: { args: { agentId: 'session-a' } } }, get: (name: string) => name === 'agents' ? { get: () => currentAgent } : name === 'skills' ? currentSkills : undefined }, assertSource: (value: string) => { if (value !== revision) throw new Error('source changed'); }, - getContext: () => ({ configured: true, registryUrl: 'https://registry.example' }), + getContext: () => ({ sourceRevision: revision, configured: true, registryUrl: 'https://registry.example' }), }); const key = (await service.listInstalled('source', new AbortController().signal)).items[0].itemKey; - await run({ use: (signal = new AbortController().signal, selected = key) => service.prepareSkillUse('source', selected, signal), + await run({ availability: (signal = new AbortController().signal, selected = key) => service.checkAvailability('source', selected, signal), + named: (signal = new AbortController().signal) => service.manageConversation('availability', { name: 'actual-name' }, { sessionId: 'session-a', cwd, callId: 'call' }, signal), + use: (signal = new AbortController().signal, selected = key) => service.prepareSkillUse('source', selected, signal), key, directory, observations: () => observations, setLookup: value => { lookup = value; }, change: kind => { + if (kind === 'provider-skills') replaceDuringProviderRead = true; + if (kind === 'inventory-skills') replaceDuringInventory = true; if (kind === 'source') revision = 'other'; if (kind === 'session') session.id = 'session-b'; if (kind === 'cwd') session.header.cwd = home; @@ -94,10 +105,14 @@ async function fixture(run: (f: { if (kind === 'fs') currentFs = { ...fs }; if (kind === 'skills') currentSkills = { ...skills }; if (kind === 'proxy') currentSkills = { ...skills, [symbols.original]: skills }; + if (kind === 'incomplete') skills.snapshot = async () => ({ complete: false, skills: [] }); + if (kind === 'no-snapshot') Reflect.deleteProperty(skills, 'snapshot'); + if (kind === 'empty-snapshot') skills.snapshot = async () => ({ complete: true, skills: [] }); if (kind === 'missing-skills') currentSkills = undefined; if (kind === 'runtime') service.localRuntime = { ...runtime }; if (kind === 'record') items.length = 0; if (kind === 'version') item.version = '2.0.0'; + if (kind === 'metadata-invalid') invalidMetadata = true; if (kind === 'shared') shared = true; if (kind === 'pending') pendingOperations = 1; if (kind === 'dispose') controller.abort(); @@ -243,3 +258,135 @@ test('examples changed at final winner lookup are blocked by the last content ba expect(await f.use()).toEqual({ status: 'blocked', reason: 'files-changed' }); }, '', multilingual); }); + + +test('availability separates integrity, session discovery and user/model policies without paths', async () => { + for (const user of [true, false]) for (const model of [true, false]) await fixture(async f => { + const result = await f.availability(); + expect(result).toMatchObject({ association: 'managed-reference', integrity: 'verified', discovery: 'winner', + invocation: { userInvocable: user, modelInvocable: model }, reason: 'observed' }); + expect(JSON.stringify(result)).not.toContain(f.directory); + expect(JSON.stringify(result)).not.toContain('Use references'); + }, `user-invocable: ${user}\ndisable-model-invocation: ${!model}\n`); +}); +test('availability distinguishes another winner from same-resource content changes', async () => { + await fixture(async f => { + f.setLookup(async skill => ({ ...skill, path: '/private/another/SKILL.md' })); + expect(await f.availability()).toMatchObject({ discovery: 'shadowed', reason: 'same-name-winner' }); + }); + await fixture(async f => { + f.setLookup(async skill => ({ ...skill, content: 'secret changed content' })); + const result = await f.availability(); + expect(result).toMatchObject({ discovery: 'unknown', reason: 'definition-changed' }); + expect(JSON.stringify(result)).not.toContain('secret'); + }); +}); +test('availability preserves incomplete and unsupported snapshot as unknown', async () => { + for (const [change, reason] of [['incomplete', 'snapshot-incomplete'], ['no-snapshot', 'snapshot-unavailable']]) await fixture(async f => { + f.change(change!); expect(await f.availability()).toMatchObject({ discovery: 'unknown', reason }); + }); + await fixture(async f => { + f.change('empty-snapshot'); f.setLookup(async () => undefined); + expect(await f.availability()).toMatchObject({ discovery: 'not-discovered' }); + }); +}); +test('availability keeps local edits, shared baseline and missing entry distinct', async () => { + await fixture(async f => { + await writeFile(join(f.directory, 'references/note.md'), 'modified'); + expect(await f.availability()).toMatchObject({ integrity: 'changed', discovery: 'winner', reason: 'local-changes' }); + }); + await fixture(async f => { + f.change('shared'); expect(await f.availability()).toMatchObject({ integrity: 'unknown', discovery: 'winner', reason: 'baseline-unverified' }); + }); + await fixture(async f => { + await rm(join(f.directory, 'SKILL.md')); + expect(await f.availability()).toMatchObject({ integrity: 'missing', discovery: 'unknown', reason: 'files-missing' }); + }); +}); +test('availability aborts stale references and context or original-service changes', async () => { + for (const change of ['source', 'session', 'cwd', 'agent', 'fs', 'skills', 'runtime', 'dispose']) await fixture(async f => { + f.setLookup(async skill => { f.change(change); return skill; }); + await expect(f.availability()).rejects.toThrow(); + }); + await fixture(async f => { + f.setLookup(async skill => { f.change('proxy'); return skill; }); + expect(await f.availability()).toMatchObject({ discovery: 'winner' }); + await expect(f.availability(undefined, 'old-key')).rejects.toThrow(); + const controller = new AbortController(); controller.abort(); + await expect(f.availability(controller.signal)).rejects.toThrow(); + }); +}); +test('name diagnostics never claim registry ownership and errors never disclose raw messages', async () => { + await fixture(async f => { + const { diagnosis } = await f.named(); + expect(diagnosis).toMatchObject({ association: 'not-associated', integrity: 'unknown', discovery: 'winner' }); + f.setLookup(async () => { throw new Error('/private/secret token'); }); + const result = await f.named(); + expect(result.diagnosis).toMatchObject({ discovery: 'unknown', reason: 'read-failed' }); + expect(JSON.stringify(result)).not.toContain('/private'); + }); +}); +test('availability rejects changing winners and allows independent concurrent read-only requests', async () => { + await fixture(async f => { + let count = 0; + f.setLookup(async skill => ++count === 1 ? skill : { ...skill, content: 'changed' }); + expect(await f.availability()).toMatchObject({ discovery: 'unknown', reason: 'observation-changed' }); + }); + await fixture(async f => { + const results = await Promise.all([f.availability(), f.availability()]); + expect(results.every(value => value.reason === 'observed')).toBe(true); + }); +}); + + +test('availability does not call unreadable files missing or invalid metadata modified', async () => { + await fixture(async f => { + await chmod(f.directory, 0); + try { expect(await f.availability()).toMatchObject({ integrity: 'unknown', discovery: 'unknown', reason: 'files-unreadable' }); } + finally { await chmod(f.directory, 0o700); } + }); + await fixture(async f => { + f.change('metadata-invalid'); + expect(await f.availability()).toMatchObject({ integrity: 'unknown', discovery: 'winner', reason: 'baseline-unverified' }); + }); +}); +test('invalid skill definitions are unknown and diagnostics preserve every installation byte', async () => { + await fixture(async f => { + const before = await Promise.all(['SKILL.md', 'references/note.md'].map(path => readFile(join(f.directory, path), 'utf8'))); + await f.availability(); await f.named(); + expect(await Promise.all(['SKILL.md', 'references/note.md'].map(path => readFile(join(f.directory, path), 'utf8')))).toEqual(before); + expect((await readdir(f.directory)).sort()).toEqual(['SKILL.md', 'references']); + await writeFile(join(f.directory, 'SKILL.md'), '---\nname: [invalid\n---\ninvalid'); + expect(await f.availability()).toMatchObject({ discovery: 'unknown', reason: 'definition-unverified' }); + }); +}); + + +test('availability binds skills identity before the initial inventory await', async () => { + await fixture(async f => { + f.change('inventory-skills'); + await expect(f.availability()).rejects.toThrow('Session changed'); + }); +}); + + +test('availability always disposes its exact provider after context invalidation during file loading', async () => { + await fixture(async f => { + const dispose = spyOn(FileSystemSkillProvider.prototype, 'dispose'); + try { + f.change('provider-skills'); + await expect(f.availability()).rejects.toThrow('Session changed'); + expect(dispose).toHaveBeenCalledTimes(1); + } finally { dispose.mockRestore(); } + }); +}); +test('missing managed definition preserves selected installation identity without inventing a call name', async () => { + await fixture(async f => { + await rm(join(f.directory, 'SKILL.md')); + const result = await f.availability(); + expect(result.name).toBeUndefined(); + expect(result.installation).toEqual({ namespace: 'demo', slug: 'market-slug', version: '1.2.0', scope: 'user' }); + expect(result.reason).toBe('files-missing'); + expect((await f.named()).diagnosis.installation).toBeUndefined(); + }); +});