diff --git a/cli/README.md b/cli/README.md index a9d33fd75..10447c1f6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -299,8 +299,8 @@ The source identity is `registry + namespace + slug`. `--force` may replace loca that full identity matches the installation metadata; it never overwrites an unmanaged directory or a Skill installed from another source. -All targets in one inventory entry are upgraded together. A filter that selects only part of that -entry is rejected because the current inventory format stores one shared version for all targets. +Each selected installation position is upgraded independently. Filters can select one position; +unselected positions keep their own versions and content. Plans and results report one row per position. The command also keeps the local files when the registry resolves to an older version. If a multi-Skill run fails after an earlier upgrade commits, execution stops and reports each item as `upgraded`, `failed`, or `not-attempted`; a committed upgrade is never rolled back implicitly. @@ -604,3 +604,26 @@ actual outgoing archive for task examples. Human output reports the recognition all declared languages. An unsupported declaration generates a nonblocking warning and is still uploaded unchanged; an unreadable archive produces an inspection warning and remains subject to the server's package validation. + +### Installation inventory compatibility + +The local installation inventory uses schema version 2. Each installation directory owns its +version, fingerprint, timestamp and ownership, so a user installation and a project installation +of the same skill can remain on different versions. `list --json` reports the actual version for +each target. `upgrade --check --json` and upgrade results contain one row per selected installation +position; the same coordinate can therefore occur more than once with different versions and +`targets`. `--dir` or `--agent` selects positions without updating unselected siblings. +Direct install/upgrade/remove and sync pruning refuse Suite-owned or shared positions, even +with `--force`. Manage those positions through Suite commands; unrelated direct installations +remain independently manageable. + +Reading a legacy inventory does not change it. The first mutation validates it, creates a +byte-verified `inventory.v1..backup.json` beside `inventory.json`, and atomically writes +v2 (`schemaVersion`, `records`, `suites`). Missing legacy fingerprint evidence stays missing. +Unknown schemas and ambiguous ownership stop the operation before skill files are changed. + +Upgrade the CLI and DSH plugin together when they manage the same installation inventory. Stop +old writers and allow each DSH host to finish pending recovery before the first v2 mutation; +the CLI cannot discover every host's private operation directory. Old clients do not understand +v2 and must not be used to mutate these installations. A saved v1 inventory is recovery evidence, +not a rollback command: restoring it alone after targets diverged would misrepresent files on disk. diff --git a/cli/src/commands/list.ts b/cli/src/commands/list.ts index 90c8d8c4b..73ede38e2 100644 --- a/cli/src/commands/list.ts +++ b/cli/src/commands/list.ts @@ -36,7 +36,7 @@ export async function listCommand(options: ListCommandOptions): Promise flat.push({ namespace: item.namespace, slug: item.slug, - version: item.version, + version: target.version, agent: target.agent, installDir: target.installDir, installedAt: target.installedAt, diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index ca0000fc1..0fba3fcb7 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -91,21 +91,21 @@ function renderUpgradePlan( const action = item.action === 'upgrade' && state.executed ? 'upgraded' : item.action const versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : '' const reason = item.reason ? ` (${item.reason})` : '' - return `${action.padEnd(10)} ${item.coordinate}${versions}${reason}` + return `${action.padEnd(10)} ${item.coordinate}${versions} ${item.targets.map(target => target.dir).join(', ')}${reason}` }) ].join('\n') } export function renderUpgradeResult(plan: UpgradePlan, result: UpgradeExecutionResult, json: boolean): string { - const executionByCoordinate = new Map(result.items.map(item => [item.coordinate, item])) - const items = plan.items.map(item => ({ + // Execution returns one row per plan position, in order. Coordinates can repeat at different versions. + const items = plan.items.map((item, index) => ({ coordinate: item.coordinate, registry: item.registry, currentVersion: item.currentVersion, remoteVersion: item.remoteVersion, - action: executionByCoordinate.get(item.coordinate)?.action ?? item.action, - reason: executionByCoordinate.get(item.coordinate)?.reason ?? item.reason, - warnings: executionByCoordinate.get(item.coordinate)?.warnings, + action: result.items[index]?.action ?? item.action, + reason: result.items[index]?.reason ?? item.reason, + warnings: result.items[index]?.warnings, changedFiles: item.changedFiles, targets: item.targets })) @@ -130,7 +130,7 @@ export function renderUpgradeResult(plan: UpgradePlan, result: UpgradeExecutionR const versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : '' const reason = item.reason ? ` (${item.reason})` : '' const warnings = item.warnings?.length ? ` [warning: ${item.warnings.join('; ')}]` : '' - return `${item.action.padEnd(13)} ${item.coordinate}${versions}${reason}${warnings}` + return `${item.action.padEnd(13)} ${item.coordinate}${versions} ${item.targets.map(target => target.dir).join(', ')}${reason}${warnings}` }) ].join('\n') } diff --git a/cli/src/integrations/install-recovery.ts b/cli/src/integrations/install-recovery.ts index cbba1395a..b00da155f 100644 --- a/cli/src/integrations/install-recovery.ts +++ b/cli/src/integrations/install-recovery.ts @@ -60,6 +60,7 @@ async function inspectInstallation(options: InstallRecoveryOptions, mode: 'inspe if (!initial || !['install', 'upgrade'].includes(initial.operation.kind)) throw new Error('Expected an owned installation operation') const result = (outcome: InstallRecoveryInspection['outcome'], reason: string, revision = initial.revision): InstallRecoveryInspection => ({ operationId, revision, outcome, reason }) + if (initial.schemaVersion === 1 && await inventory.diskVersion() === 2) return result('unknown', 'legacy-journal-needs-v1-inventory') const bound = initial.operation.targetPaths // Native recovery owns the execution lease: no staging intent means content writes never began. const unstarted = !initial.transaction && !initial.existingInstallation && !initial.removal @@ -112,7 +113,7 @@ async function inspectInstallation(options: InstallRecoveryOptions, mode: 'inspe if (actual === before) return result('inventory-before', 'inventory-matches-before') if (actual !== after) return result('unknown', 'inventory-conflict') const matches = commit.after.items.filter(item => sameInstalledSkillSource(item, identity)) - if (matches.length !== 1 || matches[0]!.version !== current.operation.version || !matches[0]!.fingerprint) { + if (matches.length !== 1) { return result('unknown', 'invalid-committed-identity') } const item = matches[0]! @@ -122,20 +123,20 @@ async function inspectInstallation(options: InstallRecoveryOptions, mode: 'inspe await assertOwnedRegularTree(path) const targets = [] for (const target of item.targets) { - if (await realpath(target.installDir) === path) targets.push(target) + if (bound.includes(target.installDir) && await realpath(target.installDir) === path) targets.push(target) } - if (targets.length !== 1) return result('unknown', 'inventory-target-mismatch') + if (targets.length !== 1 || targets[0]!.version !== current.operation.version || !targets[0]!.fingerprint) return result('unknown', 'inventory-target-mismatch') const target = targets[0]! const read = await readInstalledSkillMetadata(path) if (read.status !== 'valid') return result('unknown', 'metadata-unavailable') const metadata = read.metadata - if (!sameInstalledSkillSource(metadata, identity) || metadata.version !== item.version - || metadata.fingerprint !== item.fingerprint || metadata.agent !== target.agent + if (!sameInstalledSkillSource(metadata, identity) || metadata.version !== target.version + || metadata.fingerprint !== target.fingerprint || metadata.agent !== target.agent || metadata.installedAt !== target.installedAt || metadata.source !== 'skillhub') { return result('unknown', 'metadata-conflict') } const snapshot = await snapshotSkillDirectory(path) - if (snapshot.fingerprint !== item.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { + if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { return result('unknown', 'installed-content-changed') } } @@ -247,7 +248,7 @@ async function assertBackupContent(record: OperationRecord, move: InstallMove, p await assertOwnedRegularTree(path) const identity = { registry: record.operation.context.sourceIdentity, namespace: record.operation.namespace, slug: record.operation.slug } const items = record.transaction!.inventoryCommit!.before.items.filter(item => sameInstalledSkillSource(item, identity)) - if (items.length !== 1 || !items[0]!.fingerprint) throw new Error('Backup baseline is unavailable') + if (items.length !== 1) throw new Error('Backup baseline is unavailable') const item = items[0]! const targets = [] for (const target of item.targets) { @@ -260,11 +261,11 @@ async function assertBackupContent(record: OperationRecord, move: InstallMove, p const read = await readInstalledSkillMetadata(path) if (read.status !== 'valid') throw new Error('Backup metadata is unavailable') const metadata = read.metadata - if (!sameInstalledSkillSource(metadata, identity) || metadata.version !== item.version - || metadata.fingerprint !== item.fingerprint || metadata.installedAt !== target.installedAt + if (!sameInstalledSkillSource(metadata, identity) || metadata.version !== target.version + || metadata.fingerprint !== target.fingerprint || metadata.installedAt !== target.installedAt || metadata.agent !== target.agent || metadata.source !== 'skillhub') throw new Error('Backup metadata does not match its baseline') const snapshot = await snapshotSkillDirectory(path) - if (snapshot.fingerprint !== item.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) throw new Error('Backup content was modified') + if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) throw new Error('Backup content was modified') } async function releaseLocks(releases: Array<() => Promise>, operationError: unknown): Promise { diff --git a/cli/src/integrations/install-rollback.ts b/cli/src/integrations/install-rollback.ts index 24aaf0dce..6bc855255 100644 --- a/cli/src/integrations/install-rollback.ts +++ b/cli/src/integrations/install-rollback.ts @@ -119,9 +119,9 @@ async function oldMetadata(record: OperationRecord, path: string): Promise 1) throw new Error('Multiple records own the old target') diff --git a/cli/src/integrations/installed-projection.ts b/cli/src/integrations/installed-projection.ts index f7197201d..e85ee41ff 100644 --- a/cli/src/integrations/installed-projection.ts +++ b/cli/src/integrations/installed-projection.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { lstat, realpath } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' import { z } from 'zod' @@ -8,9 +9,8 @@ import type { InstalledProjection, InstalledProjectionItem, InstalledProjectionT export type { InstalledProjection, InstalledProjectionItem, InstalledProjectionTarget } from './native-contract' const text = z.string().min(1) -const itemSchema = z.object({ registry: text, namespace: text, slug: text, version: text, - fingerprint: z.string().optional(), targets: z.array(z.object({ - agent: text, rootDir: text, installDir: text, installedAt: text, +const itemSchema = z.object({ registry: text, namespace: text, slug: text, targets: z.array(z.object({ + version: text, fingerprint: z.string().optional(), installedBy: z.array(text).optional(), agent: text, rootDir: text, installDir: text, installedAt: text, })) }) /** Read only the selected Registry's DSH records in Host-approved roots; never repair or hash all files. */ @@ -43,7 +43,10 @@ export async function readInstalledProjection(inventory: Pick target.root === resolve(entry.rootDir))) { const path = join(target.root, item.slug) const projected: InstalledProjectionItem = { scope: target.scope, namespace: item.namespace, - slug: item.slug, version: item.version, ...(item.fingerprint ? { fingerprint: item.fingerprint } : {}), targetPath: path, state: 'present' } + slug: item.slug, version: entry.version, ...(entry.fingerprint ? { fingerprint: entry.fingerprint } : {}), targetPath: path, state: 'present', + recordRevision: createHash('sha256').update(JSON.stringify([item.registry.replace(/\/+$/, ''), item.namespace, item.slug, + entry.agent, entry.rootDir, entry.installDir, entry.version, entry.fingerprint ?? null, entry.installedAt, + [...new Set(entry.installedBy ?? ['direct'])].sort()])).digest('hex') } const key = JSON.stringify([target.scope, path, item.namespace, item.slug]) const previous = seen.get(key) if (previous) { previous.state = 'mismatch'; issue('invalid-record'); continue } @@ -70,8 +73,8 @@ export async function readInstalledProjection(inventory: Pick + | { status: 'ready'; inventoryItem: Omit & { + targets: Array<{ agent: string; rootDir: string; installDir: string; installedAt: string; version: string; fingerprint?: string; installedBy?: string[] }> }; targets: Array<{ scope: 'user' | 'project'; root: string; path: string; files: Record; metadata: UpgradeSelection & { schemaVersion?: number; versionId?: number; fingerprint?: string; diff --git a/cli/src/integrations/native-installed.ts b/cli/src/integrations/native-installed.ts index 739954170..15fbefc1f 100644 --- a/cli/src/integrations/native-installed.ts +++ b/cli/src/integrations/native-installed.ts @@ -50,7 +50,7 @@ export async function verifyExistingInstallation(current: Inventory, } const owner = owners[0] if (owners.length !== 1 || !owner || !sameInstalledSkillSource(owner.item, request) - || owner.item.version !== request.version || owner.item.fingerprint !== request.fingerprint + || owner.entry.version !== request.version || owner.entry.fingerprint !== request.fingerprint || owner.entry.agent !== 'dsh' || !targetInstalledBy(owner.item, owner.entry).includes('direct') || await canonicalizeExistingPath(owner.entry.rootDir) !== request.root) throw new Error('Existing installation conflicts with the requested skill') const metadata = await readInstalledSkillMetadata(target) diff --git a/cli/src/integrations/native-removal.ts b/cli/src/integrations/native-removal.ts index a661c01cd..652860397 100644 --- a/cli/src/integrations/native-removal.ts +++ b/cli/src/integrations/native-removal.ts @@ -17,12 +17,15 @@ export async function executeNativeRemoval(operations: OperationStore, inventory input: NativeRemovalExecution): Promise { const baseline = structuredClone(input.baseline) const { target, inventoryItem: item } = baseline + const releaseTarget = item.targets.find(entry => entry.installDir === target.path) + if (!releaseTarget) throw new Error('Removal target missing') + await inventory.ensureWritable() let record = await operations.get(input.operationId, input.ownerId) if (!record || record.operation.status.phase !== 'accepted' || record.operation.kind !== baseline.kind || !isDeepStrictEqual(record.operation.targetPaths, [target.path]) || !sameInstalledSkillSource(item, { registry: record.operation.context.sourceIdentity, namespace: record.operation.namespace, slug: record.operation.slug }) - || item.version !== record.operation.version) throw new Error('Removal is not owned and accepted') + || releaseTarget.version !== record.operation.version) throw new Error('Removal is not owned and accepted') record = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, phase: 'revalidating' }) const authorize = (paths: readonly string[], phase: 'staging' | 'replace' | 'cleanup' | 'rollback') => input.authorize(paths, phase, input.operationId) @@ -35,7 +38,7 @@ export async function executeNativeRemoval(operations: OperationStore, inventory await authorize([target.root, target.path], 'staging') release = await acquireSkillTargetLock(target.root, item.slug) await operations.assertTargetsAvailable([target.path], input) - if (!isDeepStrictEqual(await inspectRemovalBaseline(inventory, item, target), baseline)) throw new Error('Removal baseline changed') + if (!isDeepStrictEqual(await inspectRemovalBaseline(inventory, { ...item, version: releaseTarget.version }, target), baseline)) throw new Error('Removal baseline changed') const quarantine = baseline.kind === 'uninstall' ? join(dirname(target.root), '.skillhub-trash', record.operationId) : null const verifyRoots = async () => { @@ -49,7 +52,7 @@ export async function executeNativeRemoval(operations: OperationStore, inventory const metadata = await readInstalledSkillMetadata(quarantine) const snapshot = await snapshotSkillDirectory(quarantine) if (metadata.status !== 'valid' || !isDeepStrictEqual(metadata.metadata, baseline.target.metadata) - || snapshot.fingerprint !== item.fingerprint || !isDeepStrictEqual(snapshot.files, baseline.target.files)) { + || snapshot.fingerprint !== releaseTarget.fingerprint || !isDeepStrictEqual(snapshot.files, baseline.target.files)) { throw new Error('Isolated removal files changed') } } @@ -73,7 +76,7 @@ export async function executeNativeRemoval(operations: OperationStore, inventory if (quarantine) { await authorize([target.path, quarantine], 'replace') await verifyRoots() - if (!isDeepStrictEqual(await inspectRemovalBaseline(inventory, item, target), baseline)) throw new Error('Removal baseline changed') + if (!isDeepStrictEqual(await inspectRemovalBaseline(inventory, { ...item, version: releaseTarget.version }, target), baseline)) throw new Error('Removal baseline changed') await assertMissing(quarantine) await rename(target.path, quarantine) await append({ type: 'isolated' }) diff --git a/cli/src/integrations/native-runtime.ts b/cli/src/integrations/native-runtime.ts index 17e9da44c..63d43951d 100644 --- a/cli/src/integrations/native-runtime.ts +++ b/cli/src/integrations/native-runtime.ts @@ -19,6 +19,12 @@ import { acquireSkillTargetLock } from '../services/skill-target-lock' export async function openNativeRuntime(options: { stateDirectory: string; inventoryHome: string }): Promise { const operations = await OperationStore.open(options.stateDirectory) const inventory = new InventoryStore(options.inventoryHome) + const ensureCompatible = async () => { + if ((await operations.listForRecovery()).some(record => record.schemaVersion === 1)) { + throw new OperationStoreError('TARGET_RECOVERY_REQUIRED') + } + await inventory.ensureWritable() + } return { hostInstanceId: operations.hostInstanceId, readSkillExamples, @@ -47,7 +53,8 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven if (conflict) throw conflict return undefined }, - executeRemoval: request => { + executeRemoval: async request => { + await ensureCompatible() const snapshot = { ...request, baseline: structuredClone(request.baseline) } return withOperationLease(operations, snapshot.operationId, snapshot.ownerId, () => executeNativeRemoval(operations, inventory, snapshot)) @@ -63,7 +70,8 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven return result ? projectOperation(result) : undefined }, inspectRemoval: (selected, target) => inspectRemovalBaseline(inventory, { ...selected }, { ...target }), - executeUpgrade: request => { + executeUpgrade: async request => { + await ensureCompatible() const snapshot = { ...request, baseline: structuredClone(request.baseline) } return withOperationLease(operations, snapshot.operationId, snapshot.ownerId, () => executeNativeUpgrade(operations, inventory, snapshot)) @@ -77,7 +85,8 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven assessUpdate: assessSkillUpdate, inspectUpgrade: (selected, targets) => inspectUpgradeBaseline(inventory, { ...selected }, targets.map(target => ({ ...target }))), listInstalled: (registry, targets) => readInstalledProjection(inventory, registry, targets.map(target => ({ ...target }))), - executeInstall: request => { + executeInstall: async request => { + await ensureCompatible() const snapshot = { ...request } return withOperationLease(operations, snapshot.operationId, snapshot.ownerId, () => executeNativeInstall(operations, inventory, snapshot)) diff --git a/cli/src/integrations/native-upgrade.ts b/cli/src/integrations/native-upgrade.ts index ba6b6b475..4a4808def 100644 --- a/cli/src/integrations/native-upgrade.ts +++ b/cli/src/integrations/native-upgrade.ts @@ -24,13 +24,13 @@ export async function executeNativeUpgrade(operations: OperationStore, inventory || operation.context.sourceIdentity !== request.registry || operation.namespace !== request.namespace || operation.slug !== request.slug || operation.version !== request.version || !sameInstalledSkillSource(baseline.inventoryItem, request) || !Number.isInteger(request.versionId) || request.versionId <= 0 - || assessSkillUpdate(baseline.inventoryItem, request).state !== 'upgradable') throw new Error('Upgrade snapshot changed') + || baseline.inventoryItem.targets.some(target => assessSkillUpdate(target, request).state !== 'upgradable')) throw new Error('Upgrade snapshot changed') await operations.update(record.operationId, record.ownerId, record.revision, { ...operation.status, phase: 'staging' }) let coreStarted = false const verifyBaseline = async () => { for (const target of baseline.targets) await request.authorize([target.root, target.path], 'staging') await operations.assertTargetsAvailable(paths, request) - const current = await inspectUpgradeBaseline(inventory, baseline.inventoryItem, baseline.targets) + const current = await inspectUpgradeBaseline(inventory, { ...baseline.inventoryItem, version: baseline.inventoryItem.targets[0]!.version }, baseline.targets) if (!isDeepStrictEqual(current, baseline)) throw new Error('Upgrade baseline changed') } try { diff --git a/cli/src/integrations/operation-projection.ts b/cli/src/integrations/operation-projection.ts index f21b884c2..10cb2bd48 100644 --- a/cli/src/integrations/operation-projection.ts +++ b/cli/src/integrations/operation-projection.ts @@ -8,7 +8,9 @@ export function projectOperation(record: OperationRecord): NativeOperation { item.registry === operation.context.sourceIdentity && item.namespace === operation.namespace && item.slug === operation.slug) // A same-coordinate installation in another scope is not this operation's prior target. const beforeVersion = operation.targetPaths?.length && operation.targetPaths.every(path => - before?.targets.some(target => target.installDir === path)) ? before?.version ?? null : null + before?.targets.some(target => target.installDir === path)) + && new Set(before?.targets.filter(target => operation.targetPaths!.includes(target.installDir)).map(target => target.version)).size === 1 + ? before?.targets.find(target => operation.targetPaths!.includes(target.installDir))?.version ?? null : null const completed = operation.status.phase === 'completed' const files = operation.status.files const outcome = completed && ['installed', 'already-installed', 'upgraded', 'removed', 'stale-record-removed'].includes(files) diff --git a/cli/src/integrations/operation-store.ts b/cli/src/integrations/operation-store.ts index c0a14aade..935a34031 100644 --- a/cli/src/integrations/operation-store.ts +++ b/cli/src/integrations/operation-store.ts @@ -1,8 +1,8 @@ import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises' +import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' -import { canonicalizeExistingPath } from '../platform/paths' +import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' import { z } from 'zod' import { acquireSkillTargetLock } from '../services/skill-target-lock' import { recoveryAction, transactionEvent, transactionState } from './transaction-journal' @@ -28,7 +28,7 @@ const status = z.object({ phase, recovery: z.enum(['not-needed', 'restored', 'incomplete', 'unknown']) }).strict() const existingInstallation = z.object({ fingerprint: text, versionId: z.number().int().positive(), inventoryDigest: z.string().regex(/^[a-f0-9]{64}$/) }).strict() -const record = z.object({ schemaVersion: z.literal(1), operationId: id, ownerId: text, +const record = z.object({ schemaVersion: z.union([z.literal(1), z.literal(2)]), operationId: id, ownerId: text, requestKey: id, requestDigest: z.string().regex(/^[a-f0-9]{64}$/), revision: z.number().int().nonnegative(), operation: input.omit({ context: true }).extend({ context, status }), transaction: transactionState.optional(), removal: removalJournal.optional(), existingInstallation: existingInstallation.optional() }).strict() @@ -80,11 +80,11 @@ export class OperationStore { const duplicate = await this.findRequest(request) if (duplicate) return duplicate const now = new Date().toISOString() - const value: OperationRecord = { schemaVersion: 1, operationId: randomUUID(), ownerId: request.ownerId, + const value: OperationRecord = { schemaVersion: 2, operationId: randomUUID(), ownerId: request.ownerId, requestKey: request.requestKey, requestDigest: request.requestDigest, revision: 0, operation: { ...accepted, context: { ...accepted.context, hostInstanceId: this.hostInstanceId, createdAt: now, updatedAt: now }, status: { phase: 'accepted', files: 'pending', discovery: 'unconfirmed', recovery: 'not-needed', warnings: [] } } } - await atomicWrite(this.path(value.operationId), record.parse(value)) + await writeOperationRecord(this.path(value.operationId), record.parse(value)) return value }) } @@ -107,6 +107,7 @@ export class OperationStore { || targets.length !== current.operation.targetPaths.length || targets.some(path => !current.operation.targetPaths!.includes(path))) { throw new OperationStoreError('INVALID_TRANSITION') } + const canonicalTargets = await Promise.all(targets.map(canonicalOperationTarget)) for (const pending of await this.readAll()) { if (pending.operationId === current.operationId || !needsRecovery(pending)) continue const status = pending.operation.status @@ -117,7 +118,11 @@ export class OperationStore { if (!mayHaveChangedFiles) continue const bound = pending.operation.targetPaths ?? pending.transaction?.moves.map(entry => entry.move.canonicalSkillDir) // Older/incomplete records without target identity cannot safely be scoped to one directory. - if (!bound?.length || bound.some(path => targets.includes(path))) throw new OperationStoreError('TARGET_RECOVERY_REQUIRED') + if (!bound?.length) throw new OperationStoreError('TARGET_RECOVERY_REQUIRED') + for (const path of bound) { + const canonical = await canonicalOperationTarget(path) + if (canonicalTargets.some(target => pathsOverlap(target, canonical))) throw new OperationStoreError('TARGET_RECOVERY_REQUIRED') + } } }) } @@ -131,7 +136,7 @@ export class OperationStore { assertTransition(current, parsed) const value: OperationRecord = { ...current, revision: revision + 1, operation: { ...current.operation, context: { ...current.operation.context, updatedAt: new Date().toISOString() }, status: parsed } } - await atomicWrite(this.path(operationId), value) + await writeOperationRecord(this.path(operationId), value) return value }) } @@ -154,7 +159,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, existingInstallation: parsed, operation: { ...current.operation, status: next, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await atomicWrite(this.path(operationId), record.parse(value)) + await writeOperationRecord(this.path(operationId), record.parse(value)) return value }) } @@ -242,7 +247,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, transaction, operation: { ...current.operation, status: { ...current.operation.status, phase: nextPhase }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await atomicWrite(this.path(operationId), record.parse(value)) + await writeOperationRecord(this.path(operationId), record.parse(value)) return value }) } @@ -270,7 +275,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, transaction, operation: { ...current.operation, context: { ...current.operation.context, updatedAt: new Date().toISOString() }, status: { ...current.operation.status, phase: 'completed', files, recovery: 'not-needed', warnings } } } - await atomicWrite(this.path(operationId), record.parse(value)) + await writeOperationRecord(this.path(operationId), record.parse(value)) return value }) } @@ -295,7 +300,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, transaction, operation: { ...current.operation, status: { ...current.operation.status, warnings }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await atomicWrite(this.path(operationId), record.parse(value)) + await writeOperationRecord(this.path(operationId), record.parse(value)) return value }) } @@ -326,7 +331,7 @@ export class OperationStore { transaction.recoveryActions = actions const value: OperationRecord = { ...current, transaction, revision: revision + 1, operation: { ...current.operation, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await atomicWrite(this.path(operationId), record.parse(value)) + await writeOperationRecord(this.path(operationId), record.parse(value)) return value }) } @@ -344,7 +349,7 @@ export class OperationStore { operation: { ...current.operation, status: { ...current.operation.status, phase: parsed.type === 'beforeCommit' ? 'committing' : current.operation.status.phase }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await atomicWrite(this.path(operationId), record.parse(value)) + await writeOperationRecord(this.path(operationId), record.parse(value)) return value }) } @@ -464,3 +469,39 @@ async function atomicWrite(path: string, value: unknown): Promise { } } finally { await rm(temporary, { force: true }) } } + +/** Recovery may advance v1 status, but never rewrite the original serialized evidence. */ +async function writeOperationRecord(path: string, value: OperationRecord): Promise { + if (value.schemaVersion !== 1) return atomicWrite(path, value) + const previous = await readJson(path) as OperationRecord | undefined + if (!previous || previous.schemaVersion !== 1) throw new Error('Legacy operation evidence missing') + const preserved = structuredClone(value) + if (preserved.transaction && previous.transaction) { + if (preserved.transaction.preparation && previous.transaction.preparation) { + preserved.transaction.preparation.before = previous.transaction.preparation.before + } + if (preserved.transaction.inventoryCommit && previous.transaction.inventoryCommit) { + preserved.transaction.inventoryCommit.before = previous.transaction.inventoryCommit.before + preserved.transaction.inventoryCommit.after = previous.transaction.inventoryCommit.after + } + } + if (preserved.removal && previous.removal) { + preserved.removal.intent = previous.removal.intent + if (preserved.removal.inventoryCommit && previous.removal.inventoryCommit) { + preserved.removal.inventoryCommit.before = previous.removal.inventoryCommit.before + preserved.removal.inventoryCommit.after = previous.removal.inventoryCommit.after + } + } + await atomicWrite(path, preserved) +} + +/** A pending install may not exist yet; resolve the nearest existing ancestor. */ +async function canonicalOperationTarget(path: string): Promise { + try { return await realpath(path) } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(path) + if (parent === path) throw error + return join(await canonicalOperationTarget(parent), basename(path)) + } +} diff --git a/cli/src/integrations/removal-baseline.ts b/cli/src/integrations/removal-baseline.ts index de2557f86..618accac2 100644 --- a/cli/src/integrations/removal-baseline.ts +++ b/cli/src/integrations/removal-baseline.ts @@ -1,14 +1,14 @@ import { lstat } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' -import { canonicalizeExistingPath } from '../platform/paths' +import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' import { sameInstalledSkillSource } from '../services/installed-skill-metadata' import { targetInstalledBy } from '../stores/inventory-store' import type { InventoryStore } from '../stores/inventory-store' import type { InstalledProjectionTarget, RemovalBaseline, UpgradeSelection } from './native-contract' import { inspectUpgradeBaseline } from './upgrade-baseline' -/** Inspect exactly one Host-selected target; retain the full inventory item for execution-time CAS. */ +/** Inspect exactly one Host-selected target; bind only that position for execution-time CAS. */ export async function inspectRemovalBaseline(store: Pick, selected: UpgradeSelection, target: InstalledProjectionTarget): Promise { if (!isAbsolute(target.root) || resolve(target.root) !== target.root @@ -16,16 +16,19 @@ export async function inspectRemovalBaseline(store: Pick const inventory = structuredClone(await store.read()) const matches = inventory.items.filter(item => sameInstalledSkillSource(item, selected)) const item = matches[0] - if (matches.length !== 1 || !item || item.version !== selected.version) return { status: 'blocked', reason: 'record-changed' } + if (matches.length !== 1 || !item) return { status: 'blocked', reason: 'record-changed' } const path = join(target.root, selected.slug) const entries = item.targets.filter(entry => entry.installDir === path) const entry = entries[0] - if (entries.length !== 1 || !entry || entry.agent !== 'dsh' || entry.rootDir !== target.root) { + if (entries.length !== 1 || !entry || entry.version !== selected.version || entry.agent !== 'dsh' || entry.rootDir !== target.root) { return { status: 'blocked', reason: 'target-outside-context' } } const owners = targetInstalledBy(item, entry) if (owners.length !== 1 || owners[0] !== 'direct') return { status: 'blocked', reason: 'shared-target' } - if (!item.fingerprint) return { status: 'blocked', reason: 'metadata-invalid' } + if (!entry.fingerprint) return { status: 'blocked', reason: 'metadata-invalid' } + for (const suite of inventory.suites ?? []) for (const member of suite.members) { + for (const claimed of member.installDirs) if (pathsOverlap(await canonicalizeExistingPath(claimed), path)) return { status: 'blocked', reason: 'shared-target' } + } try { if (await canonicalizeExistingPath(target.root) !== target.root) return { status: 'blocked', reason: 'target-alias' } // Windows may report ENOENT for a child of a regular file, not ENOTDIR. @@ -33,7 +36,7 @@ export async function inspectRemovalBaseline(store: Pick for (const other of inventory.items) { for (const otherTarget of other.targets) { if (other === item && otherTarget === entry) continue - if (await canonicalizeExistingPath(otherTarget.installDir) === path) return { status: 'blocked', reason: 'shared-target' } + if (pathsOverlap(await canonicalizeExistingPath(otherTarget.installDir), path)) return { status: 'blocked', reason: 'shared-target' } } } } catch { return { status: 'blocked', reason: 'target-unavailable' } } @@ -45,22 +48,22 @@ export async function inspectRemovalBaseline(store: Pick } let result: RemovalBaseline if (missing) { - result = { status: 'ready', kind: 'remove-stale-record', inventoryItem: item, target: { ...target, path } } + result = { status: 'ready', kind: 'remove-stale-record', inventoryItem: { ...item, targets: [entry] }, target: { ...target, path } } } else { // Reuse the same metadata, regular-tree and fingerprint checks, but restrict inspection - // to this exact target. Other targets stay in the original item used for commit CAS. + // to this exact target. Unselected positions are preserved by the inventory reducer. const inspected = await inspectUpgradeBaseline({ read: async () => ({ ...inventory, items: inventory.items.map(record => record === item ? { ...record, targets: [entry] } : record) }) }, selected, [target]) if (inspected.status === 'blocked') return inspected - result = { status: 'ready', kind: 'uninstall', inventoryItem: item, target: inspected.targets[0]! } + result = { status: 'ready', kind: 'uninstall', inventoryItem: { ...item, targets: [entry] }, target: inspected.targets[0]! } } const latest = await store.read() const current = latest.items.filter(record => sameInstalledSkillSource(record, selected)) - if (!isDeepStrictEqual(current, [item])) return { status: 'blocked', reason: 'record-changed' } + if (!isDeepStrictEqual(current.map(record => ({ ...record, targets: record.targets.filter(value => value.installDir === path) })), [{ ...item, targets: [entry] }])) return { status: 'blocked', reason: 'record-changed' } for (const record of latest.items) { for (const targetEntry of record.targets) { if (sameInstalledSkillSource(record, selected) && targetEntry.installDir === path) continue - if (await canonicalizeExistingPath(targetEntry.installDir) === path) return { status: 'blocked', reason: 'shared-target' } + if (pathsOverlap(await canonicalizeExistingPath(targetEntry.installDir), path)) return { status: 'blocked', reason: 'shared-target' } } } if (missing) { diff --git a/cli/src/integrations/removal-inventory.ts b/cli/src/integrations/removal-inventory.ts index 8a8e9ff90..4d2b25ef7 100644 --- a/cli/src/integrations/removal-inventory.ts +++ b/cli/src/integrations/removal-inventory.ts @@ -21,7 +21,7 @@ export async function commitRemovalInventory(store: InventoryStore, const expectedState = await captureInstallInventory({ items: [expected] }, expected, paths) await store.mutateAtomic(inventory => { const matches = inventory.items.filter(item => sameInstalledSkillSource(item, expected)) - if (!isDeepStrictEqual(matches, [expected])) throw new Error('Removal inventory changed') + if (!isDeepStrictEqual(matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === path) })), [expected])) throw new Error('Removal inventory changed') const item = matches[0]! item.targets = item.targets.filter(entry => entry.installDir !== path) if (!item.targets.length) inventory.items = inventory.items.filter(entry => entry !== item) diff --git a/cli/src/integrations/removal-journal.ts b/cli/src/integrations/removal-journal.ts index ea6f08693..87ae35101 100644 --- a/cli/src/integrations/removal-journal.ts +++ b/cli/src/integrations/removal-journal.ts @@ -39,7 +39,7 @@ export function applyRemovalEvent(record: OperationRecord, event: RemovalEvent): if (record.removal || operation.status.phase !== 'revalidating' || !isDeepStrictEqual(operation.targetPaths, [value.target.path]) || !sameInstalledSkillSource(item, { registry: operation.context.sourceIdentity, namespace: operation.namespace, slug: operation.slug }) - || item.version !== operation.version || !item.fingerprint || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(item.slug) + || targets[0]?.version !== operation.version || !targets[0]?.fingerprint || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(item.slug) || join(value.target.root, item.slug) !== value.target.path || targets.length !== 1 || targets[0]!.agent !== 'dsh' || targets[0]!.rootDir !== value.target.root || !isDeepStrictEqual(targetInstalledBy(item, targets[0]!), ['direct'])) fail() @@ -61,7 +61,7 @@ export function applyRemovalEvent(record: OperationRecord, event: RemovalEvent): const before = event.commit.before const expected = structuredClone(before) const matches = expected.items.filter(item => sameInstalledSkillSource(item, state.intent.inventoryItem)) - if (!isDeepStrictEqual(matches, [state.intent.inventoryItem])) fail() + if (!isDeepStrictEqual(matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === state.intent.target.path) })), [{ ...state.intent.inventoryItem, targets: state.intent.inventoryItem.targets.filter(target => target.installDir === state.intent.target.path) }])) fail() const item = matches[0]! item.targets = item.targets.filter(target => target.installDir !== state.intent.target.path) if (!item.targets.length) expected.items = expected.items.filter(entry => entry !== item) diff --git a/cli/src/integrations/removal-recovery.ts b/cli/src/integrations/removal-recovery.ts index 0fea6483e..24b2069cb 100644 --- a/cli/src/integrations/removal-recovery.ts +++ b/cli/src/integrations/removal-recovery.ts @@ -28,6 +28,7 @@ export async function recoverRemoval(options: RemovalRecoveryOptions): Promise ({ operationId, revision: record!.revision, outcome, reason }) + if (record.schemaVersion === 1 && await inventory.diskVersion() === 2) return result('unknown', 'legacy-journal-needs-v1-inventory') if (!isDeepStrictEqual(record.operation.targetPaths, [...options.trustedTargetPaths]) || options.trustedTargetPaths.length !== 1) return result('unknown', 'target-binding-mismatch') const append = async (event: RemovalEvent) => { @@ -85,10 +86,10 @@ export async function recoverRemoval(options: RemovalRecoveryOptions): Promise entry.installDir === target.path)! if (metadata.status !== 'valid' || !sameInstalledSkillSource(metadata.metadata, item) - || metadata.metadata.version !== item.version || metadata.metadata.fingerprint !== item.fingerprint + || metadata.metadata.version !== expected.version || metadata.metadata.fingerprint !== expected.fingerprint || metadata.metadata.agent !== 'dsh' || metadata.metadata.installedAt !== expected.installedAt || !isDeepStrictEqual(metadata.metadata.files, intent.files) - || !isDeepStrictEqual(snapshot.files, intent.files) || snapshot.fingerprint !== item.fingerprint) { + || !isDeepStrictEqual(snapshot.files, intent.files) || snapshot.fingerprint !== expected.fingerprint) { throw new Error('Removal files changed') } } diff --git a/cli/src/integrations/transaction-journal.ts b/cli/src/integrations/transaction-journal.ts index 11bbc170e..cde349866 100644 --- a/cli/src/integrations/transaction-journal.ts +++ b/cli/src/integrations/transaction-journal.ts @@ -1,3 +1,4 @@ +import { inventoryMemorySchema, inventoryRecordSchema, legacyInventorySchema, parseInventory, type Inventory, type InventoryItem } from '../stores/inventory-store' import { z } from 'zod' import type { InstallTransactionObserver } from '../services/install-transaction' import type { OperationStore } from './operation-store' @@ -7,16 +8,13 @@ const source = text.refine(value => { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash } catch { return false } }, 'Expected a credential-free registry identity') -const target = z.object({ agent: text, rootDir: text, installDir: text, installedAt: text, installedBy: z.array(text).optional() }).strict() - .transform(({ installedBy, ...value }) => ({ ...value, ...(installedBy === undefined ? {} : { installedBy }) })) -const item = z.object({ registry: source, namespace: text, slug: text, version: text, fingerprint: text.optional(), - installedBy: z.array(text).optional(), targets: z.array(target) }).strict() - .transform(({ installedBy, fingerprint, ...value }) => ({ ...value, - ...(installedBy === undefined ? {} : { installedBy }), ...(fingerprint === undefined ? {} : { fingerprint }) })) -const member = z.object({ namespace: text, slug: text, version: text, fingerprint: text, installDirs: z.array(text) }).strict() -const inventory = z.object({ items: z.array(item), suites: z.array(z.object({ registry: source, namespace: text, slug: text, - version: text, fingerprint: text, members: z.array(member) }).strict()).optional() }).strict() - .transform(({ suites, ...value }) => ({ ...value, ...(suites === undefined ? {} : { suites }) })) +// Legacy snapshots remain raw on disk; normalization is only the recovery read projection. +const inventory = z.union([inventoryMemorySchema, legacyInventorySchema.transform(value => parseInventory(value))]) + .transform(value => value as Inventory) + .refine(value => [...value.items, ...value.suites ?? []].every(item => source.safeParse(item.registry).success), 'Unsafe snapshot source') +const item = z.union([inventoryRecordSchema, legacyInventorySchema.shape.items.element.transform(value => parseInventory({ items: [value] }).items[0]!)]) + .transform(value => value as InventoryItem) + .refine(value => source.safeParse(value.registry).success, 'Unsafe snapshot source') const move = z.object({ phase: z.enum(['backup', 'install']), index: z.number().int().nonnegative(), agent: text, canonicalSkillDir: text, stagingDir: text, backupDir: text.nullable(), from: text, to: text }).strict() const commit = z.object({ before: inventory, after: inventory }).strict() diff --git a/cli/src/integrations/upgrade-baseline.ts b/cli/src/integrations/upgrade-baseline.ts index d3176f410..0c4e706d1 100644 --- a/cli/src/integrations/upgrade-baseline.ts +++ b/cli/src/integrations/upgrade-baseline.ts @@ -1,7 +1,8 @@ +import { captureInstallInventory } from '../services/inventory-cas' import { realpath } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' -import { canonicalizeExistingPath } from '../platform/paths' +import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' import { snapshotSkillDirectory } from '../services/skill-fingerprint' import { targetInstalledBy } from '../stores/inventory-store' @@ -12,16 +13,17 @@ import { assertOwnedRegularTree } from './recovery-files' /** Read-only preparation. Execution must compare this complete snapshot again under mutation locks. */ export async function inspectUpgradeBaseline(store: Pick, - selected: Pick, + selected: Pick & { version: string }, approvedRoots: readonly InstalledProjectionTarget[]): Promise { const inventory = structuredClone(await store.read()) const matches = inventory.items.filter(item => sameInstalledSkillSource(item, selected)) - const item = matches[0] - if (matches.length !== 1 || !item || item.version !== selected.version || !item.targets.length + const whole = matches[0] + const item = whole && { ...whole, targets: whole.targets.filter(target => approvedRoots.some(root => root.root === target.rootDir)) } + if (matches.length !== 1 || !item || item.targets.some(target => target.version !== selected.version) || !item.targets.length || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(item.slug)) return { status: 'blocked', reason: 'record-changed' } const targets: Extract['targets'] = [] const paths = new Set() - // Check the entire shared target set before reading any skill content. + // Verify only the explicitly selected positions before reading their content. for (const target of item.targets) { const roots = approvedRoots.filter(root => isAbsolute(root.root) && root.root === resolve(root.root) && root.root === target.rootDir) @@ -34,9 +36,18 @@ export async function inspectUpgradeBaseline(store: Pick paths.add(target.installDir) } for (const other of inventory.items) { - if (other === item) continue for (const target of other.targets) { - if (paths.has(await canonicalizeExistingPath(target.installDir))) return { status: 'blocked', reason: 'shared-target' } + if (other === whole && item.targets.includes(target)) continue + const canonical = await canonicalizeExistingPath(target.installDir) + if ([...paths].some(path => pathsOverlap(path, canonical))) return { status: 'blocked', reason: 'shared-target' } + } + } + for (const suite of inventory.suites ?? []) { + for (const member of suite.members) { + for (const path of member.installDirs) { + const canonical = await canonicalizeExistingPath(path) + if ([...paths].some(selected => pathsOverlap(selected, canonical))) return { status: 'blocked', reason: 'shared-target' } + } } } for (const target of item.targets) { @@ -48,21 +59,20 @@ export async function inspectUpgradeBaseline(store: Pick const read = await readInstalledSkillMetadata(target.installDir) if (read.status !== 'valid') return { status: 'blocked', reason: 'metadata-invalid' } const metadata = read.metadata - if (!metadata.files || !item.fingerprint || !sameInstalledSkillSource(metadata, item) - || metadata.version !== item.version || metadata.fingerprint !== item.fingerprint + if (!metadata.files || !target.fingerprint || !sameInstalledSkillSource(metadata, item) + || metadata.version !== target.version || metadata.fingerprint !== target.fingerprint || metadata.agent !== 'dsh' || metadata.installedAt !== target.installedAt) { return { status: 'blocked', reason: 'metadata-invalid' } } const snapshot = await snapshotSkillDirectory(target.installDir) - if (snapshot.fingerprint !== item.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { + if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { return { status: 'blocked', reason: 'local-changes' } } targets.push({ scope: approvedRoots.find(root => root.root === target.rootDir)!.scope, root: target.rootDir, path: target.installDir, metadata, files: snapshot.files }) } catch { return { status: 'blocked', reason: 'target-unavailable' } } } - const latest = (await store.read()).items.filter(entry => sameInstalledSkillSource(entry, selected) - || entry.targets.some(target => paths.has(target.installDir))) - if (!isDeepStrictEqual(latest, [item])) return { status: 'blocked', reason: 'record-changed' } + if (await captureInstallInventory(await store.read(), selected, paths) + !== await captureInstallInventory(inventory, selected, paths)) return { status: 'blocked', reason: 'record-changed' } return { status: 'ready', inventoryItem: item, targets } } diff --git a/cli/src/platform/paths.ts b/cli/src/platform/paths.ts index 6e83d8a20..2f9a5bac3 100644 --- a/cli/src/platform/paths.ts +++ b/cli/src/platform/paths.ts @@ -1,3 +1,5 @@ +import { isAbsolute, relative, sep } from 'node:path' + export function userStateDir(home = process.env.HOME || process.env.USERPROFILE || ''): string { if (!home) { throw new Error('Cannot resolve user home directory') @@ -47,3 +49,12 @@ export async function applyCredentialPermissions(path: string): Promise { const { chmod } = await import('node:fs/promises') await chmod(path, 0o600) } + +/** Inputs must be canonical absolute paths. Compare segments, not string prefixes. */ +export function pathsOverlap(left: string, right: string): boolean { + const contains = (parent: string, child: string) => { + const rel = relative(parent, child) + return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)) + } + return contains(left, right) || contains(right, left) +} diff --git a/cli/src/services/doctor-service.ts b/cli/src/services/doctor-service.ts index cea2e2019..ee4d26743 100644 --- a/cli/src/services/doctor-service.ts +++ b/cli/src/services/doctor-service.ts @@ -1,14 +1,17 @@ +import { isDeepStrictEqual } from 'node:util' +import { acquireSkillTargetLock } from './skill-target-lock' import { lstat, readdir, writeFile, readFile } from 'node:fs/promises' import { join } from 'node:path' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' -import { InventoryStore, type Inventory, type InventoryItem, type InventoryTarget } from '../stores/inventory-store' +import { InventoryStore, type InventoryItem, type InventoryTarget } from '../stores/inventory-store' interface MetadataJson { registry: string namespace: string slug: string version: string + fingerprint?: string agent: string installedAt: string } @@ -42,14 +45,11 @@ export async function runDoctor(cwd: string, home?: string): Promise e.metadata.version)) - if (versions.size > 1) { - conflicts.push({ key, versions: [...versions] }) - continue - } + for (const group of groups.values()) { const first = group[0]! const targets: InventoryTarget[] = group.map(e => ({ + version: e.metadata.version, + ...(e.metadata.fingerprint === undefined ? {} : { fingerprint: e.metadata.fingerprint }), agent: e.metadata.agent, rootDir: join(e.installDir, '..'), installDir: e.installDir, @@ -59,18 +59,17 @@ export async function runDoctor(cwd: string, home?: string): Promise { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + }) + await store.ensureWritable() // Collect scanned installDirs const scannedInstallDirs = new Set() @@ -93,22 +92,37 @@ export async function runDoctor(cwd: string, home?: string): Promise Promise> = [] + try { + for (const item of scannedItems.flatMap(item => item.targets.map(target => ({ item, target }))) + .sort((a, b) => a.target.installDir.localeCompare(b.target.installDir))) { + releases.push(await acquireSkillTargetLock(item.target.rootDir, item.item.slug)) + } + const refreshed = await scanMetadata(cwd, []) + if (!isDeepStrictEqual(refreshed, entries)) throw new Error('Doctor scan changed; retry inspection') + await store.mutateAtomic(current => { + for (const scanned of scannedItems) { + let existing = current.items.find(item => item.registry === scanned.registry && item.namespace === scanned.namespace && item.slug === scanned.slug) + if (!existing) { existing = { ...scanned, targets: [] }; current.items.push(existing) } + for (const target of scanned.targets) { + const prior = oldInventory.items.flatMap(item => item.targets.filter(saved => saved.installDir === target.installDir).map(saved => ({ registry: item.registry, namespace: item.namespace, slug: item.slug, target: saved }))) + const actual = current.items.flatMap(item => item.targets.filter(saved => saved.installDir === target.installDir).map(saved => ({ registry: item.registry, namespace: item.namespace, slug: item.slug, target: saved }))) + if (!isDeepStrictEqual(prior, actual)) throw new Error('Doctor inventory changed; retry inspection') + if (actual.some(owner => owner.registry !== scanned.registry || owner.namespace !== scanned.namespace || owner.slug !== scanned.slug)) throw new Error('Doctor source ownership conflict') + const index = existing.targets.findIndex(saved => saved.installDir === target.installDir) + if (index >= 0) { + existing.targets[index] = { ...target, installedBy: existing.targets[index]!.installedBy ?? ['direct'] } + } else existing.targets.push({ ...target, installedBy: ['direct'] }) + } + } + }) + } finally { for (const release of releases.reverse()) await release() } - // Atomically write new inventory - const newInventory: Inventory = { items } - await store.writeAtomic(newInventory) return { inventoryPath: store.path, diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index 30ee649f8..6a46c7895 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -1,15 +1,16 @@ +import { assertDirectTargetOwnership } from './inventory-ownership' import { captureInstallInventory } from './inventory-cas' import { randomUUID } from 'node:crypto' import type { InstallMove, InstallTransactionObserver, InstallTransactionWarning } from './install-transaction' import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' import { SkillHubClient } from '../clients/skillhub-client' -import { InventoryStore, InventoryVersionConflictError, InventoryCommitUnknownError } from '../stores/inventory-store' +import { InventoryStore, InventoryCommitUnknownError } from '../stores/inventory-store' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { extractZip } from '../platform/archive' import { readBoundedResponseBody } from '../platform/download' -import { canonicalizeExistingPath, pathExists } from '../platform/paths' +import { canonicalizeExistingPath, pathsOverlap, pathExists } from '../platform/paths' import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint' import { readInstalledSkillMetadata, @@ -77,7 +78,8 @@ async function preflightInstallTargets( const rootDir = resolve(target.rootDir) const canonicalRootDir = await canonicalizeExistingPath(rootDir) const canonicalSkillDir = join(canonicalRootDir, identity.slug) - if (seenSkillDirs.has(canonicalSkillDir)) { + await assertDirectTargetOwnership(inventory, [canonicalSkillDir]) + if ([...seenSkillDirs].some(path => pathsOverlap(path, canonicalSkillDir))) { throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, { path: canonicalSkillDir, next: 'select only one target for this directory' @@ -87,6 +89,12 @@ async function preflightInstallTargets( // Use the canonical path only as an internal identity. Persist the resolved // user path so macOS aliases and Windows short names remain stable in CLI output. + for (const item of inventory.items) for (const claimed of item.targets) { + const canonicalClaim = await canonicalInventoryInstallDir(claimed) + if (canonicalClaim !== canonicalSkillDir && pathsOverlap(canonicalClaim, canonicalSkillDir)) { + throw new CliError('install target overlaps another managed installation', EXIT.validation) + } + } const resolvedTarget = { ...target, rootDir } const skillDir = join(rootDir, identity.slug) const exists = await pathExists(skillDir) @@ -107,20 +115,21 @@ async function preflightInstallTargets( export async function installSkill(options: InstallOptions): Promise { const store = options.inventoryStore ?? new InventoryStore(options.home) + await store.ensureWritable() const expectedItem = options.expectedInventoryItem === undefined ? undefined : structuredClone(options.expectedInventoryItem) const inventory = await store.read() const identity = { registry: options.registry, namespace: options.namespace, slug: options.slug } - if (expectedItem !== undefined) { - if (expectedItem && !sameInstalledSkillSource(expectedItem, identity)) throw inventoryChangedError() - const expected = await captureInstallInventory({ items: expectedItem ? [expectedItem] : [] }, identity, new Set()) - if (await captureInstallInventory(inventory, identity, new Set()) !== expected) throw inventoryChangedError() - } const preparedTargets = await preflightInstallTargets(options.targets, { registry: options.registry, namespace: options.namespace, slug: options.slug }, options.force, inventory) const canonicalDirs = new Set(preparedTargets.map(target => target.canonicalSkillDir)) + if (expectedItem !== undefined) { + if (expectedItem && !sameInstalledSkillSource(expectedItem, identity)) throw inventoryChangedError() + const expected = await captureInstallInventory({ items: expectedItem ? [expectedItem] : [] }, identity, canonicalDirs) + if (await captureInstallInventory(inventory, identity, canonicalDirs) !== expected) throw inventoryChangedError() + } const baseline = await captureInstallInventory(inventory, identity, canonicalDirs) const assertInventoryCurrent = async (current: Inventory) => { if (await captureInstallInventory(current, identity, canonicalDirs) !== baseline) throw inventoryChangedError() @@ -213,11 +222,6 @@ export async function installSkill(options: InstallOptions): Promise { @@ -280,7 +284,7 @@ export async function installSkill(options: InstallOptions): Promise item.canonicalSkillDir)) @@ -304,15 +308,6 @@ export async function installSkill(options: InstallOptions): Promise ({ agent: target.agent, dir: target.installDir })), - next: 'select all installed targets for the upgrade' - }) - } - throw error } try { await observer?.afterInventoryCommit?.() } @@ -425,30 +420,6 @@ function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error) } -async function assertNoPartialVersionChange( - inventory: Inventory, - staged: StagedInstall[], - identity: InstalledSkillIdentity, - resolved: ResolveResponse -): Promise { - const item = inventory.items.find(candidate => sameInstalledSkillSource(candidate, identity)) - if (!item) return - - const selectedInstallDirs = new Set(staged.map(candidate => candidate.canonicalSkillDir)) - const retainedTargets: typeof item.targets = [] - for (const target of item.targets) { - if (!selectedInstallDirs.has(await canonicalInventoryInstallDir(target))) retainedTargets.push(target) - } - if (retainedTargets.length === 0) return - if (item.version === resolved.version && item.fingerprint === resolved.fingerprint) return - - throw new CliError('partial-target install would create inconsistent versions', EXIT.validation, { - coordinate: `@${identity.namespace}/${identity.slug}`, - retainedTargets: retainedTargets.map(target => ({ agent: target.agent, dir: target.installDir })), - next: 'select all installed targets for the upgrade' - }) -} - async function assertReplaceableInstallation( metadataDir: string, inventoryInstallDir: string, diff --git a/cli/src/services/inventory-cas.ts b/cli/src/services/inventory-cas.ts index 394b447df..1dc03189e 100644 --- a/cli/src/services/inventory-cas.ts +++ b/cli/src/services/inventory-cas.ts @@ -1,8 +1,7 @@ import { resolve, relative } from 'node:path' -import { canonicalizeExistingPath } from '../platform/paths' -import { installedBy, targetInstalledBy } from '../stores/inventory-store' +import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' +import { targetInstalledBy } from '../stores/inventory-store' import type { Inventory, InventoryItem } from '../stores/inventory-store' -import { sameInstalledSkillSource } from './installed-skill-metadata' type Identity = Pick @@ -10,6 +9,7 @@ type Identity = Pick export async function captureInstallInventory( inventory: Inventory, identity: Identity, canonicalDirs: ReadonlySet ): Promise { + void identity const records: string[] = [] for (const item of inventory.items) { const targets = await Promise.all(item.targets.map(async target => { @@ -17,13 +17,26 @@ export async function captureInstallInventory( const canonicalRoot = await canonicalizeExistingPath(root) return { agent: target.agent, rootDir: canonicalRoot, installDir: resolve(canonicalRoot, relative(root, resolve(target.installDir))), - installedAt: target.installedAt, installedBy: [...new Set(targetInstalledBy(item, target))].sort() } + version: target.version, fingerprint: target.fingerprint ?? null, installedAt: target.installedAt, installedBy: [...new Set(targetInstalledBy(item, target))].sort() } })) - if (!sameInstalledSkillSource(item, identity) && !targets.some(target => canonicalDirs.has(target.installDir))) continue + const selected = targets.filter(target => [...canonicalDirs].some(path => pathsOverlap(path, target.installDir))) + if (!selected.length) continue records.push(JSON.stringify({ registry: item.registry, namespace: item.namespace, slug: item.slug, - version: item.version, fingerprint: item.fingerprint ?? null, - installedBy: [...new Set(installedBy(item))].sort(), - targets: targets.map(target => JSON.stringify(target)).sort() })) + targets: selected.map(target => JSON.stringify(target)).sort() })) } - return JSON.stringify(records.sort()) + const suites: string[] = [] + for (const suite of inventory.suites ?? []) { + const members: string[] = [] + for (const member of suite.members) { + const selected = [] + for (const path of member.installDirs) { + const canonical = await canonicalizeExistingPath(path) + if ([...canonicalDirs].some(path => pathsOverlap(path, canonical))) selected.push(canonical) + } + if (selected.length) members.push(JSON.stringify({ ...member, installDirs: selected.sort() })) + } + if (members.length) suites.push(JSON.stringify({ registry: suite.registry, namespace: suite.namespace, + slug: suite.slug, version: suite.version, fingerprint: suite.fingerprint, members: members.sort() })) + } + return JSON.stringify({ records: records.sort(), suites: suites.sort() }) } diff --git a/cli/src/services/inventory-ownership.ts b/cli/src/services/inventory-ownership.ts new file mode 100644 index 000000000..30bf1ae3f --- /dev/null +++ b/cli/src/services/inventory-ownership.ts @@ -0,0 +1,19 @@ +import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' +import { targetInstalledBy, type Inventory } from '../stores/inventory-store' +import { CliError } from '../shared/errors' +import { EXIT } from '../shared/constants' + +/** Direct lifecycle commands cannot replace or remove another manager's position. */ +export async function assertDirectTargetOwnership(inventory: Inventory, paths: readonly string[]): Promise { + const selected = await Promise.all(paths.map(canonicalizeExistingPath)) + const overlaps = (path: string) => selected.some(target => pathsOverlap(target, path)) + const fail = () => { throw new CliError('shared or Suite-owned target cannot be changed directly; use Suite management', EXIT.validation) } + for (const item of inventory.items) for (const target of item.targets) { + if (!overlaps(await canonicalizeExistingPath(target.installDir))) continue + const owners = targetInstalledBy(item, target) + if (owners.length !== 1 || owners[0] !== 'direct') fail() + } + for (const suite of inventory.suites ?? []) for (const member of suite.members) { + for (const path of member.installDirs) if (overlaps(await canonicalizeExistingPath(path))) fail() + } +} diff --git a/cli/src/services/remove-service.ts b/cli/src/services/remove-service.ts index 7634ad271..ea6e977aa 100644 --- a/cli/src/services/remove-service.ts +++ b/cli/src/services/remove-service.ts @@ -1,3 +1,6 @@ +import { assertDirectTargetOwnership } from './inventory-ownership' +import { captureInstallInventory } from './inventory-cas' +import { canonicalizeExistingPath } from '../platform/paths' import { rm, stat } from 'node:fs/promises' import { relative, isAbsolute } from 'node:path' import { InventoryStore } from '../stores/inventory-store' @@ -29,6 +32,7 @@ export interface RemoveResult { export async function removeLocalSkill(options: RemoveLocalOptions): Promise { const store = new InventoryStore(options.home) + await store.ensureWritable() const inventory = await store.read() const items = inventory.items.filter(item => @@ -52,6 +56,9 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise canonicalizeExistingPath(target.installDir))) + await assertDirectTargetOwnership(inventory, paths) + const baseline = await captureInstallInventory(inventory, items[0]!, new Set(paths)) const removed: RemoveResult['removed'] = [] const releases: Array<() => Promise> = [] @@ -71,6 +78,11 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise { const store = new InventoryStore(options.home) + await store.ensureWritable() const before = await store.read() const previousSuite = installedSuites(before).find(candidate => candidate.registry === options.registry && candidate.namespace === plan.namespace && candidate.slug === plan.slug) @@ -394,6 +394,7 @@ export async function removeSuite(options: SuiteRemoveOptions): Promise { const store = new InventoryStore(options.home) + await store.ensureWritable() const inventory = await store.read() const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) const source = suiteSource(suite.namespace, suite.slug, suite.version) @@ -476,7 +477,6 @@ async function removeSuiteTransaction(options: SuiteRemoveOptions): Promise candidate !== source) return { ...target, installedBy: remainingSources.length > 0 ? remainingSources : ['direct'] } }) - item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) } current.items = current.items.filter(item => item.targets.length > 0) }) @@ -633,20 +633,6 @@ async function preflightExistingTargets( allowVersionReplacement: boolean ): Promise { for (const member of plan.members) { - const selectedDirs = new Set(targets.map(target => join(resolve(target.rootDir), member.slug))) - const sameItem = inventory.items.find(item => - item.registry === registry && - item.namespace === member.namespace && item.slug === member.slug) - if (sameItem && sameItem.version !== member.version) { - const retained = sameItem.targets.filter(target => !selectedDirs.has(resolve(target.installDir))) - if (retained.length > 0) { - throw new CliError(`partial Suite install would split versions for @${member.namespace}/${member.slug}`, EXIT.validation, { - retainedTargets: retained.map(target => target.installDir), - next: 'select every installed target or keep the existing Suite version' - }) - } - } - for (const target of targets) { const installDir = join(resolve(target.rootDir), member.slug) const owner = inventory.items.find(item => @@ -666,22 +652,22 @@ async function preflightExistingTargets( const currentSuitePrefix = `suite:@${plan.namespace}/${plan.slug}@` const ownerTarget = owner?.targets.find(existing => resolve(existing.installDir) === installDir) if (owner && ownerTarget && !force && await pathExists(installDir) - && (await snapshotSkillDirectory(installDir)).fingerprint !== owner.fingerprint) { + && (await snapshotSkillDirectory(installDir)).fingerprint !== ownerTarget.fingerprint) { throw new CliError(`local changes detected at ${installDir}`, EXIT.validation, { path: installDir, next: 'pass --force only if replacing these local changes is intended' }) } - if (owner && ownerTarget && owner.version !== member.version && targetInstalledBy(owner, ownerTarget).some(source => + if (owner && ownerTarget && ownerTarget!.version !== member.version && targetInstalledBy(owner, ownerTarget).some(source => source.startsWith('suite:') && !source.startsWith(currentSuitePrefix))) { throw new CliError(`shared Suite member @${member.namespace}/${member.slug} cannot change version in place`, EXIT.validation, { path: installDir, next: 'install the Suite into another target or upgrade the sharing Suite first' }) } - if (owner && owner.version !== member.version && !allowVersionReplacement) { + if (owner && ownerTarget!.version !== member.version && !allowVersionReplacement) { throw new CliError(`different Skill version already installed at ${installDir}`, EXIT.validation, { - currentVersion: owner.version, + currentVersion: ownerTarget!.version, requestedVersion: member.version, next: 'pass --force only if replacing this same Skill is intended' }) @@ -698,8 +684,7 @@ async function isReusable( ): Promise { const item = inventory.items.find(candidate => candidate.registry === registry && candidate.namespace === member.namespace && candidate.slug === member.slug && - candidate.version === member.version && candidate.fingerprint === member.fingerprint && - candidate.targets.some(target => resolve(target.installDir) === installDir)) + candidate.targets.some(target => resolve(target.installDir) === installDir && target.version === member.version && target.fingerprint === member.fingerprint)) if (!item || !(await pathExists(installDir))) return false return (await snapshotSkillDirectory(installDir)).fingerprint === member.fingerprint } @@ -720,7 +705,6 @@ function commitInventory( installedBy: targetInstalledBy(item, target) .filter(candidate => !candidate.startsWith(suitePrefix)) })) - item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) } } for (const member of plan.members) { @@ -731,18 +715,13 @@ function commitInventory( registry, namespace: member.namespace, slug: member.slug, - version: member.version, - fingerprint: member.fingerprint, - installedBy: [source], targets: [] } inventory.items.push(item) } - item.version = member.version - item.fingerprint = member.fingerprint - item.installedBy = Array.from(new Set([...installedBy(item), source])) for (const preparedTarget of prepared.filter(candidate => candidate.member === member)) { const target: InventoryTarget = { + version: member.version, fingerprint: member.fingerprint, agent: preparedTarget.target.agent, rootDir: preparedTarget.target.rootDir, installDir: preparedTarget.installDir, @@ -759,7 +738,6 @@ function commitInventory( } else item.targets.push(target) } - item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) } const suite: InventorySuite = { @@ -792,7 +770,6 @@ function commitInventory( // preserved as user-owned instead of becoming eligible for a later automatic deletion. return { ...target, installedBy: ['direct'] } }) - item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) } inventory.items = inventory.items.filter(item => item.targets.length > 0) } diff --git a/cli/src/services/sync-service.ts b/cli/src/services/sync-service.ts index cdeeef684..a9cf70455 100644 --- a/cli/src/services/sync-service.ts +++ b/cli/src/services/sync-service.ts @@ -1,3 +1,5 @@ +import { assertDirectTargetOwnership } from './inventory-ownership' +import { acquireSkillTargetLock } from './skill-target-lock' import { readdir, readFile, rename, rm, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import { SkillHubClient, type NamespaceSyncItem } from '../clients/skillhub-client' @@ -194,6 +196,7 @@ export async function pullNamespace(options: { warnings: [] } if (options.check) return result + await new InventoryStore().ensureWritable() const remoteBySlug = new Map(inspected.remoteItems.map(item => [item.slug, item])) for (const entry of inspected.entries) { @@ -239,7 +242,10 @@ export async function pullNamespace(options: { } const installDir = join(options.rootDir, entry.slug) const backupDir = `${installDir}.skillhub-prune-${process.pid}-${Date.now()}` + let release: (() => Promise) | undefined try { + release = await acquireSkillTargetLock(options.rootDir, entry.slug) + await assertDirectTargetOwnership(await new InventoryStore().read(), [installDir]) await rename(installDir, backupDir) try { await new InventoryStore().removeTargetsByInstallDir(installDir) @@ -254,7 +260,7 @@ export async function pullNamespace(options: { slug: entry.slug, message: error instanceof Error ? error.message : 'prune failed' }) - } + } finally { if (release) await release() } } } diff --git a/cli/src/services/upgrade-service.ts b/cli/src/services/upgrade-service.ts index 6449267cf..101311f39 100644 --- a/cli/src/services/upgrade-service.ts +++ b/cli/src/services/upgrade-service.ts @@ -1,3 +1,4 @@ +import { assertDirectTargetOwnership } from './inventory-ownership' import { isAbsolute, relative, resolve } from 'node:path' import { canonicalizeExistingPath, pathExists } from '../platform/paths' import { SkillHubClient, type ResolveResponse } from '../clients/skillhub-client' @@ -81,12 +82,13 @@ export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promi const selected = selectInventoryItems(inventory.items, options) const items: UpgradePlanItem[] = [] - for (const selection of selected) { + // Each row has one actual release baseline, even when a coordinate has split versions. + for (const selection of selected.flatMap(group => group.targets.map(target => ({ item: { ...group.item, targets: [target] }, targets: [target] })))) { const coordinate = `@${selection.item.namespace}/${selection.item.slug}` const base = { coordinate, registry: selection.item.registry, - currentVersion: selection.item.version, + currentVersion: selection.targets[0]!.version, changedFiles: [] as string[], targets: selection.targets.map(target => ({ agent: target.agent, dir: target.installDir })), inventoryItem: selection.item, @@ -95,17 +97,9 @@ export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promi allowTargetDrift: options.force } - if (selection.targets.length !== selection.item.targets.length) { - items.push({ - ...base, - action: 'blocked', - reason: 'partial-target upgrades are not supported because one inventory item has one shared version' - }) - continue - } - let resolved: ResolveResponse try { + await assertDirectTargetOwnership(inventory, selection.targets.map(target => target.installDir)) const token = await options.tokenForRegistry(selection.item.registry) resolved = await new SkillHubClient(selection.item.registry, token) .resolve(selection.item.namespace, selection.item.slug) @@ -166,7 +160,7 @@ export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promi continue } - const versionOrder = compareSkillVersions(selection.item.version, resolved.version) + const versionOrder = compareSkillVersions(selection.targets[0]!.version, resolved.version) if (versionOrder === 'remote-older') { items.push({ ...base, @@ -193,7 +187,7 @@ export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promi } const unchanged = versionOrder === 'same' && - selection.item.fingerprint === resolved.fingerprint && + selection.targets[0]!.fingerprint === resolved.fingerprint && inspection.metadataCurrent if (versionOrder === 'same' && !unchanged) { items.push({ @@ -377,7 +371,7 @@ async function inspectTargets(item: InventoryItem, targets: InventoryTarget[]): changedFiles.add(`${target.installDir}:${path}`) } } - if (result.metadata.version !== item.version || result.metadata.fingerprint !== item.fingerprint) { + if (result.metadata.version !== target.version || result.metadata.fingerprint !== target.fingerprint) { metadataCurrent = false } } diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index 39d029984..d979b23f3 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -1,16 +1,20 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' import { isDeepStrictEqual } from 'node:util' -import { readFile, rename, rm, writeFile } from 'node:fs/promises' -import { dirname } from 'node:path' +import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve } from 'node:path' import { lock } from 'proper-lockfile' import { acquireAcquisitionGate } from '../services/skill-target-lock' -import { joinPath, userStateDir, ensureDir } from '../platform/paths' +import { canonicalizeExistingPath, pathsOverlap, joinPath, userStateDir, ensureDir } from '../platform/paths' export interface InventoryTarget { agent: string rootDir: string installDir: string installedAt: string - /** Sources that own this exact target. Missing values inherit the legacy item-level sources. */ + version: string + fingerprint?: string + /** Sources owning this position; legacy inheritance is expanded by parseInventory. */ installedBy?: string[] } @@ -18,10 +22,6 @@ export interface InventoryItem { registry: string namespace: string slug: string - version: string - fingerprint?: string - /** Missing on legacy records and therefore interpreted as a direct install. */ - installedBy?: string[] targets: InventoryTarget[] } @@ -48,11 +48,12 @@ export interface Inventory { } export function installedBy(item: InventoryItem): string[] { - return item.installedBy ?? ['direct'] + return [...new Set(item.targets.flatMap(target => target.installedBy ?? ['direct']))] } export function targetInstalledBy(item: InventoryItem, target: InventoryTarget): string[] { - return target.installedBy ?? installedBy(item) + void item + return target.installedBy ?? ['direct'] } function addDirectSource(item: InventoryItem, target: InventoryTarget): InventoryTarget { @@ -62,21 +63,10 @@ function addDirectSource(item: InventoryItem, target: InventoryTarget): Inventor } } -function refreshItemSources(item: InventoryItem): void { - item.installedBy = Array.from(new Set(item.targets.flatMap(target => targetInstalledBy(item, target)))) -} - export function installedSuites(inventory: Inventory): InventorySuite[] { return inventory.suites ?? [] } -export class InventoryVersionConflictError extends Error { - constructor(readonly retainedTargets: InventoryTarget[]) { - super('partial-target install would create inconsistent versions') - this.name = 'InventoryVersionConflictError' - } -} - export class InventoryCommitUnknownError extends Error { constructor(cause: unknown) { super('inventory commit outcome could not be verified', { cause }) @@ -84,6 +74,55 @@ export class InventoryCommitUnknownError extends Error { } } +const text = z.string().min(1) +const targetShape = { agent: text, rootDir: text, installDir: text, installedAt: text, installedBy: z.array(text).optional() } +const identity = { registry: text, namespace: text, slug: text } +const suiteSchema = z.object({ ...identity, version: text, fingerprint: text, members: z.array(z.object({ + namespace: text, slug: text, version: text, fingerprint: text, installDirs: z.array(text) +}).strict()) }).strict() +export const inventoryTargetSchema = z.object({ ...targetShape, version: text, fingerprint: text.optional() }).strict() +export const inventoryRecordSchema = z.object({ ...identity, targets: z.array(inventoryTargetSchema) }).strict() +export const inventoryMemorySchema = z.object({ items: z.array(inventoryRecordSchema), suites: z.array(suiteSchema).optional() }).strict() +export const legacyInventorySchema = z.object({ items: z.array(z.object({ ...identity, version: text, + fingerprint: text.optional(), installedBy: z.array(text).optional(), targets: z.array(z.object(targetShape).strict()) }).strict()), + suites: z.array(suiteSchema).optional() }).strict() +const inventoryDiskSchema = z.object({ schemaVersion: z.literal(2), records: z.array(inventoryRecordSchema), + suites: z.array(suiteSchema).optional() }).strict() + +/** A legacy read is a projection only. Missing integrity evidence is never synthesized. */ +export function parseInventory(value: unknown): Inventory { + if (typeof value !== 'object' || value === null) throw new Error('Invalid inventory') + if ('schemaVersion' in value) { + const parsed = inventoryDiskSchema.parse(value) + return { items: parsed.records, ...(parsed.suites === undefined ? {} : { suites: parsed.suites }) } as Inventory + } + const legacy = legacyInventorySchema.parse(value) + return { items: legacy.items.map(item => ({ registry: item.registry, namespace: item.namespace, slug: item.slug, + targets: item.targets.map(target => ({ ...target, version: item.version, + ...(item.fingerprint === undefined ? {} : { fingerprint: item.fingerprint }), + installedBy: target.installedBy ?? item.installedBy ?? ['direct'] })) })), + ...(legacy.suites === undefined ? {} : { suites: legacy.suites }) } as Inventory +} + +/** Validate ownership aliases before writing; content of unselected skills is not inspected. */ +async function validateInventoryClaims(inventory: Inventory): Promise { + const sources = new Set() + const paths = new Set() + for (const item of inventory.items) { + const source = JSON.stringify([item.registry.replace(/\/+$/, ''), item.namespace, item.slug]) + if (sources.has(source)) throw new Error('Duplicate inventory source identity') + sources.add(source) + for (const target of item.targets) { + if (!isAbsolute(target.rootDir) || !isAbsolute(target.installDir)) throw new Error('Inventory target paths must be absolute') + const rel = relative(resolve(target.rootDir), resolve(target.installDir)) + if (!rel || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\') || isAbsolute(rel)) throw new Error('Inventory target is outside its root') + const canonical = await canonicalizeExistingPath(target.installDir) + if ([...paths].some(path => pathsOverlap(path, canonical))) throw new Error('Ambiguous inventory target ownership') + paths.add(canonical) + } + } +} + export class InventoryStore { readonly path: string @@ -92,16 +131,54 @@ export class InventoryStore { } async read(): Promise { - try { return JSON.parse(await readFile(this.path, 'utf-8')) as Inventory } + try { return parseInventory(JSON.parse(await readFile(this.path, 'utf-8'))) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { items: [] } throw error } } - async write(inventory: Inventory): Promise { + async write(inventory: Inventory): Promise { await this.writeAtomic(inventory) } + + /** Call before any file mutation. Migration lock is released before target locks. */ + async ensureWritable(): Promise { await ensureDir(dirname(this.path)) - await writeFile(this.path, JSON.stringify(inventory, null, 2)) + const release = await this.acquireLock() + try { await this.migrateUnderLock() } finally { await release() } + } + + async diskVersion(): Promise<1 | 2> { + try { + const value: unknown = JSON.parse(await readFile(this.path, 'utf8')) + parseInventory(value) + return typeof value === 'object' && value !== null && 'schemaVersion' in value ? 2 : 1 + } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 2; throw error } + } + + private async migrateUnderLock(): Promise { + let raw: Buffer + try { raw = await readFile(this.path) } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + const value: unknown = JSON.parse(raw.toString('utf8')) + const inventory = parseInventory(value) + await validateInventoryClaims(inventory) + if (typeof value === 'object' && value !== null && 'schemaVersion' in value) return + const digest = createHash('sha256').update(raw).digest('hex') + const backup = joinPath(dirname(this.path), `inventory.v1.${digest}.backup.json`) + try { await writeFile(backup, raw, { flag: 'wx', mode: 0o600 }) } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } + const backupInfo = await lstat(backup) + if (!backupInfo.isFile() || backupInfo.isSymbolicLink()) throw new Error('Unsafe inventory migration backup') + if (!(await readFile(backup)).equals(raw)) throw new Error('Inventory migration backup mismatch') + try { await this.writeUnderLock(inventory) } + catch (error) { + try { + if (await this.diskVersion() === 2 && isDeepStrictEqual(await this.read(), inventory)) return + } catch { /* Preserve the original migration failure. */ } + throw error + } } async writeAtomic(inventory: Inventory): Promise { @@ -109,6 +186,7 @@ export class InventoryStore { let release: (() => Promise) | null = null try { release = await this.acquireLock() + await this.migrateUnderLock() await this.writeUnderLock(inventory) } finally { if (release) await release().catch(() => {}) @@ -131,6 +209,7 @@ export class InventoryStore { let release: (() => Promise) | null = null try { release = await this.acquireLock() + await this.migrateUnderLock() const inventory = await this.read() const before = structuredClone(inventory) const result = mutate(inventory) @@ -152,12 +231,14 @@ export class InventoryStore { } protected async writeUnderLock(inventory: Inventory): Promise { - const payload = JSON.stringify(inventory, null, 2) - JSON.parse(payload) + inventoryMemorySchema.parse(inventory) + await validateInventoryClaims(inventory) + const payload = JSON.stringify({ schemaVersion: 2, records: inventory.items, suites: inventory.suites }, null, 2) + parseInventory(JSON.parse(payload)) const tmpPath = `${this.path}.${process.pid}.${Date.now()}.tmp` try { await writeFile(tmpPath, payload) - JSON.parse(await readFile(tmpPath, 'utf-8')) + parseInventory(JSON.parse(await readFile(tmpPath, 'utf-8'))) await rename(tmpPath, this.path) } finally { await rm(tmpPath, { force: true }).catch(() => {}) @@ -206,29 +287,26 @@ export class InventoryStore { namespace: string, slug: string, version: string, - target: InventoryTarget, + target: Omit, fingerprint?: string ): Promise { await this.mutateAtomic(inventory => { const existing = inventory.items.find( - i => i.registry === registry && i.namespace === namespace && i.slug === slug + i => i.registry.replace(/\/+$/, '') === registry.replace(/\/+$/, '') && i.namespace === namespace && i.slug === slug ) - const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] } + const item: InventoryItem = existing ?? { registry, namespace, slug, targets: [] } if (!existing) inventory.items.push(item) - item.version = version - if (fingerprint !== undefined) item.fingerprint = fingerprint const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir) - const previous = existingIdx >= 0 ? item.targets[existingIdx]! : target - const next = addDirectSource(item, { ...target, installedBy: targetInstalledBy(item, previous) }) + const previous = existingIdx >= 0 ? item.targets[existingIdx]! : { ...target, version, ...(fingerprint === undefined ? {} : { fingerprint }) } + const next = addDirectSource(item, { ...target, version, ...(fingerprint === undefined ? {} : { fingerprint }), installedBy: targetInstalledBy(item, previous) }) if (existingIdx >= 0) item.targets[existingIdx] = next else item.targets.push(next) - refreshItemSources(item) }) } async removeTarget(registry: string, namespace: string, slug: string, installDir: string): Promise { return this.mutateAtomic(inventory => { - const item = inventory.items.find(i => i.registry === registry && i.namespace === namespace && i.slug === slug) + const item = inventory.items.find(i => i.registry.replace(/\/+$/, '') === registry.replace(/\/+$/, '') && i.namespace === namespace && i.slug === slug) if (!item) return false const idx = item.targets.findIndex(t => t.installDir === installDir) if (idx < 0) return false @@ -256,7 +334,7 @@ export class InventoryStore { namespace: string, slug: string, version: string, - target: InventoryTarget, + target: Omit, fingerprint?: string ): Promise { await this.mutateAtomic(inventory => { @@ -266,15 +344,12 @@ export class InventoryStore { inventory.items = inventory.items.filter(item => item.targets.length > 0) let item = inventory.items.find(candidate => - candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) + candidate.registry.replace(/\/+$/, '') === registry.replace(/\/+$/, '') && candidate.namespace === namespace && candidate.slug === slug) if (!item) { - item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] } + item = { registry, namespace, slug, targets: [] } inventory.items.push(item) } - item.version = version - if (fingerprint !== undefined) item.fingerprint = fingerprint - item.targets.push({ ...target, installedBy: ['direct'] }) - refreshItemSources(item) + item.targets.push({ ...target, version, ...(fingerprint === undefined ? {} : { fingerprint }), installedBy: ['direct'] }) }) } @@ -283,7 +358,7 @@ export class InventoryStore { namespace: string, slug: string, version: string, - targets: InventoryTarget[], + targets: Array>, fingerprint?: string, replacedInstallDirs: string[] = [], beforeCommit?: (before: Inventory, after: Inventory) => Promise @@ -293,29 +368,18 @@ export class InventoryStore { ...targets.map(target => target.installDir), ...replacedInstallDirs ]) - const existingItem = inventory.items.find(candidate => - candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) - const retainedTargets = existingItem?.targets.filter(target => !installDirs.has(target.installDir)) ?? [] - if (existingItem && retainedTargets.length > 0 && - (existingItem.version !== version || existingItem.fingerprint !== fingerprint)) { - throw new InventoryVersionConflictError(retainedTargets) - } - for (const item of inventory.items) { item.targets = item.targets.filter(existing => !installDirs.has(existing.installDir)) } inventory.items = inventory.items.filter(item => item.targets.length > 0) let item = inventory.items.find(candidate => - candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) + candidate.registry.replace(/\/+$/, '') === registry.replace(/\/+$/, '') && candidate.namespace === namespace && candidate.slug === slug) if (!item) { - item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] } + item = { registry, namespace, slug, targets: [] } inventory.items.push(item) } - item.version = version - if (fingerprint !== undefined) item.fingerprint = fingerprint - item.targets.push(...targets.map(target => ({ ...target, installedBy: ['direct'] }))) - refreshItemSources(item) + item.targets.push(...targets.map(target => ({ ...target, version, ...(fingerprint === undefined ? {} : { fingerprint }), installedBy: ['direct'] }))) }, beforeCommit) } } diff --git a/cli/test/fixtures/removal-crash-child.ts b/cli/test/fixtures/removal-crash-child.ts index f14afe620..7089a6e0c 100644 --- a/cli/test/fixtures/removal-crash-child.ts +++ b/cli/test/fixtures/removal-crash-child.ts @@ -44,21 +44,19 @@ if (mode === 'recover') { const identity = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' } const content = '# Removal crash fixture' const targets = [] - let fingerprint = '' for (const scope of ['selected', 'retained']) { const rootDir = join(root, scope, 'skills') const installDir = join(rootDir, identity.slug) await mkdir(join(installDir, '.skillhub'), { recursive: true }) await writeFile(join(installDir, 'SKILL.md'), content) const snapshot = await snapshotSkillDirectory(installDir) - fingerprint = snapshot.fingerprint - const target = { agent: 'dsh', rootDir, installDir, installedAt: '2026-10-14' } + const target = { version: identity.version, fingerprint: snapshot.fingerprint, agent: 'dsh', rootDir, installDir, installedAt: '2026-10-14' } targets.push(target) await writeFile(join(installDir, '.skillhub/metadata.json'), JSON.stringify({ ...identity, ...snapshot, agent: 'dsh', installedAt: target.installedAt })) } const inventory = new InventoryStore(join(root, 'home')) - const before = { items: [{ ...identity, fingerprint, targets }] } + const before = { items: [{ registry: identity.registry, namespace: identity.namespace, slug: identity.slug, targets }] } await inventory.writeAtomic(before) const selected = targets[0]! if (mode === 'stale') await rm(selected.installDir, { recursive: true }) diff --git a/cli/test/helpers/inventory-stale-lock-worker.ts b/cli/test/helpers/inventory-stale-lock-worker.ts index 2b11d08f0..097274a48 100644 --- a/cli/test/helpers/inventory-stale-lock-worker.ts +++ b/cli/test/helpers/inventory-stale-lock-worker.ts @@ -34,7 +34,7 @@ fs.stat = (path, callback) => stat(path, (error, info) => { } else callback(null, info) }) await store.mutateAtomic(inventory => { - inventory.items.push({ registry: 'https://registry.example', namespace: 'global', slug: id, version: '1.0.0', targets: [] }) + inventory.items.push({ registry: 'https://registry.example', namespace: 'global', slug: id, targets: [] }) }, async () => { await writeFile(join(home, `${id}-entered`), '') await waitFor(`${id}-commit`) diff --git a/cli/test/integration/concurrency.test.ts b/cli/test/integration/concurrency.test.ts index 8304355bc..2cf9e5692 100644 --- a/cli/test/integration/concurrency.test.ts +++ b/cli/test/integration/concurrency.test.ts @@ -70,8 +70,8 @@ describe('cross-process concurrency on inventory.json', () => { const inv = JSON.parse( await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8') - ) as { items: Array<{ slug: string }> } - const slugs = inv.items.map(i => i.slug).sort() + ) as { records: Array<{ slug: string }> } + const slugs = inv.records.map(i => i.slug).sort() expect(slugs).toEqual(['first', 'second']) await expect(access(staleLockPath)).rejects.toThrow() expect((await readdir(join(env.home, '.skillhub'))) @@ -106,8 +106,8 @@ describe('cross-process concurrency on inventory.json', () => { const inv = JSON.parse( await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8') - ) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> } - const item = inv.items.find(i => i.slug === 'race') + ) as { records: Array<{ slug: string; targets: Array<{ installDir: string }> }> } + const item = inv.records.find(i => i.slug === 'race') expect(item).toBeDefined() expect(item!.targets).toHaveLength(1) // no duplicate targets }) @@ -140,7 +140,7 @@ describe('cross-process concurrency on inventory.json', () => { const inv = JSON.parse( await readFile(join(skillhubDir, 'inventory.json'), 'utf-8') - ) as { items: Array<{ slug: string }> } - expect(inv.items.find(i => i.slug === 'after-stale')).toBeDefined() + ) as { records: Array<{ slug: string }> } + expect(inv.records.find(i => i.slug === 'after-stale')).toBeDefined() }) }) diff --git a/cli/test/integration/cross-command.test.ts b/cli/test/integration/cross-command.test.ts index 0eed997b1..071b3f208 100644 --- a/cli/test/integration/cross-command.test.ts +++ b/cli/test/integration/cross-command.test.ts @@ -149,8 +149,8 @@ describe('cross-command — local lifecycle', () => { // Inventory has exactly one target, not two duplicates. const inv = JSON.parse( await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8') - ) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> } - const item = inv.items.find(i => i.slug === 'pdf-parser') + ) as { records: Array<{ slug: string; targets: Array<{ installDir: string }> }> } + const item = inv.records.find(i => i.slug === 'pdf-parser') expect(item?.targets).toHaveLength(1) }) @@ -277,8 +277,8 @@ describe('cross-command — filesystem drift', () => { // installDir was NOT in the (now-empty) scan result. const inv = JSON.parse( await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8') - ) as { items: Array<{ slug: string }> } - expect(inv.items.find(i => i.slug === 'pdf-parser')).toBeDefined() + ) as { records: Array<{ slug: string }> } + expect(inv.records.find(i => i.slug === 'pdf-parser')).toBeDefined() // The user-facing surface for "this is gone on disk" is `list` — it // reports status="missing" by stat'ing the installDir at read time. diff --git a/cli/test/integration/doctor-command.test.ts b/cli/test/integration/doctor-command.test.ts index 0d833722d..d8c5b4a5f 100644 --- a/cli/test/integration/doctor-command.test.ts +++ b/cli/test/integration/doctor-command.test.ts @@ -93,19 +93,19 @@ describe('doctor command', () => { const inventoryPath = join(home, '.skillhub', 'inventory.json') const raw = await readFile(inventoryPath, 'utf-8') - const inventory = JSON.parse(raw) as { items: Array<{ + const inventory = JSON.parse(raw) as { records: Array<{ namespace: string slug: string version: string registry: string }> } - expect(inventory.items).toHaveLength(1) - expect(inventory.items[0]).toMatchObject({ + expect(inventory.records).toHaveLength(1) + expect(inventory.records[0]).toMatchObject({ registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser', - version: '1.2.0' + targets: [expect.objectContaining({ version: '1.2.0' })] }) }) @@ -145,9 +145,9 @@ describe('doctor command', () => { // ------------------------------------------------------------------------- // P1: same registry+namespace+slug appearing in two agent dirs with - // different versions surfaces in `conflicts` and is excluded from items. + // different versions remains one group with two target-owned releases. // ------------------------------------------------------------------------- - test('doctor reports conflicts when two agent dirs disagree on version', async () => { + test('doctor preserves distinct target versions across two agent directories', async () => { const { home, cwd } = await createTempHome() // Two installs of the same global/pdf-parser with mismatched versions. @@ -189,18 +189,15 @@ describe('doctor command', () => { conflicts: Array<{ key: string; versions: string[] }> } expect(json.ok).toBe(true) - // Conflicting group is dropped from items, recorded as a conflict. - expect(json.itemsScanned).toBe(0) - expect(json.targetsScanned).toBe(0) - expect(json.conflicts).toHaveLength(1) - expect(json.conflicts[0]?.key).toBe('https://skill.xfyun.cn|global|pdf-parser') - expect(json.conflicts[0]?.versions.sort()).toEqual(['1.0.0', '2.0.0']) - - // The persisted inventory must mirror the JSON output: no items. - const inventory = JSON.parse( - await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8') - ) as { items: unknown[] } - expect(inventory.items).toHaveLength(0) + expect(json.itemsScanned).toBe(1) + expect(json.targetsScanned).toBe(2) + expect(json.conflicts).toHaveLength(0) + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')) as { + schemaVersion: number; records: Array<{ targets: Array<{ version: string }> }> + } + expect(inventory.schemaVersion).toBe(2) + expect(inventory.records).toHaveLength(1) + expect(inventory.records[0]!.targets.map(target => target.version).sort()).toEqual(['1.0.0', '2.0.0']) }) // ------------------------------------------------------------------------- @@ -271,9 +268,9 @@ describe('doctor command', () => { expect(json.itemsScanned).toBe(1) // only good-skill const inventory = JSON.parse( await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8') - ) as { items: Array<{ slug: string }> } - expect(inventory.items).toHaveLength(1) - expect(inventory.items[0]?.slug).toBe('good-skill') + ) as { records: Array<{ slug: string }> } + expect(inventory.records).toHaveLength(1) + expect(inventory.records[0]?.slug).toBe('good-skill') }) test('doctor backs up existing inventory.json and reports backupPath', async () => { @@ -353,10 +350,10 @@ describe('doctor command', () => { const raw = await readFile(inventoryPath, 'utf-8') const inventory = JSON.parse(raw) as { - items: Array<{ slug: string }> + records: Array<{ slug: string }> } - expect(inventory.items.map(item => item.slug)).toEqual( + expect(inventory.records.map(item => item.slug)).toEqual( expect.arrayContaining(['external-skill', 'local-skill']) ) }) diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index 789964d91..275871b6f 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -81,8 +81,8 @@ describe('install command — P0', () => { // --- inventory.json --- const inventoryPath = join(env.home, '.skillhub', 'inventory.json') const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) - expect(inventory.items).toBeArray() - const item = inventory.items.find( + expect(inventory.records).toBeArray() + const item = inventory.records.find( (i: { namespace: string; slug: string }) => i.namespace === 'global' && i.slug === 'pdf-parser' ) expect(item).toBeDefined() @@ -759,9 +759,9 @@ describe('install command — multi-agent & auto-detect', () => { expect(JSON.parse(await readFile(metadataPath, 'utf-8')).agent).toBe('pi') const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) as { - items: Array<{ targets: Array<{ agent: string; installDir: string }> }> + records: Array<{ targets: Array<{ agent: string; installDir: string }> }> } - expect(inventory.items[0]?.targets[0]).toMatchObject({ agent: 'pi', installDir }) + expect(inventory.records[0]?.targets[0]).toMatchObject({ agent: 'pi', installDir }) }) test('auto-detects an existing Pi project skills directory end to end', async () => { @@ -833,9 +833,9 @@ describe('install command — multi-agent & auto-detect', () => { expect(JSON.parse(await readFile(metadataPath, 'utf-8')).agent).toBe('astudio') const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) as { - items: Array<{ targets: Array<{ agent: string }> }> + records: Array<{ targets: Array<{ agent: string }> }> } - expect(inventory.items[0]?.targets[0]?.agent).toBe('astudio') + expect(inventory.records[0]?.targets[0]?.agent).toBe('astudio') }) test('multi --agent installs the same skill into every specified user-level dir', async () => { diff --git a/cli/test/integration/inventory-resilience.test.ts b/cli/test/integration/inventory-resilience.test.ts index ac08fb7cd..795953864 100644 --- a/cli/test/integration/inventory-resilience.test.ts +++ b/cli/test/integration/inventory-resilience.test.ts @@ -96,11 +96,11 @@ describe('inventory resilience', () => { const inventory = JSON.parse( await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8') - ) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> } + ) as { records: Array<{ slug: string; targets: Array<{ installDir: string }> }> } - const slugs = inventory.items.map(i => i.slug).sort() + const slugs = inventory.records.map(i => i.slug).sort() expect(slugs).toEqual(['one', 'two']) - for (const item of inventory.items) { + for (const item of inventory.records) { expect(item.targets.length).toBeGreaterThan(0) } }) diff --git a/cli/test/integration/remove-command.test.ts b/cli/test/integration/remove-command.test.ts index 5f44f8a1a..7baec61a7 100644 --- a/cli/test/integration/remove-command.test.ts +++ b/cli/test/integration/remove-command.test.ts @@ -390,9 +390,9 @@ describe('remove command — local remove (P1)', () => { expect(result.exitCode).toBe(0) const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as { - items: Array<{ slug: string; targets: Array<{ agent: string }> }> + records: Array<{ slug: string; targets: Array<{ agent: string }> }> } - const survived = inv.items.find(i => i.slug === 'keep-others') + const survived = inv.records.find(i => i.slug === 'keep-others') expect(survived).toBeDefined() expect(survived!.targets.map(t => t.agent)).toEqual(['claude-code']) }) @@ -457,11 +457,11 @@ describe('remove command — local remove (P1)', () => { expect(await pathExists(otherDir)).toBe(true) const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as { - items: Array<{ namespace: string; slug: string; targets: Array<{ installDir: string }> }> + records: Array<{ namespace: string; slug: string; targets: Array<{ installDir: string }> }> } - expect(inv.items.map(item => item.namespace).sort()).toEqual(['global', 'other']) - expect(inv.items.every(item => item.slug === 'shared-skill')).toBe(true) - expect(inv.items.map(item => item.targets[0]?.installDir).sort()).toEqual([globalDir, otherDir].sort()) + expect(inv.records.map(item => item.namespace).sort()).toEqual(['global', 'other']) + expect(inv.records.every(item => item.slug === 'shared-skill')).toBe(true) + expect(inv.records.map(item => item.targets[0]?.installDir).sort()).toEqual([globalDir, otherDir].sort()) }) test('bare slug retains cross-namespace local removal compatibility', async () => { @@ -520,8 +520,8 @@ describe('remove command — local remove (P1)', () => { expect(await pathExists(otherDir)).toBe(false) const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as { - items: object[] + records: object[] } - expect(inv.items).toEqual([]) + expect(inv.records).toEqual([]) }) }) diff --git a/cli/test/integration/sync-command.test.ts b/cli/test/integration/sync-command.test.ts index b2cee0353..d4b159abb 100644 --- a/cli/test/integration/sync-command.test.ts +++ b/cli/test/integration/sync-command.test.ts @@ -1,3 +1,4 @@ +import { InventoryStore } from '../../src/stores/inventory-store' import { createHash } from 'node:crypto' import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -665,6 +666,41 @@ describe('sync command', () => { } }) + test('force prune refuses Suite-owned orphans without deleting their files', async () => { + const env = await createTempHome() + const skillsDir = join(env.cwd, 'team-skills') + const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n') + const skills: FakeSkill[] = [ + { namespace: 'team-a', slug: 'demo', ...fixture }, + { namespace: 'team-a', slug: 'keep', ...makeSkill('# keep\n') } + ] + const registry = await startFakeRegistry({ token: 'token', skills }) + + try { + await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--skill', 'keep', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token' + ], { HOME: env.home }, { cwd: env.cwd }) + const inventory = new InventoryStore(env.home) + await inventory.mutateAtomic(value => { value.items.find(item => item.slug === 'demo')!.targets[0]!.installedBy = ['suite:@team-a/set@1'] }) + const before = await inventory.read() + skills.splice(0, skills.length) + + const pruned = await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', skillsDir, '--prune', '--force', + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + expect(pruned.exitCode).not.toBe(0) + expect(JSON.parse(pruned.stdout).actions).toEqual([]) + expect(JSON.parse(pruned.stdout).failures[0].message).toContain('shared or Suite-owned') + expect(await inventory.read()).toEqual(before) + expect(await readFile(join(skillsDir, 'demo/SKILL.md'), 'utf8')).toContain('name: demo') + expect(await Bun.file(join(skillsDir, 'keep', 'SKILL.md')).exists()).toBe(true) + } finally { + registry.stop() + } + }) + test('push all validates packages and submits an uploaded version for review', async () => { const env = await createTempHome() const skillsDir = join(env.cwd, 'team-skills') diff --git a/cli/test/integration/upgrade-command.test.ts b/cli/test/integration/upgrade-command.test.ts index 8df61ff52..a51fb6f6c 100644 --- a/cli/test/integration/upgrade-command.test.ts +++ b/cli/test/integration/upgrade-command.test.ts @@ -108,7 +108,7 @@ describe('upgrade command', () => { }) expect(Object.keys(metadata.files)).toContain('SKILL.md') const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) - expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) + expect(inventory.records[0].targets[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) }) test('local changes block by default and --force replaces only the same source', async () => { @@ -184,7 +184,7 @@ describe('upgrade command', () => { expect(await readFile(join(rootDir, 'late-edit', 'SKILL.md'), 'utf-8')) .toBe('# edited after planning') const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) - expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint }) + expect(inventory.records[0].targets[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint }) }) test('a target removed after planning is not recreated by upgrade', async () => { @@ -224,7 +224,7 @@ describe('upgrade command', () => { expect(result.items[0]?.reason).toContain('installed target disappeared before upgrade commit') expect(await exists(skillDir)).toBe(false) const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) - expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint }) + expect(inventory.records[0].targets[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint }) }) test('new installs persist absolute targets and legacy relative targets are blocked safely', async () => { @@ -245,16 +245,16 @@ describe('upgrade command', () => { const inventoryPath = join(env.home, '.skillhub', 'inventory.json') const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) - expect(isAbsolute(inventory.items[0].targets[0].rootDir)).toBe(true) - expect(inventory.items[0].targets[0].installDir) - .toBe(join(inventory.items[0].targets[0].rootDir, 'portable')) - expect(await realpath(inventory.items[0].targets[0].rootDir)) + expect(isAbsolute(inventory.records[0].targets[0].rootDir)).toBe(true) + expect(inventory.records[0].targets[0].installDir) + .toBe(join(inventory.records[0].targets[0].rootDir, 'portable')) + expect(await realpath(inventory.records[0].targets[0].rootDir)) .toBe(await realpath(join(env.cwd, 'skills'))) - expect(await realpath(inventory.items[0].targets[0].installDir)) + expect(await realpath(inventory.records[0].targets[0].installDir)) .toBe(await realpath(join(env.cwd, 'skills', 'portable'))) - inventory.items[0].targets[0].rootDir = 'skills' - inventory.items[0].targets[0].installDir = join('skills', 'portable') + inventory.records[0].targets[0].rootDir = 'skills' + inventory.records[0].targets[0].installDir = join('skills', 'portable') await writeFile(inventoryPath, JSON.stringify(inventory)) skill.version = '1.1.0' skill.versionId = 2 @@ -393,7 +393,7 @@ describe('upgrade command', () => { expect(registry.received.downloads).toBe(1) }) - test('one resolved archive is reused for every managed target', async () => { + test('each managed position can be checked and upgraded independently', async () => { const env = await createTempHome() const skill = { namespace: 'global', @@ -417,22 +417,33 @@ describe('upgrade command', () => { const partial = await runCli([ 'upgrade', '@global/shared', '--registry', registry.url, '--agent', 'codex', '--check', '--json' ], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd }) - expect(partial.exitCode).toBe(6) - expect(JSON.parse(partial.stdout).items[0].reason).toContain('partial-target') + expect(partial.exitCode).toBe(0) + expect(JSON.parse(partial.stdout).items).toHaveLength(1) + expect(JSON.parse(partial.stdout).items[0]).toMatchObject({ action: 'upgrade', currentVersion: '1.0.0', remoteVersion: '1.1.0' }) expect(registry.received.downloads).toBe(1) + const selected = await runCli(['upgrade', '@global/shared', '--registry', registry.url, '--agent', 'codex', '--json'], { + HOME: env.home, USERPROFILE: env.home + }, { cwd: env.cwd }) + expect(selected.exitCode).toBe(0) + expect(JSON.parse(selected.stdout).items).toHaveLength(1) + expect(await readFile(join(env.home, '.codex', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') + expect(await readFile(join(env.home, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v1') + const split = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(split.records[0].targets.map((target: { version: string }) => target.version).sort()).toEqual(['1.0.0', '1.1.0']) + const upgraded = await runCli(['upgrade', '@global/shared', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd }) expect(upgraded.exitCode).toBe(0) - expect(registry.received.resolves).toBe(2) - expect(registry.received.downloads).toBe(2) + expect(registry.received.resolves).toBe(5) + expect(registry.received.downloads).toBe(3) expect(await readFile(join(env.home, '.codex', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') expect(await readFile(join(env.home, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) - expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) - expect(inventory.items[0].targets).toHaveLength(2) + expect(inventory.records[0].targets[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) + expect(inventory.records[0].targets).toHaveLength(2) const listed = await runCli(['list', '--json', '--registry', registry.url], { HOME: env.home, @@ -573,9 +584,9 @@ describe('upgrade command', () => { expect(await readFile(join(rootDir, 'second', 'SKILL.md'), 'utf-8')).toBe('# second v1') expect(await readFile(join(rootDir, 'third', 'SKILL.md'), 'utf-8')).toBe('# third v1') const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) - expect(inventory.items.find((item: { slug: string }) => item.slug === 'first').version).toBe('1.1.0') - expect(inventory.items.find((item: { slug: string }) => item.slug === 'second').version).toBe('1.0.0') - expect(inventory.items.find((item: { slug: string }) => item.slug === 'third').version).toBe('1.0.0') + expect(inventory.records.find((item: { slug: string }) => item.slug === 'first').targets[0].version).toBe('1.1.0') + expect(inventory.records.find((item: { slug: string }) => item.slug === 'second').targets[0].version).toBe('1.0.0') + expect(inventory.records.find((item: { slug: string }) => item.slug === 'third').targets[0].version).toBe('1.0.0') expect(registry.received.downloads).toBe(5) }) @@ -618,7 +629,7 @@ describe('upgrade command', () => { expect(renderUpgradeResult(plan, result, false)).toContain('[warning: target lock cleanup failed') expect(await readFile(join(rootDir, 'warned', 'SKILL.md'), 'utf-8')).toBe('# v2') const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) - expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) + expect(inventory.records[0].targets[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) }) test('legacy metadata without a file baseline requires explicit force migration', async () => { diff --git a/cli/test/integration/version-upgrade-flow.test.ts b/cli/test/integration/version-upgrade-flow.test.ts index 56705f618..da68b2328 100644 --- a/cli/test/integration/version-upgrade-flow.test.ts +++ b/cli/test/integration/version-upgrade-flow.test.ts @@ -100,11 +100,11 @@ describe('version upgrade flow', () => { expect(finalBody).not.toContain('Version one body.') const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as { - items: Array<{ namespace: string; slug: string; version: string; targets: Array<{ installDir: string }> }> + records: Array<{ namespace: string; slug: string; version: string; targets: Array<{ installDir: string; version: string }> }> } - const matching = inventory.items.filter(i => i.namespace === 'global' && i.slug === 'pdf-parser') + const matching = inventory.records.filter(i => i.namespace === 'global' && i.slug === 'pdf-parser') expect(matching).toHaveLength(1) // no duplicate v1 entry - expect(matching[0]?.version).toBe('2.0.0') + expect(matching[0]?.targets[0]?.version).toBe('2.0.0') }) // ------------------------------------------------------------------------- diff --git a/cli/test/unit/integrations/install-crash-recovery.test.ts b/cli/test/unit/integrations/install-crash-recovery.test.ts index 576aff0aa..13a5c9db6 100644 --- a/cli/test/unit/integrations/install-crash-recovery.test.ts +++ b/cli/test/unit/integrations/install-crash-recovery.test.ts @@ -73,7 +73,7 @@ test.skipIf(process.platform === 'win32')('real killed installers recover all pr else { const committed = await inventory.read() expect(committed).toEqual(saved.transaction!.inventoryCommit!.after) - expect(committed.items[0]!.version).toBe('2.0.0') + expect(committed.items[0]!.targets[0]!.version).toBe('2.0.0') expect(committed.items[0]!.targets).toHaveLength(2) } expect(await operations.listForRecovery()).toEqual([]) diff --git a/cli/test/unit/integrations/install-recovery.test.ts b/cli/test/unit/integrations/install-recovery.test.ts index ca8089812..d3e68fe8a 100644 --- a/cli/test/unit/integrations/install-recovery.test.ts +++ b/cli/test/unit/integrations/install-recovery.test.ts @@ -109,7 +109,7 @@ test('unrelated inventory additions do not obscure commit evidence; before snaps const { root, options } = await fixture() try { const current = await options.inventory.read() - current.items.push({ ...identity, slug: 'other', version: '1.0.0', targets: [] }) + current.items.push({ registry: identity.registry, namespace: identity.namespace, slug: 'other', targets: [] }) await options.inventory.writeAtomic(current) expect((await inspectInstallRecovery(options)).outcome).toBe('committed') const record = (await options.operations.get(options.operationId, options.ownerId))! diff --git a/cli/test/unit/integrations/installed-projection.test.ts b/cli/test/unit/integrations/installed-projection.test.ts index 5c93d5da2..a16159380 100644 --- a/cli/test/unit/integrations/installed-projection.test.ts +++ b/cli/test/unit/integrations/installed-projection.test.ts @@ -11,12 +11,11 @@ test('installed projection preserves both scopes and fixed versions without scan try { const user = join(root, 'user') const project = join(root, 'project') - const item: InventoryItem = { registry: registry + '/', namespace: 'demo', slug: 'writer', version: '1.2.0', - fingerprint: 'original', targets: [user, project].map(rootDir => ({ agent: 'dsh', rootDir, + const item: InventoryItem = { registry: registry + '/', namespace: 'demo', slug: 'writer', targets: [user, project].map(rootDir => ({ version: '1.2.0', fingerprint: 'original', agent: 'dsh', rootDir, installDir: join(rootDir, 'writer'), installedAt: '2026-10-14T00:00:00Z' })) } for (const target of item.targets) { await mkdir(join(target.installDir, '.skillhub'), { recursive: true }) - await writeFile(join(target.installDir, '.skillhub/metadata.json'), JSON.stringify({ ...item, + await writeFile(join(target.installDir, '.skillhub/metadata.json'), JSON.stringify({ ...item, version: target.version, fingerprint: target.fingerprint, agent: target.agent, installedAt: target.installedAt })) // No hash claim: local content may differ; presence alone is shown by this projection. await writeFile(join(target.installDir, 'SKILL.md'), 'locally edited') @@ -48,8 +47,8 @@ test('inventory failure is partial and escaping targets are reported without rea const failed = await readInstalledProjection({ read: async () => { throw new Error('permission denied') } }, registry, []) expect(failed).toEqual({ items: [], complete: false, issues: ['inventory-unreadable'] }) const trusted = resolve('/trusted') - const item: InventoryItem = { registry, namespace: 'demo', slug: 'writer', version: '1', targets: [ - { agent: 'dsh', rootDir: trusted, installDir: resolve('/untrusted/secret'), installedAt: 'today' }, + const item: InventoryItem = { registry, namespace: 'demo', slug: 'writer', targets: [ + { version: '1', agent: 'dsh', rootDir: trusted, installDir: resolve('/untrusted/secret'), installedAt: 'today' }, ] } const result = await readInstalledProjection({ read: async () => ({ items: [item, item] }) }, registry, [{ scope: 'project', root: trusted }]) diff --git a/cli/test/unit/integrations/native-install.test.ts b/cli/test/unit/integrations/native-install.test.ts index f400adbcc..4a4144785 100644 --- a/cli/test/unit/integrations/native-install.test.ts +++ b/cli/test/unit/integrations/native-install.test.ts @@ -391,8 +391,8 @@ test.each(['legacy', 'fingerprint', 'versionId', 'digest', 'metadata', 'version' if (change === 'version' || change === 'ownership') { const inventoryPath = join(f.root, 'home/.skillhub/inventory.json') const inventory = JSON.parse(await readFile(inventoryPath, 'utf8')) - if (change === 'version') inventory.items[0].version = '2.0.0' - else inventory.items[0].targets[0].installedBy = ['pack:other'] + if (change === 'version') inventory.records[0].targets[0].version = '2.0.0' + else inventory.records[0].targets[0].installedBy = ['pack:other'] await writeFile(inventoryPath, JSON.stringify(inventory)) } let calls = 0 diff --git a/cli/test/unit/integrations/native-removal.test.ts b/cli/test/unit/integrations/native-removal.test.ts index 9381d6271..f07ce95b6 100644 --- a/cli/test/unit/integrations/native-removal.test.ts +++ b/cli/test/unit/integrations/native-removal.test.ts @@ -20,9 +20,9 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni await writeFile(join(path, 'SKILL.md'), '# Original') const snapshot = await snapshotSkillDirectory(path) const selected = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' } - const entry = { agent: 'dsh', rootDir: target.root, installDir: path, installedAt: '2026-10-14' } + const entry = { version: selected.version, fingerprint: snapshot.fingerprint, agent: 'dsh', rootDir: target.root, installDir: path, installedAt: '2026-10-14' } const retained = { ...entry, agent: 'other', rootDir: join(home, 'other'), installDir: join(home, 'other/writer') } - const item = { ...selected, fingerprint: snapshot.fingerprint, targets: [entry, retained] } + const item = { registry: selected.registry, namespace: selected.namespace, slug: selected.slug, targets: [entry, retained] } const inventory = new InventoryStore(home) await inventory.writeAtomic({ items: [item] }) await writeFile(join(path, '.skillhub/metadata.json'), JSON.stringify({ ...selected, ...snapshot, @@ -129,7 +129,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni const committedInventory = { items: [{ ...item, targets: [retained] }] } for (const conflict of ['version', 'ownership', 'foreign-source', 'occupied-path'] as const) { const conflictedInventory = conflict === 'version' - ? { items: [{ ...item, version: '9.0.0', targets: [retained] }] } + ? { items: [{ ...item, targets: [{ ...entry, version: '9.0.0' }, retained] }] } : conflict === 'ownership' ? { items: [{ ...item, targets: [{ ...entry, installedBy: ['suite:other'] }, retained] }] } : conflict === 'foreign-source' diff --git a/cli/test/unit/integrations/native-runtime.test.ts b/cli/test/unit/integrations/native-runtime.test.ts index 863eb4104..f1d3b3a7e 100644 --- a/cli/test/unit/integrations/native-runtime.test.ts +++ b/cli/test/unit/integrations/native-runtime.test.ts @@ -88,17 +88,17 @@ test('native removal replay survives restart and dispatches recovery without ins const options = { stateDirectory: join(root, 'operations'), inventoryHome: join(root, 'home') } const target = { scope: 'user' as const, root: join(root, 'skills') } await mkdir(target.root) - const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0', fingerprint: 'fp', - targets: [{ agent: 'dsh', rootDir: target.root, installDir: join(target.root, 'writer'), installedAt: '2026-10-14' }] } + const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', + targets: [{ version: '1.0.0', fingerprint: 'fp', agent: 'dsh', rootDir: target.root, installDir: join(target.root, 'writer'), installedAt: '2026-10-14' }] } const inventory = new InventoryStore(options.inventoryHome) await inventory.writeAtomic({ items: [item] }) const first = await openNativeRuntime(options) - const baseline = await first.inspectRemoval(item, target) + const baseline = await first.inspectRemoval({ ...item, version: item.targets[0]!.version }, target) if (baseline.status !== 'ready') throw new Error('Expected stale record') const request: NativeOperationRequest = { hostInstanceId: first.hostInstanceId, ownerId: 'session', requestKey: randomUUID(), mode: 'new', parameters: { planId: 'confirmed-plan' } } const accepted = (await first.acceptRemoval(request, async () => ({ kind: 'remove-stale-record', - namespace: item.namespace, slug: item.slug, version: item.version, targetLabel: 'User', targetPaths: [baseline.target.path], + namespace: item.namespace, slug: item.slug, version: item.targets[0]!.version, targetLabel: 'User', targetPaths: [baseline.target.path], context: { sourceIdentity: item.registry, sourceLabel: 'Registry', sourceRevision: 'revision', hostLabel: 'Host', initiatedFrom: 'plaza' } })))! const next = await openNativeRuntime(options) expect(await next.acceptRemoval(request, async () => { throw new Error('Expired plan must not run') })).toEqual(accepted) diff --git a/cli/test/unit/integrations/native-upgrade.test.ts b/cli/test/unit/integrations/native-upgrade.test.ts index fb326cb65..aee2a7aa5 100644 --- a/cli/test/unit/integrations/native-upgrade.test.ts +++ b/cli/test/unit/integrations/native-upgrade.test.ts @@ -56,7 +56,7 @@ test('native upgrade replaces the entire confirmed target set and persists its o expect(await f.runtime.executeUpgrade(f.request)).toMatchObject({ outcome: 'committed' }) for (const target of f.roots) expect(await readFile(join(target.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 2.0.0') expect(await f.runtime.readSkillExamples(join(f.roots[0]!.root, 'writer'))).toMatchObject({ state: 'ready', items: { en: [{ title: '2.0.0' }] } }) - expect((await f.inventory.read()).items[0]!.version).toBe('2.0.0') + expect((await f.inventory.read()).items[0]!.targets[0]!.version).toBe('2.0.0') expect((await f.runtime.getOperation(f.request.operationId, 'session'))?.files).toBe('upgraded') expect(await f.runtime.listPending()).toEqual([]) const replay = await f.runtime.acceptUpgrade(f.acceptance, async () => { throw new Error('expired plan must not be resolved') }) @@ -72,7 +72,7 @@ test('native upgrade rejects edits made after confirmation without downloading', await writeFile(join(f.roots[0]!.root, 'writer/SKILL.md'), 'user edit') await expect(f.runtime.executeUpgrade(f.request)).rejects.toThrow('baseline changed') expect(f.downloads()).toBe(1) - expect((await f.inventory.read()).items[0]!.version).toBe('1.0.0') + expect((await f.inventory.read()).items[0]!.targets[0]!.version).toBe('1.0.0') expect((await f.runtime.getOperation(f.request.operationId, 'session'))?.phase).toBe('failed') } finally { await f.close() } }) @@ -85,7 +85,7 @@ test('native upgrade rechecks all targets after staging before replacing any tar } })).rejects.toThrow('baseline changed') expect(await readFile(join(f.roots[0]!.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 1.0.0') expect(await readFile(join(f.roots[1]!.root, 'writer/SKILL.md'), 'utf8')).toBe('concurrent edit') - expect((await f.inventory.read()).items[0]!.version).toBe('1.0.0') + expect((await f.inventory.read()).items[0]!.targets[0]!.version).toBe('1.0.0') } finally { await f.close() } }) @@ -98,7 +98,7 @@ test('failure after a target replacement restores both old targets and can be re } })).rejects.toThrow('injected second-target denial') expect(replacements).toBe(3) for (const target of f.roots) expect(await readFile(join(target.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 1.0.0') - expect((await f.inventory.read()).items[0]!.version).toBe('1.0.0') + expect((await f.inventory.read()).items[0]!.targets[0]!.version).toBe('1.0.0') const result = await f.runtime.recover(f.request.operationId, 'session', f.request.baseline.targets.map(target => target.path), async () => {}) expect(result.outcome).toBe('restored') @@ -108,7 +108,7 @@ test('failure after a target replacement restores both old targets and can be re for (const stage of ['before-download', 'after-staging'] as const) { test.each(['target-added', 'target-removed', 'ownership', 'version', 'fingerprint', 'metadata'] as const)( - `upgrade rejects %s drift ${stage} without overwriting either target`, async drift => { + `upgrade checks selected-target %s drift ${stage}`, async drift => { const f = await fixture() try { let changed = false @@ -122,8 +122,8 @@ for (const stage of ['before-download', 'after-staging'] as const) { installDir: join(f.root, 'foreign/writer') }) if (drift === 'target-removed') item.targets.pop() if (drift === 'ownership') item.targets[0]!.installedBy = ['direct', 'other-owner'] - if (drift === 'version') item.version = '1.0.1' - if (drift === 'fingerprint') item.fingerprint = 'sha256:' + 'f'.repeat(64) + if (drift === 'version') item.targets[0]!.version = '1.0.1' + if (drift === 'fingerprint') item.targets[0]!.fingerprint = 'sha256:' + 'f'.repeat(64) if (drift === 'metadata') { const path = join(f.roots[1]!.root, 'writer/.skillhub/metadata.json') const metadata = JSON.parse(await readFile(path, 'utf8')) @@ -133,9 +133,17 @@ for (const stage of ['before-download', 'after-staging'] as const) { expectedInventory = await f.inventory.read() } if (stage === 'before-download') await mutate() - await expect(f.runtime.executeUpgrade({ ...f.request, + const operation = f.runtime.executeUpgrade({ ...f.request, validateStaged: stage === 'after-staging' ? mutate : async () => {}, - })).rejects.toThrow('baseline changed') + }) + if (drift === 'target-added') { + expect(await operation).toMatchObject({ outcome: 'committed' }) + const targets = (await f.inventory.read()).items[0]!.targets + expect(targets.find(target => target.rootDir === join(f.root, 'foreign'))).toEqual(expectedInventory.items[0]!.targets.find(target => target.rootDir === join(f.root, 'foreign'))) + for (const target of f.roots) expect(await readFile(join(target.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 2.0.0') + return + } + await expect(operation).rejects.toThrow('baseline changed') expect(changed).toBe(true) expect(f.downloads()).toBe(stage === 'before-download' ? 1 : 2) expect(await f.inventory.read()).toEqual(expectedInventory) diff --git a/cli/test/unit/integrations/operation-projection.test.ts b/cli/test/unit/integrations/operation-projection.test.ts index 792b40946..3417dc519 100644 --- a/cli/test/unit/integrations/operation-projection.test.ts +++ b/cli/test/unit/integrations/operation-projection.test.ts @@ -12,9 +12,9 @@ function record(): OperationRecord { status: { phase: 'completed', files: 'upgraded', discovery: 'unconfirmed', recovery: 'not-needed', warnings: ['Backup cleanup requires verification.', 'private exception /secret/path'] } }, transaction: { moves: [], cleanup: [], inventoryCommit: { committed: true, - before: { items: [{ registry: 'https://other.example', namespace: 'team', slug: 'writer', version: '9.0.0', targets: [] }, - { registry: 'https://registry.example', namespace: 'team', slug: 'writer', version: '1.0.0', targets: ['/private/user/writer', '/private/project/writer'].map(installDir => - ({ agent: 'dsh', rootDir: installDir.slice(0, -7), installDir, installedAt: '2026-10-15T00:00:00Z' })) }] }, + before: { items: [{ registry: 'https://other.example', namespace: 'team', slug: 'writer', targets: [] }, + { registry: 'https://registry.example', namespace: 'team', slug: 'writer', targets: ['/private/user/writer', '/private/project/writer'].map(installDir => + ({ version: '1.0.0', agent: 'dsh', rootDir: installDir.slice(0, -7), installDir, installedAt: '2026-10-15T00:00:00Z' })) }] }, after: { items: [] } } } } } diff --git a/cli/test/unit/integrations/operation-store.test.ts b/cli/test/unit/integrations/operation-store.test.ts index 5a518cf42..c1573e0b9 100644 --- a/cli/test/unit/integrations/operation-store.test.ts +++ b/cli/test/unit/integrations/operation-store.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test' import { randomUUID } from 'node:crypto' -import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { OperationStore, operationRequestDigest } from '../../../src/integrations/operation-store' @@ -91,3 +91,26 @@ test('startup scan blocks unresolved target intents, permits unrelated paths and await reopened.update(old.operationId, old.ownerId, old.revision, { ...old.operation.status, phase: 'failed', files: 'failed', recovery: 'restored' }) await reopened.assertTargetsAvailable([target], current) })) + + +test('pending journals block parent and child targets including aliases with missing descendants', () => fixture(async (store, root) => { + const target = join(root, 'skills/writer') + const pending = (await store.accept(request(store), async () => ({ ...accepted, targetPaths: [target] })))! + await store.update(pending.operationId, pending.ownerId, 0, { ...pending.operation.status, phase: 'unknown', recovery: 'unknown' }) + await mkdir(join(root, 'skills')) + const candidates = [join(root, 'skills'), join(target, 'nested/child')] + if (process.platform !== 'win32') { + await symlink(join(root, 'skills'), join(root, 'alias')) + candidates.push(join(root, 'alias/writer/nested/child')) + } + for (const path of candidates) { + const current = (await store.accept(request(store), async () => ({ ...accepted, targetPaths: [path] })))! + await store.update(current.operationId, current.ownerId, 0, { ...current.operation.status, phase: 'staging' }) + await expect(store.assertTargetsAvailable([path], current)).rejects.toMatchObject({ code: 'TARGET_RECOVERY_REQUIRED' }) + } + const sibling = join(root, 'skills/writer-other') + const current = (await store.accept(request(store), async () => ({ ...accepted, targetPaths: [sibling] })))! + await store.update(current.operationId, current.ownerId, 0, { ...current.operation.status, phase: 'staging' }) + await store.assertTargetsAvailable([sibling], current) + expect((await store.get(pending.operationId, pending.ownerId))!.operation.status.phase).toBe('unknown') +})) diff --git a/cli/test/unit/integrations/removal-baseline.test.ts b/cli/test/unit/integrations/removal-baseline.test.ts index d88cd29ec..6b47c6308 100644 --- a/cli/test/unit/integrations/removal-baseline.test.ts +++ b/cli/test/unit/integrations/removal-baseline.test.ts @@ -6,7 +6,7 @@ import { inspectRemovalBaseline } from '../../../src/integrations/removal-baseli import { snapshotSkillDirectory } from '../../../src/services/skill-fingerprint' import type { Inventory, InventoryItem } from '../../../src/stores/inventory-store' -test('removal inspects one exact target while retaining all other targets in its baseline', async () => { +test('removal inspects one exact target without binding unrelated targets in its baseline', async () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-removal-baseline-'))) try { const target = { scope: 'project' as const, root: join(root, 'project/skills') } @@ -15,8 +15,8 @@ test('removal inspects one exact target while retaining all other targets in its await writeFile(join(path, 'SKILL.md'), 'original') const snapshot = await snapshotSkillDirectory(path) const selected = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' } - const entry = { agent: 'dsh', rootDir: target.root, installDir: path, installedAt: '2026-10-14T00:00:00Z' } - const item: InventoryItem = { ...selected, fingerprint: snapshot.fingerprint, + const entry = { version: selected.version, fingerprint: snapshot.fingerprint, agent: 'dsh', rootDir: target.root, installDir: path, installedAt: '2026-10-14T00:00:00Z' } + const item: InventoryItem = { registry: selected.registry, namespace: selected.namespace, slug: selected.slug, targets: [entry, { ...entry, agent: 'other-agent', rootDir: join(root, 'other'), installDir: join(root, 'other/writer') }] } await writeFile(join(path, '.skillhub/metadata.json'), JSON.stringify({ ...selected, ...snapshot, agent: 'dsh', installedAt: entry.installedAt })) let inventory: Inventory = { items: [structuredClone(item)] } @@ -26,7 +26,7 @@ test('removal inspects one exact target while retaining all other targets in its expect(ready.status).toBe('ready') if (ready.status !== 'ready') throw new Error('Expected valid removal') expect(ready.kind).toBe('uninstall') - expect(ready.inventoryItem.targets).toHaveLength(2) + expect(ready.inventoryItem.targets).toHaveLength(1) expect(ready.target.path).toBe(path) expect(await readFile(join(path, 'SKILL.md'), 'utf8')).toBe('original') inventory.items[0]!.targets[0]!.installedBy = ['direct', 'suite:demo'] @@ -41,7 +41,7 @@ test('removal inspects one exact target while retaining all other targets in its inventory = { items: [structuredClone(item)] } let reads = 0 expect(await inspectRemovalBaseline({ read: async () => { - reads++; return { items: [reads === 1 ? structuredClone(item) : { ...structuredClone(item), version: '2.0.0' }] } + reads++; return { items: [reads === 1 ? structuredClone(item) : { ...structuredClone(item), targets: item.targets.map(t => ({ ...t, version: '2.0.0' })) }] } } }, selected, target)).toEqual({ status: 'blocked', reason: 'record-changed' }) reads = 0 expect(await inspectRemovalBaseline({ read: async () => { diff --git a/cli/test/unit/integrations/removal-inventory.test.ts b/cli/test/unit/integrations/removal-inventory.test.ts index 6477ff71c..e299a0505 100644 --- a/cli/test/unit/integrations/removal-inventory.test.ts +++ b/cli/test/unit/integrations/removal-inventory.test.ts @@ -11,14 +11,13 @@ test('removal commits exactly one target, journals before writing and preserves try { const store = new InventoryStore(home) const root = join(home, 'skills') - const target = { agent: 'dsh', rootDir: root, installDir: join(root, 'writer'), installedAt: '2026-10-14' } + const target = { version: '1.0.0', fingerprint: 'fingerprint', agent: 'dsh', rootDir: root, installDir: join(root, 'writer'), installedAt: '2026-10-14' } const retained = { ...target, agent: 'other', rootDir: join(home, 'other'), installDir: join(home, 'other/writer') } - const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0', - fingerprint: 'fingerprint', targets: [target, retained] } + const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', targets: [target, retained] } const other = { ...item, slug: 'reader', targets: [{ ...target, installDir: join(root, 'reader') }] } const initial = { items: [item, other] } const baseline: Extract = { status: 'ready', kind: 'remove-stale-record', - inventoryItem: item, target: { scope: 'user', root, path: target.installDir } } + inventoryItem: { ...item, targets: [target] }, target: { scope: 'user', root, path: target.installDir } } await store.writeAtomic(initial) let calls = 0 await commitRemovalInventory(store, baseline, async (before, after) => { @@ -36,15 +35,19 @@ test('removal commits exactly one target, journals before writing and preserves expect(await store.read()).toEqual(initial) const occupied = { items: [...initial.items, { ...item, registry: 'https://other.example', targets: [target] }] } - await store.writeAtomic(occupied) - await expect(commitRemovalInventory(store, baseline, async () => { throw new Error('must not journal') })).rejects.toThrow('ownership changed') - expect(await store.read()).toEqual(occupied) + await expect(store.writeAtomic(occupied)).rejects.toThrow('Ambiguous inventory target ownership') + expect(await store.read()).toEqual(initial) - const changed = { items: [{ ...item, version: '2.0.0' }, other] } + const changed = { items: [{ ...item, targets: item.targets.map(t => ({ ...t, version: '2.0.0' })) }, other] } await store.writeAtomic(changed) await expect(commitRemovalInventory(store, baseline, async () => {})).rejects.toThrow('inventory changed') expect(await store.read()).toEqual(changed) + const advancedSibling = { ...retained, version: '2.0.0', fingerprint: 'new-sibling' } + await store.writeAtomic({ items: [{ ...item, targets: [target, advancedSibling] }, other] }) + await commitRemovalInventory(store, baseline, async () => {}) + expect((await store.read()).items).toEqual([{ ...item, targets: [advancedSibling] }, other]) + const single = { ...item, targets: [target] } await store.writeAtomic({ items: [single, other] }) await commitRemovalInventory(store, { ...baseline, inventoryItem: single }, async () => {}) diff --git a/cli/test/unit/integrations/removal-journal.test.ts b/cli/test/unit/integrations/removal-journal.test.ts index 824e7db16..b7f03974c 100644 --- a/cli/test/unit/integrations/removal-journal.test.ts +++ b/cli/test/unit/integrations/removal-journal.test.ts @@ -17,8 +17,8 @@ test.each(['uninstall', 'remove-stale-record'] as const)('durable %s journal enf context: { sourceIdentity: 'https://registry.example', sourceLabel: 'SkillHub', sourceRevision: 'revision', hostLabel: 'Host', initiatedFrom: 'plaza' } })))! record = await store.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, phase: 'revalidating' }) - const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0', fingerprint: 'fp', - targets: [{ agent: 'dsh', rootDir: join(root, 'skills'), installDir: path, installedAt: '2026-10-14' }] } + const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', + targets: [{ version: '1.0.0', fingerprint: 'fp', agent: 'dsh', rootDir: join(root, 'skills'), installDir: path, installedAt: '2026-10-14' }] } const intent = { inventoryItem: item, target: { root: join(root, 'skills'), path, scope: 'project' as const }, quarantine: kind === 'uninstall' ? join(root, '.skillhub-trash', record.operationId) : null, files: kind === 'uninstall' ? { 'SKILL.md': 'hash' } : null } diff --git a/cli/test/unit/integrations/transaction-journal.test.ts b/cli/test/unit/integrations/transaction-journal.test.ts index 4375315db..799aa4c0c 100644 --- a/cli/test/unit/integrations/transaction-journal.test.ts +++ b/cli/test/unit/integrations/transaction-journal.test.ts @@ -63,7 +63,7 @@ test('real installation persists move intent before rename and both inventory sn const saved = (await reopened.get(operation.operationId, ownerId))! expect(saved.transaction?.moves[0]?.completed).toBe(true) expect(saved.transaction?.inventoryCommit?.committed).toBe(true) - expect(saved.transaction?.inventoryCommit?.after.items[0]?.fingerprint).toBe(fingerprint) + expect(saved.transaction?.inventoryCommit?.after.items[0]?.targets[0]!.fingerprint).toBe(fingerprint) expect(await readFile(join(store.root, `${operation.operationId}.json`), 'utf8')).not.toContain('# Journal fixture') expect(saved.operation.status.phase).toBe('committing') // The runner must still verify/finalize the operation. } finally { await rm(root, { recursive: true, force: true }) } diff --git a/cli/test/unit/integrations/upgrade-baseline.test.ts b/cli/test/unit/integrations/upgrade-baseline.test.ts index f168ae691..0d4e4b041 100644 --- a/cli/test/unit/integrations/upgrade-baseline.test.ts +++ b/cli/test/unit/integrations/upgrade-baseline.test.ts @@ -6,24 +6,24 @@ import { inspectUpgradeBaseline } from '../../../src/integrations/upgrade-baseli import { snapshotSkillDirectory } from '../../../src/services/skill-fingerprint' import type { Inventory, InventoryItem } from '../../../src/stores/inventory-store' -test('upgrade baseline includes both approved targets and blocks drift, sharing, aliases and foreign targets', async () => { +test('upgrade baseline includes both approved targets and blocks selected drift, sharing and aliases while preserving foreign targets', async () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-upgrade-baseline-'))) try { const roots = [{ scope: 'user' as const, root: join(root, 'user') }, { scope: 'project' as const, root: join(root, 'project') }] - const item: InventoryItem = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0', - targets: roots.map(entry => ({ agent: 'dsh', rootDir: entry.root, installDir: join(entry.root, 'writer'), installedAt: '2026-10-14T00:00:00Z' })) } + const item: InventoryItem = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', + targets: roots.map(entry => ({ version: '1.0.0', agent: 'dsh', rootDir: entry.root, installDir: join(entry.root, 'writer'), installedAt: '2026-10-14T00:00:00Z' })) } for (const target of item.targets) { await mkdir(join(target.installDir, '.skillhub'), { recursive: true }) await writeFile(join(target.installDir, 'SKILL.md'), 'original') await writeFile(join(target.installDir, '.skillhub/examples.json'), '{invalid but fingerprinted}') const snapshot = await snapshotSkillDirectory(target.installDir) - item.fingerprint = snapshot.fingerprint - await writeFile(join(target.installDir, '.skillhub/metadata.json'), JSON.stringify({ ...item, ...snapshot, + target.fingerprint = snapshot.fingerprint + await writeFile(join(target.installDir, '.skillhub/metadata.json'), JSON.stringify({ ...item, version: target.version, ...snapshot, agent: 'dsh', installedAt: target.installedAt })) } let inventory: Inventory = { items: [structuredClone(item)] } const store = { read: async () => structuredClone(inventory) } - const check = () => inspectUpgradeBaseline(store, item, roots) + const check = () => inspectUpgradeBaseline(store, { ...item, version: item.targets[0]!.version }, roots) const ready = await check() expect(ready.status).toBe('ready') if (ready.status !== 'ready') throw new Error('expected ready') @@ -41,7 +41,7 @@ test('upgrade baseline includes both approved targets and blocks drift, sharing, expect(await check()).toEqual({ status: 'blocked', reason: 'target-outside-context' }) inventory = { items: [structuredClone(item)] } inventory.items[0]!.targets.push({ ...item.targets[0]!, rootDir: join(root, 'foreign'), installDir: join(root, 'foreign/writer') }) - expect(await check()).toEqual({ status: 'blocked', reason: 'target-outside-context' }) + expect((await check()).status).toBe('ready') inventory = { items: [structuredClone(item)] } inventory.items[0]!.targets[0]!.installedBy = ['direct', 'suite:demo'] expect(await check()).toEqual({ status: 'blocked', reason: 'shared-target' }) @@ -56,7 +56,7 @@ test('upgrade baseline includes both approved targets and blocks drift, sharing, await writeFile(join(item.targets[0]!.installDir, 'SKILL.md'), 'original') const metadataPath = join(item.targets[0]!.installDir, '.skillhub/metadata.json') const snapshot = await snapshotSkillDirectory(item.targets[0]!.installDir) - const metadata = { ...item, ...snapshot, agent: 'dsh', installedAt: item.targets[0]!.installedAt } + const metadata = { ...item, version: item.targets[0]!.version, ...snapshot, agent: 'dsh', installedAt: item.targets[0]!.installedAt } await writeFile(metadataPath, JSON.stringify({ ...metadata, files: undefined })) // Authored metadata files cannot be considered owned without a recorded file baseline. expect(await check()).toEqual({ status: 'blocked', reason: 'target-unavailable' }) @@ -64,8 +64,8 @@ test('upgrade baseline includes both approved targets and blocks drift, sharing, let reads = 0 expect(await inspectUpgradeBaseline({ read: async () => { reads++ - return { items: [reads === 1 ? structuredClone(item) : { ...structuredClone(item), version: '2.0.0' }] } - } }, item, roots)).toEqual({ status: 'blocked', reason: 'record-changed' }) + return { items: [reads === 1 ? structuredClone(item) : { ...structuredClone(item), targets: item.targets.map(t => ({ ...t, version: '2.0.0' })) }] } + } }, { ...item, version: item.targets[0]!.version }, roots)).toEqual({ status: 'blocked', reason: 'record-changed' }) await rm(item.targets[1]!.installDir, { recursive: true }) await symlink(item.targets[0]!.installDir, item.targets[1]!.installDir) expect(await check()).toEqual({ status: 'blocked', reason: 'target-alias' }) diff --git a/cli/test/unit/services/doctor-service.test.ts b/cli/test/unit/services/doctor-service.test.ts index 8f24fc891..d48b1d6ee 100644 --- a/cli/test/unit/services/doctor-service.test.ts +++ b/cli/test/unit/services/doctor-service.test.ts @@ -5,10 +5,9 @@ import { join } from 'node:path' import { runDoctor } from '../../../src/services/doctor-service' type InventoryFile = { - items: Array<{ + records: Array<{ slug: string - version: string - targets: unknown[] + targets: Array<{ version: string }> }> } @@ -53,7 +52,7 @@ describe('doctor-service', () => { expect(result.skipped[0]!.reason).toBe('no .skillhub directory') }) - test('detects version conflicts', async () => { + test('rebuilds distinct versions at different positions', async () => { const cwd = await mkdtemp(join(tmpdir(), 'doctor-test-')) const home = await mkdtemp(join(tmpdir(), 'doctor-home-')) @@ -67,10 +66,10 @@ describe('doctor-service', () => { }) const result = await runDoctor(cwd, home) - expect(result.itemsScanned).toBe(0) - expect(result.conflicts).toHaveLength(1) - expect(result.conflicts[0]!.versions).toContain('1.0.0') - expect(result.conflicts[0]!.versions).toContain('2.0.0') + expect(result.itemsScanned).toBe(1) + expect(result.conflicts).toHaveLength(0) + const disk = JSON.parse(await readFile(result.inventoryPath, 'utf8')) as InventoryFile + expect(disk.records[0]!.targets.map(target => target.version).sort()).toEqual(['1.0.0', '2.0.0']) }) test('skips symlinked skill directories', async () => { @@ -176,16 +175,16 @@ describe('doctor-service', () => { expect(result.conflicts).toHaveLength(0) const finalInventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as InventoryFile - expect(finalInventory.items).toHaveLength(2) + expect(finalInventory.records).toHaveLength(2) - const externalSkillItem = finalInventory.items.find(item => item.slug === 'external-skill')! + const externalSkillItem = finalInventory.records.find(item => item.slug === 'external-skill')! expect(externalSkillItem).toBeDefined() - expect(externalSkillItem.version).toBe('1.0.0') + expect(externalSkillItem.targets[0]!.version).toBe('1.0.0') expect(externalSkillItem.targets).toHaveLength(1) - const localSkillItem = finalInventory.items.find(item => item.slug === 'local-skill')! + const localSkillItem = finalInventory.records.find(item => item.slug === 'local-skill')! expect(localSkillItem).toBeDefined() - expect(localSkillItem.version).toBe('2.0.0') + expect(localSkillItem.targets[0]!.version).toBe('2.0.0') expect(localSkillItem.targets).toHaveLength(1) }) @@ -236,12 +235,12 @@ describe('doctor-service', () => { // Verify the inventory file has the updated version const finalInventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) - expect(finalInventory.items).toHaveLength(1) - expect(finalInventory.items[0].version).toBe('2.0.0') - expect(finalInventory.items[0].targets).toHaveLength(1) + expect(finalInventory.records).toHaveLength(1) + expect(finalInventory.records[0].targets[0].version).toBe('2.0.0') + expect(finalInventory.records[0].targets).toHaveLength(1) }) - test('conflict groups do not delete unrelated old items', async () => { + test('split version groups do not delete unrelated old items', async () => { const cwd = await mkdtemp(join(tmpdir(), 'doctor-test-')) const home = await mkdtemp(join(tmpdir(), 'doctor-home-')) @@ -287,16 +286,14 @@ describe('doctor-service', () => { const result = await runDoctor(cwd, home) - // Should detect conflict - expect(result.conflicts).toHaveLength(1) - expect(result.conflicts[0]!.versions).toContain('1.0.0') - expect(result.conflicts[0]!.versions).toContain('2.0.0') + expect(result.conflicts).toHaveLength(0) + expect(result.targetsScanned).toBe(2) // Final inventory should still contain image-resizer const finalInventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as InventoryFile - const imageResizerItem = finalInventory.items.find(item => item.slug === 'image-resizer')! + const imageResizerItem = finalInventory.records.find(item => item.slug === 'image-resizer')! expect(imageResizerItem).toBeDefined() - expect(imageResizerItem.version).toBe('3.0.0') + expect(imageResizerItem.targets[0]!.version).toBe('3.0.0') expect(imageResizerItem.targets).toHaveLength(1) }) }) diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 543a26075..ce959a9ee 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { zipSync } from 'fflate' import type { InstallOptions } from '../../../src/services/install-service' import type { Inventory } from '../../../src/stores/inventory-store' -import { InventoryStore } from '../../../src/stores/inventory-store' +import { InventoryStore, parseInventory } from '../../../src/stores/inventory-store' import { installSkill } from '../../../src/services/install-service' import { planSkillUpgrades } from '../../../src/services/upgrade-service' import { removeLocalSkill } from '../../../src/services/remove-service' @@ -247,12 +247,12 @@ describe('installSkill', () => { beforeInventoryCommit: async commit => { events.push('before:commit') expect(commit.before).toEqual(previous) - expect(commit.after.items[0]?.fingerprint).toBe(skillFingerprint({ 'SKILL.md': '# New' })) + expect(commit.after.items[0]?.targets[0]!.fingerprint).toBe(skillFingerprint({ 'SKILL.md': '# New' })) commit.after.items.length = 0 // Observers cannot mutate the actual pending inventory. }, afterInventoryCommit: async () => { events.push('after:commit') - expect((await new InventoryStore(home).read()).items[0]?.fingerprint).toBe(skillFingerprint({ 'SKILL.md': '# New' })) + expect((await new InventoryStore(home).read()).items[0]?.targets[0]!.fingerprint).toBe(skillFingerprint({ 'SKILL.md': '# New' })) throw new Error('journal completion failed') }, cleanupResult: async cleanup => { @@ -294,14 +294,14 @@ describe('installSkill', () => { expect(result.transactionWarnings?.[0]).toMatchObject({ stage: 'backupCleanup', path: backup }) expect(await exists(backup)).toBe(true) expect(await readFile(join(rootDir, 'demo/SKILL.md'), 'utf8')).toBe('# New') - expect((await new InventoryStore(home).read()).items[0]?.fingerprint).toBe(skillFingerprint({ 'SKILL.md': '# New' })) + expect((await new InventoryStore(home).read()).items[0]?.targets[0]!.fingerprint).toBe(skillFingerprint({ 'SKILL.md': '# New' })) } finally { if (backup) await chmod(backup, 0o700).catch(() => {}) await rm(root, { recursive: true, force: true }) } }) - test.each(['version', 'fingerprint', 'ownership', 'targets', 'source'] as const)('inventory CAS rejects %s drift introduced after moving files', async drift => { + test.each(['version', 'fingerprint', 'ownership', 'targets', 'source'] as const)('inventory CAS scopes %s drift introduced after moving files', async drift => { const root = await mkdtemp(join(tmpdir(), 'skillhub-cas-drift-')) const rootDir = join(root, 'skills') const home = join(root, 'home') @@ -313,25 +313,34 @@ describe('installSkill', () => { globalThis.fetch = installFetch({ 'SKILL.md': '# Old' }) await installSkill(options) globalThis.fetch = installFetch({ 'SKILL.md': drift === 'targets' ? '# Old' : '# New' }) - await expect(installSkill({ ...options, force: true, transactionObserver: { + const operation = installSkill({ ...options, force: true, transactionObserver: { afterMove: async move => { if (move.phase !== 'install') return await store.mutateAtomic(inventory => { const item = inventory.items[0]! - if (drift === 'version') item.version = '9.0.0' - if (drift === 'fingerprint') item.fingerprint = 'changed' + if (drift === 'version') item.targets[0]!.version = '9.0.0' + if (drift === 'fingerprint') item.targets[0]!.fingerprint = 'changed' if (drift === 'ownership') item.targets[0]!.installedBy = ['suite:other'] if (drift === 'targets') item.targets.push({ ...item.targets[0]!, rootDir: join(root, 'other'), installDir: join(root, 'other/demo') }) if (drift === 'source') item.namespace = 'another' }) }, beforeInventoryCommit: async () => { loggedCommit = true } - } })).rejects.toThrow('installed skill records changed before commit') + } }) + if (drift === 'targets') { + await operation + expect(loggedCommit).toBe(true) + expect((await store.read()).items[0]!.targets).toHaveLength(2) + expect((await store.read()).items[0]!.targets.find(target => target.rootDir === join(root, 'other'))).toBeDefined() + expect(await readFile(join(rootDir, 'demo/SKILL.md'), 'utf8')).toBe('# Old') + return + } + await expect(operation).rejects.toThrow('installed skill records changed before commit') expect(loggedCommit).toBe(false) const changed = (await store.read()).items[0]! - expect(drift === 'version' ? changed.version : drift === 'fingerprint' ? changed.fingerprint - : drift === 'ownership' ? changed.targets[0]!.installedBy?.[0] : drift === 'targets' ? changed.targets.length : changed.namespace) - .toBe(drift === 'version' ? '9.0.0' : drift === 'fingerprint' ? 'changed' : drift === 'ownership' ? 'suite:other' : drift === 'targets' ? 2 : 'another') + expect(drift === 'version' ? changed.targets[0]!.version : drift === 'fingerprint' ? changed.targets[0]!.fingerprint + : drift === 'ownership' ? changed.targets[0]!.installedBy?.[0] : changed.namespace) + .toBe(drift === 'version' ? '9.0.0' : drift === 'fingerprint' ? 'changed' : drift === 'ownership' ? 'suite:other' : 'another') expect(await readFile(join(rootDir, 'demo/SKILL.md'), 'utf8')).toBe('# Old') } finally { await rm(root, { recursive: true, force: true }) } }) @@ -347,7 +356,7 @@ describe('installSkill', () => { globalThis.fetch = installFetch({ 'SKILL.md': '# Old' }) await installSkill(options) const plan = (await store.read()).items[0]! - await store.mutateAtomic(inventory => { inventory.items[0]!.version = '2.0.0' }) + await store.mutateAtomic(inventory => { inventory.items[0]!.targets[0]!.version = '2.0.0' }) globalThis.fetch = (() => { throw new Error('must not download') }) as unknown as typeof fetch await expect(installSkill({ ...options, force: true, expectedInventoryItem: plan })).rejects.toThrow('installed skill records changed') const current = (await store.read()).items[0]! @@ -369,7 +378,7 @@ describe('installSkill', () => { class LostResponseStore extends InventoryStore { protected override async writeUnderLock(inventory: Inventory): Promise { await super.writeUnderLock(inventory) - if (outcome === 'unknown') await this.write({ items: [{ ...inventory.items[0]!, version: 'unverifiable' }] }) + if (outcome === 'unknown') await super.writeUnderLock({ items: [{ ...inventory.items[0]!, targets: inventory.items[0]!.targets.map(target => ({ ...target, version: 'unverifiable' })) }] }) throw new Error('response lost after rename') } } @@ -392,7 +401,7 @@ describe('installSkill', () => { const backups = (await readdir(rootDir)).filter(name => name.includes('.skillhub-backup-')) expect(backups).toHaveLength(1) expect(await readFile(join(rootDir, backups[0]!, 'SKILL.md'), 'utf8')).toBe('# Old') - expect((await new InventoryStore(home).read()).items[0]?.version).toBe('unverifiable') + expect((await new InventoryStore(home).read()).items[0]?.targets[0]!.version).toBe('unverifiable') } expect(await readFile(join(rootDir, 'demo/SKILL.md'), 'utf8')).toBe('# New') } finally { await rm(root, { recursive: true, force: true }) } @@ -478,7 +487,9 @@ describe('installSkill', () => { }) expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Existing') - expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBefore) + expect(await new InventoryStore(home).read()).toEqual(parseInventory(JSON.parse(inventoryBefore))) + const backup = (await readdir(join(home, '.skillhub'))).find(name => name.startsWith('inventory.v1.'))! + expect(await readFile(join(home, '.skillhub', backup), 'utf8')).toBe(inventoryBefore) expect((await readdir(rootDir)).some(name => name.includes('.install-'))).toBe(false) }) @@ -553,8 +564,8 @@ describe('installSkill', () => { }) const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) - expect(inventory.items).toHaveLength(1) - expect(inventory.items[0].targets).toEqual([ + expect(inventory.records).toHaveLength(1) + expect(inventory.records[0].targets).toEqual([ expect.objectContaining({ rootDir: aliasRoot, installDir: aliasSkillDir }) ]) @@ -574,7 +585,7 @@ describe('installSkill', () => { home }) expect(await exists(realSkillDir)).toBe(false) - expect((JSON.parse(await readFile(inventoryPath, 'utf-8'))).items).toEqual([]) + expect((JSON.parse(await readFile(inventoryPath, 'utf-8'))).records).toEqual([]) }) test('force replaces the old skill directory instead of overlaying files', async () => { @@ -618,7 +629,7 @@ describe('installSkill', () => { expect(result.warnings).toEqual(['target lock cleanup failed: simulated release failure']) expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Committed') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')) - expect(inventory.items[0]).toMatchObject({ + expect(inventory.records[0].targets[0]).toMatchObject({ version: '1.0.0', fingerprint: skillFingerprint({ 'SKILL.md': '# Committed' }) }) @@ -659,10 +670,10 @@ describe('installSkill', () => { })).rejects.toThrow('source conflict') const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) - expect(inventory.items).toHaveLength(1) - expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo' }) - expect(inventory.items[0].targets).toHaveLength(1) - expect(inventory.items[0].targets[0].installDir).toBe(skillDir) + expect(inventory.records).toHaveLength(1) + expect(inventory.records[0]).toMatchObject({ namespace: 'global', slug: 'demo' }) + expect(inventory.records[0].targets).toHaveLength(1) + expect(inventory.records[0].targets[0]!.installDir).toBe(skillDir) expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Global Demo') }) @@ -850,8 +861,8 @@ describe('installSkill', () => { expect(await readFile(join(firstSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old A') expect(await readFile(join(secondSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old B') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')) - expect(inventory.items[0]).toMatchObject({ version: '0.1.0' }) - expect(inventory.items[0].targets).toHaveLength(2) + expect(inventory.records[0].targets[0]).toMatchObject({ version: '0.1.0' }) + expect(inventory.records[0].targets).toHaveLength(2) for (const rootDir of [firstRoot, secondRoot]) { const entries = await readdir(rootDir) expect(entries.some(name => name.includes('skillhub-backup'))).toBe(false) @@ -922,9 +933,10 @@ describe('installSkill', () => { expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old') const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) - expect(inventory.items).toHaveLength(1) - expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' }) - expect(inventory.items[0].targets[0].installDir).toBe(skillDir) + expect(inventory.records).toHaveLength(1) + expect(inventory.records[0]).toMatchObject({ namespace: 'global', slug: 'demo' }) + expect(inventory.records[0].targets[0].version).toBe('0.1.0') + expect(inventory.records[0].targets[0]!.installDir).toBe(skillDir) }) test('force restores the old installation when inventory persistence fails', async () => { diff --git a/cli/test/unit/services/remove-service.test.ts b/cli/test/unit/services/remove-service.test.ts index e5e105714..5eae2c3a3 100644 --- a/cli/test/unit/services/remove-service.test.ts +++ b/cli/test/unit/services/remove-service.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, symlink } from 'node:fs/promises' +import { access, mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, test } from 'bun:test' @@ -31,15 +31,13 @@ describe('removeLocalSkill', () => { registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'demo', - version: '1.0.0', - targets: [{ agent: 'codex', rootDir: join(root, 'codex'), installDir: globalDir, installedAt: '2026-04-20T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'codex', rootDir: join(root, 'codex'), installDir: globalDir, installedAt: '2026-04-20T00:00:00Z' }] }, { registry: 'https://skill.xfyun.cn', namespace: 'team', slug: 'demo', - version: '1.0.0', - targets: [{ agent: 'claude-code', rootDir: join(root, 'claude'), installDir: teamDir, installedAt: '2026-04-20T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'claude-code', rootDir: join(root, 'claude'), installDir: teamDir, installedAt: '2026-04-20T00:00:00Z' }] } ] }) @@ -67,15 +65,13 @@ describe('removeLocalSkill', () => { registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'demo', - version: '1.0.0', - targets: [{ agent: 'codex', rootDir: join(root, 'codex'), installDir: globalDir, installedAt: '2026-04-20T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'codex', rootDir: join(root, 'codex'), installDir: globalDir, installedAt: '2026-04-20T00:00:00Z' }] }, { registry: 'https://skill.xfyun.cn', namespace: 'team', slug: 'demo', - version: '1.0.0', - targets: [{ agent: 'claude-code', rootDir: join(root, 'claude'), installDir: teamDir, installedAt: '2026-04-20T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'claude-code', rootDir: join(root, 'claude'), installDir: teamDir, installedAt: '2026-04-20T00:00:00Z' }] } ] }) @@ -104,8 +100,7 @@ describe('removeLocalSkill', () => { registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'demo', - version: '1.0.0', - targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] }] }) @@ -142,8 +137,7 @@ describe('removeLocalSkill', () => { registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'demo', - version: '1.0.0', - targets: [{ agent: 'codex', rootDir: aliasRoot, installDir: aliasSkillDir, installedAt: '2026-09-01T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'codex', rootDir: aliasRoot, installDir: aliasSkillDir, installedAt: '2026-09-01T00:00:00Z' }] }] }) @@ -172,8 +166,7 @@ describe('removeLocalSkill', () => { registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'demo', - version: '1.0.0', - targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] }] }) @@ -189,20 +182,20 @@ describe('removeLocalSkill', () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-traversal-')) const store = new InventoryStore(home) - await store.write({ - items: [ + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(store.path, JSON.stringify({ + schemaVersion: 2, records: [ { registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'evil', - version: '1.0.0', - targets: [{ agent: 'codex', rootDir: '/safe/root', installDir: '/etc/passwd', installedAt: '2026-04-20T00:00:00Z' }] + targets: [{ version: '1.0.0', agent: 'codex', rootDir: '/safe/root', installDir: '/etc/passwd', installedAt: '2026-04-20T00:00:00Z' }] } ] - }) + })) await expect( removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'evil', home }) - ).rejects.toThrow('unsafe remove path') + ).rejects.toThrow('Inventory target is outside its root') }) }) diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts index c42b43e0c..dc13abea7 100644 --- a/cli/test/unit/services/suite-service.test.ts +++ b/cli/test/unit/services/suite-service.test.ts @@ -144,10 +144,10 @@ describe('Suite local lifecycle', () => { expect(result.installed).toHaveLength(2) expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + const inventory = await new InventoryStore(home).read() expect(inventory.suites).toEqual([expect.objectContaining({ slug: 'starter-pack', version: '1.0.0' })]) expect(inventory.items).toHaveLength(2) - expect(inventory.items[0].installedBy).toEqual(['suite:@global/starter-pack@1.0.0']) + expect(inventory.items[0]!.targets[0]!.installedBy).toEqual(['suite:@global/starter-pack@1.0.0']) const checked = await checkSuite({ registry, @@ -568,8 +568,8 @@ describe('Suite local lifecycle', () => { const retainedBackup = (await readdir(rootDir)).find(name => name.startsWith('alpha.skillhub-suite-backup-')) expect(retainedBackup).toBeDefined() expect(await readFile(join(rootDir, retainedBackup!, 'SKILL.md'), 'utf8')).toBe('# Old Alpha') - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) - expect(inventory.items[0]).toMatchObject({ slug: 'alpha', version: '0.9.0', installedBy: ['direct'] }) + const inventory = await new InventoryStore(home).read() + expect(inventory.items[0]).toMatchObject({ slug: 'alpha', targets: [expect.objectContaining({ version: '0.9.0', installedBy: ['direct'] })] }) }) test('installs one exact snapshot across multiple Agent targets', async () => { @@ -594,8 +594,8 @@ describe('Suite local lifecycle', () => { expect(result.installed).toHaveLength(4) expect(await readFile(join(codexRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') expect(await readFile(join(claudeRoot, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) - expect(inventory.suites[0].members[0].installDirs).toHaveLength(2) + const inventory = await new InventoryStore(home).read() + expect(inventory.suites![0]!.members[0]!.installDirs).toHaveLength(2) }) test('rejects member coordinates that collide in the local directory layout', async () => { @@ -675,8 +675,8 @@ describe('Suite local lifecycle', () => { expect(removed.preserved).toEqual([{ dir: join(rootDir, 'alpha'), reason: 'shared' }]) expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) - expect(inventory.items[0].installedBy).toEqual(['suite:@global/editor-pack@1.0.0']) + const inventory = await new InventoryStore(home).read() + expect(inventory.items[0]!.targets[0]!.installedBy).toEqual(['suite:@global/editor-pack@1.0.0']) }) test('removing a Suite does not rewrite the same coordinate from another registry', async () => { @@ -716,7 +716,7 @@ describe('Suite local lifecycle', () => { expect.objectContaining({ registry: secondRegistry, slug: 'alpha', - installedBy: ['suite:@global/starter-pack@1.0.0'] + targets: [expect.objectContaining({ installedBy: ['suite:@global/starter-pack@1.0.0'] })] }) ]) expect(await readFile(join(secondRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') @@ -765,9 +765,9 @@ describe('Suite local lifecycle', () => { const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) expect(removed.preserved).toEqual([{ dir: skillDir, reason: 'shared' }]) expect(await exists(skillDir)).toBe(true) - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + const inventory = await new InventoryStore(home).read() expect(inventory.suites).toEqual([]) - expect(inventory.items[0].installedBy).toEqual(['direct']) + expect(inventory.items[0]!.targets[0]!.installedBy).toEqual(['direct']) }) test('requires force before replacing a locally modified same-version member', async () => { @@ -854,7 +854,7 @@ describe('Suite local lifecycle', () => { expect(removed.removed.sort()).toEqual([join(rootDir, 'alpha'), join(rootDir, 'beta')].sort()) expect(await exists(join(rootDir, 'alpha'))).toBe(false) expect(await exists(join(rootDir, 'beta'))).toBe(false) - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + const inventory = await new InventoryStore(home).read() expect(inventory).toMatchObject({ items: [], suites: [] }) }) @@ -905,8 +905,8 @@ describe('Suite local lifecycle', () => { expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2') expect(await exists(join(rootDir, 'beta'))).toBe(false) expect(await readFile(join(rootDir, 'gamma', 'SKILL.md'), 'utf8')).toBe('# Gamma') - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) - expect(inventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) + const inventory = await new InventoryStore(home).read() + expect(inventory.suites![0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) expect(inventory.items.map((item: { slug: string }) => item.slug).sort()).toEqual(['alpha', 'gamma']) }) @@ -946,8 +946,8 @@ describe('Suite local lifecycle', () => { })).rejects.toThrow('local changes') expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Locally modified Alpha') - const unchangedInventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) - expect(unchangedInventory.suites[0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) + const unchangedInventory = await new InventoryStore(home).read() + expect(unchangedInventory.suites![0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) await upgradeSuite({ registry, @@ -959,8 +959,8 @@ describe('Suite local lifecycle', () => { }) expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2') - const upgradedInventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) - expect(upgradedInventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) + const upgradedInventory = await new InventoryStore(home).read() + expect(upgradedInventory.suites![0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) }) test('does not reinstall a Suite removed after upgrade planning', async () => { @@ -997,7 +997,7 @@ describe('Suite local lifecycle', () => { await expect(upgrading).rejects.toThrow('installed Suite changed while waiting for target locks') expect(await readdir(rootDir)).toEqual([]) - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + const inventory = await new InventoryStore(home).read() expect(inventory).toMatchObject({ items: [], suites: [] }) }) @@ -1051,9 +1051,9 @@ describe('Suite local lifecycle', () => { await expect(upgrading).rejects.toThrow('installed Suite changed while waiting for target locks') expect(await readdir(originalRoot)).toEqual([]) expect(await readFile(join(replacementRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') - const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) - expect(inventory.suites[0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) - expect(inventory.suites[0].members.every((member: { installDirs: string[] }) => + const inventory = await new InventoryStore(home).read() + expect(inventory.suites![0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) + expect(inventory.suites![0]!.members.every((member: { installDirs: string[] }) => member.installDirs.every(dir => dir.startsWith(replacementRoot)))).toBe(true) }) @@ -1087,7 +1087,7 @@ describe('Suite local lifecycle', () => { const inventory = await new InventoryStore(home).read() expect(inventory.items).toEqual([expect.objectContaining({ slug: 'beta', - installedBy: ['direct'] + targets: [expect.objectContaining({ installedBy: ['direct'] })] })]) }) @@ -1133,6 +1133,6 @@ describe('Suite local lifecycle', () => { expect(await readFile(join(betaDir, 'SKILL.md'), 'utf8')).toBe('# Beta') const inventory = await new InventoryStore(home).read() - expect(inventory.items.find(item => item.slug === 'beta')).toMatchObject({ installedBy: ['direct'] }) + expect(inventory.items.find(item => item.slug === 'beta')).toMatchObject({ targets: [expect.objectContaining({ installedBy: ['direct'] })] }) }) }) diff --git a/cli/test/unit/stores/inventory-stale-lock.test.ts b/cli/test/unit/stores/inventory-stale-lock.test.ts index dd19a96a2..d15bf5b70 100644 --- a/cli/test/unit/stores/inventory-stale-lock.test.ts +++ b/cli/test/unit/stores/inventory-stale-lock.test.ts @@ -58,8 +58,8 @@ test('a delayed stale-lock observer cannot delete a replacement inventory owner' expect(await Promise.all(processes.map(child => child.exited))).toEqual([0, 0]) expect(secondObservedFresh).toBe(true) expect(secondEnteredEarly).toBe(false) - const inventory = JSON.parse(await readFile(join(home, '.skillhub/inventory.json'), 'utf8')) as { items: { slug: string }[] } - expect(inventory.items.map(item => item.slug).sort()).toEqual(['first', 'second']) + const inventory = JSON.parse(await readFile(join(home, '.skillhub/inventory.json'), 'utf8')) as { records: { slug: string }[] } + expect(inventory.records.map(item => item.slug).sort()).toEqual(['first', 'second']) expect(await exists(lockPath)).toBe(false) expect(await readdir(`${lockPath}.acquire`)).toEqual([]) } finally { await rm(home, { recursive: true, force: true }) } diff --git a/cli/test/unit/stores/inventory-store.test.ts b/cli/test/unit/stores/inventory-store.test.ts index b49a7bda4..7fe411020 100644 --- a/cli/test/unit/stores/inventory-store.test.ts +++ b/cli/test/unit/stores/inventory-store.test.ts @@ -25,7 +25,7 @@ describe('InventoryStore', () => { protected override async writeUnderLock(inventory: Inventory): Promise { if (mode === 'before') throw new Error('write failed before rename') await super.writeUnderLock(inventory) - if (mode === 'other') await this.write({ items: [{ ...inventory.items[0]!, version: 'unverifiable' }] }) + if (mode === 'other') await super.writeUnderLock({ items: [{ ...inventory.items[0]!, targets: inventory.items[0]!.targets.map(target => ({ ...target, version: 'unverifiable' })) }] }) if (mode === 'unreadable') await writeFile(this.path, '{invalid-json') throw new Error('write response lost') } @@ -36,7 +36,7 @@ describe('InventoryStore', () => { const operation = store.upsertTarget('https://registry.example', 'team', 'writer', '1.0.0', makeTarget('writer')) if (mode === 'after') { await operation - expect((await store.read()).items[0]?.version).toBe('1.0.0') + expect((await store.read()).items[0]?.targets[0]!.version).toBe('1.0.0') } else if (mode === 'before') { await expect(operation).rejects.toThrow('write failed before rename') expect((await store.read()).items).toEqual([]) @@ -92,11 +92,11 @@ describe('InventoryStore', () => { test('locked inspection does not persist callback changes and releases its lock after failure', async () => { const store = new InventoryStore(await makeTempHome()) await expect(store.withLockedSnapshot(async snapshot => { - snapshot.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'local', version: '1', targets: [] }) + snapshot.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'local', targets: [] }) throw new Error('Inspection interrupted') })).rejects.toThrow('Inspection interrupted') expect(await store.read()).toEqual({ items: [] }) - await store.mutateAtomic(snapshot => { snapshot.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'saved', version: '1', targets: [] }) }) + await store.mutateAtomic(snapshot => { snapshot.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'saved', targets: [] }) }) expect(await store.withLockedSnapshot(async snapshot => snapshot.items[0]?.slug)).toBe('saved') }) @@ -134,7 +134,7 @@ describe('InventoryStore', () => { expect(inventory.items).toHaveLength(1) }) - test('rejects a version change while an unselected target is retained', async () => { + test('allows a different version while preserving an unselected target', async () => { const home = await makeTempHome() const store = new InventoryStore(home) const retained = makeTarget('shared-a') @@ -148,18 +148,20 @@ describe('InventoryStore', () => { ) const replacement = makeTarget('shared-b') - await expect(store.replaceTargetsAtInstallDirs( + await store.replaceTargetsAtInstallDirs( 'https://skill.xfyun.cn', 'global', 'shared', '1.1.0', [replacement], 'fp-v2', - )).rejects.toThrow('partial-target install would create inconsistent versions') + ) const inventory = await store.read() expect(inventory.items).toHaveLength(1) - expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: 'fp-v1' }) - expect(inventory.items[0]?.targets).toEqual([{ ...retained, installedBy: ['direct'] }]) + expect(inventory.items[0]?.targets).toEqual([ + { ...retained, version: '1.0.0', fingerprint: 'fp-v1', installedBy: ['direct'] }, + { ...replacement, version: '1.1.0', fingerprint: 'fp-v2', installedBy: ['direct'] } + ]) }) }) diff --git a/cli/test/unit/stores/inventory-v2.test.ts b/cli/test/unit/stores/inventory-v2.test.ts new file mode 100644 index 000000000..49d3a8bde --- /dev/null +++ b/cli/test/unit/stores/inventory-v2.test.ts @@ -0,0 +1,290 @@ +import { planSkillUpgrades, executeSkillUpgradePlan } from '../../../src/services/upgrade-service' +import { removeLocalSkill } from '../../../src/services/remove-service' +import { expect, test } from 'bun:test' +import { createHash, randomUUID } from 'node:crypto' +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { zipSync } from 'fflate' +import { InventoryStore, type Inventory } from '../../../src/stores/inventory-store' +import { installSkill } from '../../../src/services/install-service' +import { openNativeRuntime } from '../../../src/integrations/native-runtime' +import { captureInstallInventory } from '../../../src/services/inventory-cas' + +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-v2-'))) + const store = new InventoryStore(join(root, 'home')) + const roots = ['user', 'project-a', 'project-b'].map((label, i) => ({ root: join(root, label), scope: i ? 'project' as const : 'user' as const })) + const releases = ['1.0.0', '2.0.0', '3.0.0'].map((version, i) => { + const content = `# Version ${version}` + const fingerprint = 'sha256:' + createHash('sha256').update(`SKILL.md:${createHash('sha256').update(content).digest('hex')}\n`).digest('hex') + return { version, versionId: i + 1, content, fingerprint } + }) + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { + const url = new URL(request.url) + const release = releases.find(item => item.version === url.searchParams.get('version')) ?? releases[2]! + if (url.pathname.endsWith('/resolve')) return Response.json({ data: { namespace: 'team', slug: 'writer', ...release, downloadUrl: `/download?version=${release.version}` } }) + return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(release.content) })).buffer) + } }) + const identity = { registry: server.url.origin, namespace: 'team', slug: 'writer' } + const install = (index: number, version: string) => installSkill({ ...identity, version, force: true, inventoryStore: store, + targets: [{ agent: 'dsh', rootDir: roots[index]!.root, scope: roots[index]!.scope, source: 'explicit' }] }) + const runtime = await openNativeRuntime({ stateDirectory: join(root, 'operations'), inventoryHome: join(root, 'home') }) + const prepare = async (index: number) => { + const baseline = await runtime.inspectUpgrade({ ...identity, version: '1.0.0' }, [roots[index]!]) + if (baseline.status !== 'ready') throw new Error(baseline.reason) + const candidate = releases[2]! + const operation = await runtime.acceptUpgrade({ hostInstanceId: runtime.hostInstanceId, ownerId: 'session', requestKey: randomUUID(), mode: 'new', parameters: { plan: randomUUID() } }, async () => ({ namespace: 'team', slug: 'writer', version: candidate.version, targetPaths: baseline.targets.map(target => target.path), targetLabel: roots[index]!.scope, + context: { sourceIdentity: identity.registry, sourceLabel: 'Fixture', sourceRevision: 'source', hostLabel: 'Host', initiatedFrom: 'plaza' } })) + return { ...identity, ...candidate, baseline, operationId: operation!.operationId, ownerId: 'session', authorize: async () => {}, validateStaged: async () => {} } + } + return { root, roots, store, install, runtime, prepare, identity, close: async () => { await server.stop(true); await rm(root, { recursive: true, force: true }) } } +} + +test('v1 read preserves bytes and migration expands ownership without inventing fingerprints', async () => { + const f = await fixture() + try { + const legacy = { items: [{ ...f.identity, version: '1.0.0', installedBy: ['suite:@team/set@1'], targets: f.roots.map(target => ({ agent: 'dsh', rootDir: target.root, installDir: join(target.root, 'writer'), installedAt: '2026-10-01', ...(target.scope === 'user' ? { installedBy: ['direct'] } : {}) })) }], suites: [] } + const bytes = JSON.stringify(legacy, null, 3) + await mkdir(dirname(f.store.path), { recursive: true }); await writeFile(f.store.path, bytes) + const read = await f.store.read() + expect(await readFile(f.store.path, 'utf8')).toBe(bytes) + expect(read.items[0]).not.toHaveProperty('version') + expect(read.items[0]!.targets.map(target => target.version)).toEqual(['1.0.0', '1.0.0', '1.0.0']) + expect(read.items[0]!.targets[1]!.installedBy).toEqual(['suite:@team/set@1']) + expect(read.items[0]!.targets[0]).not.toHaveProperty('fingerprint') + await f.store.ensureWritable(); await f.store.ensureWritable() + const backups = (await readdir(dirname(f.store.path))).filter(name => name.includes('.backup.json')) + expect(backups).toHaveLength(1) + expect(await readFile(join(dirname(f.store.path), backups[0]!), 'utf8')).toBe(bytes) + const disk = JSON.parse(await readFile(f.store.path, 'utf8')) + expect(disk.schemaVersion).toBe(2); expect(disk).not.toHaveProperty('items'); expect(disk.records[0]).not.toHaveProperty('version') + expect(await f.store.read()).toEqual(read) + } finally { await f.close() } +}) + +test.each(['before', 'after'] as const)('migration %s rename failure leaves one authority', async mode => { + const f = await fixture() + try { + await mkdir(dirname(f.store.path), { recursive: true }); await writeFile(f.store.path, JSON.stringify({ items: [] })) + class FailingStore extends InventoryStore { protected override async writeUnderLock(value: Inventory) { + if (mode === 'after') await super.writeUnderLock(value) + throw new Error('injected rename response') + } } + const store = new FailingStore(join(f.root, 'home')) + if (mode === 'before') await expect(store.ensureWritable()).rejects.toThrow('injected') + else await store.ensureWritable() + expect(await store.diskVersion()).toBe(mode === 'before' ? 1 : 2) + expect(await store.read()).toEqual({ items: [] }) + expect((await readdir(dirname(store.path))).filter(name => name.includes('.backup.json'))).toHaveLength(1) + } finally { await f.close() } +}) + +test('future schema blocks install before target filesystem mutation', async () => { + const f = await fixture() + try { + await mkdir(dirname(f.store.path), { recursive: true }); await writeFile(f.store.path, JSON.stringify({ schemaVersion: 99, records: [] })) + await expect(f.install(0, '1.0.0')).rejects.toThrow() + expect(await readdir(f.root)).not.toContain('user') + } finally { await f.close() } +}) + +test('selected native upgrade preserves modified and suite-owned siblings', async () => { + const f = await fixture() + try { + for (let i = 0; i < 3; i++) await f.install(i, '1.0.0') + await writeFile(join(f.roots[0]!.root, 'writer/SKILL.md'), 'user custom edit') + await f.store.mutateAtomic(value => { value.items[0]!.targets[2]!.installedBy = ['suite:@team/set@1'] }) + const before = await f.store.read() + const originalMetadata = await readFile(join(f.roots[0]!.root, 'writer/.skillhub/metadata.json'), 'utf8') + const request = await f.prepare(1) + expect(request.baseline.inventoryItem.targets).toHaveLength(1) + expect(await f.runtime.executeUpgrade(request)).toMatchObject({ outcome: 'committed' }) + const after = await f.store.read() + for (const index of [0, 2]) expect(after.items[0]!.targets.find(target => target.rootDir === f.roots[index]!.root)).toEqual(before.items[0]!.targets[index]) + expect(await readFile(join(f.roots[0]!.root, 'writer/SKILL.md'), 'utf8')).toBe('user custom edit') + expect(await readFile(join(f.roots[0]!.root, 'writer/.skillhub/metadata.json'), 'utf8')).toBe(originalMetadata) + expect(after.items[0]!.targets.find(target => target.rootDir === f.roots[1]!.root)?.version).toBe('3.0.0') + } finally { await f.close() } +}) + +test('independent prepared upgrades commit without losing siblings', async () => { + const f = await fixture() + try { + for (let i = 0; i < 3; i++) await f.install(i, '1.0.0') + const first = await f.prepare(0); const second = await f.prepare(1) + expect((await Promise.all([f.runtime.executeUpgrade(first), f.runtime.executeUpgrade(second)])).map(result => result.outcome)).toEqual(['committed', 'committed']) + const targets = (await f.store.read()).items[0]!.targets + expect(f.roots.map(root => targets.find(target => target.rootDir === root.root)?.version)).toEqual(['3.0.0', '3.0.0', '1.0.0']) + } finally { await f.close() } +}) + +test('selected CAS detects conflicting owners and ignores sibling release changes', async () => { + const f = await fixture() + try { + await f.install(0, '1.0.0'); await f.install(1, '1.0.0') + const before = await f.store.read(); const paths = new Set([join(f.roots[0]!.root, 'writer')]) + const baseline = await captureInstallInventory(before, f.identity, paths) + before.items[0]!.targets[1]!.version = '2.0.0' + expect(await captureInstallInventory(before, f.identity, paths)).toBe(baseline) + before.items.push({ ...f.identity, registry: 'https://other.example', targets: [before.items[0]!.targets[0]!] }) + expect(await captureInstallInventory(before, f.identity, paths)).not.toBe(baseline) + } finally { await f.close() } +}) + +test('v2 recovery preserves a sibling advanced after the failed selected update', async () => { + const f = await fixture() + try { + await f.install(0, '1.0.0'); await f.install(1, '1.0.0') + const request = await f.prepare(0) + let replacements = 0 + await expect(f.runtime.executeUpgrade({ ...request, authorize: async (_paths, phase) => { + if (phase === 'replace' && ++replacements === 2) throw new Error('injected replacement failure') + } })).rejects.toThrow('injected replacement failure') + await f.install(1, '2.0.0') + const sibling = (await f.store.read()).items[0]!.targets.find(target => target.rootDir === f.roots[1]!.root) + expect(await f.runtime.recover(request.operationId, 'session', request.baseline.targets.map(target => target.path), async () => {})).toMatchObject({ outcome: 'restored' }) + expect((await f.store.read()).items[0]!.targets.find(target => target.rootDir === f.roots[1]!.root)).toEqual(sibling) + expect(await readFile(join(f.roots[1]!.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 2.0.0') + expect(await readFile(join(f.roots[0]!.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 1.0.0') + } finally { await f.close() } +}) + +function legacySnapshot(inventory: Inventory) { + return { ...inventory, items: inventory.items.map(item => ({ ...item, version: item.targets[0]!.version, + ...(item.targets[0]!.fingerprint ? { fingerprint: item.targets[0]!.fingerprint } : {}), + targets: item.targets.map(({ version, fingerprint, ...target }) => { void version; void fingerprint; return target }) })) } +} + +test('pending v1 recovery refuses v2 inventory; on v1 it preserves original journal snapshots', async () => { + const f = await fixture() + try { + await f.install(0, '1.0.0') + const request = await f.prepare(0) + await f.runtime.executeUpgrade(request) + const operationPath = join(f.root, 'operations', `${request.operationId}.json`) + const record = JSON.parse(await readFile(operationPath, 'utf8')) + record.schemaVersion = 1 + record.operation.status.phase = 'committing' + record.operation.status.files = 'unknown' + record.transaction.preparation.before = legacySnapshot(record.transaction.preparation.before) + record.transaction.inventoryCommit.before = legacySnapshot(record.transaction.inventoryCommit.before) + record.transaction.inventoryCommit.after = legacySnapshot(record.transaction.inventoryCommit.after) + record.transaction.inventoryCommit.committed = false + await writeFile(operationPath, JSON.stringify(record)) + const args = [request.operationId, 'session', request.baseline.targets.map(target => target.path), async () => {}] as const + expect(await f.runtime.recover(...args)).toMatchObject({ outcome: 'unknown', reason: 'legacy-journal-needs-v1-inventory' }) + const blockedRequest = await f.prepare(0).catch(() => null) + expect(blockedRequest).toBeNull() // Current installed version is no longer the earlier 1.0.0. + await writeFile(f.store.path, JSON.stringify(legacySnapshot(await f.store.read()))) + expect(await f.runtime.recover(...args)).toMatchObject({ outcome: 'committed' }) + const recovered = JSON.parse(await readFile(operationPath, 'utf8')) + expect(recovered.schemaVersion).toBe(1) + expect(recovered.transaction.preparation.before).toEqual(record.transaction.preparation.before) + expect(recovered.transaction.inventoryCommit.before).toEqual(record.transaction.inventoryCommit.before) + expect(recovered.transaction.inventoryCommit.after).toEqual(record.transaction.inventoryCommit.after) + expect(await f.store.diskVersion()).toBe(1) + } finally { await f.close() } +}) + +test('migration refuses ambiguous paths and conflicting backup without replacing the old authority', async () => { + const f = await fixture() + try { + const target = { agent: 'dsh', rootDir: f.roots[0]!.root, installDir: join(f.roots[0]!.root, 'writer'), installedAt: 'now' } + const raw = JSON.stringify({ items: [{ ...f.identity, version: '1.0.0', targets: [target, { ...target }] }] }) + await mkdir(dirname(f.store.path), { recursive: true }); await writeFile(f.store.path, raw) + await expect(f.store.ensureWritable()).rejects.toThrow('Ambiguous') + expect(await readFile(f.store.path, 'utf8')).toBe(raw) + const valid = JSON.stringify({ items: [{ ...f.identity, version: '1.0.0', targets: [target] }] }) + await writeFile(f.store.path, valid) + const digest = createHash('sha256').update(valid).digest('hex') + await writeFile(join(dirname(f.store.path), `inventory.v1.${digest}.backup.json`), 'different bytes') + await expect(f.store.ensureWritable()).rejects.toThrow('backup mismatch') + expect(await readFile(f.store.path, 'utf8')).toBe(valid) + } finally { await f.close() } +}) + +test('selected suite claim blocks native upgrade even if its ownership metadata is inconsistent', async () => { + const f = await fixture() + try { + await f.install(0, '1.0.0'); await f.install(1, '1.0.0') + await f.store.mutateAtomic(value => { value.suites = [{ ...f.identity, slug: 'set', version: '1', fingerprint: 'suite', members: [{ namespace: 'team', slug: 'writer', version: '1.0.0', fingerprint: value.items[0]!.targets[0]!.fingerprint!, installDirs: [join(f.roots[0]!.root, 'writer')] }] }] }) + expect(await f.runtime.inspectUpgrade({ ...f.identity, version: '1.0.0' }, [f.roots[0]!])).toEqual({ status: 'blocked', reason: 'shared-target' }) + expect((await f.runtime.inspectUpgrade({ ...f.identity, version: '1.0.0' }, [f.roots[1]!])).status).toBe('ready') + } finally { await f.close() } +}) + +test('installed projection revision binds target timestamp and ownership without contaminating sibling revisions', async () => { + const f = await fixture() + try { + await f.install(0, '1.0.0'); await f.install(1, '1.0.0') + const before = await f.runtime.listInstalled(f.identity.registry, f.roots) + await f.store.mutateAtomic(value => { value.items[0]!.targets[0]!.installedAt = 'changed timestamp' }) + const after = await f.runtime.listInstalled(f.identity.registry, f.roots) + expect(before.items[0]!.recordRevision).toMatch(/^[a-f0-9]{64}$/) + expect(after.items[0]!.recordRevision).not.toBe(before.items[0]!.recordRevision) + expect(after.items[1]!.recordRevision).toBe(before.items[1]!.recordRevision) + } finally { await f.close() } +}) + +test('ancestor and descendant claims block writes but prefix siblings remain independent', async () => { + const f = await fixture() + try { + await f.install(0, '1.0.0') + const before = await f.store.read() + const selected = before.items[0]!.targets[0]! + const paths = new Set([selected.installDir]) + const baseline = await captureInstallInventory(before, f.identity, paths) + for (const installDir of [join(selected.installDir, 'nested'), selected.rootDir]) { + const overlapping = structuredClone(before) + overlapping.items.push({ ...f.identity, slug: 'other', targets: [{ ...selected, rootDir: f.root, installDir }] }) + expect(await captureInstallInventory(overlapping, f.identity, paths)).not.toBe(baseline) + await expect(f.store.writeAtomic(overlapping)).rejects.toThrow('Ambiguous inventory target ownership') + expect(await f.store.read()).toEqual(before) + // Simulate a pre-existing or externally corrupted claim; read-only native previews must fail closed too. + await writeFile(f.store.path, JSON.stringify({ schemaVersion: 2, records: overlapping.items })) + expect(await f.runtime.inspectRemoval({ ...f.identity, version: '1.0.0' }, f.roots[0]!)).toEqual({ status: 'blocked', reason: 'shared-target' }) + expect(await f.runtime.inspectUpgrade({ ...f.identity, version: '1.0.0' }, [f.roots[0]!])).toEqual({ status: 'blocked', reason: 'shared-target' }) + await writeFile(f.store.path, JSON.stringify({ schemaVersion: 2, records: before.items })) + expect(await readFile(join(selected.installDir, 'SKILL.md'), 'utf8')).toBe('# Version 1.0.0') + } + const sibling = structuredClone(before) + sibling.items.push({ ...f.identity, slug: 'other', targets: [{ ...selected, installDir: `${selected.installDir}-other` }] }) + expect(await captureInstallInventory(sibling, f.identity, paths)).toBe(baseline) + await f.store.writeAtomic(sibling) + expect((await f.store.read()).items).toHaveLength(2) + await expect(installSkill({ ...f.identity, slug: 'child', inventoryStore: f.store, force: true, + targets: [{ agent: 'dsh', rootDir: selected.installDir, scope: 'user', source: 'explicit' }] })).rejects.toThrow('overlaps another managed installation') + expect(await readFile(join(selected.installDir, 'SKILL.md'), 'utf8')).toBe('# Version 1.0.0') + expect((await f.store.read()).items).toHaveLength(2) + } finally { await f.close() } +}) + + +test.each(['suite-only', 'shared', 'suite-claim'] as const)('direct CLI mutation protects %s targets, including force, but allows an unrelated position', async mode => { + const f = await fixture() + try { + await f.install(0, '1.0.0'); await f.install(1, '1.0.0') + await f.store.mutateAtomic(inventory => { + const target = inventory.items[0]!.targets[0]! + if (mode === 'suite-only') target.installedBy = ['suite:@team/set@1'] + if (mode === 'shared') target.installedBy = ['direct', 'suite:@team/set@1'] + if (mode === 'suite-claim') inventory.suites = [{ ...f.identity, slug: 'set', version: '1.0.0', fingerprint: 'suite', + members: [{ namespace: 'team', slug: 'writer', version: target.version, fingerprint: target.fingerprint!, installDirs: [target.installDir] }] }] + }) + const before = await f.store.read() + await expect(f.install(0, '2.0.0')).rejects.toThrow('shared or Suite-owned') + const options = { coordinates: ['@team/writer'], registry: f.identity.registry, home: join(f.root, 'home'), force: true, tokenForRegistry: async () => undefined } + const blocked = await planSkillUpgrades({ ...options, dir: f.roots[0]!.root }) + expect(blocked.blocked).toBe(1) + expect(blocked.items[0]!.reason).toContain('shared or Suite-owned') + await expect(removeLocalSkill({ ...f.identity, home: join(f.root, 'home') })).rejects.toThrow('shared or Suite-owned') + expect(await f.store.read()).toEqual(before) + for (const root of f.roots.slice(0, 2)) expect(await readFile(join(root.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 1.0.0') + const allowed = await planSkillUpgrades({ ...options, dir: f.roots[1]!.root }) + expect(allowed.upgrades).toBe(1) + expect((await executeSkillUpgradePlan(allowed, options)).upgraded).toBe(1) + expect((await f.store.read()).items[0]!.targets.find(target => target.rootDir === f.roots[0]!.root)).toEqual(before.items[0]!.targets[0]) + expect(await readFile(join(f.roots[0]!.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 1.0.0') + } finally { await f.close() } +}) diff --git a/openspec/changes/add-dsh-native-plugin/acceptance.md b/openspec/changes/add-dsh-native-plugin/acceptance.md index fd53095bf..f38e976df 100644 --- a/openspec/changes/add-dsh-native-plugin/acceptance.md +++ b/openspec/changes/add-dsh-native-plugin/acceptance.md @@ -178,3 +178,38 @@ flowchart LR | USE-N05 | 双支持RC安装同一候选并实际浏览器操作 | 官方API与slot可用,核验精确原安装范围/版本、草稿附件保留,零消息发送和模型调用 | 本轮组合门禁已通过,结果见 verification.md 的“原生新对话与输入框案例交互”。USE-N01–N03、N05 的正常交互与 USE-N04 的重复点击、切会话、确认期间编辑,均在两版真实浏览器验证;创建失败、工作区/来源/语言失效、无案例和 USE-N06 的容量/清理分支由行为测试与独立评审覆盖。不是所有边界都逐一执行了浏览器操作,历史弹窗结果不替代本轮。 + +## 对话管理与按位置版本验收(任务 42) + +| 场景 | 必须观察的结果 | +| --- | --- | +| 新安装不指定范围 | 默认用户级;项目级在高级入口;明确项目请求不误装用户级 | +| 两位置 v1,项目升级 v2 | 用户级文件/版本/指纹/时间/归属不变;项目显示 v2 | +| 多份安装目标不明确 | 先选择,再确认;默认安装范围不用于猜测修改目标 | +| 已有项目级安装 | 查询和管理保留,不迁移或隐藏 | +| 取消与伪造确认 | 不下载/更新/卸载;模型 confirmed 标志不能授权 | +| 本地修改、共享目标、来源不符 | 操作阻断,保留原文件 | +| 切换来源/会话或旧引用 | 旧计划失效,不回退为最新版本或另一安装 | +| 并发更新与响应丢失 | 无库存丢失;重复请求对应同一操作;不覆盖其他位置 | +| 库存迁移与崩溃恢复 | 原始备份可验证;明确唯一权威;文件与目标记录一致 | +| CLI/sync/suite回归 | 正确读取各位置版本,不改变未选目标;归属保护不减弱 | +| 真实宿主与模型 | 双RC浏览器、原生确认与回退;真实模型证据独立记录 | + +Desktop 旧版、SaaS、npm 发布不计为本轮已验收;需要单独任务或授权。 + + +任务 42 的代码、行为测试及双 RC 浏览器门禁已通过;最终候选真实模型完成项目单位置更新与原操作查询。完整结果、历史候选取消场景和执行前冗余追问的体验限制见 verification.md“对话管理与按安装位置版本”。未授权的来源、未选位置、共享目标和模型伪造确认仍由 Host/核心硬校验保护;模型措辞不作为写入授权。 + + +### 42.8 分组与场景操作增量验收 + +以下是本轮需验证的准则,不沿用早期逐行 UI 的通过结果: + +- 同一来源与技能身份只展示一次;同名不同 namespace 不合并。同 scope 多目标保留且选中标签与确认一致。 +- 用户/项目版本随选择显示,按钮使用对应引用;在检查未完成时切位置,迟到结果不能更新另一目标。 +- 单份无 Tab,正常无刷新;低频操作在更多中,更新检查/确认/终态仅出现相关按钮。 +- 确认目标锁定,取消不写;变更仅影响选定位置,另一份文件与库存不变。 +- 旧引用仍拒绝;重新查询只读替换内容,不能自动执行旧意图。 +- 部分读取失败不视为未安装;本地修改、共享归属、同名覆盖及禁止用户调用保持原边界。 +- 双支持 RC 验证实际组件,深浅主题和窄屏无目标截断,菜单键盘操作和关闭后焦点可用。 +- 原型场景截图与正式宿主结果分开保存;本轮未调用模型时明确说明,不重写既有真实历史。 diff --git a/openspec/changes/add-dsh-native-plugin/conversation-design.md b/openspec/changes/add-dsh-native-plugin/conversation-design.md index b358c0ac8..dbe121c0d 100644 --- a/openspec/changes/add-dsh-native-plugin/conversation-design.md +++ b/openspec/changes/add-dsh-native-plugin/conversation-design.md @@ -125,13 +125,13 @@ flowchart TD | astron_skillhub_search | q 字符串 | 来源快照、查询、items(含 Host 签发的 selectionRef)、total/returned/limit、展示类型和协议版本 | listSkills;只读,Host 注入来源 | | astron_skillhub_install | scope 可选 user/project;selectionRef 或明确新输入的 namespace、slug(二选一) | accepted:operationId、调用/主机标识、技能身份/固定版本、来源/目标标签及接收时间;awaiting-confirmation:候选身份/精确版本及 UI 选择引用;not-confirmed:调用标识与错误代码。文件和发现终态不包含在工具回执中 | getSkillDetail + startInstall;Client 另经 getOperation 查询终态 | -工具名称与 schema 分别定义于插件的 search-tool.ts 和 install-tool.ts,注册时拒绝同名冲突;这些是 DSH Agent 工具,不是外部 HTTP API。scope 仅在用户明确指定当前用户或当前项目时传入;未指定时省略,工具返回 awaiting-confirmation,不默认写入当前用户。sourceRevision、targetToken、requestKey 由可信适配层生成,不能信任模型构造的 token 或历史卡片 HTML 属性。selectionRef 由 Host 签发并保存,绑定来源修订、会话、搜索条目坐标和项目上下文;搜索阶段可只有候选版本,详情打开后另签发绑定精确版本/fingerprint 的引用。引用不是模型可编辑的字段集合。基于搜索卡片、详情或“第二个”等历史选择安装必须提交 selectionRef;Host 保存搜索引用到最近一次成功展示所用详情快照的关联;有已展示快照时必须提升到该快照,没有已展示快照时由 Host 按搜索时精确版本核验并固定安装快照,两者均不得重新解析 latest。若同一搜索引用关联多个不同固定版本且无法确定用户所指,则阻断并重新选择。Host 校验归属、时效、来源、项目和已有固定版本,失败返回 CONTEXT_STALE,不回退到最新版本。只有用户在当前上下文明示一个新的完整坐标时才可走无引用入口;工具引导与适配上下文须区分新输入和引用选择,不能把缺失或过期的选择引用改写为新坐标安装。该解析歧义无法消除时先让用户重新选择。 +工具名称与 schema 分别定义于插件的 search-tool.ts 和 install-tool.ts,注册时拒绝同名冲突;这些是 DSH Agent 工具,不是外部 HTTP API。scope 仅在用户明确指定当前用户或当前项目时传入;用户明确安装且未指定范围时省略,由 Host 默认当前用户。sourceRevision、targetToken、requestKey 由可信适配层生成,不能信任模型构造的 token 或历史卡片 HTML 属性。selectionRef 由 Host 签发并保存,绑定来源修订、会话、搜索条目坐标和项目上下文;搜索阶段可只有候选版本,详情打开后另签发绑定精确版本/fingerprint 的引用。引用不是模型可编辑的字段集合。基于搜索卡片、详情或“第二个”等历史选择安装必须提交 selectionRef;Host 保存搜索引用到最近一次成功展示所用详情快照的关联;有已展示快照时必须提升到该快照,没有已展示快照时由 Host 按搜索时精确版本核验并固定安装快照,两者均不得重新解析 latest。若同一搜索引用关联多个不同固定版本且无法确定用户所指,则阻断并重新选择。Host 校验归属、时效、来源、项目和已有固定版本,失败返回 CONTEXT_STALE,不回退到最新版本。只有用户在当前上下文明示一个新的完整坐标时才可走无引用入口;工具引导与适配上下文须区分新输入和引用选择,不能把缺失或过期的选择引用改写为新坐标安装。该解析歧义无法消除时先让用户重新选择。 展示数据以结构化 envelope 传递,搜索与安装使用各自的 schema,不假设字段相同。搜索结果包含 kind、schemaVersion、查询、来源标签/修订、创建时间、数量与候选快照;toolCallId 由宿主 ToolView props.callId 提供。安装结果使用 §9 的 accepted/awaiting-confirmation/cancelled/not-confirmed 四种互斥结构,只有 accepted 包含 operationId。Host 保留本地记录归属;Client 不接收带凭证 URL、内部下载 URL 或可执行命令。工具通过 output.presentationMeta 提供结构化结果;Client 在 tool.call.toolview 插槽按工具名注册,从 props.block.meta 解析并校验。无法识别的历史格式显示只读提示,不触发安装。 工具适配层按持久 hostInstanceId、会话、toolCallId 映射稳定 requestKey(进程重启不改变 hostInstanceId);相同工具调用重新等待不能重新写入。不同 toolCallId 的重复请求仍由共享目标锁、内容检查和 inventory 幂等处理,不能只做 UI 禁用。 -该映射使用持久 hostInstanceId 作为 UUID v5 命名空间,名称为 JSON 数组 `["skillhub-conversation-install-v1", sessionId, callId]`;通过结构化编码避免字段拼接歧义。模型不提供 requestKey。参数变化不能产生另一个键以绕开冲突检查:原始 selectionRef 或新坐标及显式 scope 必须参与日志参数摘要;已明确范围的安装先查已有接受记录,再解析可能过期的引用。未明确范围的请求仅保存在当前 Host 的等待选择缓存中,同调用参数变化直接拒绝,不创建 accepted 日志。请求键用于幂等关联,不作为授权凭证。 +该映射使用持久 hostInstanceId 作为 UUID v5 命名空间,名称为 JSON 数组 `["skillhub-conversation-install-v1", sessionId, callId]`;通过结构化编码避免字段拼接歧义。模型不提供 requestKey。参数变化不能产生另一个键以绕开冲突检查:原始 selectionRef 或新坐标及显式 scope 必须参与日志参数摘要;已明确范围的安装先查已有接受记录,再解析可能过期的引用。新安装省略范围时按用户级生成请求摘要;历史等待选择引用不能自动重放,同调用参数变化仍拒绝。请求键用于幂等关联,不作为授权凭证。 ```mermaid sequenceDiagram @@ -148,15 +148,9 @@ sequenceDiagram H-->>C: 已接收的历史快照 else 用户未指定范围 A->>H: install(selectionRef),省略 scope - H-->>C: awaiting-confirmation,零写入 - A-->>U: 请在卡片选择范围并确认 - U->>C: 选择范围并安装 - C->>H: openConversationDetail,读取精确版本 - H-->>C: 详情与受控 token - U->>C: 选择当前项目/用户并确认 - C->>H: startInstall,受控 token 与稳定请求键 - H->>I: 真实安装事务 - I-->>C: 操作引用 + H->>I: 默认用户级,核验固定版本与可信目标 + I-->>H: accepted / operationId + H-->>C: 展示用户级安装的原操作回执 end C->>H: getOperation 查询文件与发现结果 Note over C,I: 关闭或取消确认不提交安装;accepted 不表示安装完成 @@ -220,17 +214,11 @@ Host 从当前会话的持久日志读取匹配且唯一的成功搜索调用/ Agent 在下一次明确安装请求中仍可传原搜索引用,Host 根据关联解析到已查看版本,Client 无需向对话插入工具消息。不允许新版本发布导致重新 resolve latest。未成功展示的新快照不覆盖旧关联;一次来源/项目变化使整个引用链失效。历史快照的重新查看会展示新来源/版本,用户须重新发起安装。原引用关联多版本有歧义时返回 CONTEXT_STALE,由用户重新选择。 -### 未指定范围的原生选择与确认 +### 默认用户级与显式项目级 -用户说“安装第二个”等未明确范围时,模型保留搜索 selectionRef 并省略 scope。Host 固定候选版本,优先调用 DSH `ctx.userQuestions.ask`,在聊天输入框旁临时展示单选问题:当前项目、当前用户、取消安装。问题列明技能完整坐标、精确版本、来源和范围说明;提交选项即确认安装,不要求再次打开详情。只列出 Host 实际可用的范围;不标记推荐或默认提交。 +用户明确说“安装第二个”等请求时,模型保留有效 selectionRef。省略 scope 由 Host 默认当前用户;明确要求当前项目时使用 project,并核验可信工作区。项目不可用不改装用户级。页面默认用户级,项目级置于高级选项,确认前不安装。 -Host 可读取固定版本详情、指纹和目标供确认,但回答前不得创建操作、下载技能包或写入技能文件。仅识别对应问题的一条准确范围选项;取消、跳过、自定义答案、未知或多个答案均不提交。等待后重新校验来源、Agent 实例、会话、项目、运行实例、选择引用和目标凭据;不得重新解析 latest。重复同一调用共享一次问题和结果,同 callId 改参数仍拒绝。 - -使用非计时原生问答,带真实调用 Agent 和 AbortSignal;关闭、取消或执行中止不授权安装。不使用异步迟到回答跨重启恢复写权限。`cancelled` 结果只表示未提交,不包含 operationId,也不自动重试。 - -宿主没有服务或回答器时,降级为 `awaiting-confirmation` 卡片;该结果仍无操作。点击“选择范围并安装”直接打开紧凑确认弹窗,展示固定身份与版本、范围选择和取消/确认按钮,不打开完整详情。专用 pending 引用只用于 UI,不可直接作为工具安装凭据;来源、会话或运行实例变化后须重新搜索。后续用户在新消息中明确范围,可用原来仍有效的搜索引用发起新调用。 - -当前项目仅当前工作区使用,不自动同步给协作者;当前用户可在该 DSH 主机用户的多个项目中发现,实际读取仍受会话权限限制。 +当前项目仅当前工作区使用,不自动同步给协作者;当前用户可在该 DSH 主机用户的多个项目中发现,实际读取仍受会话权限限制。历史 awaiting-confirmation/cancelled 回执继续可读,不自动重放。管理已有技能不能沿用安装默认范围猜测目标;多份安装先选择,更新和卸载始终需要真人确认,详见 conversation-management-design.md。 ### 安装请求未确认的结果 diff --git a/openspec/changes/add-dsh-native-plugin/conversation-management-design.md b/openspec/changes/add-dsh-native-plugin/conversation-management-design.md new file mode 100644 index 000000000..07b7f0924 --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/conversation-management-design.md @@ -0,0 +1,235 @@ +# 对话管理已安装技能 + +状态:共享核心与 Host 的既有实现及验收保留;按技能分组的 Client 增量已完成本地实现与双 RC 验证。各候选的实际检查、双 RC 宿主和真实模型结果分别记录于 verification.md。 + +## 目标与范围 + +用户在 DSH 对话中查询当前 Registry 管理的安装、检查更新、指定版本更新及确认卸载。按钮与自然语言共享 Host 校验和安装核心。首期单技能操作,不做批量、跨来源接管、强制覆盖、启停、SaaS 或发布。 + +## 已确认产品交互 + +1. 查询按技能身份合并展示名称;单份安装显示范围,多份安装在组内选择范围并联动版本。正常突出使用,检查更新/卸载收进更多菜单。明确只包含当前来源;更多结果按需展开,不宣称是本机全部技能。 +2. 模糊名称或结果序号必须绑定有效结果;多个安装位置先选择,不由模型猜测。 +3. 检查更新展示当前和目标版本;失败不当作无更新;无法确定版本顺序时展示不可升级原因,不自动选择替代版本。 +4. 更新和卸载均须 Host 发起用户确认,优先输入框附近的原生问题界面;不可用时使用对话确认卡,不能自动执行或让模型伪造确认。 +5. 确认后原操作卡显示进行中和最终结果,不刷新整个页面,不强制打开详情。历史回执不等于当前安装状态。 +6. 用户取消不写文件;执行期间重复点击不重复执行;来源、会话、根目录或安装记录改变后失效并重新核验。 +7. 本地内容变化阻止更新及卸载;其他来源或归属未知安装不认领、不修改。列表存在检查不冒充完整内容核验。 +8. 结果仅展示技能、范围、版本与核心状态;详细诊断按需展开,不默认展示内部引用、路径、操作 ID 或来源 URL。 + +## 实现模块与职责 + +- plugins/dsh/src/host/index.ts:保留搜索、安装模型工具,新增安装查询、更新检查、更新、卸载和操作查询工具;页面与对话复用 listInstalled、checkUpdate、checkVersionUpdate、prepareUpgrade、prepareVersionUpgrade 和删除流程。 +- plugins/dsh/src/host/local-runtime.ts:复用本机库存、更新/卸载预检、操作记录与共享安装核心;不调用 shell CLI。 +- cli/src/services/installed-skill-metadata.ts:registry(忽略末尾斜杠)+ namespace + slug 匹配来源,与库存交叉核验。 +- cli/src/integrations/upgrade-baseline.ts:按所选位置核验版本、指纹、归属及重叠路径,提交时只合并所选位置;其余位置保持不变。 +- cli/src/services/skill-fingerprint.ts:比较逐文件 SHA-256 与总指纹,排除安装器 metadata.json;完整内容核验与列表存在检查分离。 + +## 已确认:按安装位置更新 + +用户选择方案 B:同一技能可在当前用户和不同项目保持不同版本。一次对话更新只改变用户确认的那个安装位置,未选位置的文件、版本、来源及归属保持不变。多位置不是首期拒绝更新的理由。 + +库存必须能表达每个安装位置的版本和指纹。Host 列表、更新比较、预检、确认、文件提交、库存提交和恢复均使用同一目标身份。页面与对话共享行为;CLI、同步和套件处理必须理解该模型,不能只修改对话入口。 + +### 兼容与恢复约束 + +- 旧库存可读,转换前保存可验证的恢复基准;不得仅为读取而静默改变真实安装版本。 +- 对已在不同位置形成不同版本的安装,不能通过恢复旧库存文件假装完成降级;库存与文件必须一致。 +- 旧消费者不理解新模型,须通过真实旧版本测试明确读取/写入行为;不得把 schemaVersion 字段当作旧代码会自动遵守的保护。 +- 不同目标并发更新不得互相覆盖库存;同一目标串行化或明确拒绝。共享归属和本地内容变化仍按原规则保护。 +- 断电、进程退出、文件提交后响应丢失等恢复只能处理本次目标,不能重放成全位置更新。 + +### 版本语义 + +本轮支持指定可升级版本,保留现有版本比较约束。当前实现拒绝降级、无法排序以及同版本不同内容;因此早期交互描述中的“切换版本”不能被当作已支持任意版本替换。UI 应展示明确的不可升级原因,不通过改按钮文案绕过核心。 + +## 交付顺序 + +1. 冻结按位置版本模型、旧库存兼容和旧消费者边界,更新正式生命周期契约及验收矩阵。 +2. 实现共享库存/安装/更新/卸载/同步/套件/恢复消费者及回归,证明未选目标不变。 +3. 适配 DSH 页面、列表与更新计划;建立显式可信会话上下文 helper。 +4. 接入对话查询、检查更新、更新/卸载确认及操作结果工具,按钮与模型入口共享流程。 +5. 执行共享核心和插件检查、双 RC 真实浏览器与真实模型验收;模型配置和调用授权另行核对,未执行不得宣称通过。 + +## 验收要求 + +- 当前来源、其他来源、手工技能、同名技能与用户/项目目标区分正确。 +- 列表完整性和不可读状态不丢失,不将未知等同未安装。 +- 最新/指定版本、不可下载版本、非 SemVer、无更新与请求失败分别覆盖。 +- 确认、取消、重复点击、旧引用、来源切换、会话切换和本地修改都有行为测试。 +- 查询结果、确认目标、实际文件与库存、操作回执一致。 +- 在当前声明支持的两个 DSH RC 上分别验证原生确认与回退卡、历史结果、窄窗口和多条目;真实模型调用与手动工具测试分别记录。 +- 插件 build/typecheck/lint/tests、相关共享核心回归、OpenSpec 严格校验通过后方可报告实现完成。 + +- 同一技能用户级 v1 与项目级 v2 并存;只更新项目到 v3,用户级内容和记录保持 v1。 +- 不同项目并发更新、旧库存读取、旧消费者访问和恢复后的完整性均须有独立证据。 + +## 库存迁移方案 + +采用显式第二版库存模型:技能来源坐标与每个安装位置的版本、指纹、安装时间和归属分离。不能保留一个有歧义的 item.version 并让部分消费者继续使用它。所有读取者使用共享解析/投影函数,写入在已有库存锁及目标锁下执行。 + +库存继续使用单一 inventory.json,以显式 schemaVersion: 2 区分;读取旧格式时仅投影,第一次写入前在同一锁域内迁移并保存不可变备份。不维护第二份可写库存或双文件迁移标记。协调升级已是使用前提,额外文件不能保护技能文件免受旧程序写入,不为此引入双权威恢复状态。 + +迁移流程:在统一锁顺序下检查旧库存、未完成操作和目标身份 → 保存原始字节及摘要备份 → 把组级版本、指纹和继承归属展开到每个目标 → 验证新格式 → 临时文件原子 rename 提交。提交前再次核验旧库存未变化;提交失败按实际文件判定结果,禁止猜测成功。旧未完成操作必须先使用兼容恢复路径处理,或阻止迁移。 + +已确认兼容边界:启用本功能要求同一主机共同管理技能的 CLI 同步升级;旧 CLI 与新插件混用不能保证安全。源码证据表明旧 InventoryStore 不检查 schema,部分 sync 删除流程还可能在库存写入前移动目录。必须实测选定历史消费者,并明确区分“拒绝写入”与“已修改文件后报错”。 + +在两个位置形成不同版本后,回滚插件或库存格式前应停用写入并核对实际文件;不能仅恢复旧库存备份。版本一致性恢复或导出方案须在实现前定义,不承诺一键降级。 + +## 最终安装范围交互 + +- 新安装默认当前用户;普通流程不要求先理解或选择范围。页面保留安装确认,对话明确安装请求省略范围时由 Host 使用用户级默认,模型不能擅自选项目。 +- 项目级安装入口置于高级选项;用户明确要求当前项目时仍支持,必须有可信项目根。无项目根不得回退到用户安装。 +- 查询和管理仍展示已有项目级安装,不自动迁移、不隐藏、不清理。 +- 更新或卸载唯一匹配安装时直接准备该安装的确认;多份且用户没有明确目标时才提供选择。默认用户级仅用于新安装,不能用来猜测更新/卸载目标。 +- 更新、卸载始终经过真实用户确认;确认展示技能、当前/目标版本、实际范围,不能把自然语言定位目标当成完成确认。 +- 同一技能不同位置保持不同版本;只改变选定位置,其他位置文件、版本、时间和归属保持不变。 +- Desktop 兼容为独立任务,本轮继续针对已声明支持的宿主基线验收,不宣称覆盖旧 Desktop。 + +## 交付边界 + +用户已接受共同管理这些技能的 CLI 配套升级要求。DSH 插件内嵌共享核心,普通 DSH 用户无需单独安装 CLI。发布版本号和真实旧消费者矩阵以发布前验证为准;迁移保护不能阻止任意旧程序直接修改文件。 + +## 持久化与操作日志契约 + +库存 v2 使用 schemaVersion: 2、records 和 suites。records 按 registry/namespace/slug 分组,target 保存 agent、rootDir、installDir、installedAt、installedBy、version 和可选 fingerprint。组级不再保存可用于执行判断的 version/fingerprint;旧缺失指纹保持未知,不通过迁移伪造完整性。现有套件成员固定版本语义不变。 + +读取旧库存时保留原文件,只在内存中将组级版本、指纹和继承归属展开至目标。第一次写入前,在旧库存锁路径下验证并保存原始字节备份,再原子替换 inventory.json;临时文件和备份不会成为另一权威。新客户端拒绝未知格式。备份命名按原内容 SHA-256 区分,相同备份必须校验一致,不能覆盖冲突文件。 + +操作日志需显式区分 v1/v2,不能直接把历史日志强制转换成新类型。终态 v1 日志继续可读;未完成 v1 操作需在 v1 库存上先恢复,遇到 v2 库存不能用旧快照覆盖,应保持待恢复。CLI 无法发现全部 DSH Host 的日志目录,迁移前要求停止旧写入进程、由对应宿主处理未完成操作;不声称存在全局自动检查能力。 + +按位置 CAS 比较来源、规范化路径、版本、指纹、安装时间、归属及重叠路径认领;提交时在最新库存中仅合并所选目标的变化。其他目标并发更新必须保留;同一目标冲突则串行化或拒绝。空目标也需要缺失基线,不能覆盖并发新增安装。保持目标锁到库存锁的统一顺序,迁移预检不得持库存锁等待目标锁。 + +v2 恢复只比较和恢复本次目标的 before/after,其他位置的新状态保留。未选目标的本地修改或 Suite 归属不使其被连带修改;选中共享目标仍阻断。进程中断与断电耐久性需按实际文件系统行为分别描述,不能把原子 rename 当作所有断电场景保证。 + +## 实现切片与门槛 + +| 顺序 | 模块 | 进入下一步前的证据 | +| --- | --- | --- | +| 1 | 库存 schema、旧格式适配、迁移备份 | 旧文件只读不变、归属/套件保留、未知格式拒绝、原子提交结果唯一 | +| 2 | 安装/更新/卸载与日志恢复同步适配 | 项目升级后用户级字节及记录不变,各提交边界失败可核验 | +| 3 | CLI list/upgrade/remove/sync/suite/doctor | 每位置真实版本、未选目标不变、现有 JSON 契约明确记录变化 | +| 4 | DSH 页面、引用、计划及使用入口 | 各位置显示和使用正确,旧引用不能切换目标,默认用户级 | +| 5 | 对话工具、原生确认和结果卡 | 查询、歧义选择、取消、确认、防重复、结果查询闭环 | +| 6 | 独立评审与验收 | CLI全检查、插件全检查、双RC浏览器、真实模型证据分别记录 | + +每一步可以独立评审,但库存与恢复必须一起完成后才可交付;不发布新库存配旧恢复的中间版本。 + + +## 模型结果与用户提示 + +多位置选择使用原生问题界面(可用时),每次只选择一个安装位置,不推荐批量。模型工具结果不包含来源 URL、确认引用、路径或原始诊断;所需内部定位引用仅用于后续工具调用,不应复述给用户。 + +`accepted` 明确表示真人批准已经完成、操作已经提交;模型不得再次询问是否执行或再次提交变更,应查询原操作再报告结果。`awaiting-confirmation` 引导用户操作已有确认卡;`cancelled` 不自动重试。这些引导减少模型误解,文件写入仍由 Host 的精确计划、真人确认和幂等记录保障。 + +CLI 直接安装覆盖、升级、删除和同步清理共享目标均核验 Suite/非单一 direct 归属,`--force` 不绕过;Suite 自身使用既有管理流程。目录重叠包括规范化后的父子关系,不能只检查路径相等,也不将同前缀兄弟目录误认为重叠。 + + +## 人工评审补充:结果密度与历史按钮 + +查询安装、检查更新、操作回执使用不同信息层级:安装查询保留紧凑列表;检查更新仅突出技能/范围、当前与目标版本以及更新动作,不复制使用/卸载等列表工具条;更新/卸载和操作查询用简短状态条,不重复大尺寸技能卡。成功结果不展示查询或刷新按钮;仅处理中或失败/未知时提供必要的查询或重试。正常回执不附技术说明和诊断折叠入口,必要告警与恢复提示仍显示。 + +列表/检查结果属于原查询时刻。升级、卸载、来源或会话变化可能使旧引用失效;Host 拒绝是正确保护,但 Client 不得继续显示可反复点击的失效写入动作或只有无法处理的泛化错误。应明确提示记录需刷新,提供显式只读刷新入口。刷新后的当前查询结果替换该工具卡的显示内容,标明已刷新;不在旧安装条目中嵌套或追加另一份完整列表,原工具历史数据仍保留。刷新仅展示新查询结果,不把旧版本的更新/卸载意图自动转移到新引用;后续动作重新由用户选择并确认。使用技能同样不能把历史版本自动替换为当前版本。 + +本增量按新旧引用分别实测使用、检查更新、更新和卸载;包含取消、确认、重复点击、刷新不写入、历史回执重载以及实际文件/库存不变的核验。 + + +### 对话结果密度约定 + +- 正常安装列表不常驻“刷新安装状态”。仅记录失效或读取失败时显示短提示与“重新查询”;仍为显式只读行为,不自动重绑或重放变更。 +- 安装条目使用平铺紧凑行:技能名、版本、安装范围、可用操作;不为每条套多层大卡片。长名称可换行,窄屏操作允许换行。 +- 检查更新保留一次技能身份、范围、版本变化和更新按钮;已是最新版本时仅显示结论,不留下空操作区域。 +- 更新、卸载及操作查询的终态回执采用简短状态行,不重复旧版本、技能身份、完成说明或技术诊断入口。必要的范围信息保留以区分同名技能的不同安装。 +- 加载、错误、告警及恢复未完成必须可辨识,不能为压缩高度伪装成成功。正常内容不靠隐藏溢出或固定高度截断实现压缩。 + + +## 已确认:按技能分组的对话交互 + +状态:用户已批准按技能合并方案 A,正式实现与本地验收已完成。下文取代早期按安装记录逐行展示的布局规则,底层按位置管理契约不变。 + +当前紧凑列表仍按安装记录重复显示技能。采用方案按来源 + namespace + slug 分组,仅显示一次技能身份;有两份安装时在组内选择当前用户或当前项目,单份安装只显示范围文本。位置选择切换对应版本和操作目标,不合并底层安装记录。全局范围 Tab 仅作为历史对照原型保留,不纳入正式实现。 + +正常查询突出“使用”,低频检查更新、卸载放在更多菜单;已明确更新或卸载意图时直接显示对应操作确认,不再重复完整列表。多个位置且用户未明确目标时先选择,不将展示默认值当作更新或卸载授权。确认显示准确技能、范围及版本,取消保持原状。不同来源同名技能不能合并或被当前来源接管。 + +原型覆盖矩阵: + +| 场景组 | 用例 | 评估重点 | +| --- | --- | --- | +| 查询 | 空、单用户、单项目、双份同版本、双份不同版本、多技能 | 身份不重复、范围与版本明确,正常不展示刷新 | +| 选择 | 模糊同名、多安装位置、明确位置 | 只在有歧义时要求选择,目标与操作绑定 | +| 使用 | 有案例、无案例、取消 | 案例或草稿流程保留,不自动发送 | +| 更新 | 可升级、最新、指定较新版本、不可下载、不可排序、降级拒绝 | 不把失败当作无更新,不给无效更新按钮 | +| 确认 | 更新/卸载确认、取消、重复点击、切换位置 | 一次仅改变明确目标,取消不写入 | +| 结果 | 处理中、成功、失败、结果未知、告警或恢复中 | 核心状态简短,未知不显示成功或直接重复执行 | +| 保护 | 本地修改、文件缺失、共享归属、其他来源 | 不出现强制覆盖或接管入口 | +| 历史 | 过期引用、来源或会话切换、卸载后的旧卡 | 旧动作不自动转移,必要时重新查询 | +| 读取 | 加载、失败、部分失败 | 保持已知信息,未知不冒充空列表 | +| 布局 | 长名称、多条结果、窄屏、深浅主题 | 不截断范围与确认目标,按钮不拥挤 | + +场景切换、故障注入和重置属于原型演示控制台,不是最终产品控件。模拟数据不表示真实安装、模型调用或宿主兼容验收;正式实现必须使用真实 Host 状态和组件,不复用模拟写入逻辑。 + + +原型重点交叉场景: + +1. 检查用户版期间切到项目版,迟到结果仍属于用户版,不能覆盖项目版状态;确认期间不切换原目标。 +2. 用户版被项目同名技能覆盖,或技能禁止用户调用时,不能悄悄使用另一份;安装存在不等于可调用。 +3. 用户位置可读、项目位置不可读时,项目状态保持未知,不展示成未安装或宣称只有一份。 +4. 明确要求更新或卸载某位置时直接呈现该操作;默认展示位置不代表用户授权。 +5. 卸载一份后另一份仍保留,原回执、失败重试和后续操作不能自动转移到剩余位置。 + + +### 卡片尺寸与按钮取舍 + +少量内容不铺满对话区。常规单技能结果采用内容宽度约束,宽屏最大约 560–600px、内间距 10–12px;单份安装不额外占一行位置选择,双份安装保留紧凑位置选择和对应版本。小屏允许换行,不截断目标范围。高度是布局目标而非固定裁剪。 + +| 当前状态或用户意图 | 保留操作 | 不显示 | +| --- | --- | --- | +| 正常查询且可用 | 使用;更多内检查更新/卸载 | 常驻刷新、重复详情 | +| 已确认存在更新 | 更新;其他低频管理按需收起 | 多个同权重主按钮 | +| 已是最新 / 指定版本无需更新 | 必要版本结论 | 重复检查、更新 | +| 目标不明确 | 技能或位置选择、取消 | 未选择就执行 | +| 更新/卸载确认 | 取消、确认具体动作 | 使用、更多、切换目标 | +| 正在执行 | 状态;仅需要人工恢复时提供原操作查询 | 再次提交 | +| 成功或已取消 | 结果 | 刷新、重试、重复返回入口 | +| 读取失败 / 过期记录 | 重试读取或重新查询 | 直接执行旧动作 | +| 写入结果未知 | 查询原操作 | 重新执行、假定成功 | +| 本地修改 / 共享归属 / 外部来源 | 简短限制说明;仅明确安全可用的动作 | 强制覆盖、强制卸载或接管 | +| 文件缺失 | 清理记录并确认 | 使用、更新文件 | +| 空列表 / 读取中 | 空态或加载状态 | 无效操作 | +| 使用案例选择 | 选择案例、填入、取消 | 自动发送 | + +恢复操作按真实错误原因呈现;不能把所有错误统一塞入“重试”。原型对这份按钮取舍进行场景检查,正式插件按此规则实现,完成情况以 verification.md 为准。 + +分组不改变安装目标身份:同范围存在多个位置时须保留可区分目标,不能仅以 scope 去重。切换位置只改变显示和后续操作选择;正在执行的请求及其结果仍绑定原 itemKey,确认期间锁定原目标。 + + +## 多技能双列与连续对话验收 + +用户要求在真实 DSH 中展示:多技能结果按实际工具容器宽度排列为两列,小容器或窄屏降为一列;单技能保持紧凑宽度,不把两张 590px 卡并排。每个技能独立保留位置选择和操作目标,长名称允许换行,不截断范围与版本。更多菜单必须在底部或边缘可见、可点击。 + +按钮语义保持一致:使用技能只启动案例/草稿流程,不自动发送模型消息;检查更新只读;更新/卸载经真人确认才写入选定目标。按钮改变真实安装状态,不伪造或改写历史模型工具结果。后续模型对话对当前安装状态的回答应依据新查询,不把旧回执当现状;Host 对过期引用继续拒绝,不能自动重绑新目标。 + +本轮需在真实模型会话交替执行自然语言与按钮:查询多个技能 → 按钮更新一份 → 自然语言再问版本 → 旧卡片防护 → 对话卸载并取消 → 再问是否仍安装。记录模型是否重新查询、是否重复提交、操作是否正确隔离;模型回复缺陷和实际写入保护分开判断。测试技能可用隔离夹具,但模型历史必须真实产生。 + + +### 连续对话状态同步 + +实测证明只要求模型重新查询不足以避免旧状态回答。采用两版支持宿主的官方异步 `system-prompt/assemble` 通道,使用可信会话与工作目录读取当前来源的本地安装投影,追加独立命名的运行时上下文。该段是插件提供的当前事实,不伪造用户意图或工具历史,也不触发额外模型调用。 + +上下文只包含技能身份、位置、版本、存在状态与完整性标记;不包含凭证、真实路径、来源 URL、内部调用引用或原始异常。读取失败、部分不可读与截断均明确说明,模型不得用未列出推断未安装。条数与字符数有上限,不请求远程市场;保留宿主其余上下文与工具。写入仍使用新查询取得的真实引用并经真人确认,运行时快照不是授权。 + +宿主明确抑制可选运行时上下文的模式应遵守官方语义,不绕过抑制。该模式下不能声称自动提供最新状态,仍须工具查询;实际兼容与降级限制需在验收结果中单独记录。 + +每轮重新读取,但相同公开状态生成稳定上下文文本,不加入每步变化时间戳以免破坏宿主去重。观察时间由官方运行时事件记录;状态变化才产生新的事实段。 + +更多操作展开不得把位置、版本和主要操作挤入菜单区域;展开项应有清晰独立布局,关闭恢复紧凑状态,首末卡片及窄容器均可触达。布局调整不改变原请求引用、重复点击锁及真人确认。 + + +### 混合安装卡片对齐 + +正常卡片统一为身份、位置、版本与操作三行。单份位置文本与多份位置选择占据同等高度的行,不给单份安装绘制无效 Tab。正常短名称场景各卡等高、对应行对齐;长名称及窄屏可换行,不固定高度裁切。展开管理操作独立增加末行,不挤动原来的三行。多技能列表初始展示 6 个技能,每次追加 6 个,以匹配双列布局;未展示项通过“查看更多”进入,不改变安装记录数量或查询范围。 + + +### 同名技能的使用限制 + +“使用此技能”必须核验所选安装是否为 DSH 当前会话实际发现的技能。另一份同名安装生效时,阻止草稿填入,不自动切换目标或删除任何安装。提示说明所选安装当前未生效,引导用户检查安装位置;响应未提供胜出位置时,不猜测用户级或项目级优先关系。使用错误展示在卡片独立全宽行,不挤压版本与操作按钮,也不因此禁用原本允许的检查更新或卸载。切换安装目标后清除上一目标的使用错误。 diff --git a/openspec/changes/add-dsh-native-plugin/interface-design.md b/openspec/changes/add-dsh-native-plugin/interface-design.md index 6b327ec83..0bae6aee6 100644 --- a/openspec/changes/add-dsh-native-plugin/interface-design.md +++ b/openspec/changes/add-dsh-native-plugin/interface-design.md @@ -107,8 +107,8 @@ interface SkillHubPluginConfig { | `listInstallTargets` | detailToken,可信 agent scope | hostInstanceId、targets(targetToken、scope)、unavailable(范围列表) | 只读;从 Agent 文件系统解析,来源/项目/详情变化时拒绝;不返回路径,不代表已授权写入 | | `checkUpdate` | sourceRevision,itemKey | 更新状态、当前/候选版本、checkedAt | 只读,不下载完整包 | | `checkVersionUpdate` | sourceRevision,itemKey,version | InstalledUpdateCheck | 按所选精确版本检查,不回退 latest | -| `prepareVersionUpgrade` | sourceRevision,itemKey,version | UpgradePlanView | 固定精确候选,复用完整目标升级计划 | -| `prepareUpgrade` | sourceRevision,itemKey | UpgradePlanView | 解析全部共享目标,仅允许当前用户/项目范围 | +| `prepareVersionUpgrade` | sourceRevision,itemKey,version | UpgradePlanView | 固定精确候选,准备所选安装位置的升级计划 | +| `prepareUpgrade` | sourceRevision,itemKey | UpgradePlanView | 只准备选中安装位置,核验当前用户/项目可信根 | | `prepareUninstall` | sourceRevision,itemKey | UninstallPlanView | 只解析一个目标;离线可用 | | `executePlan` | input:planId,requestKey;可信 agent scope | NativeOperation 或 null | 校验计划和精确 before,提交后不能复用 | | `getOperation` | operationId,hostInstanceId;可信 agent scope | NativeOperation 或 null | 只返回当前会话在指定 Host 上可见的操作;不存在或不属于该会话返回 null,Client 显示未知,不能视为成功 | @@ -292,7 +292,7 @@ local-only 详情不能发起远端安装或升级,但可通过独立 prepareU ## 对话工具接口 -首期提供 astron_skillhub_search 与 astron_skillhub_install。模型仅提供关键词或技能坐标/范围;来源、可信目标、幂等键由 Host 适配层派生。具体输入、结果 envelope、历史操作绑定与错误语义见 [对话设计第 6 节](conversation-design.md#6-工具与服务契约)。页面与工具均复用本文 startInstall/getOperation,工具不接受任意文件路径或下载地址。用户未明确范围时省略 scope,工具仅返回 awaiting-confirmation(无 operationId),卡片经 openConversationDetail 和既有 InstallControl 选择范围后才调用 startInstall;该等待状态只固定候选版本,不固定指纹、不下载包、不创建安装事务,也不能由历史恢复自动重放。 +首期提供 astron_skillhub_search 与 astron_skillhub_install。模型仅提供关键词或技能坐标/范围;来源、可信目标、幂等键由 Host 适配层派生。具体输入、结果 envelope、历史操作绑定与错误语义见 [对话设计第 6 节](conversation-design.md#6-工具与服务契约)。页面与工具均复用本文 startInstall/getOperation,工具不接受任意文件路径或下载地址。用户明确安装但未指定范围时省略 scope,由 Host 默认当前用户;明确项目级时使用可信项目根,不自动回退。历史 awaiting-confirmation 仍允许通过现有 UI 选择,但不能自动恢复写入。 ## 10. 已接受操作的查重顺序与历史快照 @@ -336,12 +336,11 @@ accepted 不表示安装完成。工具立即返回 accepted 及 operationId, 测试不使用当前来源写路径,不改变配置、sourceRevision、目录缓存或本地文件;不尝试下载或安装。Client取消旧请求并以请求代次隔离结果,防止地址变更后显示旧地址成功信息。mode为paged仅说明分页目录可读,limited仅说明基础搜索可读,二者均不证明写入或可选Tab能力。 -### 原生安装范围确认 +### 安装默认范围与管理确认 -安装工具省略 scope 时由 Host 使用可选的 `ctx.userQuestions.ask` 显示输入框旁的单选题,问题含固定版本、来源、实际可用范围及取消。回答必须匹配该问题的一条准确范围选项;取消、空、自定义或多个答案返回 `{schemaVersion:1,kind:"skillhub-install",status:"cancelled",toolCallId}`,不含操作 ID。用户回答后重新检查上下文,再复用现有安装事务。无服务或回答器时保留 `awaiting-confirmation` 卡片;该卡片直接打开确认弹窗,不打开详情抽屉。显式 scope 的原流程保留。 - -此能力是宿主可选 peer,不改变既有写入依赖基线。不使用新计时问题 API,不回放旧提问授权,不向模型发送目录或目标 token。问答取消与执行中止不转换成用户级安装。 +新的对话安装调用省略 scope 时默认当前用户;项目级必须由用户明确选择,页面放在高级选项中。安装仍核验固定版本、来源和可信目标。历史待选择回执保持只读或显式确认,不自动重放。 +更新和卸载使用可选的 `ctx.userQuestions.ask` 发起真实确认,展示技能、版本变化与范围。只有对应问题的单个准确确认选项能执行;取消、跳过、自定义及多个答案均不授权。原生回答器不可用时返回对话确认卡;真实 Client 点击通过 Agent-scoped Remote 确认,模型无法提交 confirmed 标志。 ### 安装进度与列表刷新边界 @@ -357,7 +356,7 @@ accepted 不表示安装完成。工具立即返回 accepted 及 operationId, ### 选定版本升级 -保留 `checkUpdate` 与 `prepareUpgrade` 的原签名及“检查最新版”行为,新增 Agent-scoped `checkVersionUpdate(sourceRevision,itemKey,version,signal)` 和 `prepareVersionUpgrade(sourceRevision,itemKey,version,signal)`。两组接口共享当前库存重读、完整目标基线、版本比较、文件修改保护与确认计划;定向接口必须按指定 version 解析,响应版本不一致即拒绝,不使用 latest 替换不可用版本。执行仍只提交原 executePlan,无新文件写入接口或 SaaS 安装接口。 +保留 `checkUpdate` 与 `prepareUpgrade` 的原签名及“检查最新版”行为,新增 Agent-scoped `checkVersionUpdate(sourceRevision,itemKey,version,signal)` 和 `prepareVersionUpgrade(sourceRevision,itemKey,version,signal)`。两组接口共享当前库存重读、所选目标基线、版本比较、文件修改保护与确认计划;定向接口必须按指定 version 解析,响应版本不一致即拒绝,不使用 latest 替换不可用版本。执行仍只提交原 executePlan,无新文件写入接口或 SaaS 安装接口。 详情按 source/session/itemKey/所选 version 隔离只读检查缓存,切换仅触发读取。较新且可核验的版本显示升级到该版本,相同版本不重复显示升级,旧版本或不可排序版本明确阻断。确认使用捕获的库存项与精确目标,不因后台库存刷新重建另一升级意图;升级完成使库存失效,旧 itemKey 的检查结果不会绑定到新记录。 @@ -384,3 +383,26 @@ Client 使用原生 `InputActions.captureInsertion()` 和 `insertText(text, span ## 固定技能案例协议 案例直接随技能包分发,已安装使用读取 `.skillhub/examples.json`;不新增案例查询端点或数据库权威内容。现有精确版本 file 接口可返回原始文件流,不能按 ApiResponse.data 解析。prepareSkillUse 的 ready 返回值扩展有界案例投影,发布预检扩展可选识别摘要;精确定义和状态规则统一见 [案例实现设计](skill-examples-design.md#3-本地读取与原生草稿衔接)。既有 Remote 参数及不自动发送边界保持。扩展已在开源代码实现,旧 API 响应允许缺少预检字段并按未知处理;SaaS 需后续同步。 + +## 对话管理扩展 + +查询/检查更新/更新/卸载/操作结果工具使用显式可信会话上下文的 Host 内部服务,不伪造 Remote invocation。模型只提供有效选择引用与候选版本,不提供文件路径、下载 URL 或 confirmed 标志。每次更新/卸载由 Host 生成不可变计划并获取真人确认;缺少原生问题服务时返回待确认卡,不降级为自动执行。 + +唯一安装直接定位,多份时选择;新安装省略范围由 Host 默认用户级。列表与操作响应包含真实目标范围和该位置版本,内部引用不展示给用户。工具由 management-tool.ts 注册并严格校验参数,契约如下。来源、会话、根目录、版本或基线变化均使旧确认失效。 + +每次模型调用关联一个 Host 保存的 UUIDv4 请求键,不能把现有安装 UUIDv5 直接传给仅接受 v4 的确认计划;重复回执查询不创建新操作。工具和页面最终使用同一执行与恢复服务。完整产品范围见 conversation-management-design.md。 + + +| 模型工具 | 输入 | 结果种类 | +| --- | --- | --- | +| `astron_skillhub_list_installed` | 空对象 | `skillhub-installed`:items、complete、issues、unavailable 与 sourceRevision | +| `astron_skillhub_check_update` | sourceRevision、itemKey、可选精确 version | `skillhub-update-check`:所选 item 与 check | +| `astron_skillhub_upgrade` | sourceRevision、itemKey、可选精确 version | `skillhub-management`:blocked / awaiting-confirmation / cancelled / accepted | +| `astron_skillhub_uninstall` | sourceRevision、itemKey | 同上,操作限于所选位置 | +| `astron_skillhub_operation` | operationId、hostInstanceId | `skillhub-operation`:本会话原操作或 null | + +管理结果带 schemaVersion=1 和真实 toolCallId。工具拒绝未知参数;业务未确认返回 not-confirmed 与安全原因,不伪造成功。模型投影移除来源 URL、确认引用、原始诊断和路径;Client 通过 presentationMeta 获取经校验的展示状态。 + +Client 的 `beginConversationManagement(sourceRevision,itemKey,action,version,requestKey,signal)` 只准备计划并返回确认卡,requestKey 必须是 UUIDv4;`confirmConversationManagement(confirmationRef,confirmed,signal)` 绑定当前 Agent、来源及原计划。二者均不是模型工具。接受后沿用 getOperation 查询,不重复提交。 + +多个位置时通过原生问题(可用时)要求用户选定一个位置,随后再由 Host 确认操作。模型引导禁止推荐同时处理所有位置;本期不提供批量工具。Host 的确定性保证是精确引用、单位置计划和真实人工确认,不宣称能直接证明模型理解了用户自然语言。 diff --git a/openspec/changes/add-dsh-native-plugin/lifecycle-design.md b/openspec/changes/add-dsh-native-plugin/lifecycle-design.md index b7d51c43e..b8de8a31a 100644 --- a/openspec/changes/add-dsh-native-plugin/lifecycle-design.md +++ b/openspec/changes/add-dsh-native-plugin/lifecycle-design.md @@ -43,9 +43,9 @@ flowchart TD C -->|同版本不同内容| E C -->|远端更新| F[显示可升级版本] F --> G[用户点击升级并生成计划] - G --> H{全部共享目标都在允许范围} + G --> H{选定目标可信且独立可管理} H -->|否| I[阻断,不扩大到其他项目或 Agent] - H -->|是| J[显示全部受影响目标与旧新版本] + H -->|是| J[显示所选位置与旧新版本] J --> K{基线完整且无本地修改} K -->|否| E K -->|是| L[用户确认升级] @@ -60,21 +60,19 @@ flowchart TD 已发现预发布版本是否可用由现有 Registry resolve 决定;插件不另造“稳定/测试通道”。计划绑定精确版本和 fingerprint;确认后该版本不可下载就失败,不换成另一个版本。 -### 3.2 多目标共享版本 +### 3.2 按安装位置独立版本 -现有 inventory 的一个来源+坐标只有一个 version/fingerprint,所有 targets 共用。必须保持这一约束,不在本期悄悄迁移 inventory schema。 +对话管理扩展将库存迁移为显式 v2,各 target 独立持有 version/fingerprint;来源坐标组不再有可用于执行判断的单一版本。迁移与兼容约束见 conversation-management-design.md,正式实现计划须覆盖 CLI、套件、同步和恢复。 -- 只有一个目标:升级该行对应目标。 -- 同一 inventory item 的所有目标恰好都在本插件可管理范围(当前 DSH 用户级和当前项目),且无共享来源:计划列出全部目标。用户确认的是**这些明确列出的目标一起升级**,不是默认静默扩散。 -- 任何 target 属于其他 Agent、其他项目、非可信路径、共享 Suite 或同目录别名风险:返回 blocked;不得自动加入不可见目标,不允许仅升级选中一行导致记录失真。 -- 计划检查完整 target set;执行时 inventory 新增/删除目标也会使计划失效,即便选中目录本身没有变化。 -- 用户取消计划不下载、不写文件。此处的确认是版本和文件影响确认,不是新的账号授权产品。 +- 一次 DSH 更新只操作用户选定的目标。唯一匹配直接定位;多份且未指定目标时先选择。 +- 其他项目、Agent 或用户级副本保留原版本、文件、时间及归属,不因属于同一来源组而被连带更新。 +- 选定目标必须为可信路径、直接归属且通过内容基线校验;物理目录别名或共享所有权仍阻断。 +- 提交使用当前库存的比较与交换检查,不能用旧快照覆盖其他目标。并发冲突可要求重试,不得丢失其他目标修改。 +- 用户取消不下载、不写文件;模型不能代替用户确认。 ### 3.3 升级确认内容 -详情页内展开升级计划,显示完整坐标、来源、`旧版本 → 新版本`、每个目标的范围和只读规范化位置、是否存在本地修改、计划有效截止时间,以及“确认升级 / 取消”。超过一个目标时文案必须明确“将升级以下 N 个安装位置”。 - -正文长时,先完整显示目标清单再确认,不仅显示“升级成功”toast。实现前校验同路径别名去重,不能同目录写两次。 +就地确认展示技能、所选范围和旧新版本,详情按需展开;不默认展示原始路径、来源 URL 或内部过期时间。对话优先原生输入框附近确认,缺少该能力时回退为不执行的确认卡。 ## 4. 卸载流程与影响确认 @@ -158,16 +156,16 @@ sequenceDiagram participant K as 安装核心 participant I as Inventory C->>H: prepareUpgrade(itemKey) - H->>I: 读取完整目标集合和所有权 + H->>I: 读取所选目标版本、基线与归属 H->>R: 匿名 resolve 最新版本 - H-->>C: planId 与旧新版本、全部目标 + H-->>C: planId 与旧新版本、所选目标 C->>H: executePlan(planId,requestKey) H->>H: 保存 accepted operation H->>K: 固定版本下载与 staging 校验 K->>K: 按规范化目标排序获取目标锁 K->>I: 重读并比较目标集合与基线 K->>K: 原目录移备份,再提交新目录 - K->>I: 锁内 CAS 更新完整目标集合的版本 + K->>I: 锁内 CAS 更新所选目标版本并保留其他目标 alt 提交成功 I-->>H: 已提交 K->>K: 清理备份,失败则保留 warning @@ -239,7 +237,7 @@ Host 启动扫描到的未完成操作,在查询时将 accepted/staging/revali | CAS 已提交,清理备份/隔离失败 | 保留残留并告知精确位置 | completed+cleanupWarning | | 重读既不匹配 before 也不匹配 after | 停止自动恢复,不覆盖任何一方 | recovery-required | -对多目标升级,判断必须比较整个确认集合的版本、metadata、文件与清单,不能仅判断第一个目录。恢复日志在变更前已持久保存确定的 backup/staging 路径,因此 committed 日志失败仍可由 precommit 日志和真实状态核验;无可信日志时不猜测删除遗留目录。 +恢复必须比较本次确认目标的版本、metadata、文件与清单,并保持其他位置不变。恢复日志在变更前已持久保存确定的 backup/staging 路径,因此 committed 日志失败仍可由 precommit 日志和真实状态核验;无可信日志时不猜测删除遗留目录。 ```mermaid flowchart TD @@ -256,7 +254,7 @@ flowchart TD 统一顺序为按 canonical lock key 排序的 target locks → inventory lock;CLI 和插件共用 orderSkillTargetLocks,不能各自实现不同排序。锁身份由规范化目标路径生成,路径别名及输入顺序不同也必须得到同一取锁顺序。禁止另一路先持 inventory lock 再等 target lock,避免死锁。取锁前 canonical 去重与冲突检查;由事务核心唯一负责取锁,插件外围不得再持同批锁调用旧 installSkill。网络下载在持目标锁之前进行,提交前重新验证;安装幂等的早期锁检查完成后释放,再下载。 -Inventory 变更必须在 mutateAtomic 的最新快照里比较旧身份、版本、完整目标集合和 installedBy,不能用预检读到的旧 inventory 覆盖他人更新。沿用 schema,无 target 粒度版本字段。 +Inventory 变更必须在 mutateAtomic 的最新快照里比较所选位置的旧身份、版本、指纹、时间、installedBy 及重叠路径认领,不能用预检读到的旧 inventory 覆盖他人更新。显式 v2 中每个 target 持有自己的版本、指纹和归属;旧格式仅经验证适配和受控迁移读取。 `targetInstalledBy` 非单一 direct 的目标视为共享来源,本期升级与卸载均阻断。缺失 installedBy 按现有 CLI 的 legacy direct 语义读取,但 metadata/文件基线缺失仍阻断。任何 canonical path 不同来源记录共享、符号链接逃逸或目录重定向均需停止变更。 @@ -279,7 +277,7 @@ Inventory 变更必须在 mutateAtomic 的最新快照里比较旧身份、版 ### 定向升级补充 -用户在详情选中精确版本时,先通过 checkVersionUpdate 核验升级方向,再通过 prepareVersionUpgrade 固定候选;最新版检查与计划仍使用原接口。固定候选不可用或版本不符时阻断,不回退 latest。用户确认的计划列出全部共享库存目标,执行使用原锁定基线与候选快照。较旧与不可排序版本不允许覆盖安装;取消计划不写文件。 +用户在详情选中精确版本时,先通过 checkVersionUpdate 核验升级方向,再通过 prepareVersionUpgrade 固定候选;最新版检查与计划仍使用原接口。固定候选不可用或版本不符时阻断,不回退 latest。用户确认的计划仅列出本次选定安装位置,执行使用该位置锁定基线与候选快照;合并库存时保留其他位置的最新状态。较旧与不可排序版本不允许覆盖安装;取消计划不写文件。 ## 固定技能案例文件(待实施补充) diff --git a/openspec/changes/add-dsh-native-plugin/product-design.md b/openspec/changes/add-dsh-native-plugin/product-design.md index 2057b02fc..c040d8e0a 100644 --- a/openspec/changes/add-dsh-native-plugin/product-design.md +++ b/openspec/changes/add-dsh-native-plugin/product-design.md @@ -42,7 +42,7 @@ SkillHub 网站负责发布、审核与维护内容;Registry 提供技能数 | --- | --- | | DSH 原生包、一个 SkillHub 入口 | DSH 插件市场、网站重构 | | 匿名列表、关键词搜索、详情 | 登录、Token、私有访问、账号与权限管理 | -| 页面预选可信项目,不可用时预选用户;对话未指定需选择确认 | 任意安装路径、多目标批量安装 | +| 新安装默认用户级;项目级置于高级选项;管理多份安装时选择 | 任意安装路径、多目标批量安装 | | 完整安装、主动升级、精确卸载、冲突保护 | 自动更新、降级、强制覆盖本地修改 | | 一个来源地址的必要配置 | 多来源聚合、认证中心、收藏和排行榜 | | 对话搜索卡片、详情抽屉、显式安装工具、结果卡片 | 自动发现后安装、模型自主升级卸载 | @@ -232,7 +232,7 @@ Markdown 不执行 HTML/脚本;外部链接明确打开新页面,阻止危 ### 7.1 升级与卸载交互 - “检查更新”仅比较版本与本地状态,不改文件。检查结果显示时间,点击升级前重新生成不可变计划。 -- “升级”在详情内展示旧版→新版、全部受影响安装位置和检查结果;确认后执行。现有 inventory 多目标共用版本,不能静默只升级其中一个;完整规则见操作分册。 +- “升级”就地展示旧版→新版、选定安装位置和检查结果;确认后只更新该位置。按位置版本模型见操作分册与对话管理设计。 - “卸载”针对选中一行,预检后弹窗显示坐标、版本、主机、范围和只读路径;用户确认后移除精确目标,不调用 Registry 删除接口。 - 本地修改、未知来源、缺失基线、Suite 共享、目标变化都要阻断;不提供强制覆盖/强制删除。 - 升级/卸载结果留在页面;刷新已安装条目和详情版本,发现/加载状态单独报告。 @@ -457,7 +457,7 @@ Skeleton沿用局部shadcn组件风格和DSH主题变量,不引入新依赖或 ### 19.1 直接安装确认与原生输入框选择 -市场卡片的“安装”不改变浏览位置、不打开详情页,直接展示技能名、坐标、固定版本和范围选择。有效项目默认预选项目,无可用项目时禁用该项并预选用户;确认按钮在技能、目标和库存核验完成前禁用。取消、Escape、关闭只关闭弹窗,不提交;已提交后的关闭不取消操作。详情页原有安装入口保留。 +市场卡片的“安装”不改变浏览位置、不打开详情页,直接展示技能名、坐标、固定版本和范围选择。新安装默认用户级,项目级置于高级选项,无可信项目时不可选择项目;确认按钮在技能、目标和库存核验完成前禁用。取消、Escape、关闭只关闭弹窗,不提交;已提交后的关闭不取消操作。详情页原有安装入口保留。 对话优先使用 DSH 官方 user-questions 交互,在输入框旁临时作答;用户可选择项目、用户或取消。确认后复用原安装事务和结果卡,不要求模型再猜测答案。该界面由 DSH 渲染,插件不覆盖或手工插入宿主输入框 DOM。 @@ -471,7 +471,7 @@ Skeleton沿用局部shadcn组件风格和DSH主题变量,不引入新依赖或 ### 安装范围与当前状态 -页面安装范围使用“仅此项目 / 所有项目”,分别对应项目级与当前 Host 用户级;“所有项目”不扩展到其他用户或其他 Host。只保留一行范围说明。已有安装时默认选中其范围;两处均有记录时优先当前项目,用户可手动切换。只在另一范围安装时,显示已有安装位置,按钮明确指向当前选择的另一范围,不自动复制。 +页面安装范围使用“仅此项目 / 所有项目”,分别对应项目级与当前 Host 用户级;“所有项目”不扩展到其他用户或其他 Host。只保留一行范围说明。管理已有唯一安装时使用其真实范围;两处均有记录且用户未明确目标时先选择,不自动优先项目或用户。只在另一范围安装时,显示已有安装位置,按钮明确指向当前选择的另一范围,不自动复制。 详情和重新打开的安装弹窗以当前库存为准:已安装显示版本与卸载入口,异常记录显示管理入口,未安装显示明确范围的安装按钮。已完成的历史安装回执不再混入此处;卸载后不得同时展示旧成功条和未安装状态。卸载复用现有目标核验和确认,不因范围改名改变目录或权限。尚在执行或结果不明的安装继续查询原请求,不创建第二个写入意图。 @@ -487,7 +487,7 @@ Skeleton沿用局部shadcn组件风格和DSH主题变量,不引入新依赖或 切换版本是查看,不自动改变安装。已有安装时,查看的精确版本比当前版本新且通过来源核验,操作区显示“升级到 vX”;相同版本仅显示已安装,较旧版本说明不支持降级,不提供覆盖安装;不可可靠比较的版本阻断自动升级。不可访问版本不回退到最新版。 -升级使用原地确认弹窗,展示旧版本→精确新版本及实际受影响的全部范围;当前底层共享库存升级可能同时更新用户和当前项目的安装,不能暗示只更新下拉框选中范围。存在当前会话不能管理的其他目标时仍整体阻断。市场和已安装列表的检查更新继续面向最新版,也使用同一精简弹窗。取消不修改文件;确认后显示处理与结果,异常保留原请求核验和恢复。 +升级使用原地确认弹窗,展示旧版本→精确新版本及选中位置;仅改变选中位置,用户级和项目级可保持不同版本。未选目标不参与文件变更;选中目标本身共享、修改或身份不明时阻断。市场和已安装列表的检查更新继续面向最新版,也使用同一精简弹窗。取消不修改文件;确认后显示处理与结果,异常保留原请求核验和恢复。 ## 使用此技能 diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md index 1b4e938e7..2912814d6 100644 --- a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md @@ -5,7 +5,7 @@ The plugin SHALL enrich conversation search and plaza cards with read-only local #### Scenario: Installed candidates across scopes - **WHEN** a searched skill has matching user and project installation records -- **THEN** cards and model-visible results preserve each recorded version and scope while retaining the market candidate version independently, without relaxing the shared core's cross-target version-conflict protection +- **THEN** cards and model-visible results preserve each recorded version and scope while retaining the market candidate version independently, with release identity owned by each installed target and no inference that sibling targets share a version #### Scenario: Foreign or untracked skills - **WHEN** another Registry, namespace, manual copy or external provider supplies a same-name skill without a matching current-source managed record @@ -107,10 +107,9 @@ The plugin SHALL provide an installation tool and a custom operation result card - **THEN** the Host resolves a fixed version and trusted scope, starts a shared installation operation, and presents separate file and discovery outcomes #### Scenario: Installation request without a specified scope -- **WHEN** the user requests installation of an identified skill without specifying current project or current user -- **THEN** the Host presents a native input-adjacent scope question bound to the selected exact version; it MAY read detail and available targets but SHALL NOT create an operation, download the skill archive or mutate installed files before an exact answer -- **AND** the native question offers only available project/user targets plus cancellation; if the service or answerer is unavailable the result is awaiting-confirmation and a card opens a compact confirmation dialog without detail navigation -- **AND** the model does not claim installation started before human confirmation or issuing another call to select a scope on the user's behalf; a later user message explicitly choosing a scope may initiate a new call with the original still-valid search reference, never the dedicated pending reference +- **WHEN** the user explicitly requests installation of an unambiguously identified skill without specifying a scope +- **THEN** the Host resolves the user-level target and exact selected version without asking a scope question +- **AND** the model cannot select project scope on the user's behalf, and existing-target conflicts are not silently upgraded or overwritten #### Scenario: Pending confirmation is canceled or becomes stale - **WHEN** the user closes the confirmation or its source, conversation, project, Agent, runtime or Host changes @@ -350,7 +349,7 @@ The market SHALL use an independent whole-card detail trigger and sibling instal ### Requirement: Direct market installation and safe native answers -Clicking a market install action SHALL open a compact scope-confirmation dialog without navigating to the detail page. When current installation records exist, the default scope SHALL prefer an installed scope (project first when both exist); otherwise it SHALL be the trusted current project when available, then current user; a default selection SHALL NOT submit installation. The native conversation question SHALL carry the exact live Agent and cancellation signal. Only one exact supported scope answer SHALL allow a single transaction after all source/session/runtime/reference bindings are rechecked. Skipped, custom, cancelled, ambiguous or stale answers SHALL NOT install. Missing native providers SHALL retain the no-write pending-card fallback. +Clicking a market install action SHALL open a compact confirmation dialog without navigating to the detail page. New installations SHALL default to current user. Project installation SHALL be available through advanced options and explicit user requests with a trusted project root. Existing installation management SHALL retain its actual scope rather than being silently redirected to the new-install default. A default selection SHALL NOT submit a page installation. When a native selection question is needed, it SHALL carry the exact live Agent and cancellation signal; skipped, custom, ambiguous or stale answers SHALL NOT authorize writes. Missing native providers SHALL retain a no-write confirmation-card fallback. #### Scenario: Native scope question is canceled - **WHEN** the user cancels or skips the question instead of selecting a supported scope @@ -414,7 +413,7 @@ Selecting a version SHALL be read-only. For an installed skill, a newer verifiab - **WHEN** version 1.0 is installed, 1.2 is latest and the user selects 1.1 - **THEN** checking and confirmation bind 1.1 and no execution occurs until confirmation - **AND** cancellation leaves files and inventory unchanged -- **AND** confirmed execution uses the existing complete-target transaction and displays every affected scope +- **AND** confirmed execution changes only the selected installation target, displays that scope, and preserves all sibling releases #### Scenario: Local file changes or unmanaged targets block upgrade - **WHEN** any required installed target cannot pass the existing baseline or ownership checks @@ -552,3 +551,79 @@ Catalog skill-use entries SHALL create and activate a new conversation in the sa - **THEN** the detail remains open with an error - **WHEN** the user cancels case selection - **THEN** no draft is written and the detail is not reopened. + +### Requirement: Conversation management of current-source installations +The plugin SHALL provide conversation tools and compact cards for listing current-source managed installations, checking latest or specified newer versions, requesting a single-target upgrade or removal, and querying owned operation outcomes. Tools SHALL use trusted session context and the shared lifecycle core, not shell commands. Model-provided confirmation booleans or arbitrary paths SHALL NOT authorize mutation. + +#### Scenario: Multiple installations need selection +- **WHEN** a requested skill has multiple matching installed targets and the user has not specified which one +- **THEN** the Host presents the available installations with versions and scopes and waits for selection rather than applying the new-install default + +#### Scenario: Mutation requires human confirmation +- **WHEN** the target and target release are known +- **THEN** the Host presents an input-adjacent confirmation or a no-write fallback card before any upgrade or removal starts +- **AND** even an explicit scope in model arguments cannot bypass human confirmation + +#### Scenario: Compact operation result +- **WHEN** the user confirms a ready plan +- **THEN** its operation card shows actual progress and a concise outcome without full-page reload or mandatory detail navigation +- **AND** repeated submissions reuse the operation, and historical receipts do not assert present installation state + +#### Scenario: Foreign or modified content +- **WHEN** a target is not attributable to the current source or has changed local contents +- **THEN** management refuses mutation with a relevant reason and does not offer a force action + + +#### Scenario: Accepted management operation does not need another approval +- **WHEN** a real human confirms an upgrade or removal and the Host returns accepted +- **THEN** model-visible guidance states approval is complete and the action is submitted, directs operation-status queries, and prohibits asking for another approval or submitting a duplicate mutation +- **AND** the Client continues to show the same operation rather than creating a new plan + + +#### Scenario: Distinct compact management views +- **WHEN** installed queries, update checks and mutation receipts appear in one conversation +- **THEN** only installed queries use a multi-item list; update checks show version comparison and the relevant update action; mutation and status receipts show compact identity, scope and outcome without duplicate action toolbars +- **AND** successful terminal receipts do not foreground status-query buttons + +#### Scenario: Historical installation buttons become stale +- **WHEN** an installation reference no longer matches the current target or source +- **THEN** no mutation or skill insertion is automatically redirected to a replacement reference +- **AND** the Client replaces unusable actions with a clear explanation and an explicit read-only refresh entry, requiring a new deliberate action on refreshed records + +- **AND** explicit refresh replaces the tool card's visible body with a clearly labelled current result, rather than nesting another installation list inside a historical row; stored transcript metadata remains unchanged + + +#### Scenario: Routine management results omit maintenance controls +- **WHEN** an installation list is readable or an operation has completed successfully without warnings +- **THEN** the view omits persistent refresh controls, technical detail disclosures, duplicate identities and repeated completion explanations +- **AND** installation rows use compact identity, version, scope and relevant actions without nested item cards; small viewports wrap content without clipping +- **WHEN** a historical reference is stale or a read is incomplete +- **THEN** the view offers a contextual read-only query retry without replaying any mutation +- **AND** warnings, recovery uncertainty and human confirmation remain visible when applicable + + +#### Scenario: Group installations without merging management targets +- **WHEN** current-source results contain multiple installations of one namespace and slug +- **THEN** the Client shows the skill identity once with a compact installation selector, displaying the selected target version and actions +- **AND** single installations show a scope label without an unnecessary selector; distinct targets sharing a scope are not dropped +- **AND** each action retains the exact installation reference; changing the visible selection cannot redirect in-flight responses or confirmation to another target + +#### Scenario: Contextual controls in compact grouped cards +- **WHEN** a normal installation is usable +- **THEN** use is prominent and low-frequency check/update-removal management actions are available through a menu +- **WHEN** a specific update check or confirmation is shown +- **THEN** only relevant update or cancel/confirm actions appear, and confirmation locks its target +- **AND** successful and cancelled receipts do not add routine refresh or retry controls; failures expose only applicable recovery actions +- **AND** compact cards have a constrained desktop width and wrap on narrow screens without hiding target identity or scope + + +#### Scenario: Button changes precede a new model turn +- **WHEN** a UI action changes an installation after a tool result was recorded +- **THEN** stored tool history remains a snapshot, and the button does not fabricate or send a model message +- **AND** current installation answers use the freshly read official runtime-context snapshot or a fresh installed query, while new management mutations obtain current tool references rather than treating an old receipt as current state +- **AND** stale target references continue to be rejected by the Host, and user-facing explanations do not quote internal reference values or prefixes as evidence + +#### Scenario: Multiple skill cards fit the conversation container +- **WHEN** multiple grouped skills are shown in a wide tool container +- **THEN** cards appear in at most two columns, each with its own installation selection and relevant actions +- **AND** narrow containers use one column and a single skill stays compact; opened management controls remain visible and operable within their card 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 d357c005c..a3a323e0d 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 @@ -140,13 +140,13 @@ The plugin SHALL check for updates only on user request and SHALL upgrade only t - **WHEN** the candidate is older, incomparable, or the same version with different content - **THEN** upgrade is blocked and local files remain unchanged; an identical current release is reported as unchanged -#### Scenario: All shared targets are manageable -- **WHEN** one inventory item has multiple targets entirely within trusted user and current-project scope with direct ownership -- **THEN** the plan lists all affected targets and upgrades them together only after explicit confirmation of that complete set +#### Scenario: Independent target releases +- **WHEN** a skill is installed in user and project scopes and the user confirms an upgrade of the project installation +- **THEN** only the project files and target release record change; user files, release, timestamps and ownership remain unchanged -#### Scenario: Shared target outside scope -- **WHEN** the item also has another Agent, another project, untrusted or shared-ownership target -- **THEN** upgrade is blocked without expanding scope or partially changing the shared version +#### Scenario: Unselected targets outside current context +- **WHEN** the same source coordinate also has another project, Agent or Suite-owned installation +- **THEN** that sibling is not included in the upgrade and is preserved; shared ownership or an alias of the selected physical target still blocks the operation #### Scenario: Local content modified or baseline missing - **WHEN** existing content differs from its recorded baseline or has no usable baseline diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index 443ad948a..3d0160b9c 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -342,3 +342,27 @@ - [x] 41.5 修复模板填入空输入框时多出首行空白;验证已有换行、中文/emoji、原生引用及附件保留,完成双 RC 浏览器复验。 - [x] 41.6 最终独立评审修复对话详情弹层阻挡案例选择及 Windows 分隔符 ZIP 目录识别;补相应回归,核对最终产物与正式协议。 + +## 42. 对话管理已安装技能 + +- [x] 42.1 确认默认用户级、项目级高级入口、多份时选择,以及按安装位置独立更新的产品范围。 +- [x] 42.1a 冻结逐位置库存、迁移、旧消费者与恢复契约,完成实现设计评审。 +- [x] 42.1b 适配共享核心、CLI、同步及套件,验证不同位置版本与未选位置不变。 +- [x] 42.2 实现当前来源安装查询与检查更新工具,保留范围、异常状态及历史引用边界。 +- [x] 42.3 实现原生目标选择、更新/卸载确认与统一结果卡,复用 Host 和安装核心。 +- [x] 42.4 完成取消、并发、过期上下文、本地修改和来源隔离回归,以及双 RC 浏览器验证。 +- [x] 42.5 独立记录真实模型验收、更新操作说明和验证结果;发布另行安排。 + +- [x] 42.6 人工评审:区分查询列表、更新检查与简洁结果条;严格验证全部历史卡片按钮,增加显式只读刷新且不自动重绑写入。 + +- [x] 42.7 人工验收反馈:移除正常结果常驻刷新和冗余说明,压缩安装行、更新检查与终态回执;保留失效重查及安全确认,验证实际宿主展示。 + +- [x] 42.8 按已批准原型实现同技能分组、组内位置选择、紧凑尺寸和场景按钮策略;验证异步切换、确认目标、旧引用保护与双 RC 实际交互。 + +- [x] 42.9 真实 DSH 多技能双列布局,验证菜单边界及窄屏;交替按钮与真实模型后续轮次,验证新查询、历史引用及更新/取消隔离。 + +- [x] 42.10 修复真实 DSH 更多菜单展开挤乱按钮行的问题,验证多卡/窄屏/缩放、键盘与原目标操作矩阵,打开正确的真实模型会话供人工验收。 + +- [x] 42.11 统一单份/双份安装卡片三行布局与正常等高,列表按6条展开;真实混合多卡验证深浅/窄屏/菜单及目标标识。 + +- [x] 42.12 修复同名技能使用阻断提示挤乱操作行;保留精确安装目标保护,验证生效安装使用、取消及草稿/文件不变。 diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md index 5424e7263..753fb678d 100644 --- a/openspec/changes/add-dsh-native-plugin/verification.md +++ b/openspec/changes/add-dsh-native-plugin/verification.md @@ -281,3 +281,129 @@ - 已同步主线发布记录;公开diff仅为94份源码、回归测试和正式文档,不包含本机任务、日志、截图、宿主配置或凭证。过程证据保存在仓库外 `dsh-use-skill-pr-review-20261022/`,代码评审证据与候选身份单独绑定。 用户已完成人工预览并确认。此次进入草稿PR评审,不表示整栈镜像/auth smoke、远端CI、原生Windows/macOS、真实模型、SaaS或发布已完成;npm版本保持不变,合并与发布另行处理。 + + +## 对话管理与按安装位置版本:实现验收 + +已实现当前来源安装查询、最新/指定较新版本检查、单位置更新、卸载及原操作查询。新安装默认用户级,项目级保留高级入口;用户与项目可保持不同版本。共享核心使用库存 v2,读取 v1 不写文件,首次变更前保存可校验备份。更新、卸载仍需真人确认;本地修改、共享归属、过期上下文及重叠路径均受保护。 + +| 验证层 | 实际结果 | +| --- | --- | +| CLI 完整回归 | Bun 1.3.13:771 tests、3356 assertions,通过;包含真实进程中断和恢复 | +| 最后恢复保护增量 | 47 tests、354 assertions,通过;父子目录及未落库存的待恢复操作不被绕过 | +| 最后共享归属增量 | 141 tests、728 assertions,通过;直接安装/更新/删除及同步清理不能剥离 Suite 归属,force 不绕过 | +| 插件完整检查 | build、typecheck、lint;504 tests、11469 assertions,通过 | +| 双 RC 真实宿主 | 0.2.0-rc.2 与 0.2.1-rc.2 安装同一冻结候选;每版 10 项实际浏览器检查通过,包内 88 个文件逐项匹配,零页面脚本异常 | +| 旧消费者 | 已发布 CLI 0.1.10 在隔离 v2 库存上 list/remove 均报错,所测文件未改变;不推断其他旧命令或版本都安全 | + +双 RC 覆盖卡片确认/取消、项目独立更新且用户副本不变、单范围卸载、原生问题确认/取消、原操作查询、历史重载、旧引用拒绝和 460px 范围可读性。原生工具脚本使用真实 Host 和文件系统,但初始历史由验收脚本构造,不冒充模型消息。NO_PROVIDER 分支由行为测试验证;真实浏览器确认卡使用实际 begin/confirm RPC。首次操作读取仍在 committing 时继续查询同一操作,不重放写入。 + +评审修复了窄屏范围截断、父子目录的恢复冲突,以及 CLI 共享归属绕过。最后独立只读评审未发现新增明确阻断问题。公开 diff 仅源码、回归测试和正式文档;配置、模型凭证、截图、日志与 tarball 均保留在仓库外。 + +CLI 全量结果与后续专项分开记录,不声称最终新增用例都来自同一次全量运行。旧 CLI 混写不支持,CLI 与插件须协调升级;旧库存备份不能直接回滚已分位置升级的文件。SaaS、旧 Desktop、原生 Windows/macOS、合并及 npm 发布均不计为本轮验收。 + +本机详细证据保存于仓库外 `dsh-conversation-management-20261023`,包含 `logs`、`evidence/native-acceptance.md`、双 RC 结果及分候选模型记录。正式运行契约以本目录设计和 specs 为准,测试报告不成为另一套需求。 + + +### 真实模型验收 + +使用用户此前指定的 `astron-code-latest`,在隔离的新 RC 原生对话中执行;真实会话没有人工伪造的工具历史。最终候选完成 list_installed → check_update → upgrade → 真人原生确认 → operation(completed),模型报告完成。核对项目副本 1.3、用户副本 1.2 的实际目录指纹与库存一致,所有未选位置记录不变;已接受操作后没有重复确认或再次提交。最终回复没有复述来源 URL 或修订引用。 + +前轮真实模型暴露了“两范围一起更新”的推荐、来源 URL 回显,以及 accepted 后再次追问确认;已修正工具指引、模型投影和分状态提示。批量推荐与 URL 修复在中间候选复验,accepted 修复在最终候选完整闭环复验。较早候选也完成了原生范围选择及取消卸载后库存不变;不把这些历史结果冒充最终候选新增模型测试。 + +剩余体验限制:最终模型在执行前仍重复询问范围和文字确认,尽管用户已明确项目;用户回应后才进入原生操作确认。Host 不会因此误选、提前写入或重复执行,但模型对话尚非零摩擦。最终候选用了 3 个用户回合,整个调试验收共 8 回合;未继续追加模型请求。该结果只覆盖指定模型与所测场景,不证明所有模型或任意自然语言均能正确定位。 + + +### 人工评审 42.6:历史按钮与精简回执 + +人工截图暴露两个此前未完整覆盖的交互问题:升级后旧引用被 Host 正确拒绝,但四个按钮仍可点击且只有泛化错误;检查更新、管理结果及操作查询重复使用大卡片,难以区分。 + +已在真实历史对话逐一点击使用、检查更新、更新和卸载:RPC 正常发出,业务返回 context-stale,整个库存和目标文件均未改变。修复保留 Host 保护,Client 使用原始引用只读预检,失效后撤销不可用操作并提供显式刷新。刷新替换整张工具卡内容,不嵌套新列表,不改原工具历史,不自动执行旧请求。未知读取失败不误报已删除;正常取消案例不标记安装失效。 + +呈现分为安装列表、仅突出版本变化的更新检查、简洁的操作结果条。成功结果不突出重复查询按钮;告警、发现待确认与恢复状态继续保留。初版刷新仍嵌套列表的呈现问题在实际浏览器发现后再次修正,其旧结果不作为最终候选证据。 + +- 完整插件 build/typecheck/lint 与 509 tests、11499 assertions 通过;独立增量复核 81 tests、326 assertions 通过。 +- 两版支持 RC 安装同一最终候选,88 个包文件匹配;每版 19 项实际浏览器验证通过,涵盖有效四按钮、使用/更新/卸载取消与确认、升级后四个旧动作拒绝、只读刷新与整卡替换、来源切换/恢复、深色及窄屏。零页面脚本异常,所有未选位置的记录与实际文件哈希保持不变。 +- 原真实模型历史额外只读复验:项目旧卡刷新为 1.3,检查为最新版,使用案例取消后行仍可操作;库存和全部技能文件不变。本增量无模型发送请求,不能冒充再次运行模型推理;前一轮真实模型证据仍单独保留。 + +证据为仓库外 `dsh-conversation-management-20261023/evidence/button-review.md` 及其列出的前后矩阵和截图。预览更新保留端口和历史会话;未提交、合并或发布。 + + +### 精简回执候选:再次真实对话验收 + +在包含 42.6 修复的同一候选上,以 astron-code-latest 完成 4 个真实用户回合:查询安装和检查更新 → 原生 UI 确认项目升级 → 主动查询操作完成 → 原生 UI 取消卸载 → 再查安装。项目版本从 1.2.0 升为 1.3.0,用户副本仍为 1.2.0;未选位置元数据与 24 个文件指纹不变,取消卸载前后库存一致。本轮没有升级接受后的重复确认。 + +模型曾使用升级前的旧引用请求卸载,被 Host 拒绝后重新查询;未发生越过确认的写入。仍有一项表达缺陷:模型重查后在普通文字中回显内部 itemKey,尽管工具说明禁止展示。它不是凭证,但不适合产品展示,尚未修复;不将本轮判定为全部无问题。 + +本轮为隔离 Registry/技能夹具、真实 Host 和真实模型;初始项目 1.2.0 是测试准备,不是模型安装。本轮不验证真实市场或 SaaS。真实历史、截图和指纹核验保留在仓库外 `dsh-conversation-management-20261023/evidence/ui-real-model/`,预览会话标题为“人工验收|真实对话|技能管理”。未修改历史、提交或发布。 + + +### 人工评审 42.7:进一步压缩对话结果 + +正常安装列表和完成/取消回执移除常驻刷新;去掉头像、嵌套条目边框、重复身份与默认技术详情,使用紧凑行排列名称、版本、范围和操作。只有过期引用、读取失败或不完整时提供“重新查询”,成功后隐藏;告警、恢复状态与真人确认保留。重新查询仍只读且替换整卡,不自动重绑旧写入意图。 + +完整插件 build/typecheck/lint 与 510 tests、11512 assertions 通过。独立复核发现更新检查的 local-record-unverified 分支缺少重新查询入口,已修复并补断言;独立 82 tests、339 assertions 及 diff 检查通过。两版支持 RC 各 20 项实际浏览器验证通过,覆盖紧凑展示、正常状态无刷新/详情、有效与过期按钮、取消与确认、显式重新查询整卡替换、来源切换和窄屏;零页面异常、零新模型请求,未选记录及文件指纹不变。 + +同一真实模型历史、相同 1440×1000 深色视口测量:两条安装列表由 428px 降至 117px,更新检查由 214px 降至 44px,成功回执由 172px 降至 37px。历史消息未改写,本轮仅重新渲染及只读复验,不冒充新模型推理。证据保存在仓库外 `dsh-conversation-management-20261023/evidence/compact-ui/`,同端口预览已更新,未提交或发布。 + + +### 42.8 按技能分组的正式实现 + +按已确认原型实现同来源技能分组,身份显示一次,多安装位置用组内选择器、单份安装保留范围文字;每个目标仍以原 itemKey 操作。卡片限制为 590px,正常管理操作收进更多;确认替换组并锁定原目标,取消返回原选择。未知结果只查询原操作,正常完成不附加刷新。未引入生产依赖或修改 Host/共享安装核心。 + +完整插件 build/typecheck/lint 及 514 tests、11552 assertions 通过;独立定向 86 tests、378 assertions 与 diff 检查通过。单份无对应 Trigger 的 TabPanel 可访问性问题已在复核中修正。OpenSpec 严格校验通过。上述结果不替代真实浏览器或新模型验收;最终样式修正后双 RC 各 25 项实际浏览器验证通过。 + + +实际宿主验证覆盖分组、两位置不同版本、菜单键盘关闭与焦点、更新/卸载取消和确认、旧引用拒绝与只读重新查询、来源切换以及浅深色/窄屏。通过拦截已获取的真实检查响应并延迟送回,验证等待期间切换位置不会污染新目标;没有伪造接口业务数据。未选位置记录和文件保持不变,零新模型请求。 + +目视检查发现继承的 Tab 下边距导致卡片偏高、浅色选中文字对比不足;局部 CSS 已修正,并再次通过 build/typecheck/lint、18 tests/121 assertions 与双 RC 全部 25 项浏览器回归。最终所测双安装卡片宽 590px、高 114px,范围、版本对齐且浅深色可辨识。前述 514 项全量与最后 CSS 定向检查分别记录,不混称同一轮执行。 + +证据保存在仓库外 `grouped-management-20261026/`。同端口预览恢复,真实模型历史保留;本轮新建工具验收会话明确标记未调用模型,不冒充新模型验证。未提交、合并或发布。 + + +### 42.9 多技能双列与按钮/模型连续对话 + +多技能结果按实际容器宽度最多两列,整个结果最大 720px;单技能最大 380px,窄容器单列。更多菜单在所属卡片内按需展开,避免浮层被宿主裁切。未改变 Host 安装状态与确认规则。完整 build/typecheck/lint、514 tests/11555 assertions,独立 86 tests/382 assertions 与 diff 检查通过。 + +源码核对确认 UI 按钮直接调用 Host RPC,不伪造用户消息或重写原工具历史;模型后续看见的历史快照可能落后于按钮更新。过期引用写入保护和模型回答是否重新查询,是分别验收的两层。真实模型与最终浏览器结果待本轮回写。 + + +真实连续对话发现尚未解决的问题:按钮更新后第 2 轮模型主动重新查询并回答正确;卸载取消后第 4 轮也重新查询并回答正确。但在增强快照提示的候选上,按钮真实卸载 data-summary 后,第 5 轮模型没有查询工具,仍引用旧历史声称技能存在。库存和实际文件已确认移除。提示词不能作为当前状态一致性的强保证,本轮不得标记连续对话全面通过;保留失败历史并调查官方上下文更新机制。模型复述过期结果与 Host 是否正确拒绝过期写入分别记录。 + + +机制修复复验:在原真实会话第 6 轮逐字复用第 5 轮自然问题,模型正确回答 data-summary 已不在安装记录、meeting-notes 项目版 1.3/用户版 1.2。该轮没有调用工具,依据官方 `runtime-context` 事件中本轮本地读取的 7 条完整安装记录;不是旧历史或伪造消息。两版官方装配检查确认独立命名快照、JSON 可解析、相同事实文本稳定。前 5 轮与机制候选第 6 轮分别保存,不混称同一候选全流程通过。 + +真实模型共 6 个用户回合,隔离测试技能通过真实 Host 写入。UI 操作未触发模型消息;取消卸载前后文件和库存相同,仅指定项目更新及指定项目卸载改变文件,未选位置不变。前轮内部引用回显及第 5 轮错误回答保留。该复验覆盖所测模型与顺序操作;禁用可选运行时上下文时仍须查询工具,不提供自动状态同步保证。 + + +动态上下文实现最终检查:build/typecheck/lint 与 520 tests/11670 assertions 通过;独立新增上下文检查 9 tests/132 assertions 通过。复核修复了断开来源无新状态、变化时间戳破坏官方去重,以及旧 RC 模板插值编码导致预算膨胀三项问题。条目数组以编码后的 JSON 计量最多 6000 字符、最多 40 条,固定说明和元数据另计;超限明确截断,不能据此推断缺项卸载。读取等待上限 3 秒,不承诺强制终止底层文件 I/O。 + +第 6 轮模型证据对应机制候选;其后最终包仅调整特殊字符编码后的预算计算,不再调用模型。最终包的官方装配和浏览器证据单独保存,不能把此前模型推理冒充最终字节完全相同的重跑。 + +最终包两版官方装配通过,89 个包文件分别匹配,旧版布局 smoke 与新宿主真实会话深浅色/窄屏菜单验证通过、零页面异常。宽屏实际两列各约 355px,窄屏单列。最终源码验证及各候选模型、文件隔离、上下文事件证据见仓库外 `dsh-multi-real-20261026/summary.md`。预览 39905 恢复并在 Windows 打开,“人工验收|多技能双列|连续对话”会话保留;未提交或发布。 + + +### 42.10 更多菜单展开与按钮逻辑复验 + +用户截图经真实宿主复现:原展开使卡片 86px 增至 182px,并挤动位置和使用按钮。已改为受控展开按钮,展开项置于独立下一行横向排列;Escape 回焦、失焦关闭及点击执行后收起保留。此改动含 Client 展开状态,不能只称 CSS 修复;Host 请求、确认及原目标校验未改变。 + +完整插件 build/typecheck/lint、520 tests/11671 assertions,独立 93 tests/427 assertions 通过。双 RC 各 29 项实际浏览器检查通过:1440px、1152px(125%缩放等效可用容器)及 460px 中,展开仅增加 36px,主要控件纵坐标不变,两菜单项同行;核对首末卡片、键盘焦点和原目标。 + +按钮逻辑实证:检查只读;更新/卸载连点各只产生一份 begin 和 confirm,实际操作日志各一条;取消不写,未选位置文件指纹不变;旧引用拒绝且重新查询不重放写入;使用只填草稿、不发送模型消息。后续模型旧状态误答的修复与第 6 轮复验沿用 42.9 证据,本轮没有新增模型调用。独立证据为仓库外 `dsh-menu-review-20261026/`,旧真实模型历史完整保留。 + + +### 42.11 混合单份/双份卡片对齐 + +正常安装卡统一身份、位置、版本与操作三行,单份位置文字和多份 Tab 使用相同高度区域。真实模型历史中 5 张混合卡由原单份 86px/双份 120px 统一为 107px;位置行高 28px,版本与按钮中心对齐。每张卡的管理展开增加 36px,原三行不移位。深浅主题、1440px、1152px(缩放等效可用宽度)及 460px 窄屏检查通过,零页面异常。未新增模型请求或修改真实历史。 + +列表每批改为 6 个技能,隔离的真实组件浏览器夹具实际点击通过 6→12→13,末次无查看更多;此项是 UI 夹具,不冒称 13 项真实 Host 库存或模型验收。完整 build/typecheck/lint、522 tests/11688 assertions,独立 27 tests/186 assertions 与 OpenSpec 严格校验通过。证据为仓库外 `dsh-card-alignment-20261026/`;操作业务逻辑未改。 + + +## 42.12 同名安装使用阻断与布局 + +真实手动会话中,用户范围 multi-1b1b769d/meeting-notes 与项目范围 multi-manual-e29e0eba/meeting-notes 均为1.2.0;官方发现项目副本,用户副本返回 skill-shadowed 是精确目标保护。修复使用错误为独立全宽行,文案说明所选安装未生效,不猜测所有宿主的范围优先级,不绕过保护。build/typecheck/lint及523 tests/11693 assertions通过,OpenSpec严格校验通过。最终候选 d6b643afcbe1f589ada040ed2e5664da4bb762e961966d6713c70a831c5949b1 更新两RC,详细交互在39905验证:用户版阻断且按钮不移位,项目版成功开案例,取消后草稿为空,meeting相关文件不变。用户正在人工操作,因此未填入草稿;无新增模型调用,不宣称本轮模型E2E通过。保留人工更新/卸载,不恢复夹具。证据:仓库外 `dsh-shadowed-use-20261026/summary.md`。未提交或发布。 + + +### 提交前主线集成验证 + +无冲突合入最新主线,保留 Registry 子路径下载修复。集成后使用 Bun 1.3.13:CLI lint/typecheck/build 与 783 项测试、3429 次断言通过;DSH build/typecheck/lint 与 523 项测试、11693 次断言通过。OpenSpec 严格校验和 diff 检查通过。最终只读评审未发现新增阻断问题;公开改动为 105 份源码、回归测试和正式文档,无本地截图、日志、配置或构建包。远端 CI、合并和 npm 发布独立记录,不以本地结果替代。 diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index 1598608cf..f90273f2f 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -125,7 +125,7 @@ Each visibility setting accepts `auto`, `show` or `hide`. Auto detects actual an - Browse supports search, server sorting, real labels and explicit **Load more**, normally 12 results per page. A first-page 404/405 can fall back to bounded legacy search, with an explicit pagination/sorting limitation. A failed label filter never silently returns unfiltered results. - Details show fixed-version documentation and optional tabs. Select a downloadable version before confirming installation. Existing-target protection remains; skill downgrades are not supported. -- Installed records can be filtered by source, scope and keyword. The right-side version card contains detail refresh and update checks. Upgrade confirms the complete target set; removal affects one confirmed location. Missing directories have a separate stale-record cleanup action. +- Installed records can be filtered by source, scope and keyword. The right-side version card contains detail refresh and update checks. Upgrade and removal affect only the selected location; other locations retain their versions. Missing directories have a separate stale-record cleanup action. - `astron_skillhub_search` only searches. Result cards provide a detail action; the drawer offers explicit installation confirmation. - An explicit request to install a uniquely selected result does not require opening the drawer first. `astron_skillhub_install` verifies the search-time version and content snapshot. It accepts a `selectionRef` or a full coordinate, exclusively, and a `user`/`project` scope. Expired or ambiguous references are rejected without switching to current latest. @@ -183,7 +183,7 @@ See the [release guide](../../openspec/changes/add-dsh-native-plugin/release-gui ### Open-source interaction update (unreleased) -Market cards open details and offer separate installation-management actions. Multiple installed scopes require a selected target. Conversation installs without an explicit scope use the Host input-adjacent question UI to choose current project, current user, or cancel before any installation write. Hosts without an answerer retain a pending card that opens a compact confirmation dialog. Market install actions open the same dialog without detail-page navigation. A trusted project is preselected when available, but never submitted automatically. A later explicit user scope may start a new call. Details omit complete leading frontmatter and reuse public reads for 60 seconds without bypassing write validation. The versions tab requests `downloadableOnly=true`, requiring the updated open-source backend; SaaS synchronization and acceptance remain separate. +Market cards open details and offer separate installation-management actions. Multiple installed scopes require a selected target. After an explicit install request, an omitted scope defaults to current user; project scope must be explicitly selected. Market install actions open the same dialog without detail-page navigation. Current user is preselected, with project scope in advanced options; the dialog never submits automatically. A later explicit user scope may start a new call. Details omit complete leading frontmatter and reuse public reads for 60 seconds without bypassing write validation. The versions tab requests `downloadableOnly=true`, requiring the updated open-source backend; SaaS synchronization and acceptance remain separate. After confirmation, the installation dialog expands to the right, keeping the skill, version, and scope on the left and concise real progress on the right. Narrow screens stack the sections. Progress remains indeterminate until completion; closing the dialog does not cancel a submitted installation. @@ -191,4 +191,11 @@ Installation controls use current inventory rather than historical success recei Removal from market cards, installed rows, and detail controls opens an in-place confirmation. It shows the skill, version, selected scope, and impact. Opening only prepares a plan; confirmation executes it. Cancel and Escape never remove files, and existing expiry, identity, and local-file checks still apply. -Version selection is read-only. A verifiable newer selected version offers an explicit exact-version upgrade; equal versions do not upgrade again, older versions cannot downgrade, and unorderable versions are blocked. Confirmation lists all installed targets that the shared transaction updates. Latest-update checks remain available, with in-place confirmation, local-change protection, and recovery. +Version selection is read-only. A verifiable newer selected version offers an explicit exact-version upgrade; equal versions do not upgrade again, older versions cannot downgrade, and unorderable versions are blocked. Confirmation lists only the selected installation location. Latest-update checks remain available, with in-place confirmation, local-change protection, and recovery. + + +### Conversation management + +`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. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index a95ef8f01..5f4b708ad 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -125,7 +125,7 @@ Loader entry ID 为 `astron-skillhub`;`skillhub` 是 Host/Remote 服务名。 - **浏览技能**:搜索、服务端排序、真实标签筛选、点击加载更多。新目录接口每批 12 项;旧来源首批 404/405 可回退受限搜索,明确提示不支持分页和排序。选中标签失败不会退回未过滤结果。 - **技能详情**:查看文档、版本和可选模块;可“查看此版本”后确认安装指定的可下载版本。已有其他版本时保留覆盖保护,不执行降级。 -- **已安装**:按来源、范围和关键词查看本地记录。详情右侧“版本与更新”提供重新读取及检查更新;升级确认完整目标集合,卸载只影响一个明确位置。目录缺失时可单独确认清理失效记录。 +- **已安装**:按来源、范围和关键词查看本地记录。详情右侧“版本与更新”提供重新读取及检查更新;升级与卸载只影响选中的一个安装位置,其他位置的版本保持不变。目录缺失时可单独确认清理失效记录。 - **对话搜索**:`astron_skillhub_search` 只搜索,不安装或执行技能。结果提供明确详情动作;详情抽屉可确认安装。 - **对话安装**:用户明确要求安装唯一搜索结果时,不必先打开详情。`astron_skillhub_install` 核验搜索时的精确版本和快照;接受 `selectionRef` 或完整坐标,二者互斥,范围为 `user`/`project`。过期或歧义引用拒绝执行,不改装当前 latest。 @@ -183,7 +183,7 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh ### 开源交互更新(待发布) -市场卡片支持点击进入详情及独立安装、卸载、检查更新/升级操作;多范围记录先选择管理目标。未指定范围的对话安装优先在 DSH 输入框旁显示原生范围选项,选择并提交才安装;可取消。无宿主问答能力时降级为待确认卡片,直接打开紧凑确认弹窗。市场卡片的安装按钮也直接打开确认弹窗;有效项目默认预选项目,但不自动提交。用户后续明确范围也可发起新调用。详情隐藏完整首部元数据,保留安全 Markdown;公开读取缓存 60 秒,写入仍重新核验。版本 Tab 请求 `downloadableOnly=true`,需要本次开源后端契约;SaaS 尚需单独同步和验收。 +市场卡片支持点击进入详情及独立安装、卸载、检查更新/升级操作;多范围记录先选择管理目标。用户明确要求安装后,未指定范围默认当前用户;明确指定当前项目时安装到项目。市场卡片的安装按钮也直接打开确认弹窗;默认预选当前用户,项目级位于高级选项,但不自动提交。用户后续明确范围也可发起新调用。详情隐藏完整首部元数据,保留安全 Markdown;公开读取缓存 60 秒,写入仍重新核验。版本 Tab 请求 `downloadableOnly=true`,需要本次开源后端契约;SaaS 尚需单独同步和验收。 安装确认后弹窗保留并向右展开:左侧固定技能、版本和范围,右侧展示简洁的实际进度与结果;窄屏上下排列。处理中不模拟百分比,关闭窗口不取消已提交的安装。 @@ -191,4 +191,14 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh 市场卡片、已安装列表和详情中的卸载可直接打开确认弹窗,不再为卸载跳转到另一详情页。确认框保留技能、版本、所选范围和影响说明;打开仅核验,确认才执行。取消与 Esc 不卸载,过期计划和异常安装仍由原事务校验阻断。 -切换版本只查看内容,不会自动更新。已安装时选择可核验的较新版本,可“升级到该版本”;同版本不重复升级,旧版本不支持降级,不可排序版本阻断升级。确认框列出实际同步更新的全部安装范围。检查最新版仍可从市场或已安装列表使用;升级原地确认,保留本地修改保护与恢复。 +切换版本只查看内容,不会自动更新。已安装时选择可核验的较新版本,可“升级到该版本”;同版本不重复升级,旧版本不支持降级,不可排序版本阻断升级。确认框只列出本次选定的安装位置。检查最新版仍可从市场或已安装列表使用;升级原地确认,保留本地修改保护与恢复。 + + +### 对话管理已安装技能 + +- `astron_skillhub_list_installed`:当前来源在当前用户/项目中的受管理记录,不代表全机技能;文件存在不是内容完整性证明。 +- `astron_skillhub_check_update`:以列表中的位置引用检查最新或指定较新版本;失败、无更新和不可升级分别展示。 +- `astron_skillhub_upgrade` / `astron_skillhub_uninstall`:必须使用列表中的精确引用,并由 Host 发起真实用户确认。模型不能传路径、确认标志或执行计划。原生问答不可用时返回确认卡;取消不修改文件。 +- `astron_skillhub_operation`:以原回执查询本会话操作结果,不重复提交操作;历史成功不代表当前仍安装。 + +多份安装先选择目标;更新不改变其他位置版本。本地修改、来源/会话变化和失效引用阻止操作。启用逐位置库存后,同机共同管理技能的 CLI 必须同步升级;旧版 CLI 与新库存混用不受支持。 diff --git a/plugins/dsh/src/client/index.tsx b/plugins/dsh/src/client/index.tsx index 3bd54b20e..9727de591 100644 --- a/plugins/dsh/src/client/index.tsx +++ b/plugins/dsh/src/client/index.tsx @@ -1,3 +1,4 @@ +import { ManagementToolView, type ManagementViewServices } from './management-tool-view.js'; import { SkillDraftRetentions } from './use-skill-drafts.js'; import { newSkillConversation } from './use-skill-navigation.js'; import type {} from '@deepseek-ai/dsh-client-locale/client'; @@ -137,6 +138,35 @@ 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']) { + ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name, + inject: sessionId => { + const receipt = receiptReadsFor(sessionId); + const call = async (action: (remote: AgentContext['remote']) => Promise): Promise => { + receipt.assertCurrent(); + if (!(ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.retainedBy.mainView ?? 0)) throw new Error('请回到原会话后重试。'); + const scope = ctx.sessions.scope(SessionId(sessionId)); + if (!scope) throw new Error('当前会话已关闭。'); + return withSessionRemote(scope, action); + }; + const services: ManagementViewServices = { client, installation: installationFor(sessionId), assertCurrent: receipt.assertCurrent, + refreshInstalled: signal => call(async remote => { + const source = await ctx.remote.skillhub.getContext(); + if (!source.ok || !source.value.configured) throw new Error('当前技能来源不可用。'); + receipt.assertCurrent(); + const installed = await remote.skillhub.listInstalled(source.value.sourceRevision, signal); + if (!installed.ok) throw new Error('无法读取当前安装记录。'); + receipt.assertCurrent(); + return { sourceRevision: source.value.sourceRevision, list: installed.value }; + }), + 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)), + }; + return { services }; + }, + }, props => )); + } ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'astron_skillhub_install', inject: sessionId => { diff --git a/plugins/dsh/src/client/install-control.tsx b/plugins/dsh/src/client/install-control.tsx index c851247ba..fe9c5dc63 100644 --- a/plugins/dsh/src/client/install-control.tsx +++ b/plugins/dsh/src/client/install-control.tsx @@ -37,7 +37,7 @@ export function InstallControl({ detail, sourceRevision, services, initiatedFrom const trigger = useRef(null); const [confirmation, setConfirmation] = useState<{ key: string; targetToken: string; reinstall: boolean } | null>(null); const writableHost = useWritableHost(services); - const [scope, setScope] = useState<'project' | 'user'>('project'); + const [scope, setScope] = useState<'project' | 'user'>('user'); const [upgradeCandidate, setUpgradeCandidate] = useState<{ item: InstalledSkillList['items'][number]; version: string; source: string } | null>(null); const [submissionError, setSubmissionError] = useState(null); const refreshedToken = useRef(null); @@ -110,9 +110,13 @@ export function InstallControl({ detail, sourceRevision, services, initiatedFrom services={services.upgrades} sourceRevision={sourceRevision} source={upgradeCandidate.source} item={upgradeCandidate.item} targetVersion={upgradeCandidate.version} onClosed={() => setUpgradeCandidate(null)} />} -