mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
feat(dsh): diagnose skill availability in the current session
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
5f5fcf9bb9
commit
775e6bf731
23 changed files with 717 additions and 25 deletions
|
|
@ -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` 表示此次按名观察没有建立管理归属,不能解释为已确认属于其他市场。未知状态不得转成未安装。结果不携带路径、原始宿主对象、文档正文或异常文本。所有只读结果均不授予修改权限。
|
||||
|
|
|
|||
|
|
@ -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 验证与真实模型对话分开记录,未调用模型不能称为真实对话验收。
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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。本轮不改现有用户预览,不提交测试过程、截图、模型配置或临时环境。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -211,3 +211,10 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh
|
|||
- `astron_skillhub_operation`:以原回执查询本会话操作结果,不重复提交操作;历史成功不代表当前仍安装。
|
||||
|
||||
多份安装先选择目标;更新不改变其他位置版本。本地修改、来源/会话变化和失效引用阻止操作。启用逐位置库存后,同机共同管理技能的 CLI 必须同步升级;旧版 CLI 与新库存混用不受支持。
|
||||
|
||||
|
||||
### 技能可用性诊断(待发布)
|
||||
|
||||
在对话安装列表的技能卡片中选择“更多 → 检查可用性”,检查当前选定位置。也可以在对话中询问“这个技能为什么不能用”,由 `astron_skillhub_check_availability` 返回只读结果。
|
||||
|
||||
诊断分别核验本地文件、当前会话加载结果,以及用户/模型调用策略。同名技能生效、本地修改和宿主无法核验会给出不同结论。按技能名查询只说明当前宿主观察到的事实,不证明安装来源或内容完整性。检查不会修改、卸载、启停技能或发送消息;历史结果不保证下一次调用仍然可用。
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
|||
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<void>> {
|
|||
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)),
|
||||
|
|
|
|||
|
|
@ -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<typeof schema>;
|
||||
export type ManagementReceipt = z.infer<typeof management>;
|
||||
/** Transcript metadata is display data, never authority to mutate. Host rechecks every reference. */
|
||||
|
|
|
|||
|
|
@ -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<RemoteResult<SkillAvailability>>;
|
||||
check(revision: string, itemKey: string, signal: AbortSignal): Promise<RemoteResult<InstalledUpdateCheck>>;
|
||||
begin(revision: string, itemKey: string, action: 'upgrade' | 'uninstall', version: string | null, requestKey: string, signal: AbortSignal): Promise<RemoteResult<ConversationManagementResult>>;
|
||||
confirm(ref: string, confirmed: boolean, signal: AbortSignal): Promise<RemoteResult<ConversationManagementResult>>;
|
||||
|
|
@ -71,11 +72,35 @@ export function ManagementContent({ result, services }: { result: ManagementView
|
|||
return <ManagementRefreshBoundary services={services} previousRevision={'sourceRevision' in result ? result.sourceRevision : undefined}><ManagementResultContent result={result} services={services} /></ManagementRefreshBoundary>;
|
||||
}
|
||||
function ManagementResultContent({ result, services }: { result: ManagementViewResult; services: ManagementViewServices }) {
|
||||
if (result.kind === 'skillhub-availability') return <AvailabilityResult value={result.diagnosis} standalone />;
|
||||
if (result.kind === 'skillhub-operation') return <ManagementOperation result={result} services={services} />;
|
||||
if (result.kind === 'skillhub-management') return <ManagementConfirmation initial={result} services={services} />;
|
||||
if (result.kind === 'skillhub-update-check') return <ManagementRow item={result.item} revision={result.sourceRevision} services={services} initialCheck={result.check} checkOnly />;
|
||||
return <InstalledList list={result} revision={result.sourceRevision} services={services} />;
|
||||
}
|
||||
export function AvailabilityResult({ value, standalone = false }: { value: SkillAvailability; standalone?: boolean }) {
|
||||
const messages: Record<SkillAvailability['reason'], [string, string]> = {
|
||||
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 <div className="astron-sh-management-availability" role="status" aria-label="技能可用性">
|
||||
{standalone && <p><strong>{value.installation ? `@${value.installation.namespace}/${value.installation.slug} · v${value.installation.version}` : value.name ?? '技能可用性'}</strong>{value.scope && ` · ${scopeLabel(value.scope)}`}</p>}
|
||||
<p>{message}</p>{value.invocation && <p>用户调用{value.invocation.userInvocable ? '允许' : '禁用'} · 模型调用{value.invocation.modelInvocable ? '允许' : '禁用'}</p>}<small>{advice}</small>
|
||||
</div>;
|
||||
}
|
||||
function ManagementOperation({ result, services }: { result: Extract<ManagementViewResult, { kind: 'skillhub-operation' }>; 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<HTMLButtonElement>(null);
|
||||
|
|
@ -209,13 +243,15 @@ function ManagementRow({ item, revision, services, initialCheck, checkOnly = fal
|
|||
{check?.state === 'upgradable' && <Button size="sm" variant="default" disabled={pending} onClick={() => void run('upgrade')}>更新</Button>}
|
||||
{check?.state === 'unavailable' && <Button size="sm" variant="ghost" disabled={pending} onClick={() => void run('check')}>{pending ? '检查中…' : '重试检查'}</Button>}
|
||||
{!checkOnly && !check && item.state === 'present' && <UseSkillButton services={skillUse} revision={revision} itemKey={item.itemKey} onError={setUseError} />}
|
||||
{!checkOnly && item.state === 'present' && (!check || check.state === 'upgradable') && <Button ref={moreTrigger} size="sm" variant="ghost" className="astron-sh-management-more-trigger" aria-label="更多操作" aria-expanded={moreOpen} disabled={pending} onClick={() => setMoreOpen(value => !value)}>···</Button>}
|
||||
{!checkOnly && (services.availability || (item.state === 'present' && (!check || check.state === 'upgradable'))) && <Button ref={moreTrigger} size="sm" variant="ghost" className="astron-sh-management-more-trigger" aria-label="更多操作" aria-expanded={moreOpen} disabled={pending} onClick={() => setMoreOpen(value => !value)}>···</Button>}
|
||||
{!checkOnly && item.state === 'missing' && <Button size="sm" disabled={pending} onClick={() => void run('uninstall')}>清理记录</Button>}
|
||||
</div>
|
||||
{useError && <p className="astron-sh-management-use-error" role="alert">{useError}</p>}
|
||||
{availability.isFetching ? <p role="status">正在检查可用性…</p> : availability.isError ? <><p role="alert">{stale(availability.error) ? '安装记录已变化,请重新查询。' : '可用性暂时无法核验。'}</p>{stale(availability.error) && <RefreshInstalled />}</> : availability.data && <AvailabilityResult value={availability.data} />}
|
||||
{moreOpen && <div className="astron-sh-management-more-content" aria-label="技能操作">
|
||||
{!check && <Button size="sm" variant="ghost" disabled={pending} onClick={() => { setMoreOpen(false); void run('check'); }}>检查更新</Button>}
|
||||
<Button size="sm" variant="ghost" disabled={pending} onClick={() => { setMoreOpen(false); void run('uninstall'); }}>卸载</Button>
|
||||
{services.availability && <Button size="sm" variant="ghost" disabled={pending || availability.isFetching} onClick={() => { setMoreOpen(false); if (!availability.isFetching) void availability.refetch(); }}>检查可用性</Button>}
|
||||
{!check && item.state === 'present' && <Button size="sm" variant="ghost" disabled={pending} onClick={() => { setMoreOpen(false); void run('check'); }}>检查更新</Button>}
|
||||
{item.state === 'present' && <Button size="sm" variant="ghost" disabled={pending} onClick={() => { setMoreOpen(false); void run('uninstall'); }}>卸载</Button>}
|
||||
</div>}
|
||||
</>}
|
||||
</article>;
|
||||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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<SkillAvailability> {
|
||||
return this.checkAvailabilityForAgent({ sourceRevision, itemKey }, signal);
|
||||
}
|
||||
|
||||
private async checkAvailabilityForAgent(request: ManagementRequest, signal: AbortSignal, agentId?: string): Promise<SkillAvailability> {
|
||||
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<SkillUsePreparation> {
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): object {
|
||||
if (result.kind === 'skillhub-availability') {
|
||||
const value = result.diagnosis as Record<string, unknown> | undefined;
|
||||
const installation = value?.installation as Record<string, unknown> | undefined;
|
||||
const invocation = value?.invocation as Record<string, unknown> | 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<string, unknown>;
|
||||
|
|
@ -51,12 +65,12 @@ export function registerManagementTools(tools: Pick<Context['tools'], 'get' | 'r
|
|||
for (const action of Object.keys(MANAGEMENT_TOOLS) as ManagementAction[]) {
|
||||
const name = MANAGEMENT_TOOLS[action];
|
||||
if (tools.get(name)) throw new Error('SkillHub management tool name is already registered.');
|
||||
const parameters: ParameterSchemaSpec = action === 'list' ? {} : action === 'operation' ? {
|
||||
const parameters: ParameterSchemaSpec = action === 'availability' ? { name: { type: 'string' as const }, sourceRevision: { type: 'string' as const }, itemKey: { type: 'string' as const } } : action === 'list' ? {} : action === 'operation' ? {
|
||||
operationId: { type: 'string' as const, required: true as const }, hostInstanceId: { type: 'string' as const, required: true as const },
|
||||
} : { sourceRevision: { type: 'string' as const, required: true as const }, itemKey: { type: 'string' as const, required: true as const },
|
||||
...(action === 'uninstall' ? {} : { version: { type: 'string' as const } }) };
|
||||
disposers.push(tools.register(defineTool({ name,
|
||||
description: `${action} a current-source SkillHub managed installation. List is NOT all machine skills. Use exact itemKey and sourceRevision from the list; if the user has not uniquely selected a skill and installation scope, ask the user to select exactly ONE installation location using the native question UI when available, never choose arbitrarily. Only single-location operations are supported: NEVER offer or recommend both scopes, all locations, or batch updates/removals. Do not display registry URLs or any internal reference, even shortened prefixes. User scope is for the same OS user across projects on this Host. Project scope is this workspace only, not synchronization to teammates or other machines. Upgrade/uninstall ALWAYS require real human UI confirmation, even if scope was explicitly requested. Never supply paths, confirmation flags or plan IDs. Optional version must be a newer exact version explicitly requested; no downgrade/force. Operation reads only a prior receipt belonging to this conversation. Do not retry mutations with a new call to check status. ${modelGuidance}`,
|
||||
description: `${action === 'availability' ? 'READ ONLY availability diagnosis: supply EITHER actual skill name OR both sourceRevision and itemKey from a fresh list, never both modes. Name lookup observes the current session winner only and does not associate Registry ownership. Integrity, discovery and user/model invocation are separate facts; unknown is not missing or not installed. This result does not authorize installation, removal or repair. ' : ''}${action} a current-source SkillHub managed installation. List is NOT all machine skills. Use exact itemKey and sourceRevision from the list; if the user has not uniquely selected a skill and installation scope, ask the user to select exactly ONE installation location using the native question UI when available, never choose arbitrarily. Only single-location operations are supported: NEVER offer or recommend both scopes, all locations, or batch updates/removals. Do not display registry URLs or any internal reference, even shortened prefixes. User scope is for the same OS user across projects on this Host. Project scope is this workspace only, not synchronization to teammates or other machines. Upgrade/uninstall ALWAYS require real human UI confirmation, even if scope was explicitly requested. Never supply paths, confirmation flags or plan IDs. Optional version must be a newer exact version explicitly requested; no downgrade/force. Operation reads only a prior receipt belonging to this conversation. Do not retry mutations with a new call to check status. ${modelGuidance}`,
|
||||
parameters,
|
||||
output: { schema: { type: 'object', additionalProperties: true, properties: {} },
|
||||
render: (_args, result) => [{ type: 'text', text: JSON.stringify(modelView(result)) + modelInstructions(result) }],
|
||||
|
|
|
|||
100
plugins/dsh/src/host/skill-availability.ts
Normal file
100
plugins/dsh/src/host/skill-availability.ts
Normal file
|
|
@ -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<LoadedSkill | undefined>;
|
||||
}
|
||||
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<RemovalBaseline> };
|
||||
}): Promise<SkillAvailability> {
|
||||
const { reader, managed, signal } = input;
|
||||
const valid = () => { signal.throwIfAborted(); input.valid(); };
|
||||
const read = async <T>(work: () => Promise<T>): Promise<T> => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
145
plugins/dsh/test/integration/skill-availability-fixture.mjs
Normal file
145
plugins/dsh/test/integration/skill-availability-fixture.mjs
Normal file
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('<button'); expect(html).not.toContain('未安装');
|
||||
expect(render({ ...value, diagnosis: { ...value.diagnosis, invocation: undefined, discovery: 'unknown', reason: 'snapshot-incomplete' } })).toContain('宿主技能列表不完整');
|
||||
});
|
||||
|
||||
|
||||
test('standalone missing definition identifies the selected installation without a fabricated invocation name', () => {
|
||||
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('<button');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ describe('native M0 package', () => {
|
|||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SkillAvailability>;
|
||||
named(signal?: AbortSignal): Promise<{ diagnosis: SkillAvailability }>;
|
||||
use(signal?: AbortSignal, key?: string): Promise<SkillUsePreparation>; key: string; directory: string;
|
||||
setLookup(callback: (loaded: LoadedSkill) => Promise<LoadedSkill | undefined>): 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<LoadedSkill | undefined> => 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<RemovalBaseline> => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue