From 6c276d38fc770deedd881d26b0e2e11798cb332a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:08:14 +0800 Subject: [PATCH] fix(dsh): preserve legacy compatibility and Windows lifecycle safety (#1033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保持共享库存 v2 与旧版可读普通日志,从真实入口派生启停;隔离扩展日志并保护恢复时的合法后续写入。修复 Windows 库存/日志原子替换及目标锁的短时共享冲突,不删除原数据、不抢占活锁、不重放事务。 补充安全探针诊断与仅公共下载内容的 CI 缓存,Profile 和安装状态仍逐次隔离。准备 CLI 0.3.0 / DSH 0.6.0,npm 发布另行执行。 最终提交的 CLI 三平台、DSH 14 项矩阵及 12 份启停场景摘要均通过。Windows 新 RC 依赖准备仍接近限额,作为 CI 准备稳定性风险保留。 Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/workflows/check-dsh-plugin.yml | 76 ++++++++- .github/workflows/release-cli.yml | 1 + cli/CHANGELOG.md | 23 +++ cli/README.md | 13 +- cli/package.json | 2 +- cli/src/generated/pkg-info.ts | 2 +- cli/src/integrations/installed-projection.ts | 11 +- cli/src/integrations/native-contract.d.ts | 2 +- cli/src/integrations/native-runtime.ts | 2 +- cli/src/integrations/native-toggle.ts | 32 ++-- cli/src/integrations/operation-store.ts | 147 +++++++++++++++--- cli/src/integrations/removal-inventory.ts | 4 +- cli/src/integrations/removal-journal.ts | 4 +- cli/src/platform/atomic-rename.ts | 16 ++ cli/src/services/inventory-cas.ts | 2 +- cli/src/services/skill-target-lock.ts | 34 ++-- cli/src/stores/inventory-store.ts | 71 +++++++-- cli/test/fixtures/toggle-crash-child.ts | 13 +- cli/test/integration/doctor-command.test.ts | 2 +- .../install-crash-recovery.test.ts | 8 +- .../integrations/installed-projection.test.ts | 29 +++- .../unit/integrations/native-removal.test.ts | 18 +-- .../unit/integrations/native-toggle.test.ts | 136 ++++++++++++++-- .../unit/integrations/operation-store.test.ts | 103 +++++++++++- .../removal-crash-recovery.test.ts | 6 +- .../integrations/removal-inventory.test.ts | 2 +- .../integrations/transaction-journal.test.ts | 6 +- cli/test/unit/platform/atomic-rename.test.ts | 29 ++++ .../unit/services/skill-target-lock.test.ts | 69 ++++++++ cli/test/unit/stores/inventory-store.test.ts | 32 +++- cli/test/unit/stores/inventory-v2.test.ts | 38 ++++- .../add-dsh-native-plugin/release-guide.md | 27 +++- .../add-dsh-native-plugin/release-notes.md | 14 +- .../skill-toggle-design.md | 39 +++-- .../specs/dsh-skill-consumption/spec.md | 19 +++ .../changes/add-dsh-native-plugin/tasks.md | 9 ++ .../add-dsh-native-plugin/verification.md | 14 ++ plugins/dsh/README.en.md | 18 ++- plugins/dsh/README.md | 18 ++- plugins/dsh/package.json | 2 +- plugins/dsh/scripts/probe-host.mjs | 30 +++- plugins/dsh/scripts/smoke-native-host.mjs | 131 ++++++++++++++-- plugins/dsh/src/host/index.ts | 4 +- plugins/dsh/src/host/installation-summary.ts | 2 +- plugins/dsh/src/host/management-context.ts | 4 +- .../skill-availability-fixture.mjs | 49 +++++- .../test/integration/skill-toggle-fixture.mjs | 108 ++++++++----- plugins/dsh/test/management-context.test.ts | 11 +- plugins/dsh/test/probe-diagnostics.test.ts | 52 +++++++ plugins/dsh/test/search-installation.test.ts | 4 +- .../dsh/test/skill-primary-actions.test.tsx | 11 +- plugins/dsh/test/smoke-native-host.test.ts | 114 +++++++++++++- 52 files changed, 1365 insertions(+), 248 deletions(-) create mode 100644 cli/src/platform/atomic-rename.ts create mode 100644 cli/test/unit/platform/atomic-rename.test.ts diff --git a/.github/workflows/check-dsh-plugin.yml b/.github/workflows/check-dsh-plugin.yml index 6c90778d9..a25a2ce85 100644 --- a/.github/workflows/check-dsh-plugin.yml +++ b/.github/workflows/check-dsh-plugin.yml @@ -81,9 +81,26 @@ jobs: run: | mkdir -p artifacts npm pack --ignore-scripts --pack-destination artifacts + - name: Validate public download cache before restore + working-directory: plugins/dsh + run: node --input-type=module -e "import { prepareDownloadCache } from './scripts/smoke-native-host.mjs'; await prepareDownloadCache(process.argv[1]);" "${{ runner.temp }}/skillhub-public-npm" + - name: Restore public npm download content + id: public-downloads + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-0.2.0-rc.2 + restore-keys: | + dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}- - name: Verify native DSH Host in an isolated Web Profile working-directory: plugins/dsh - run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-smoke-summary.json + run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-smoke-summary.json --download-cache "${{ runner.temp }}/skillhub-public-npm" + - name: Save public npm download content + if: matrix.node == '24.2.0' && steps.public-downloads.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-0.2.0-rc.2 - name: Verify retained Cordis family in an isolated Web Profile if: matrix.os == 'ubuntu-latest' && matrix.node == '24.2.0' working-directory: plugins/dsh @@ -147,3 +164,60 @@ jobs: path: plugins/dsh/host-release-summary.json if-no-files-found: warn retention-days: 7 + + activation: + name: Installed skill activation (${{ matrix.os }}, DSH ${{ matrix.dsh }}) + needs: check + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + dsh: ['0.2.0-rc.2', '0.2.1-rc.2'] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24.2.0' + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + - name: Install local Registry fixture dependencies + working-directory: cli + run: bun install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: skillhub-dsh-development-package-${{ matrix.os }}-node-24.2.0 + path: plugins/dsh/artifacts + - name: Validate public download cache before restore + working-directory: plugins/dsh + run: node --input-type=module -e "import { prepareDownloadCache } from './scripts/smoke-native-host.mjs'; await prepareDownloadCache(process.argv[1]);" "${{ runner.temp }}/skillhub-public-npm" + - name: Restore public npm download content + id: public-downloads + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-${{ matrix.dsh }} + restore-keys: | + dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}- + - name: Verify installed skill activation in a fresh isolated Profile + working-directory: plugins/dsh + run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-toggle-summary.json --download-cache "${{ runner.temp }}/skillhub-public-npm" --scenario skill-toggle --dsh-version '${{ matrix.dsh }}' + - name: Verify watch-off activation in a fresh isolated Profile + working-directory: plugins/dsh + run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-toggle-watch-off-summary.json --download-cache "${{ runner.temp }}/skillhub-public-npm" --scenario skill-toggle-watch-off --dsh-version '${{ matrix.dsh }}' + - name: Save public npm download content + if: steps.public-downloads.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-${{ matrix.dsh }} + - name: Upload sanitized activation summaries + if: always() + uses: actions/upload-artifact@v4 + with: + name: skillhub-dsh-activation-${{ matrix.os }}-${{ matrix.dsh }} + path: plugins/dsh/host-toggle*-summary.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index c54b7e6fb..129829624 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -297,6 +297,7 @@ jobs: EOF gh release create "$TAG" \ + --latest=false \ --title "CLI ${VERSION}" \ --notes-file release-notes.md \ "skillhub-cli-${VERSION}.tar.gz" \ diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 5622c3832..7d36dbbc3 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,29 @@ All notable CLI behavior changes are documented in this file. +## 0.3.0 + +### Changed + +- Retain strict inventory v2 and derive activation from trusted entry files, without a second + persisted activation state. Preserve normal published CLI 0.2.0 interoperability. +- Preserve disabled installations across namespace synchronization and doctor reconciliation. + CLI upgrade refuses disabled targets, including with `--force`; Suite operations cannot adopt + disabled positions. This release does not add CLI enable/disable commands. +- Supply the compatible local installation core for DSH `0.6.0`, including entry-only + enable/disable and recovery. The DSH plugin bundles this core and does not invoke CLI commands. + +### Upgrade and rollback + +- No coordinated CLI upgrade is required for shared inventory. Historical explicit force + install/upgrade/sync may overwrite disabled entries; explicit remove may delete them. + New readers must reflect the resulting files; default paths must not unintentionally enable. +- Unpublished v3 inventories require validated, locked conversion with original-byte backup. + Compatibility implementation and real old-package acceptance are in progress; publication + remains blocked until they pass. +- Candidate versions are not a publication claim. Final package integrity, clean installation + and migration acceptance must be recorded separately for the published packages. + ## 0.2.0 ### Added diff --git a/cli/README.md b/cli/README.md index 66da5873a..9d4db4f0a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -621,7 +621,7 @@ subject to the server's package validation. ### Installation inventory compatibility -The local installation inventory uses schema version 3 in the unreleased enable/disable increment. Each installation directory owns its +Version `0.3.0` retains installation inventory 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 @@ -631,16 +631,9 @@ Direct install/upgrade/remove and sync pruning refuse Suite-owned or shared posi with `--force`. Manage those positions through Suite commands; unrelated direct installations remain independently manageable. -Reading a v1/v2 inventory does not change it. The first mutation validates it, preserves its -original bytes in an immutable digest-addressed backup beside `inventory.json`, and atomically -writes v3 (`schemaVersion`, `records`, `suites`). Each target records `installationState` -(`enabled` or `disabled`); legacy targets project to enabled without inventing missing integrity. +Shared inventory remains strict v2 without a persisted `installationState`; activation is derived from trusted `SKILL.md` / `SKILL.md.disabled` entries. A coordinated CLI upgrade is not required. Published CLI `0.2.0` must retain normal queries and ordinary-target management; default update, sync and Suite paths must not unintentionally enable disabled targets. Historical explicit force install, upgrade and sync retain their overwrite semantics and may restore the standard entry; explicit remove retains deletion semantics. New readers must reflect these actual file changes. -Stop incompatible CLI writers and let DSH finish pending recovery before migration. Published -CLI `0.2.0` and `0.1.12` must not share writes with v3. Protocol rejection is not a complete -cross-process guard: historical commands have different read paths. Release the compatible CLI -before exposing this plugin increment. Restoring old inventory alone is not a safe downgrade; -verify entry names, package bytes, metadata and operation logs together. +Reading v1/v2 is non-mutating. Unpublished v3 inventories require validated, locked conversion to v2 with original-byte backup; never restore stale inventory over newer records. Ordinary DSH journals remain compatible; toggle journals must not block unrelated historical operations. Compatibility fixes and real historical-package acceptance are in progress; release remains blocked until they pass. This CLI does not add enable/disable commands. Use DSH for explicit toggles and disabled updates; CLI upgrade refuses disabled targets even with `--force`. Namespace sync retains disabled diff --git a/cli/package.json b/cli/package.json index 990587273..3f60629e6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub", - "version": "0.2.0", + "version": "0.3.0", "description": "Manage and install skills for AI coding agents", "keywords": [ "skillhub", diff --git a/cli/src/generated/pkg-info.ts b/cli/src/generated/pkg-info.ts index 4391833da..f871a8442 100644 --- a/cli/src/generated/pkg-info.ts +++ b/cli/src/generated/pkg-info.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-pkg-info.ts - do not edit by hand. export const PKG_NAME = "@astron-team/skillhub" -export const PKG_VERSION = "0.2.0" +export const PKG_VERSION = "0.3.0" diff --git a/cli/src/integrations/installed-projection.ts b/cli/src/integrations/installed-projection.ts index 98dd449dd..99192908b 100644 --- a/cli/src/integrations/installed-projection.ts +++ b/cli/src/integrations/installed-projection.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto' import { lstat, realpath } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' import { z } from 'zod' -import type { InventoryStore } from '../stores/inventory-store' +import { readInstallationState, type InventoryStore } from '../stores/inventory-store' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' import type { InstalledProjection, InstalledProjectionItem, InstalledProjectionTarget } from './native-contract' @@ -49,7 +49,7 @@ export async function readInstalledProjectionRecords(records: readonly unknown[] for (const target of roots.filter(target => target.root === resolve(entry.rootDir))) { const path = join(target.root, item.slug) const projected: InstalledProjectionItem = { scope: target.scope, namespace: item.namespace, - slug: item.slug, installationState: entry.installationState ?? 'enabled', version: entry.version, ...(entry.fingerprint ? { fingerprint: entry.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.installationState ?? 'enabled', entry.agent, entry.rootDir, entry.installDir, entry.version, entry.fingerprint ?? null, entry.installedAt, [...new Set(entry.installedBy ?? ['direct'])].sort()])).digest('hex') } @@ -72,11 +72,14 @@ export async function readInstalledProjectionRecords(records: readonly unknown[] if (!directory.isDirectory() || await realpath(path) !== path) { projected.state = 'mismatch'; issue('invalid-record'); continue } - for (const [file, kind] of [[join(path, '.skillhub'), 'directory'], [join(path, '.skillhub/metadata.json'), 'file'], [join(path, entry.installationState === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'file']] as const) { + const installationState = await readInstallationState(path) + if (!installationState) { projected.state = 'unreadable'; issue('target-unreadable'); continue } + projected.installationState = installationState + for (const [file, kind] of [[join(path, '.skillhub'), 'directory'], [join(path, '.skillhub/metadata.json'), 'file'], [join(path, installationState === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'file']] as const) { const stat = await lstat(file) if (kind === 'directory' ? !stat.isDirectory() : !stat.isFile()) throw new Error('Unexpected local file type') } - if (entry.installationState === 'disabled') await assertEntryAbsent(join(path, 'SKILL.md')) + await assertEntryAbsent(join(path, installationState === 'disabled' ? 'SKILL.md' : 'SKILL.md.disabled')) const metadata = await readInstalledSkillMetadata(path) if (metadata.status !== 'valid') { projected.state = 'metadata-invalid'; issue('target-unreadable'); continue } const local = metadata.metadata diff --git a/cli/src/integrations/native-contract.d.ts b/cli/src/integrations/native-contract.d.ts index c626d5f38..c533ae8c4 100644 --- a/cli/src/integrations/native-contract.d.ts +++ b/cli/src/integrations/native-contract.d.ts @@ -26,7 +26,7 @@ export interface InstalledProjectionItem { fingerprint?: string /** Internal digest of this exact target's release, timestamp and ownership. */ recordRevision?: string - installationState: InstallationState + installationState?: InstallationState state: 'present' | 'missing' | 'metadata-invalid' | 'mismatch' | 'unreadable' } export interface InstalledProjection { diff --git a/cli/src/integrations/native-runtime.ts b/cli/src/integrations/native-runtime.ts index 98294a520..16223bace 100644 --- a/cli/src/integrations/native-runtime.ts +++ b/cli/src/integrations/native-runtime.ts @@ -25,7 +25,7 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven const operations = await OperationStore.open(options.stateDirectory) const inventory = new InventoryStore(options.inventoryHome) const ensureCompatible = async () => { - if ((await operations.listForRecovery()).some(record => record.schemaVersion < 3)) { + if (await inventory.diskVersion() === 3 && (await operations.listForRecovery()).length) { throw new OperationStoreError('TARGET_RECOVERY_REQUIRED') } await inventory.ensureWritable() diff --git a/cli/src/integrations/native-toggle.ts b/cli/src/integrations/native-toggle.ts index 9c0236dd9..fff53f4e9 100644 --- a/cli/src/integrations/native-toggle.ts +++ b/cli/src/integrations/native-toggle.ts @@ -1,7 +1,7 @@ import { link, lstat, open, realpath, unlink } from 'node:fs/promises' import { join } from 'node:path' import { isDeepStrictEqual } from 'node:util' -import type { InventoryStore } from '../stores/inventory-store' +import { inventoryWithoutState, type InventoryStore } from '../stores/inventory-store' import { captureInstallInventory } from '../services/inventory-cas' import { acquireSkillTargetLock } from '../services/skill-target-lock' import { assertEntryAbsent, snapshotJournalInstallation } from '../services/installed-skill-snapshot' @@ -61,18 +61,14 @@ export async function executeNativeToggle(operations: OperationStore, inventory: await syncDirectory(target.installDir) record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'moved' }) const paths = new Set([target.installDir]) - const expected = await captureInstallInventory({ items: [item] }, item, paths) - await inventory.mutateAtomic(current => { - const match = current.items.find(candidate => sameInstalledSkillSource(candidate, item)) - const entry = match?.targets.find(entry => entry.installDir === target.installDir) - if (!entry || !isDeepStrictEqual(entry, target)) throw new Error('Toggle inventory changed') - entry.installationState = baseline.afterState - }, async before => { - if (await captureInstallInventory(before, item, paths) !== expected) throw new Error('Toggle ownership changed') + const expected = await captureInstallInventory(inventoryWithoutState({ items: [item] }), item, paths) + await inventory.withLockedSnapshot(async current => { + if (await captureInstallInventory(inventoryWithoutState(current), item, paths) !== expected) throw new Error('Toggle ownership changed') await request.authorize([target.rootDir, target.installDir], 'replace', request.operationId) await verifyFiles(record!, baseline.afterState) + // The durable journal is the commit point; entry state is only a file projection. + record = await operations.recordToggle(record!.operationId, record!.ownerId, record!.revision, { ...intent, stage: 'committed' }) }) - record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'committed' }) return complete(operations, record) } catch (error) { const current = await operations.get(request.operationId, request.ownerId) @@ -118,19 +114,25 @@ async function reconcileToggle(operations: OperationStore, inventory: InventoryS try { const intent = record.toggle! const before = intent.inventoryItem - const after = structuredClone(before) - after.targets[0]!.installationState = intent.afterState const paths = new Set([intent.target.path]) return await inventory.withLockedSnapshot(async actual => { - const fingerprint = await captureInstallInventory(actual, before, paths) - if (fingerprint === await captureInstallInventory({ items: [after] }, after, paths)) { + const fingerprint = await captureInstallInventory(inventoryWithoutState(actual), before, paths) + if (fingerprint !== await captureInstallInventory(inventoryWithoutState({ items: [before] }), before, paths)) return unknown() + let legacyCommitted = false + if (await inventory.diskVersion() === 3) { + const stored = await inventory.readPersisted() + const entry = stored.items.find(item => sameInstalledSkillSource(item, before))?.targets.find(target => target.installDir === intent.target.path) + if (!entry || ![intent.beforeState, intent.afterState].includes(entry.installationState!)) return unknown() + legacyCommitted = entry.installationState === intent.afterState + if (intent.stage === 'committed' && !legacyCommitted) return unknown() + } + if (intent.stage === 'committed' || legacyCommitted) { await verifyFiles(record, intent.afterState) if (record.operation.status.phase === 'completed') return { operationId: record.operationId, revision: record.revision, outcome: 'committed', reason: intent.afterState } if (['unknown', 'recovery-required'].includes(record.operation.status.phase)) record = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, phase: 'recovering' }) if (record.toggle!.stage !== 'committed') record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'committed' }) return complete(operations, record) } - if (record.toggle!.stage === 'committed' || fingerprint !== await captureInstallInventory({ items: [before] }, before, paths)) return unknown() if (record.operation.status.phase === 'failed' && record.operation.status.recovery === 'restored') { await verifyFiles(record, intent.beforeState) return { operationId: record.operationId, revision: record.revision, outcome: 'restored', reason: 'toggle-restored' } diff --git a/cli/src/integrations/operation-store.ts b/cli/src/integrations/operation-store.ts index a4710e1e5..4fecab694 100644 --- a/cli/src/integrations/operation-store.ts +++ b/cli/src/integrations/operation-store.ts @@ -1,8 +1,10 @@ +import { renameAtomicFile } from '../platform/atomic-rename' +import { inventoryWithoutState, type Inventory } from '../stores/inventory-store' import { isDeepStrictEqual } from 'node:util' import { toggleJournal, validateToggleJournal, type ToggleJournal } from './toggle-journal' import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises' +import { chmod, link, lstat, mkdir, open, readdir, readFile, realpath, rm, unlink } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' import { z } from 'zod' @@ -52,21 +54,24 @@ export function operationRequestDigest(parameters: Record { + static async open(root: string, replaceFile: typeof renameAtomicFile = renameAtomicFile): Promise { if (!isAbsolute(root)) throw new Error('Operation state requires a trusted absolute directory') await mkdir(root, { recursive: true, mode: 0o700 }) const info = await lstat(root) if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) throw new Error('Unsafe operation state directory') if (process.platform !== 'win32') await chmod(root, 0o700) - const pending = new OperationStore(root, '') + const pending = new OperationStore(root, '', replaceFile) return pending.locked(async () => { const path = join(root, 'host.json') const saved = await readJson(path) const hostInstanceId = saved === undefined ? randomUUID() : z.object({ hostInstanceId: id }).strict().parse(saved).hostInstanceId - if (saved === undefined) await atomicWrite(path, { hostInstanceId }) - return new OperationStore(root, hostInstanceId) + if (saved === undefined) await atomicWrite(path, { hostInstanceId }, replaceFile) + const store = new OperationStore(root, hostInstanceId, replaceFile) + await store.isolateExtendedRecords() + return store }) } @@ -82,11 +87,11 @@ export class OperationStore { const duplicate = await this.findRequest(request) if (duplicate) return duplicate const now = new Date().toISOString() - const value: OperationRecord = { schemaVersion: 3, operationId: randomUUID(), ownerId: request.ownerId, + const value: OperationRecord = { schemaVersion: ['enable', 'disable'].includes(accepted.kind) ? 3 : 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 writeOperationRecord(this.path(value.operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -138,7 +143,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 writeOperationRecord(this.path(operationId), value) + await this.writeRecord(value) return value }) } @@ -161,7 +166,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 writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -249,7 +254,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 writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -277,7 +282,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 writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -302,7 +307,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 writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -333,7 +338,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 writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -351,7 +356,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 writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -375,7 +380,7 @@ export class OperationStore { operation: { ...current.operation, status: { ...current.operation.status, phase: current.operation.status.phase === 'recovering' ? 'recovering' : parsed.stage === 'intent' ? 'revalidating' : 'committing' }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } }) - await writeOperationRecord(this.path(operationId), value) + await this.writeRecord(value) return value }) } @@ -388,7 +393,11 @@ export class OperationStore { } private path(operationId: string): string { return join(this.root, `${id.parse(operationId)}.json`) } private async read(operationId: string): Promise { - const json = await readJson(this.path(operationId)) + await this.validateExtendedDirectory() + const extended = await readJson(this.extendedPath(operationId)) + const ordinary = await readJson(this.path(operationId)) + if (extended !== undefined && ordinary !== undefined && !isDeepStrictEqual(extended, ordinary)) throw new OperationStoreError('CORRUPT_RECORD') + const json = extended ?? ordinary if (json === undefined) return undefined const parsed = record.safeParse(json) if (!parsed.success || parsed.data.operationId !== operationId || parsed.data.operation.context.hostInstanceId !== this.hostInstanceId) throw new OperationStoreError('CORRUPT_RECORD') @@ -408,7 +417,9 @@ export class OperationStore { } private async readAll(): Promise { const values: OperationRecord[] = [] - for (const name of (await readdir(this.root)).sort()) { + await this.validateExtendedDirectory() + const extended = await readdir(join(this.root, 'activation')).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return []; throw error }) + for (const name of [...new Set([...(await readdir(this.root)), ...extended])].sort()) { if (name === 'host.json' || !name.endsWith('.json')) continue const value = await this.read(name.slice(0, -5)) if (!value) throw new OperationStoreError('CORRUPT_RECORD') @@ -417,6 +428,83 @@ export class OperationStore { return values } + private extendedPath(operationId: string): string { return join(this.root, 'activation', `${id.parse(operationId)}.json`) } + + private async validateExtendedDirectory(): Promise { + try { + const info = await lstat(join(this.root, 'activation')) + if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) throw new OperationStoreError('CORRUPT_RECORD') + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } + } + + private async prepareExtendedDirectory(): Promise { + const path = join(this.root, 'activation') + await mkdir(path, { recursive: true, mode: 0o700 }) + const info = await lstat(path) + if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) throw new OperationStoreError('CORRUPT_RECORD') + } + + /** The intermediate duplicate must be identical; never prefer a competing revision. */ + private async isolateRecord(operationId: string): Promise { + await this.prepareExtendedDirectory() + const source = this.path(operationId) + const destination = this.extendedPath(operationId) + const original = await readJson(source) + if (original === undefined) return + await this.read(operationId) + const bytes = await readFile(source) + const backup = join(this.root, 'activation', `${operationId}.${createHash('sha256').update(bytes).digest('hex')}.backup`) + try { + const file = await open(backup, 'wx', 0o600) + try { await file.writeFile(bytes); await file.sync() } finally { await file.close() } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } + const info = await lstat(backup) + if (!info.isFile() || info.isSymbolicLink() || !(await readFile(backup)).equals(bytes)) throw new OperationStoreError('CORRUPT_RECORD') + try { await link(source, destination) } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || !isDeepStrictEqual(await readJson(destination), original)) throw error + } + await syncJournalDirectory(join(this.root, 'activation')) + await unlink(source) + await syncJournalDirectory(this.root) + } + + /** Preserve original extended evidence while keeping legacy readers' root scan usable. */ + private async isolateExtendedRecords(): Promise { + await this.validateExtendedDirectory() + for (const name of await readdir(this.root)) { + if (name === 'host.json' || !name.endsWith('.json')) continue + const operationId = name.slice(0, -5) + const raw = await readJson(this.path(operationId)) as OperationRecord | undefined + if (raw?.schemaVersion === 3 || await readJson(this.extendedPath(operationId)) !== undefined) await this.isolateRecord(operationId) + } + } + + private async writeRecord(value: OperationRecord): Promise { + const extended = value.schemaVersion === 3 || await readJson(this.extendedPath(value.operationId)) !== undefined || operationSnapshots(value).some(snapshot => snapshot.items.some(item => item.targets.some(target => target.installationState === 'disabled'))) + if (extended) { + await this.prepareExtendedDirectory() + await this.isolateRecord(value.operationId) + await writeOperationRecord(this.extendedPath(value.operationId), { ...value, schemaVersion: 3 }, this.replaceFile) + return + } + // v2 snapshots contain ownership only, exactly as consumed by published clients. + const compatible = structuredClone(value) + if (compatible.transaction?.preparation) compatible.transaction.preparation.before = inventoryWithoutState(compatible.transaction.preparation.before) + if (compatible.transaction?.inventoryCommit) { + compatible.transaction.inventoryCommit.before = inventoryWithoutState(compatible.transaction.inventoryCommit.before) + compatible.transaction.inventoryCommit.after = inventoryWithoutState(compatible.transaction.inventoryCommit.after) + } + if (compatible.removal) { + compatible.removal.intent.inventoryItem = inventoryWithoutState({ items: [compatible.removal.intent.inventoryItem] }).items[0]! + if (compatible.removal.inventoryCommit) { + compatible.removal.inventoryCommit.before = inventoryWithoutState(compatible.removal.inventoryCommit.before) + compatible.removal.inventoryCommit.after = inventoryWithoutState(compatible.removal.inventoryCommit.after) + } + } + await writeOperationRecord(this.path(value.operationId), compatible, this.replaceFile) + } + private async locked(run: () => Promise): Promise { let release: (() => Promise) | undefined const deadline = Date.now() + 10_000 @@ -484,14 +572,14 @@ async function readJson(path: string): Promise { } finally { await file.close() } } -async function atomicWrite(path: string, value: unknown): Promise { +async function atomicWrite(path: string, value: unknown, replaceFile: typeof renameAtomicFile): Promise { const temporary = `${path}.${randomUUID()}.tmp` try { const payload = JSON.stringify(value) if (Buffer.byteLength(payload) > 1024 * 1024) throw new OperationStoreError('CORRUPT_RECORD') const file = await open(temporary, 'wx', 0o600) try { await file.writeFile(payload); await file.sync() } finally { await file.close() } - await rename(temporary, path) + await replaceFile(temporary, path) if (process.platform !== 'win32') { const directory = await open(dirname(path), 'r') try { await directory.sync() } finally { await directory.close() } @@ -499,10 +587,23 @@ async function atomicWrite(path: string, value: unknown): Promise { } finally { await rm(temporary, { force: true }) } } +function operationSnapshots(value: OperationRecord): Inventory[] { + return [value.transaction?.preparation?.before, value.transaction?.inventoryCommit?.before, + value.transaction?.inventoryCommit?.after, value.removal ? { items: [value.removal.intent.inventoryItem] } : undefined, + value.removal?.inventoryCommit?.before, value.removal?.inventoryCommit?.after].filter((snapshot): snapshot is Inventory => snapshot !== undefined) +} + +async function syncJournalDirectory(path: string): Promise { + if (process.platform === 'win32') return + const directory = await open(path, 'r') + try { await directory.sync() } finally { await directory.close() } +} + /** Recovery may advance v1 status, but never rewrite the original serialized evidence. */ -async function writeOperationRecord(path: string, value: OperationRecord): Promise { - if (value.schemaVersion === 3) return atomicWrite(path, value) +async function writeOperationRecord(path: string, value: OperationRecord, replaceFile: typeof renameAtomicFile): Promise { + if (value.schemaVersion === 3) return atomicWrite(path, value, replaceFile) const previous = await readJson(path) as OperationRecord | undefined + if (!previous && value.schemaVersion === 2) return atomicWrite(path, value, replaceFile) if (!previous || previous.schemaVersion !== value.schemaVersion) throw new Error('Legacy operation evidence missing') const preserved = structuredClone(value) if (preserved.transaction && previous.transaction) { @@ -521,7 +622,7 @@ async function writeOperationRecord(path: string, value: OperationRecord): Promi preserved.removal.inventoryCommit.after = previous.removal.inventoryCommit.after } } - await atomicWrite(path, preserved) + await atomicWrite(path, preserved, replaceFile) } /** A pending install may not exist yet; resolve the nearest existing ancestor. */ diff --git a/cli/src/integrations/removal-inventory.ts b/cli/src/integrations/removal-inventory.ts index 4d2b25ef7..cd80c352d 100644 --- a/cli/src/integrations/removal-inventory.ts +++ b/cli/src/integrations/removal-inventory.ts @@ -1,7 +1,7 @@ import { isDeepStrictEqual } from 'node:util' import { captureInstallInventory } from '../services/inventory-cas' import { sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { targetInstalledBy } from '../stores/inventory-store' +import { inventoryWithoutState, targetInstalledBy } from '../stores/inventory-store' import type { Inventory, InventoryStore } from '../stores/inventory-store' import type { RemovalBaseline } from './native-contract' @@ -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.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === path) })), [expected])) throw new Error('Removal inventory changed') + if (!isDeepStrictEqual(inventoryWithoutState({ items: matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === path) })) }), inventoryWithoutState({ items: [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 87ae35101..900cd303a 100644 --- a/cli/src/integrations/removal-journal.ts +++ b/cli/src/integrations/removal-journal.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { inventoryItemSchema, inventorySnapshotSchema } from './transaction-journal' import type { OperationRecord } from './operation-store' import { sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { targetInstalledBy } from '../stores/inventory-store' +import { inventoryWithoutState, targetInstalledBy } from '../stores/inventory-store' const path = z.string().min(1).max(2048).refine(value => isAbsolute(value) && resolve(value) === value) const intent = z.object({ inventoryItem: inventoryItemSchema, @@ -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.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() + if (!isDeepStrictEqual(inventoryWithoutState({ items: matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === state.intent.target.path) })) }), inventoryWithoutState({ items: [{ ...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/platform/atomic-rename.ts b/cli/src/platform/atomic-rename.ts new file mode 100644 index 000000000..572ab4de6 --- /dev/null +++ b/cli/src/platform/atomic-rename.ts @@ -0,0 +1,16 @@ +import { rename } from 'node:fs/promises' + +/** Retry only Windows sharing failures; never remove the destination or replay a transaction. */ +export async function renameAtomicFile(from: string, to: string, + renameFile: typeof rename = rename, platform: NodeJS.Platform = process.platform): Promise { + const delays = [20, 40, 80, 160, 320] + for (let attempt = 0; ; attempt++) { + try { await renameFile(from, to); return } + catch (error) { + const code = (error as NodeJS.ErrnoException).code + const delay = delays[attempt] + if (platform !== 'win32' || !['EPERM', 'EBUSY'].includes(code ?? '') || delay === undefined) throw error + await new Promise(resolve => setTimeout(resolve, delay)) + } + } +} diff --git a/cli/src/services/inventory-cas.ts b/cli/src/services/inventory-cas.ts index a71da8e23..1dc03189e 100644 --- a/cli/src/services/inventory-cas.ts +++ b/cli/src/services/inventory-cas.ts @@ -17,7 +17,7 @@ export async function captureInstallInventory( const canonicalRoot = await canonicalizeExistingPath(root) return { agent: target.agent, rootDir: canonicalRoot, installDir: resolve(canonicalRoot, relative(root, resolve(target.installDir))), - installationState: target.installationState ?? 'enabled', version: target.version, fingerprint: target.fingerprint ?? null, 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() } })) const selected = targets.filter(target => [...canonicalDirs].some(path => pathsOverlap(path, target.installDir))) if (!selected.length) continue diff --git a/cli/src/services/skill-target-lock.ts b/cli/src/services/skill-target-lock.ts index ed1510375..ef14cf585 100644 --- a/cli/src/services/skill-target-lock.ts +++ b/cli/src/services/skill-target-lock.ts @@ -21,7 +21,8 @@ export async function orderSkillTargetLocks(targe } /** Serializes every local lifecycle mutation for one Skill target directory. */ -export async function acquireSkillTargetLock(rootDir: string, slug: string): Promise<() => Promise> { +export async function acquireSkillTargetLock(rootDir: string, slug: string, + acquire: typeof acquireTargetLock = acquireTargetLock): Promise<() => Promise> { const lockPath = await skillTargetLockPath(rootDir, slug) // proper-lockfile's stale deletion is not serialized. Gate only the acquisition attempt so two // recoverers cannot remove and replace the same target lock concurrently. @@ -36,7 +37,7 @@ export async function acquireSkillTargetLock(rootDir: string, slug: string): Pro let releaseTarget: () => Promise try { - releaseTarget = await acquireTargetLock(lockPath) + releaseTarget = await acquire(lockPath) } catch (operationError) { try { await releaseAcquisitionGate() @@ -61,14 +62,27 @@ export async function acquireSkillTargetLock(rootDir: string, slug: string): Pro return releaseTarget } -function acquireTargetLock(lockPath: string): Promise<() => Promise> { - return lock(lockPath, { - lockfilePath: lockPath, - realpath: false, - stale: 10_000, - update: 3_000, - retries: 0 - }) +/** The acquisition gate remains held while retrying a Windows mkdir sharing failure. */ +export async function acquireTargetLock(lockPath: string, acquire: typeof lock = lock, + platform: NodeJS.Platform = process.platform): Promise<() => Promise> { + const delays = [20, 40, 80, 160, 320] + for (let attempt = 0; ; attempt++) { + try { + return await acquire(lockPath, { + lockfilePath: lockPath, + realpath: false, + stale: 10_000, + update: 3_000, + retries: 0 + }) + } catch (error) { + const failure = error as NodeJS.ErrnoException + const delay = delays[attempt] + if (platform !== 'win32' || !['EPERM', 'EBUSY'].includes(failure.code ?? '') + || failure.syscall !== 'mkdir' || failure.path !== lockPath || delay === undefined) throw error + await new Promise(resolve => setTimeout(resolve, delay)) + } + } } /** Serialize stale-lock recovery attempts without replacing a live contender. */ diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index 790198873..0d0b83be3 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -1,8 +1,9 @@ +import { renameAtomicFile } from '../platform/atomic-rename' import { createHash } from 'node:crypto' import { z } from 'zod' import { isDeepStrictEqual } from 'node:util' -import { lstat, open, readFile, rename, rm } from 'node:fs/promises' -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { lstat, open, readFile, rm } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, join } from 'node:path' import { lock } from 'proper-lockfile' import { acquireAcquisitionGate } from '../services/skill-target-lock' import { canonicalizeExistingPath, pathsOverlap, joinPath, userStateDir, ensureDir } from '../platform/paths' @@ -109,6 +110,28 @@ export function parseInventory(value: unknown): Inventory { ...(legacy.suites === undefined ? {} : { suites: legacy.suites }) } as Inventory } +/** Persisted ownership excludes the independently observed entry state. */ +export function inventoryWithoutState(inventory: Inventory): Inventory { + return { ...inventory, items: inventory.items.map(item => ({ ...item, targets: item.targets.map(target => { + const stored = { ...target } + delete stored.installationState + return stored + }) })) } +} + +/** Ambiguous, missing and unsafe entries have no usable state. */ +export async function readInstallationState(directory: string): Promise<'enabled' | 'disabled' | undefined> { + const inspect = async (name: string) => { + try { const stat = await lstat(join(directory, name)); return stat.isFile() && !stat.isSymbolicLink() ? 'file' : 'unsafe' } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; return 'unsafe' } + } + const enabled = await inspect('SKILL.md') + const disabled = await inspect('SKILL.md.disabled') + if (enabled === 'file' && disabled === 'missing') return 'enabled' + if (disabled === 'file' && enabled === 'missing') return 'disabled' + return undefined +} + /** Validate ownership aliases before writing; content of unselected skills is not inspected. */ async function validateInventoryClaims(inventory: Inventory): Promise { const sources = new Set() @@ -135,12 +158,20 @@ export class InventoryStore { this.path = joinPath(userStateDir(home), 'inventory.json') } - async read(): Promise { + /** Raw legacy evidence, used only while reconciling an existing v3 transaction. */ + async readPersisted(): Promise { try { return parseInventory(JSON.parse(await readFile(this.path, 'utf-8'))) } - catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { items: [] } - throw error + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { items: [] }; throw error } + } + + async read(): Promise { + const inventory = await this.readPersisted() + for (const item of inventory.items) for (const target of item.targets) { + const state = await readInstallationState(target.installDir) + if (state) target.installationState = state + else delete target.installationState } + return inventory } async write(inventory: Inventory): Promise { await this.writeAtomic(inventory) } @@ -157,7 +188,7 @@ export class InventoryStore { const value: unknown = JSON.parse(await readFile(this.path, 'utf8')) parseInventory(value) return typeof value === 'object' && value !== null && 'schemaVersion' in value ? value.schemaVersion as 2 | 3 : 1 - } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 3; throw error } + } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 2; throw error } } private async migrateUnderLock(): Promise { @@ -169,9 +200,16 @@ export class InventoryStore { const value: unknown = JSON.parse(raw.toString('utf8')) const inventory = parseInventory(value) await validateInventoryClaims(inventory) - if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion === 3) return + if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion === 2) return + if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion === 3) { + const { snapshotInstalledSkillDirectory } = await import('../services/installed-skill-snapshot') + for (const item of inventory.items) for (const target of item.targets) { + if (await readInstallationState(target.installDir) !== target.installationState) throw new Error('Inventory v3 entry state requires recovery') + if ((await snapshotInstalledSkillDirectory(item, target)).fingerprint !== target.fingerprint) throw new Error('Inventory v3 content requires verification') + } + } const digest = createHash('sha256').update(raw).digest('hex') - const backup = joinPath(dirname(this.path), `inventory.v${typeof value === 'object' && value !== null && 'schemaVersion' in value ? 2 : 1}.${digest}.backup.json`) + const backup = joinPath(dirname(this.path), `inventory.v${typeof value === 'object' && value !== null && 'schemaVersion' in value ? value.schemaVersion : 1}.${digest}.backup.json`) try { const file = await open(backup, 'wx', 0o600) try { await file.writeFile(raw); await file.sync() } finally { await file.close() } @@ -183,7 +221,7 @@ export class InventoryStore { try { await this.writeUnderLock(inventory) } catch (error) { try { - if (await this.diskVersion() === 3 && isDeepStrictEqual(await this.read(), inventory)) return + if (await this.diskVersion() === 2 && isDeepStrictEqual(inventoryWithoutState(await this.read()), inventoryWithoutState(inventory))) return } catch { /* Preserve the original migration failure. */ } throw error } @@ -221,7 +259,6 @@ export class InventoryStore { const inventory = await this.read() const before = structuredClone(inventory) const result = mutate(inventory) - for (const item of inventory.items) for (const target of item.targets) target.installationState ??= 'enabled' if (beforeCommit) await beforeCommit(structuredClone(before), structuredClone(inventory)) try { await this.writeUnderLock(inventory) } catch (writeError) { @@ -229,8 +266,8 @@ export class InventoryStore { try { actual = await this.read() } catch (readError) { throw new InventoryCommitUnknownError(new AggregateError([writeError, readError])) } // Still holding the inventory lock: a failed transport response is not proof of failed rename. - if (isDeepStrictEqual(actual, inventory)) return result - if (!isDeepStrictEqual(actual, before)) throw new InventoryCommitUnknownError(writeError) + if (isDeepStrictEqual(inventoryWithoutState(actual), inventoryWithoutState(inventory))) return result + if (!isDeepStrictEqual(inventoryWithoutState(actual), inventoryWithoutState(before))) throw new InventoryCommitUnknownError(writeError) throw writeError } return result @@ -242,14 +279,14 @@ export class InventoryStore { protected async writeUnderLock(inventory: Inventory): Promise { inventoryMemorySchema.parse(inventory) await validateInventoryClaims(inventory) - const payload = JSON.stringify(inventoryV3Schema.parse({ schemaVersion: 3, records: inventory.items.map(item => ({ ...item, targets: item.targets.map(target => ({ ...target, installationState: target.installationState ?? 'enabled' })) })), suites: inventory.suites }), null, 2) + const payload = JSON.stringify(inventoryDiskSchema.parse({ schemaVersion: 2, records: inventoryWithoutState(inventory).items, suites: inventory.suites }), null, 2) parseInventory(JSON.parse(payload)) const tmpPath = `${this.path}.${process.pid}.${Date.now()}.tmp` try { const file = await open(tmpPath, 'wx', 0o600) try { await file.writeFile(payload); await file.sync() } finally { await file.close() } parseInventory(JSON.parse(await readFile(tmpPath, 'utf-8'))) - await rename(tmpPath, this.path) + await this.replaceInventoryFile(tmpPath) if (process.platform !== 'win32') { const directory = await open(dirname(this.path), 'r') try { await directory.sync() } finally { await directory.close() } @@ -259,6 +296,10 @@ export class InventoryStore { } } + protected async replaceInventoryFile(temporaryPath: string): Promise { + await renameAtomicFile(temporaryPath, this.path) + } + private async acquireLock(): Promise<() => Promise> { // proper-lockfile stale stat/remove is not atomic: serialize acquisition so a delayed // recoverer cannot remove another process's newly acquired inventory lock. diff --git a/cli/test/fixtures/toggle-crash-child.ts b/cli/test/fixtures/toggle-crash-child.ts index 38576fdb7..1c1ef3419 100644 --- a/cli/test/fixtures/toggle-crash-child.ts +++ b/cli/test/fixtures/toggle-crash-child.ts @@ -1,10 +1,11 @@ +import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { InventoryStore } from '../../src/stores/inventory-store' import { OperationStore } from '../../src/integrations/operation-store' import { inspectToggle, executeNativeToggle } from '../../src/integrations/native-toggle' -const [home, operationId, boundary] = process.argv.slice(2) -if (!home || !operationId || !['intent', 'linked', 'moved', 'restore-linked', 'committed'].includes(boundary!)) throw new Error('Invalid fixture arguments') +const [home, operationId, boundary, readyFile] = process.argv.slice(2) +if (!home || !operationId || !readyFile || !['intent', 'linked', 'moved', 'restore-linked', 'committed'].includes(boundary!)) throw new Error('Invalid fixture arguments') const inventory = new InventoryStore(home) const operations = await OperationStore.open(join(home, 'operations')) const baseline = await inspectToggle(inventory, { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' }, @@ -12,10 +13,14 @@ const baseline = await inspectToggle(inventory, { registry: 'https://registry.ex if (baseline.status !== 'ready') throw new Error('Fixture baseline unavailable') const append = operations.recordToggle.bind(operations) operations.recordToggle = async (...args) => { + if (boundary === 'restore-linked' && args[3].stage === 'committed') throw new Error('Injected commit rejection') const saved = await append(...args) - if (args[3].stage === boundary) process.kill(process.pid, 'SIGKILL') + if (args[3].stage === boundary) { + await writeFile(readyFile!, boundary, { flag: 'wx' }) + // The parent force-terminates this owned process using the native platform API. + await new Promise(() => { setInterval(() => {}, 1000) }) + } return saved } -if (boundary === 'restore-linked') inventory.mutateAtomic = async () => { throw new Error('Injected CAS rejection') } await executeNativeToggle(operations, inventory, { operationId, ownerId: 'owner', baseline, authorize: async () => {} }) throw new Error('Crash boundary was not reached') diff --git a/cli/test/integration/doctor-command.test.ts b/cli/test/integration/doctor-command.test.ts index 4a4bf9fe6..d8c5b4a5f 100644 --- a/cli/test/integration/doctor-command.test.ts +++ b/cli/test/integration/doctor-command.test.ts @@ -195,7 +195,7 @@ describe('doctor command', () => { 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(3) + 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']) }) diff --git a/cli/test/unit/integrations/install-crash-recovery.test.ts b/cli/test/unit/integrations/install-crash-recovery.test.ts index 13a5c9db6..cf7eee088 100644 --- a/cli/test/unit/integrations/install-crash-recovery.test.ts +++ b/cli/test/unit/integrations/install-crash-recovery.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { recoverInstallation } from '../../../src/integrations/install-recovery' import { OperationStore } from '../../../src/integrations/operation-store' -import { InventoryStore, type Inventory } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore, type Inventory } from '../../../src/stores/inventory-store' interface Fixture { operationId: string; ownerId: string; trustedTargetPaths: string[]; before: Inventory; oldContent: string; newContent: string } @@ -52,7 +52,7 @@ test.skipIf(process.platform === 'win32')('real killed installers recover all pr expect(denied.outcome).toBe('unknown') expect((await operations.get(fixture.operationId, fixture.ownerId))!.operation.status) .toMatchObject({ phase: 'recovery-required', recovery: 'incomplete' }) - expect(await inventory.read()).toEqual(fixture.before) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState(fixture.before)) expect(await Promise.all(['user', 'project'].map(scope => readdir(join(root, scope))))).toEqual(directories) } const result = await recoverInstallation({ ...fixture, operations, inventory }) @@ -69,10 +69,10 @@ test.skipIf(process.platform === 'win32')('real killed installers recover all pr expect(await readdir(join(root, scope))).toEqual(first && boundary !== 'after-commit' ? [] : ['writer']) if (!first || boundary === 'after-commit') expect(await readFile(join(root, scope, 'writer/SKILL.md'), 'utf8')).toBe(boundary === 'after-commit' ? fixture.newContent : fixture.oldContent) } - if (boundary !== 'after-commit') expect(await inventory.read()).toEqual(fixture.before) + if (boundary !== 'after-commit') expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState(fixture.before)) else { const committed = await inventory.read() - expect(committed).toEqual(saved.transaction!.inventoryCommit!.after) + expect(inventoryWithoutState(committed)).toEqual(inventoryWithoutState(saved.transaction!.inventoryCommit!.after)) expect(committed.items[0]!.targets[0]!.version).toBe('2.0.0') expect(committed.items[0]!.targets).toHaveLength(2) } diff --git a/cli/test/unit/integrations/installed-projection.test.ts b/cli/test/unit/integrations/installed-projection.test.ts index a16159380..b30732ad2 100644 --- a/cli/test/unit/integrations/installed-projection.test.ts +++ b/cli/test/unit/integrations/installed-projection.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import { realpath, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { realpath, mkdtemp, mkdir, rm, rename, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { readInstalledProjection } from '../../../src/integrations/installed-projection' @@ -57,3 +57,30 @@ test('inventory failure is partial and escaping targets are reported without rea expect(result.items[0]!.state).toBe('mismatch') expect(result.items[0]!.targetPath).toBe(join(trusted, 'writer')) }) + + +test('entry projection rejects missing, ambiguous and unsafe entries without inventing enabled state', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-projection-entry-'))) + try { + const path = join(root, 'writer') + const target = { version: '1.2.0', fingerprint: 'original', agent: 'dsh', rootDir: root, + installDir: path, installedAt: '2026-10-14T00:00:00Z' } + const item: InventoryItem = { registry, namespace: 'demo', slug: 'writer', targets: [target] } + await mkdir(join(path, '.skillhub'), { recursive: true }) + await writeFile(join(path, '.skillhub/metadata.json'), JSON.stringify({ ...item, ...target })) + const inventory = { read: async () => ({ items: [item] }) } + const read = () => readInstalledProjection(inventory, registry, [{ scope: 'project', root }]) + for (const mode of ['missing', 'enabled', 'both', 'disabled', 'symlink'] as const) { + if (mode === 'enabled') await writeFile(join(path, 'SKILL.md'), 'content') + if (mode === 'both') await writeFile(join(path, 'SKILL.md.disabled'), 'content') + if (mode === 'disabled') await rm(join(path, 'SKILL.md')) + if (mode === 'symlink') { await rename(join(path, 'SKILL.md.disabled'), join(path, 'other.md')); await symlink(join(path, 'other.md'), join(path, 'SKILL.md')) } + const result = await read() + const valid = mode === 'enabled' || mode === 'disabled' + expect(result.complete).toBe(valid) + expect(result.items[0]!.state).toBe(valid ? 'present' : 'unreadable') + expect(result.items[0]!.installationState).toBe(valid ? mode : undefined) + expect(result.issues).toEqual(valid ? [] : ['target-unreadable']) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/integrations/native-removal.test.ts b/cli/test/unit/integrations/native-removal.test.ts index aca0e8f74..f788cef19 100644 --- a/cli/test/unit/integrations/native-removal.test.ts +++ b/cli/test/unit/integrations/native-removal.test.ts @@ -7,7 +7,7 @@ import { executeNativeRemoval } from '../../../src/integrations/native-removal' import { recoverRemoval } from '../../../src/integrations/removal-recovery' import { inspectRemovalBaseline } from '../../../src/integrations/removal-baseline' import { OperationStore, operationRequestDigest } from '../../../src/integrations/operation-store' -import { InventoryStore } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore } from '../../../src/stores/inventory-store' import { snapshotSkillDirectory } from '../../../src/services/skill-fingerprint' test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-denied', 'cleanup-denied', 'commit-ack-lost', 'commit-log-failed', 'inventory-response-lost'] as const)( @@ -75,21 +75,21 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni } }) if (scenario === 'stale-root-file') { await expect(execute()).rejects.toThrow('Removal root changed') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) const recover = () => recoverRemoval({ operations, inventory, operationId: record.operationId, ownerId: 'session', trustedTargetPaths: [path], authorizeMutation: async () => {} }) expect((await recover()).outcome).toBe('unknown') expect(await readFile(target.root, 'utf8')).toBe('Concurrent root replacement') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) await rm(target.root) await mkdir(target.root) expect((await recover()).outcome).toBe('restored') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) return } if (scenario === 'commit-ack-lost' || scenario === 'commit-log-failed' || scenario === 'inventory-response-lost') { await expect(execute()).rejects.toThrow('Commit acknowledgment lost') - expect(await inventory.read()).toEqual({ items: [{ ...item, targets: [retained] }] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [{ ...item, targets: [retained] }] })) await expect(readFile(join(path, 'SKILL.md'))).rejects.toMatchObject({ code: 'ENOENT' }) expect(await readFile(join(quarantine, 'SKILL.md'), 'utf8')).toBe('# Original') const saved = (await operations.get(record.operationId, 'session'))! @@ -97,7 +97,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni expect(saved.removal?.inventoryCommit?.committed).toBe(scenario === 'commit-ack-lost') } else if (scenario === 'local-change' || scenario === 'commit-denied') { await expect(execute()).rejects.toThrow(scenario === 'local-change' ? 'baseline changed' : 'Commit denied') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) if (scenario === 'local-change') { expect(await readFile(join(path, 'SKILL.md'), 'utf8')).toBe('# User edit') expect((await operations.get(record.operationId, 'session'))?.operation.status.phase).toBe('failed') @@ -108,7 +108,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni } } else { expect((await execute()).outcome).toBe('committed') - expect(await inventory.read()).toEqual({ items: [{ ...item, targets: [retained] }] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [{ ...item, targets: [retained] }] })) await expect(readFile(join(path, 'SKILL.md'))).rejects.toMatchObject({ code: 'ENOENT' }) const saved = (await operations.get(record.operationId, 'session'))! expect(saved.operation.status.phase).toBe('completed') @@ -146,7 +146,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni operationId: record.operationId, ownerId: 'session', trustedTargetPaths: [path], authorizeMutation: async () => {}, }) expect(outcome.outcome).toBe('unknown') - expect(await inventory.read()).toEqual(conflictedInventory) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState(conflictedInventory)) expect(await readFile(join(quarantine, 'SKILL.md'), 'utf8')).toBe('# Original') if (conflict === 'occupied-path') { expect(await readFile(join(path, 'SKILL.md'), 'utf8')).toBe('# New occupant after commit') @@ -178,7 +178,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni expect((await recover()).outcome).toBe(recovered.outcome) if (scenario === 'commit-denied') { expect(await readFile(join(path, 'SKILL.md'), 'utf8')).toBe('# Original') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) } expect(await operations.listForRecovery()).toEqual([]) } finally { await rm(home, { recursive: true, force: true }) } diff --git a/cli/test/unit/integrations/native-toggle.test.ts b/cli/test/unit/integrations/native-toggle.test.ts index e1e6b2e6d..52b9a7e5f 100644 --- a/cli/test/unit/integrations/native-toggle.test.ts +++ b/cli/test/unit/integrations/native-toggle.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test' import { randomUUID } from 'node:crypto' -import { cp, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { cp, mkdtemp, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' import { tmpdir } from 'node:os' import { join } from 'node:path' import { InventoryStore } from '../../../src/stores/inventory-store' @@ -41,6 +42,9 @@ test('offline toggles rename only entry, preserve bytes, reject stale plans, and })) const result = await f.runtime.executeToggle({ operationId: operation!.operationId, ownerId: 'owner', baseline, authorize: async () => {} }) expect(result.outcome).toBe('committed') + const disk = JSON.parse(await readFile(f.inventory.path, 'utf8')) + expect(disk.schemaVersion).toBe(2) + expect(disk.records[0].targets[0]).not.toHaveProperty('installationState') expect((await f.runtime.getOperation(operation!.operationId, 'owner'))?.files).toBe(desired) expect(await readFile(join(f.path, desired === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Original\n') expect(await readFile(join(f.path, '.skillhub/metadata.json'))).toEqual(metadata) @@ -81,16 +85,12 @@ test.each(['cas-before', 'cas-after', 'intent', 'committed-log'] as const)('togg namespace: f.selected.namespace, slug: f.selected.slug, version: f.selected.version, kind: 'disable', targetLabel: 'User', targetPaths: [f.path], context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } })) - const mutate = f.inventory.mutateAtomic.bind(f.inventory) - if (mode.startsWith('cas')) f.inventory.mutateAtomic = async (...args) => { - if (mode === 'cas-after') await mutate(...args) - throw new Error('Injected CAS failure') - } const append = operations.recordToggle.bind(operations) let once = true operations.recordToggle = async (...args) => { - if (once && (mode === 'intent' && args[3].stage === 'intent' || mode === 'committed-log' && args[3].stage === 'committed')) { + if (once && (mode === 'intent' && args[3].stage === 'intent' || mode !== 'intent' && args[3].stage === 'committed')) { once = false + if (mode === 'cas-after' || mode === 'committed-log') await append(...args) throw new Error('Injected journal failure') } return append(...args) @@ -123,7 +123,7 @@ test.each([false, true])('restart recovers a renamed entry without overwriting a } await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async (_paths, phase) => { if (phase === 'rollback') throw new Error('Recovery unavailable') } })).rejects.toThrow('Crash window') - expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('enabled') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('disabled') expect(await readFile(join(f.path, 'SKILL.md.disabled'), 'utf8')).toBe('# Original\n') if (conflict) await writeFile(join(f.path, 'SKILL.md'), 'New occupant') const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) @@ -135,7 +135,7 @@ test.each([false, true])('restart recovers a renamed entry without overwriting a } finally { await rm(f.home, { recursive: true, force: true }) } }) -test.skipIf(process.platform === 'win32')('killed toggle writers recover intent, renamed and committed boundaries', async () => { +test('killed toggle writers recover intent, renamed and committed boundaries', async () => { const fixtures = await Promise.all(['intent', 'linked', 'moved', 'restore-linked', 'committed'].map(async boundary => { const f = await fixture() const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { boundary } }, async () => ({ @@ -145,12 +145,47 @@ test.skipIf(process.platform === 'win32')('killed toggle writers recover intent, return { ...f, boundary, operationId: accepted!.operationId } })) try { - await Promise.all(fixtures.map(async f => { - const child = Bun.spawn([process.execPath, new URL('../../fixtures/toggle-crash-child.ts', import.meta.url).pathname, f.home, f.operationId, f.boundary], + const crashes = await Promise.allSettled(fixtures.map(async f => { + const readyFile = join(f.home, 'crash-ready') + const child = Bun.spawn([process.execPath, fileURLToPath(new URL('../../fixtures/toggle-crash-child.ts', import.meta.url)), f.home, f.operationId, f.boundary, readyFile], { stdout: 'pipe', stderr: 'pipe' }) - expect(await child.exited).not.toBe(0) - expect(await new Response(child.stderr).text()).toBe('') + const waitForExit = async (exited: Promise) => { + let timer: ReturnType | undefined + try { + return await Promise.race([exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Owned crash process did not exit within 10 seconds')), 10000) + })]) + } finally { clearTimeout(timer) } + } + const terminate = async () => { + if (child.exitCode !== null) return + if (process.platform === 'win32') { + const killer = Bun.spawn(['taskkill.exe', '/PID', String(child.pid), '/T', '/F'], { stdout: 'ignore', stderr: 'pipe' }) + try { expect(await waitForExit(killer.exited)).toBe(0) } finally { if (killer.exitCode === null) killer.kill() } + } else child.kill('SIGKILL') + } + try { + const deadline = Date.now() + 20000 + let ready = false + while (Date.now() < deadline && child.exitCode === null) { + try { ready = (await readFile(readyFile, 'utf8')) === f.boundary } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (ready) break + await Bun.sleep(25) + } + expect(ready).toBe(true) + await terminate() + expect(await waitForExit(child.exited)).not.toBe(0) + expect(await new Response(child.stderr).text()).toBe('') + } finally { + // Cleanup uses only this child handle even if taskkill failed; it is not a passing crash assertion. + if (child.exitCode === null) child.kill() + await waitForExit(child.exited) + } })) + const failures = crashes.filter((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Crash fixtures failed after all owned children settled') // Reverse-link recovery holds the inventory lease; its stale timeout is 30 seconds. await Bun.sleep(31_500) for (const f of fixtures) { @@ -162,7 +197,7 @@ test.skipIf(process.platform === 'win32')('killed toggle writers recover intent, expect(await readFile(join(f.path, state === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Original\n') } } finally { await Promise.all(fixtures.map(f => rm(f.home, { recursive: true, force: true }))) } -}, 60_000) +}, 90_000) test.each(['forward', 'rollback'] as const)('exclusive entry creation preserves a competitor appearing after preflight: %s', async direction => { const f = await fixture() @@ -181,11 +216,14 @@ test.each(['forward', 'rollback'] as const)('exclusive entry creation preserves if (direction === 'rollback' && args[3].stage === 'restoring') await writeFile(args[3].from, 'Competing entry', { flag: 'wx' }) return saved } - if (direction === 'rollback') f.inventory.mutateAtomic = async () => { throw new Error('CAS denied') } + if (direction === 'rollback') { + const write = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { if (args[3].stage === 'committed') throw new Error('Commit denied'); return write(...args) } + } await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async () => {} })).rejects.toThrow() expect(await readFile(join(f.path, direction === 'forward' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('Competing entry') expect(await readFile(join(f.path, direction === 'forward' ? 'SKILL.md' : 'SKILL.md.disabled'), 'utf8')).toBe('# Original\n') - expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('enabled') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBeUndefined() expect((await f.runtime.getOperation(accepted!.operationId, 'owner'))?.phase).toBe('recovery-required') } finally { await rm(f.home, { recursive: true, force: true }) } }) @@ -210,7 +248,71 @@ test('independent toggle transactions preserve both targets in shared inventory' })) return { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async () => {} } })) - expect((await Promise.all(requests.map(request => f.runtime.executeToggle(request)))).map(result => result.outcome)).toEqual(['committed', 'committed']) + const results = await Promise.allSettled(requests.map(request => f.runtime.executeToggle(request))) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Independent toggle transactions failed') + expect(results.map(result => result.status === 'fulfilled' ? result.value.outcome : undefined)).toEqual(['committed', 'committed']) expect((await f.inventory.read()).items[0]!.targets.map(target => target.installationState)).toEqual(['disabled', 'disabled']) } finally { await rm(f.home, { recursive: true, force: true }) } }) + +test.each(['enabled', 'disabled'] as const)('existing v3 recovery respects persisted %s commit evidence', async storedState => { + const f = await fixture() + try { + const baseline = await f.runtime.inspectToggle(f.selected, f.target, 'disabled') + if (baseline.status !== 'ready') throw new Error('Missing baseline') + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { storedState } }, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'disable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + const operations = await OperationStore.open(f.stateDirectory) + const append = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { + const saved = await append(...args) + if (args[3].stage === 'moved') throw new Error('Lost legacy response') + return saved + } + await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, + authorize: async (_paths, phase) => { if (phase === 'rollback') throw new Error('Recovery unavailable') } })).rejects.toThrow() + const inventory = await f.inventory.read() + inventory.items[0]!.targets[0]!.installationState = storedState + await writeFile(f.inventory.path, JSON.stringify({ schemaVersion: 3, records: inventory.items })) + const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) + expect((await restarted.recover(accepted!.operationId, 'owner', [f.path], async () => {})).outcome).toBe(storedState === 'disabled' ? 'committed' : 'restored') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe(storedState) + await f.inventory.ensureWritable() + expect(await f.inventory.diskVersion()).toBe(2) + } finally { await rm(f.home, { recursive: true, force: true }) } +}) + +test('recovery never overwrites a legitimate replacement after an interrupted enable', async () => { + const f = await fixture() + try { + await rename(join(f.path, 'SKILL.md'), join(f.path, 'SKILL.md.disabled')) + const baseline = await f.runtime.inspectToggle(f.selected, f.target, 'enabled') + if (baseline.status !== 'ready') throw new Error('Missing disabled baseline') + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { enable: true } }, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'enable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + const operations = await OperationStore.open(f.stateDirectory) + const append = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { + const saved = await append(...args) + if (args[3].stage === 'moved') throw new Error('Interrupted enable') + return saved + } + await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, + authorize: async (_paths, phase) => { if (phase === 'rollback') throw new Error('Recovery unavailable') } })).rejects.toThrow() + await writeFile(join(f.path, 'SKILL.md'), '# Legitimate replacement\n') + const snapshot = await snapshotSkillDirectory(f.path) + const target = { ...baseline.inventoryItem.targets[0]!, version: '2.0.0', installedAt: '2026-10-29', fingerprint: snapshot.fingerprint, installationState: 'enabled' as const } + await writeFile(join(f.path, '.skillhub/metadata.json'), JSON.stringify({ ...f.selected, version: target.version, ...snapshot, agent: 'dsh', installedAt: target.installedAt })) + await f.inventory.writeAtomic({ items: [{ ...baseline.inventoryItem, targets: [target] }] }) + const paths = [f.inventory.path, join(f.path, 'SKILL.md'), join(f.path, '.skillhub/metadata.json'), join(f.path, 'resource.txt')] + const before = await Promise.all(paths.map(path => readFile(path))) + const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) + expect((await restarted.recover(accepted!.operationId, 'owner', [f.path], async () => {})).outcome).toBe('unknown') + expect(await Promise.all(paths.map(path => readFile(path)))).toEqual(before) + } finally { await rm(f.home, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/integrations/operation-store.test.ts b/cli/test/unit/integrations/operation-store.test.ts index c1573e0b9..a6adcd89e 100644 --- a/cli/test/unit/integrations/operation-store.test.ts +++ b/cli/test/unit/integrations/operation-store.test.ts @@ -1,6 +1,7 @@ +import { renameAtomicFile } from '../../../src/platform/atomic-rename' import { expect, test } from 'bun:test' import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rename, 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' @@ -114,3 +115,103 @@ test('pending journals block parent and child targets including aliases with mis await store.assertTargetsAvailable([sibling], current) expect((await store.get(pending.operationId, pending.ownerId))!.operation.status.phase).toBe('unknown') })) + +test('ordinary journals remain v2 while activation journals are isolated and replayable', () => fixture(async (store, root) => { + const ordinary = (await store.accept(request(store), async () => accepted))! + const key = request(store) + const activation = (await store.accept(key, async () => ({ ...accepted, kind: 'disable' })))! + expect(JSON.parse(await readFile(join(root, `${ordinary.operationId}.json`), 'utf8')).schemaVersion).toBe(2) + expect(JSON.parse(await readFile(join(root, 'activation', `${activation.operationId}.json`), 'utf8')).schemaVersion).toBe(3) + expect((await readdir(root)).filter(name => name.endsWith('.json')).sort()).toEqual([`${ordinary.operationId}.json`, 'host.json'].sort()) + const reopened = await OperationStore.open(root) + expect((await reopened.accept({ ...key, mode: 'retry' }, async () => { throw new Error('No replay mutation') }))?.operationId).toBe(activation.operationId) + expect((await reopened.listForRecovery()).map(record => record.operationId)).toContain(activation.operationId) + expect((await reopened.accept(request(reopened), async () => accepted))?.schemaVersion).toBe(2) +})) + +test('existing root v3 journal is moved byte-for-byte outside legacy scan', () => fixture(async (store, root) => { + const ordinary = (await store.accept(request(store), async () => accepted))! + const bytes = JSON.stringify({ ...ordinary, schemaVersion: 3 }, null, 3) + await writeFile(join(root, `${ordinary.operationId}.json`), bytes) + const reopened = await OperationStore.open(root) + expect(await readFile(join(root, 'activation', `${ordinary.operationId}.json`), 'utf8')).toBe(bytes) + expect(await readdir(root)).not.toContain(`${ordinary.operationId}.json`) + expect((await reopened.get(ordinary.operationId, ordinary.ownerId))?.schemaVersion).toBe(3) +})) + +test.each(['identical', 'changed'] as const)('interrupted journal isolation handles %s duplicate without hiding evidence', mode => fixture(async (store, root) => { + const ordinary = (await store.accept(request(store), async () => accepted))! + const path = join(root, `${ordinary.operationId}.json`) + await mkdir(join(root, 'activation')) + await writeFile(join(root, 'activation', `${ordinary.operationId}.json`), await readFile(path)) + if (mode === 'changed') { + await writeFile(path, JSON.stringify({ ...ordinary, revision: 1 })) + await expect(OperationStore.open(root)).rejects.toMatchObject({ code: 'CORRUPT_RECORD' }) + expect(JSON.parse(await readFile(path, 'utf8')).revision).toBe(1) + expect(JSON.parse(await readFile(join(root, 'activation', `${ordinary.operationId}.json`), 'utf8')).revision).toBe(0) + } else { + const reopened = await OperationStore.open(root) + expect(await readdir(root)).not.toContain(`${ordinary.operationId}.json`) + await reopened.update(ordinary.operationId, ordinary.ownerId, 0, { ...ordinary.operation.status, phase: 'staging' }) + expect((await reopened.get(ordinary.operationId, ordinary.ownerId))?.revision).toBe(1) + expect(await readdir(root)).not.toContain(`${ordinary.operationId}.json`) + } +})) + + +test.each(['EPERM', 'EBUSY'])('journal replacement retries %s under the store lock without replaying validation or revision', async code => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-journal-retry-')) + let armed = false; let attempts = 0; let validations = 0 + const reachedRename = Promise.withResolvers() + let firstAttempt: { source: string; target: string; payload: string } | undefined + let previous: string | undefined + let competing: Promise[]> | undefined + try { + const store = await OperationStore.open(root, (from, to) => renameAtomicFile(from, to, async (source, target) => { + if (armed) { + const observation = { source: String(source), target: String(target), payload: (await readFile(source)).toString('hex') } + if (!firstAttempt) firstAttempt = observation + else expect(observation).toEqual(firstAttempt) + expect((await readFile(target)).toString('hex')).toEqual(previous!) + if (++attempts < 3) { reachedRename.resolve(); throw Object.assign(new Error('Sharing failure'), { code }) } + } + await rename(source, target) + }, 'win32')) + const key = request(store) + const initial = (await store.accept(key, async () => { validations++; return accepted }))! + const other = await OperationStore.open(root) + previous = (await readFile(join(root, initial.operationId + '.json'))).toString('hex') + armed = true + const updating = store.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' }) + await reachedRename.promise + competing = Promise.allSettled([other.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' })]) + const staged = await updating + expect(staged.revision).toBe(1) + expect((await competing)[0]!.status).toBe('rejected') + expect(attempts).toBe(3) + expect(validations).toBe(1) + expect((await store.get(initial.operationId, key.ownerId))!.revision).toBe(1) + expect((await store.accept({ ...key, mode: 'retry' }, async () => { throw new Error('must not replay') }))!.operationId).toBe(initial.operationId) + } finally { await competing; await rm(root, { recursive: true, force: true }) } +}) + +test('exhausted journal replacement preserves original bytes and releases the store lock', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-journal-exhaust-')) + let armed = false; let attempts = 0 + const failure = Object.assign(new Error('Sharing failure'), { code: 'EPERM' }) + try { + const store = await OperationStore.open(root, (from, to) => renameAtomicFile(from, to, async (source, target) => { + if (armed) { attempts++; throw failure } + await rename(source, target) + }, 'win32')) + const key = request(store); const initial = (await store.accept(key, async () => accepted))! + const path = join(root, initial.operationId + '.json'); const before = await readFile(path) + armed = true + await expect(store.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' })).rejects.toBe(failure) + expect(attempts).toBe(6) + expect(await readFile(path)).toEqual(before) + expect((await readdir(root)).filter(name => name.endsWith('.tmp'))).toEqual([]) + armed = false + expect((await store.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' })).revision).toBe(1) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/integrations/removal-crash-recovery.test.ts b/cli/test/unit/integrations/removal-crash-recovery.test.ts index 8ec0bdd1c..ef4846db5 100644 --- a/cli/test/unit/integrations/removal-crash-recovery.test.ts +++ b/cli/test/unit/integrations/removal-crash-recovery.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { openNativeRuntime } from '../../../src/integrations/native-runtime' -import { InventoryStore, type Inventory } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore, type Inventory } from '../../../src/stores/inventory-store' interface Fixture { operationId: string; ownerId: string; path: string; retainedPath: string; quarantine: string | null; before: Inventory; content: string } @@ -41,9 +41,9 @@ test.skipIf(process.platform === 'win32')('killed removal and recovery processes const result = await runtime.recover(fixture.operationId, fixture.ownerId, [fixture.path], async () => {}) expect(result.outcome, `${boundary}/${mode}: ${result.reason}`).toBe(committed ? 'committed' : 'restored') const beforeItem = fixture.before.items[0]! - expect(await new InventoryStore(join(root, 'home')).read()).toEqual(committed + expect(inventoryWithoutState(await new InventoryStore(join(root, 'home')).read())).toEqual(inventoryWithoutState(committed ? { items: [{ ...beforeItem, targets: beforeItem.targets.filter(target => target.installDir !== fixture.path) }] } - : fixture.before) + : fixture.before)) expect(await readFile(join(fixture.retainedPath, 'SKILL.md'), 'utf8')).toBe(fixture.content) if (committed || mode === 'stale') await expect(readFile(join(fixture.path, 'SKILL.md'))).rejects.toMatchObject({ code: 'ENOENT' }) else expect(await readFile(join(fixture.path, 'SKILL.md'), 'utf8')).toBe(fixture.content) diff --git a/cli/test/unit/integrations/removal-inventory.test.ts b/cli/test/unit/integrations/removal-inventory.test.ts index 3c227a40b..e299a0505 100644 --- a/cli/test/unit/integrations/removal-inventory.test.ts +++ b/cli/test/unit/integrations/removal-inventory.test.ts @@ -11,7 +11,7 @@ test('removal commits exactly one target, journals before writing and preserves try { const store = new InventoryStore(home) const root = join(home, 'skills') - const target = { installationState: 'enabled' as const, version: '1.0.0', fingerprint: 'fingerprint', 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', targets: [target, retained] } const other = { ...item, slug: 'reader', targets: [{ ...target, installDir: join(root, 'reader') }] } diff --git a/cli/test/unit/integrations/transaction-journal.test.ts b/cli/test/unit/integrations/transaction-journal.test.ts index 799aa4c0c..5def5c732 100644 --- a/cli/test/unit/integrations/transaction-journal.test.ts +++ b/cli/test/unit/integrations/transaction-journal.test.ts @@ -8,7 +8,7 @@ import { OperationStore, operationRequestDigest } from '../../../src/integration import { createInstallTransactionObserver } from '../../../src/integrations/transaction-journal' import { installSkill } from '../../../src/services/install-service' import { SkillHubClient } from '../../../src/clients/skillhub-client' -import { InventoryStore } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore } from '../../../src/stores/inventory-store' class LocalClient extends SkillHubClient { async download(): Promise { @@ -54,7 +54,7 @@ test('real installation persists move intent before rename and both inventory sn beforeInventoryCommit: async commit => { await observer.beforeInventoryCommit!(commit) const saved = (await store.get(operation.operationId, ownerId))! - expect(saved.transaction?.inventoryCommit).toEqual({ ...commit, committed: false }) + expect(saved.transaction?.inventoryCommit).toEqual({ before: inventoryWithoutState(commit.before), after: inventoryWithoutState(commit.after), committed: false }) expect((await new InventoryStore(home).read()).items).toEqual([]) } } @@ -135,7 +135,7 @@ test.each([false, true])('preparation acknowledgment failure leaves target uncha expect(await readdir(join(root, 'skills'))).toEqual(existing ? ['writer'] : []) const saved = (await store.get(operation.operationId, ownerId))! expect(saved.transaction!.moves).toEqual([]) - expect(saved.transaction!.preparation!.before).toEqual(before) + expect(inventoryWithoutState(saved.transaction!.preparation!.before)).toEqual(inventoryWithoutState(before)) if (existing) expect(await readFile(join(root, 'skills/writer/SKILL.md'), 'utf8')).toBe('# Journal fixture') } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/cli/test/unit/platform/atomic-rename.test.ts b/cli/test/unit/platform/atomic-rename.test.ts new file mode 100644 index 000000000..ee0522b3e --- /dev/null +++ b/cli/test/unit/platform/atomic-rename.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { renameAtomicFile } from '../../../src/platform/atomic-rename' + +test.each(['EPERM', 'EBUSY'])('Windows atomic rename retries transient %s without removing the destination', async code => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-atomic-rename-')) + const from = join(root, 'temporary'); const to = join(root, 'inventory') + try { + await writeFile(from, 'next'); await writeFile(to, 'original') + let calls = 0 + await renameAtomicFile(from, to, async (source, destination) => { + expect(await readFile(to, 'utf8')).toBe('original') + expect(await readFile(from, 'utf8')).toBe('next') + if (++calls < 3) throw Object.assign(new Error('Sharing failure'), { code }) + await rename(source, destination) + }, 'win32') + expect(calls).toBe(3) + expect(await readFile(to, 'utf8')).toBe('next') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test.each([['linux', 'EPERM'], ['darwin', 'EBUSY'], ['win32', 'EACCES'], ['win32', 'ENOENT']] as const)( + '%s does not retry %s outside the Windows sharing-failure boundary', async (platform, code) => { + const error = Object.assign(new Error('Permanent failure'), { code }); let calls = 0 + await expect(renameAtomicFile('source', 'destination', async () => { calls++; throw error }, platform)).rejects.toBe(error) + expect(calls).toBe(1) + }) diff --git a/cli/test/unit/services/skill-target-lock.test.ts b/cli/test/unit/services/skill-target-lock.test.ts index 99964f6f3..5802a2b3a 100644 --- a/cli/test/unit/services/skill-target-lock.test.ts +++ b/cli/test/unit/services/skill-target-lock.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'bun:test' import { acquireSkillTargetLock, + acquireTargetLock, assertPrivateLockDir, ensurePrivateLockDir, skillTargetLockPath, @@ -236,3 +237,71 @@ describe('skill target lifecycle lock', () => { expect(await exists(lockPathBeforeRemoval)).toBe(false) }) }) + + +test.each(['EPERM', 'EBUSY'])('Windows target lock retries only a transient mkdir %s on the selected lock', async code => { + const path = join(tmpdir(), 'skillhub-injected-lock'); let calls = 0 + const release = async () => {} + expect(await acquireTargetLock(path, async (target, options) => { + expect(target).toBe(path) + expect(options).toEqual({ lockfilePath: path, realpath: false, stale: 10000, update: 3000, retries: 0 }) + if (++calls < 3) throw Object.assign(new Error('Sharing failure'), { code, syscall: 'mkdir', path }) + return release + }, 'win32')).toBe(release) + expect(calls).toBe(3) +}) + +test('Windows target lock exhaustion preserves the original mkdir error', async () => { + const path = join(tmpdir(), 'skillhub-injected-lock') + const error = Object.assign(new Error('Sharing failure'), { code: 'EPERM', syscall: 'mkdir', path }); let calls = 0 + await expect(acquireTargetLock(path, async () => { calls++; throw error }, 'win32')).rejects.toBe(error) + expect(calls).toBe(6) +}) + +test.each([ + ['linux', 'EPERM', 'mkdir', true], ['darwin', 'EBUSY', 'mkdir', true], + ['win32', 'ELOCKED', 'mkdir', true], ['win32', 'EACCES', 'mkdir', true], + ['win32', 'EPERM', 'rmdir', true], ['win32', 'EPERM', 'mkdir', false], +] as const)('target lock rejects %s %s %s without broadening retry ownership', async (platform, code, syscall, selectedPath) => { + const path = join(tmpdir(), 'skillhub-injected-lock') + const error = Object.assign(new Error('No retry'), { code, syscall, path: selectedPath ? path : path + '-other' }); let calls = 0 + await expect(acquireTargetLock(path, async () => { calls++; throw error }, platform)).rejects.toBe(error) + expect(calls).toBe(1) +}) + +test('Windows target lock retries never steal a live proper-lockfile owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-lock-live-owner-')) + const path = join(root, 'target.lock'); const release = await acquireTargetLock(path) + try { + const original = await lstat(path) + await expect(acquireTargetLock(path, undefined, 'win32')).rejects.toMatchObject({ code: 'ELOCKED' }) + expect((await lstat(path)).mtimeMs).toBe(original.mtimeMs) + } finally { await release(); await rm(root, { recursive: true, force: true }) } +}) + + +test('target acquisition gate excludes another contender throughout Windows retry backoff', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-lock-retry-gate-')) + let secondEntered = false; let attempts = 0 + let second: Promise Promise>[]> | undefined + let release: (() => Promise) | undefined + try { + release = await acquireSkillTargetLock(root, 'writer', path => acquireTargetLock(path, async () => { + if (++attempts === 1) { + second = Promise.allSettled([acquireSkillTargetLock(root, 'writer', async otherPath => { + secondEntered = true; return acquireTargetLock(otherPath) + })]) + throw Object.assign(new Error('Sharing failure'), { code: 'EPERM', syscall: 'mkdir', path }) + } + expect(secondEntered).toBe(false) + return acquireTargetLock(path) + }, 'win32')) + const result = await second! + expect(attempts).toBe(2) + expect(secondEntered).toBe(true) + expect(result[0]!.status).toBe('rejected') + } finally { + if (second) for (const result of await second) if (result.status === 'fulfilled') await result.value() + await release?.(); await rm(root, { 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 d5a710c4c..a049acbcc 100644 --- a/cli/test/unit/stores/inventory-store.test.ts +++ b/cli/test/unit/stores/inventory-store.test.ts @@ -1,5 +1,6 @@ +import { renameAtomicFile } from '../../../src/platform/atomic-rename' import { describe, expect, test } from 'bun:test' -import { mkdir, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, dirname } from 'node:path' @@ -15,7 +16,6 @@ function makeTarget(slug: string) { agent: 'claude', rootDir: `/projects/${slug}`, installDir: `/projects/${slug}/.skillhub/skills/${slug}`, - installationState: 'enabled' as const, installedAt: new Date().toISOString(), } } @@ -166,3 +166,31 @@ describe('InventoryStore', () => { ]) }) }) + + test('exhausted Windows rename retries preserve inventory, clean temporary files, and do not replay the mutation', async () => { + const home = await makeTempHome() + const original = new InventoryStore(home) + let attempts = 0 + const failure = Object.assign(new Error('Persistent sharing failure'), { code: 'EPERM' }) + class FailingRenameStore extends InventoryStore { + protected override async replaceInventoryFile(temporaryPath: string): Promise { + await renameAtomicFile(temporaryPath, this.path, async () => { attempts++; throw failure }, 'win32') + } + } + try { + await original.writeAtomic({ items: [] }) + const bytes = await readFile(original.path) + const failing = new FailingRenameStore(home) + let mutations = 0 + await expect(failing.mutateAtomic(inventory => { + mutations++ + inventory.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'writer', targets: [] }) + })).rejects.toBe(failure) + expect(attempts).toBe(6) + expect(mutations).toBe(1) + expect(await readFile(original.path)).toEqual(bytes) + expect((await readdir(dirname(original.path))).filter(name => name.endsWith('.tmp'))).toEqual([]) + await original.mutateAtomic(inventory => { inventory.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'other', targets: [] }) }) + expect((await original.read()).items.map(item => item.slug)).toEqual(['other']) + } finally { await rm(home, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/stores/inventory-v2.test.ts b/cli/test/unit/stores/inventory-v2.test.ts index c497e0f3e..3be485183 100644 --- a/cli/test/unit/stores/inventory-v2.test.ts +++ b/cli/test/unit/stores/inventory-v2.test.ts @@ -58,7 +58,7 @@ test('v1 read preserves bytes and migration expands ownership without inventing 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(3); expect(disk).not.toHaveProperty('items'); expect(disk.records[0]).not.toHaveProperty('version') + 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() } }) @@ -74,7 +74,7 @@ test.each(['before', 'after'] as const)('migration %s rename failure leaves one 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 : 3) + 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() } @@ -113,7 +113,10 @@ test('independent prepared upgrades commit without losing siblings', async () => 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 results = await Promise.allSettled([f.runtime.executeUpgrade(first), f.runtime.executeUpgrade(second)]) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Independent upgrades failed') + expect(results.map(result => result.status === 'fulfilled' ? result.value.outcome : undefined)).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() } @@ -289,7 +292,7 @@ test.each(['suite-only', 'shared', 'suite-claim'] as const)('direct CLI mutation } finally { await f.close() } }) -test('v2 read is byte-preserving; v3 migration backs up exact v2 bytes and makes target states explicit', async () => { +test('v2 read and writable checks preserve exact compatible bytes', async () => { const f = await fixture() try { await f.install(0, '1.0.0') @@ -301,10 +304,29 @@ test('v2 read is byte-preserving; v3 migration backs up exact v2 bytes and makes expect((await f.store.read()).items[0]!.targets[0]!.installationState).toBe('enabled') expect(await readFile(f.store.path, 'utf8')).toBe(bytes) await f.store.ensureWritable() - const digest = createHash('sha256').update(bytes).digest('hex') - expect(await readFile(join(dirname(f.store.path), `inventory.v2.${digest}.backup.json`), 'utf8')).toBe(bytes) + expect(await readFile(f.store.path, 'utf8')).toBe(bytes) const disk = JSON.parse(await readFile(f.store.path, 'utf8')) - expect(disk.schemaVersion).toBe(3) - expect(disk.records).toEqual(v3.items) + expect(disk.schemaVersion).toBe(2) + expect(disk.records).toEqual(v2.records) + } finally { await f.close() } +}) + + test.each(['matching', 'conflict'] as const)('v3 conversion verifies files and preserves original bytes: %s', async mode => { + const f = await fixture() + try { + await f.install(0, '1.0.0') + const inventory = await f.store.read() + if (mode === 'conflict') inventory.items[0]!.targets[0]!.installationState = 'disabled' + const bytes = JSON.stringify({ schemaVersion: 3, records: inventory.items }) + await writeFile(f.store.path, bytes) + if (mode === 'conflict') { + await expect(f.store.ensureWritable()).rejects.toThrow('requires recovery') + expect(await readFile(f.store.path, 'utf8')).toBe(bytes) + } else { + await f.store.ensureWritable() + expect(await f.store.diskVersion()).toBe(2) + expect(await readFile(join(dirname(f.store.path), `inventory.v3.${createHash('sha256').update(bytes).digest('hex')}.backup.json`), 'utf8')).toBe(bytes) + expect(JSON.parse(await readFile(f.store.path, 'utf8')).records[0].targets[0]).not.toHaveProperty('installationState') + } } finally { await f.close() } }) diff --git a/openspec/changes/add-dsh-native-plugin/release-guide.md b/openspec/changes/add-dsh-native-plugin/release-guide.md index eca6cde82..e4a2c3923 100644 --- a/openspec/changes/add-dsh-native-plugin/release-guide.md +++ b/openspec/changes/add-dsh-native-plugin/release-guide.md @@ -10,11 +10,22 @@ 插件运行在 DSH 主机,不部署进 SkillHub 网站,也不要求网站嵌入插件 UI。插件内部 Remote 接口随插件版本交付;Registry API 随服务端交付。两者分别管理版本和发布节奏。 -### 1.1 当前插件发布 +### 1.1 当前候选与发布顺序 -本轮仅发布 DSH 插件 `0.5.0`,提供只读技能可用性诊断。CLI 保持已发布的 `0.2.0`,无需再次发布;库存格式与兼容范围不变。发布标签为 `dsh-v0.5.0`,通过专用工作流上传经过检查的 tarball;发布后从 npm 重新下载安装验证。 +本轮拟发布 CLI `0.3.0`(`cli-v0.3.0`)和 DSH `0.6.0`(`dsh-v0.6.0`),范围为技能启停、统一只读检查更新、紧凑已安装卡片,以及发布前库存兼容修复。不发布服务端镜像或部署 SaaS。 -### 1.2 0.4.0 库存升级说明 +共享库存保持严格 v2,不写入 `installationState`;启停状态从可信的 `SKILL.md` / `SKILL.md.disabled` 入口派生,不要求 CLI 用户同步升级。已发布 CLI `0.2.0` 的正常查询和普通目标管理必须继续兼容;默认更新、同步及 Suite 不得无意重新启用停用目标。旧 CLI 显式 force 重装、更新、同步仍按原覆盖语义执行,可能恢复标准入口;显式 remove 仍可删除,新版重新读取须反映真实变化。 + +1. 使用真实旧公开 CLI `0.2.0`、DSH `0.5.0` 建立基线,在同一隔离 HOME 交替操作,验证普通/停用目标混存、来源与位置隔离、默认保护和显式覆盖语义。仅证明拒写或字节未损坏不算兼容通过。 +2. 读 v1/v2 不迁移。未发布 v3 必须在锁内校验身份、入口与未完成事务,备份原始字节后安全转换 v2;普通操作日志保持旧 DSH 可读,启停日志隔离影响。并发及五阶段强杀恢复重新验收。 +3. 完成候选检查、双 RC、跨平台、真实浏览器及草稿保护;旧候选结果仅在输入等价时可复用。兼容修复正在进行,未通过前阻断发布。 +4. 通过后按专用工作流依次发布 CLI 和 DSH,核对公开字节、干净安装和升级保留。此顺序是交付安排,不是要求用户同步升级 CLI。当前不声明任何候选已发布。 + +旧消费者不承诺识别启停专用日志:中断启用已留下合法标准入口时,旧版仍可能执行合法管理。新恢复必须先核精确目标身份、版本、时间、指纹和归属,发现旧版变更即保留现场并返回 unknown,不能用旧意图覆盖新内容。CLI 的 v3 转换只核当前库存和入口并保留日志,不扫描全机 Host;NativeRuntime 在自己的日志域先处理恢复。 + +插件内置核心,不调用 CLI。回退不能只恢复库存:须核对入口、内容、元数据和日志,不能用过时备份覆盖新记录。完整协议以 [启停设计 §7](skill-toggle-design.md#7-库存协议与实施门禁) 为准。 + +### 1.2 历史:0.4.0 库存升级说明 此前库存升级交付 CLI `0.2.0` 和 DSH 插件 `0.4.0`,不发布服务端镜像、不部署 SaaS。插件内置安装核心,不要求另装 CLI;但同一用户使用 CLI 共同管理技能时,两者必须使用兼容库存格式。 @@ -80,6 +91,10 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output `check-dsh-plugin.yml` 使用 Linux、macOS、Windows 三平台矩阵,Node 24.2.0 与当前 Node 24 双版本、Bun 1.3.13,分别运行共享 CLI 检查、插件 `bun run check` 和隔离 Web Profile 的 Host 安装冒烟,并保存带平台名的 tarball 与脱敏摘要。新 RC `0.2.1-rc.2` 另有 Linux 双 Node 原生 Host 检查,复用对应 Node 档的原矩阵产物;不将此结果扩大为新 RC 的 Windows/macOS 原生文件操作验收。步骤显式使用 Bash,避免 Windows 默认 shell 对脚本的不同解释;平台失败不取消其他平台任务。矩阵通过只证明该次构建与自动化测试结果,真实 DSH Profile 安装、页面和对话验收仍须单独完成。平台测试中的跳过项必须记录,不能算作通过。 +新增独立安装启停验收矩阵:Linux/macOS/Windows × DSH `0.2.0-rc.2`/`0.2.1-rc.2`,固定 Node `24.2.0`,复用对应平台检查过的候选 tarball。`--scenario skill-toggle` 与 `--scenario skill-toggle-watch-off` 分别创建全新隔离 Host/Profile,不能复用可用性探针遗留的安装状态。核心恢复测试在五个持久化边界终止本次测试子进程;Windows 使用原生进程终止,POSIX 使用 SIGKILL。核心进程恢复、Host 启停发现及真实浏览器交互分别记录,不将核心测试称为完整 Host 强杀重启验收。矩阵配置不等于本次已通过,结果见最终发布验证记录。 + +Host 准备允许通过显式 `--download-cache` 共享受校验临时目录中的公共 npm `_cacache` 下载内容。CI 恢复缓存前及实际使用时都检查目录归属、链接与权限;按平台、架构、依赖准备代码和 DSH 版本区分缓存,可复用同族旧版本的公共内容。不得缓存 `node_modules`、Profile、技能、库存、历史、凭证或日志;只有 `install-host` 使用该缓存,候选安装与 Host 运行恢复独立私有缓存。每次验收仍创建全新状态,冷缓存未命中执行真实安装,安装 600 秒与各业务检查时限不变;耗时、缓存内容是否存在及固定错误码只作诊断,准备失败不得记为业务通过。 + ### 2.2 插件包检查 - Host、Client、patch、Remote 元数据、声明及许可证包含在包中,exports 指向有效文件。 @@ -90,7 +105,7 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output ### 2.3 隔离环境验证 -本次 `0.5.0` 候选分别验证 DSH `0.2.0-rc.2` 与 `0.2.1-rc.2`,Node `>=24.2.0 <25`。Host、app-boot、核心模块及注入 Client 模块须为同一精确发行版。新 RC 仅使用 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`;旧 RC 的 Cordis、Loader、Include、Timer 须为已验证的完整组合:`4.0.4 / 1.0.5 / 1.0.9 / 1.1.6` 或 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`,不得交叉混装;以实际 Host 解析结果为准。已发布 `0.1.0` 尚不包含后一组合支持及历史回执修复,`0.1.1` 起包含这些修复,`0.2.0` 延续此兼容基线;发布前使用本次 tarball 验证。在独立测试账户或容器中准备 Web Profile、工作区和测试技能,连接测试 Registry。仅隔离 Profile/DSH home 不足以隔离共享 inventory。 +本次 `0.6.0` 候选分别验证 DSH `0.2.0-rc.2` 与 `0.2.1-rc.2`,Node `>=24.2.0 <25`。Host、app-boot、核心模块及注入 Client 模块须为同一精确发行版。新 RC 仅使用 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`;旧 RC 的 Cordis、Loader、Include、Timer 须为已验证的完整组合:`4.0.4 / 1.0.5 / 1.0.9 / 1.1.6` 或 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`,不得交叉混装;以实际 Host 解析结果为准。已发布 `0.1.0` 尚不包含后一组合支持及历史回执修复,`0.1.1` 起包含这些修复,`0.2.0` 延续此兼容基线;发布前使用本次 tarball 验证。在独立测试账户或容器中准备 Web Profile、工作区和测试技能,连接测试 Registry。仅隔离 Profile/DSH home 不足以隔离共享 inventory。 首次创建测试 Web Profile,然后安装本地候选包: @@ -175,7 +190,7 @@ SaaS 使用其现有流水线部署;公共接口契约、测试数据规则与 ### 4.1 独立发布流程 -插件使用独立版本,tag 前缀固定为 `dsh-v`(本次为 `dsh-v0.5.0`),不复用 CLI 的 `cli-v*` 发布流程。专用工作流为 `.github/workflows/release-dsh-plugin.yml`;发布前仍须确认 npm 包名、兼容基线及候选包验收,本次候选为 `0.5.0`,manifest 已显式设为 `private: false`;缺省 Registry 为 `https://skill.xfyun.cn`,显式空串/空白仍断开,已有自定义来源保留。发布前分别验证缺省、断开和自定义配置,以及正式域名的固定版本安装、状态识别和本地管理。正式发布仍以最终提交、候选验收和审批结果为准。 +插件使用独立版本,tag 前缀固定为 `dsh-v`(本次为 `dsh-v0.6.0`),不复用 CLI 的 `cli-v*` 发布流程。专用工作流为 `.github/workflows/release-dsh-plugin.yml`;发布前仍须确认 npm 包名、兼容基线及候选包验收,本次候选为 `0.6.0`,manifest 已显式设为 `private: false`;缺省 Registry 为 `https://skill.xfyun.cn`,显式空串/空白仍断开,已有自定义来源保留。发布前分别验证缺省、断开和自定义配置,以及正式域名的固定版本安装、状态识别和本地管理。正式发布仍以最终提交、候选验收和审批结果为准。 发布流程依次完成:校验版本 → 测试与构建 → 生成并检查 tarball → 隔离 DSH 候选验收 → 发布审批 → 发布 npm → 校验已发布包。当前工作流自动执行版本校验、测试与构建、包检查、上传及完整性校验;隔离 DSH 的 UI、真实模型和平台验收按本次确认范围另行执行,未验项目须单独注明;CI 矩阵成功不等同于这些验收通过。工作流使用仓库的 `NPM_TOKEN` 发布凭证,源码及 npm 包不包含凭证;配置了环境保护规则时还须完成对应审批。 @@ -198,7 +213,7 @@ SaaS 使用其现有流水线部署;公共接口契约、测试数据规则与 ### 4.3 发布说明 -`0.5.0` 的变更说明见 [release-notes.md](release-notes.md)。正式版本发布前按最终候选及验收结果更新,不沿用开发版本的支持声明。 +`0.6.0` 的变更说明见 [release-notes.md](release-notes.md)。正式版本发布前按最终候选及验收结果更新,不沿用开发版本的支持声明。 每个版本包含: diff --git a/openspec/changes/add-dsh-native-plugin/release-notes.md b/openspec/changes/add-dsh-native-plugin/release-notes.md index e536cfcd0..d3a9a0587 100644 --- a/openspec/changes/add-dsh-native-plugin/release-notes.md +++ b/openspec/changes/add-dsh-native-plugin/release-notes.md @@ -1,6 +1,16 @@ # SkillHub DSH 原生插件发布说明 +## 0.6.0 候选:启停、检查更新与紧凑卡片 + +- **受管理技能启停**:[#1025](https://github.com/iflytek/skillhub/pull/1025) 按确认的位置将 `SKILL.md` 转换为 `SKILL.md.disabled`,启用恢复入口,保留目录和资源。更新停用安装后仍保持停用;文件修改、共享归属、目标被占用及不可信记录仍阻止写操作。停用不撤回已加载会话内容,也不停止任务。 +- **统一检查、逐项更新**:[#1030](https://github.com/iflytek/skillhub/pull/1030) 主动只读检查当前来源库存,渐进显示结果和失败重试。一次可信快照、有界并发、同来源技能共用远端 resolve;取消及上下文变化不接纳迟到结果。真正更新仍进入详情按位置确认,不提供批量自动覆盖。 +- **日常围绕使用技能**:[#1032](https://github.com/iflytek/skillhub/pull/1032) 已安装页采用桌面两列、窄屏一列的紧凑横向卡片,显示真实本地名称、一行摘要和实际安装版本;至多两个语义标签。整卡进入详情,保留使用技能主按钮,管理安装/检查更新收进更多菜单。单卡检查不清除其他卡结果,使用和菜单不误触详情。 +- **库存兼容**:共享库存保持严格 v2,不写入 `installationState`;启停状态从可信的 `SKILL.md` / `SKILL.md.disabled` 入口派生,不要求 CLI 用户同步升级。已发布 CLI `0.2.0` 的正常查询和普通目标管理必须继续兼容;默认更新、同步及 Suite 不得无意重新启用停用目标。旧 CLI 显式 force 重装、更新、同步仍按原覆盖语义执行,可能恢复标准入口;显式 remove 仍可删除,新版重新读取须反映真实变化。 未发布 v3 的安全转换、普通/启停日志兼容隔离及真实旧包交替验收正在进行;未通过前阻断发布。 +- **交付边界**:保留现有双 RC、Node/Cordis 与来源边界;详情官网链接仅使用 API 的安全 `webUrl`,缺失即隐藏。该增量不包含 SaaS 部署、场景助手或技能清单导入导出。 + +上述包含已合并功能及正在实施的发布前兼容修复,不声明 `0.6.0`/CLI `0.3.0` 已发布,也不把历史功能验收等同本轮最终候选、跨平台恢复或公开包验收。发布顺序和限制见 [发布指南](release-guide.md),结果由 [验证记录](verification.md) 单独维护。 + ## 0.5.0 技能可用性诊断 - 技能卡片“更多 → 检查可用性”与对话工具 `astron_skillhub_check_availability` 提供只读诊断,区分文件完整性、同名覆盖、宿主未知及用户/模型调用策略([#1023](https://github.com/iflytek/skillhub/pull/1023))。 @@ -10,7 +20,7 @@ ## 版本与交付状态 -当前准备版本为 `0.5.0`;上一公开版本为 `0.4.0`。发布状态以对应 npm 版本和发布工作流为准。 +当前准备版本为 DSH `0.6.0` 和配套 CLI `0.3.0`,尚不据此声明已公开发布。历史版本说明保留;实际公开状态以对应 npm 版本和专用发布工作流为准。 `@astron-team/skillhub-dsh@0.3.1` 已发布,独立标签为 `dsh-v0.3.1`。[发布准备 PR #995](https://github.com/iflytek/skillhub/pull/995) 已合并,[专用发布工作流](https://github.com/iflytek/skillhub/actions/runs/39556075587) 成功;[公开 npm 包](https://www.npmjs.com/package/@astron-team/skillhub-dsh/v/0.3.1) 与工作流产物逐字节一致,发布时 `latest` 指向 `0.3.1`。两版 Linux 的严格安装、原生探针及浏览器操作复验通过,测试工件与公开包字节一致,结果见 [验证摘要](verification.md)。历史已发布版本保持不变。 @@ -100,7 +110,7 @@ Node 保持 `>=24.2.0 <25`。精确兼容与验证边界见 [兼容设计](compa ## 安装、更新与维护 -可通过 `dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.3.1 --ignore-scripts` 安装到已有 Web Profile。发布前从源码执行 `bun run check` 后生成 `.tgz`,使用固定版本 DSH 的 `plugin ... add` 安装。具体准备、命令、配置和测试流程见 [中文指南](../../../plugins/dsh/README.md)、[英文指南](../../../plugins/dsh/README.en.md) 及 [发布指南](release-guide.md)。 +`0.6.0` 公开可用后,可通过 `dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.6.0 --ignore-scripts` 安装到已有 Web Profile;发布前使用本地候选 tarball。发布前从源码执行 `bun run check` 后生成 `.tgz`,使用固定版本 DSH 的 `plugin ... add` 安装。具体准备、命令、配置和测试流程见 [中文指南](../../../plugins/dsh/README.md)、[英文指南](../../../plugins/dsh/README.en.md) 及 [发布指南](release-guide.md)。 更新或回退前保留原包和校验值、停止新操作并处理待恢复事务,然后停止 Host、安装兼容包并重新启动。浏览器旧资源可强制刷新;这不能代替 Host 依赖重载。禁用或移除包不会卸载技能,不应删除技能目录、inventory 或操作日志。 diff --git a/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md b/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md index c48258bcb..4d16bd9dc 100644 --- a/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md +++ b/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md @@ -26,9 +26,9 @@ 停用入口不是垃圾,不得按清理期限删除。状态记录绑定精确安装及入口别名;不能以 `SKILL.md` 缺失推导 disabled,不能看到任意 `.disabled` 文件就认领。所有安装列表、详情、诊断、使用前校验及运行时上下文读取同一共享投影,模型和 UI 只收到白名单公开状态。 -稳态读取指纹仅在受信库存记录为 disabled 且精确安装身份匹配时,按该状态将该安装内精确 `SKILL.md.disabled` 逻辑映射为 `SKILL.md` 计算,保留原始内容字节;其余作者文件照常参与。不对任意扩展名归一化,不跳过别名内容。两入口同时存在、入口缺失、别名冲突、目录或链接类型异常均阻断;原包自带该同名作者文件时必须阻断启停;升级候选包自带该文件则阻断停用状态下的升级,不能覆盖合法作者资源,也不因此改变普通上传协议。发布/导出必须先恢复原稿,或由经过验证的规范导出流程在输出副本恢复入口名并校验原指纹,不能把停用目录原样作为标准包发布。标准打包仍要求 SKILL.md,停用目录不能直接发布。 首次停用改名后且库存提交前、启用恢复及升级暂存等转换阶段,只能由受信操作日志与不可变计划绑定的精确目标、阶段和基线授权入口映射;不得通过通用布尔参数对任意目录放开映射。 +稳态读取指纹仅在精确受管安装由可信停用入口投影为 disabled 且安装身份匹配时,按该状态将该安装内精确 `SKILL.md.disabled` 逻辑映射为 `SKILL.md` 计算,保留原始内容字节;其余作者文件照常参与。不对任意扩展名归一化,不跳过别名内容。两入口同时存在、入口缺失、别名冲突、目录或链接类型异常均阻断;原包自带该同名作者文件时必须阻断启停;升级候选包自带该文件则阻断停用状态下的升级,不能覆盖合法作者资源,也不因此改变普通上传协议。发布/导出必须先恢复原稿,或由经过验证的规范导出流程在输出副本恢复入口名并校验原指纹,不能把停用目录原样作为标准包发布。标准打包仍要求 SKILL.md,停用目录不能直接发布。 首次停用改名后且库存提交前、启用恢复及升级暂存等转换阶段,只能由受信操作日志与不可变计划绑定的精确目标、阶段和基线授权入口映射;不得通过通用布尔参数对任意目录放开映射。 -停用成功仅表示选定位置的文件与库存提交完成。项目副本停用后用户副本可能接替,外部 Provider 也可能仍提供同名技能;不自动启停赢家、不自动将旧“使用”动作改绑给赢家。停用安装不提供“使用”,可提供启用、检查更新、更新和卸载。启用后仍需发现和策略核验,不能直接标记可调用。 +停用成功仅表示选定位置的入口变更与持久日志提交完成。项目副本停用后用户副本可能接替,外部 Provider 也可能仍提供同名技能;不自动启停赢家、不自动将旧“使用”动作改绑给赢家。停用安装不提供“使用”,可提供启用、检查更新、更新和卸载。启用后仍需发现和策略核验,不能直接标记可调用。 ## 3. 精确计划与真人确认 @@ -61,17 +61,17 @@ flowchart TD C -->|是| E[持久化入口变更意图与恢复基线] E --> F[排他创建目标硬链接并记录 linked 阶段] F --> F2[核验同 inode 后移除旧入口] - F2 --> G[在最新库存 CAS 提交所选位置状态] + F2 --> G[核验最新库存所有权并持久化日志提交点] G -->|确定已提交| H[保留提交结果并完成日志] G -->|确定未提交| I[核验后无覆盖恢复入口] G -->|未知| J[重读库存与两侧文件] - J -->|匹配 after| H - J -->|匹配 before 且可逆| I + J -->|日志 committed 且文件匹配 after| H + J -->|未提交且所有权未变、入口可逆| I J -->|冲突或证据不足| K[保留现场并要求恢复] H --> L[单独观察发现状态] ``` -CAS 是逻辑提交点;只合并所选目标状态,保留其他位置最新变更。入口变更前日志保存身份、before/after、精确路径与阶段。文件转换失败不得直接改状态;不支持同目录硬链接时拒绝,不退化为可能覆盖目标的 rename 或复制删除。日志与库存采用既有持久化流程;进程强杀恢复测试不等于硬件断电耐久性验证。 +持有目标锁与库存锁核验最新所有权后,持久日志 committed 阶段是启停逻辑提交点;磁盘库存不保存入口状态,保留其他位置最新变更。入口变更前日志保存身份、before/after、精确路径与阶段。文件转换失败不得直接改状态;不支持同目录硬链接时拒绝,不退化为可能覆盖目标的 rename 或复制删除。日志与库存采用既有持久化流程;进程强杀恢复测试不等于硬件断电耐久性验证。 启用时标准入口必须不存在且停用入口可信;标准入口存在任何内容、链接或无法读取均阻断,不覆盖、不合并。停用时别名目标必须不存在。停用入口被修改、指纹未知或两入口均缺失同样阻断。两入口均存在、库存与文件矛盾或未知协议进入待核验状态。 @@ -80,13 +80,13 @@ CAS 是逻辑提交点;只合并所选目标状态,保留其他位置最新 | 故障窗口 | 结果与恢复规则 | | --- | --- | | intent 写入失败或未移动 | 不移动、不更新状态;报告失败 | -| linked 中间态或已转换,确定 CAS 未提交 | 同一锁域检查两侧与原基线,安全时逆向移动;否则保留现场并要求恢复 | -| CAS 报错但提交结果未知 | 重读 inventory 和两侧文件,禁止立即反向移动 | -| CAS 已提交,完成日志失败或响应丢失 | 保留新状态与文件,回执告警或待核验;不得反向恢复旧入口 | +| linked 中间态或已转换,日志确定未提交 | 同一锁域检查两侧与原基线,安全时逆向移动;否则保留现场并要求恢复 | +| 日志提交报错且结果未知 | 重读日志、inventory 所有权和两侧文件,禁止立即反向移动 | +| 日志 committed 已持久化,终态日志失败或响应丢失 | 保留新状态与文件,回执告警或待核验;不得反向恢复旧入口 | | 重启发现未完成事务 | 获取执行租约和兼容锁后核验;不自动重放启停,不抢占活跃操作 | | 恢复目标被占用 | 保留停用入口及日志,拒绝覆盖并提供受控诊断;不删除新占用内容 | -目标恢复完成前阻断冲突写入;其他独立目标仍可按共享库存 CAS 更新。恢复仅处理本次目标,不恢复整份旧库存快照。共享核心需支持新日志版本,旧未完成操作必须先按兼容路径收尾或阻止迁移,不能将旧日志强制转换。 +目标恢复完成前阻断冲突写入;其他独立目标仍可按共享库存 CAS 更新。恢复仅处理本次目标,不恢复整份旧库存快照。兼容未发布旧 v3 事务时,若持久库存的目标状态已经是 after 且文件完整匹配,则保留旧 CAS 的已提交结果;不能因旧日志仍为 moved 就回滚已提交事务。共享核心需支持新日志版本,旧未完成操作必须先按兼容路径收尾或阻止迁移,不能将旧日志强制转换。 ## 6. 停用期间的生命周期 @@ -99,16 +99,15 @@ CAS 是逻辑提交点;只合并所选目标状态,保留其他位置最新 ## 7. 库存协议与实施门禁 -先前 `inventory-store.ts` 的磁盘协议为 `schemaVersion: 2`,target/record/顶层均为 strict schema,target 无启停或入口状态字段。不得在 v2 上偷偷添加字段,不能依赖旧客户端忽略字段,也不能把启停状态另存为绕过库存的第二可写权威。 +共享库存继续使用严格 `schemaVersion: 2`,不新增 installationState 字段,不要求用户同步升级 CLI。启停状态不再作为磁盘库存字段,而是新消费者对精确受管安装入口的内存投影:只有标准入口为 enabled,只有可信停用入口为 disabled。两入口并存、入口缺失、类型异常和内容不符按完整性冲突处理,不伪报可用。不建立第二份可写启停状态文件。 -本轮冻结库存与操作日志为显式 v3。库存保留 records/suites 结构,target 新增必填 installationState(enabled/disabled);入口名由状态推导,不另存冗余字段。v1/v2 读取投影为 enabled;正常首次写入 v3 前,在兼容锁域保留旧库存原始字节及 SHA-256 对应的不可覆盖备份。旧 strict v2 消费者应拒绝新格式,实际写入前拒绝仍须矩阵验证。日志 v3 增加 enable/disable、精确入口 before/after 与恢复阶段,兼容读取旧 v2 日志,未知版本拒绝。以下门禁约束实现与交付: - -1. 旧 v1/v2 读取只做投影,不写回;旧记录默认逻辑 enabled,但缺失文件、缺失基线仍保留异常,不伪造可用。 -2. 首次写迁移在兼容锁域保存不可变原始字节及摘要备份,原子提交;保留所有其他位置、Suite、归属、版本及未知完整性事实。未完成旧日志阻断不安全迁移。 -3. CLI 与插件共同升级;所有库存/生命周期消费者通过同一模型。旧 strict v2 拒绝新格式的源码事实不证明所有旧命令在修改文件前拒绝,必须运行明确历史版本的读写矩阵,覆盖安装、升级、删除、sync 和 Suite。 -4. 迁移前停止不兼容写入进程;当前核心只检查可信操作状态目录内可发现的未完成日志,不宣称能够扫描或协调全部外部进程;新 schema 不能阻止任意旧程序直接修改文件。未通过旧消费者证据门禁不得开放启停或发布仅 Host 支持的中间版本。 -5. 回滚前停写并核验全部停用入口、标准入口占用和版本;先安全恢复或导出并完成目标协议验证,再讨论降级。只恢复 v2 库存备份不是有效回滚;冲突时保留现状,不提供未经证明的一键降级。 -6. 入口别名映射与库存状态必须参与同一逻辑目标锁和 CAS 比较;v3、恢复日志及旧消费者验证通过前不得发布仅支持 Host 的中间版本。平台耐久保证与发现边界由实际验证记录,不以方案文字代替通过。 +1. 普通读写和启停均不得把共享库存升级为旧 CLI 无法读取的格式。读已有 v1/v2 不引入写入;已有未发布 v3 库存须校验身份、文件状态与未完成事务后,在同一锁域保留原始字节备份并安全转换为 v2,不能直接恢复过时备份覆盖新记录。 +2. 入口转换由现有目标锁、持久操作日志和库存 CAS 共同协调。改名后的文件状态变化不等于库存身份发生并发修改;提交和恢复需分别核验身份、版本、指纹、归属与日志授权的转换阶段,保留其他目标的最新变更。 +3. 普通安装、更新、删除操作日志继续采用旧 DSH 可读协议;启停专用新日志须隔离兼容影响,不能使旧 DSH 的普通管理全局失效。未知或未完成事务仍须阻断冲突目标操作,不得跳过恢复保护。 +4. 已发布 CLI 0.2.0 必须能查询合法 v2 库存并继续管理普通目标。默认更新、同步及 Suite 不得因新版启停引起无意重新启用;需要以真实旧 npm 包验证。旧版不提供启停语义,其显式 force 重装、force 更新、force 同步沿用原覆盖契约,可能恢复标准入口;显式 remove 沿用删除契约。新版重新读取必须反映这些真实文件变化,不能残留虚假停用状态。 +5. 新 CLI 的停用升级、sync、Suite 和 doctor 保护继续保留。不能用旧版本显式覆盖行为作为理由放松新版默认保护。 +6. 验收覆盖同一 HOME 下新旧消费者交替操作、不同来源/位置、普通与停用目标混存、并发与五阶段强杀恢复。仅证明旧版拒绝整份库存或数据未损坏不算兼容通过;未通过前保持发布阻断。 +7. 旧工具不会识别隔离的新启停日志。若启用中断后已出现有效标准入口,旧工具仍可按既有契约执行合法操作;新版恢复必须在任何入口修改前比较来源、位置、版本、安装时间、指纹、归属及 Suite 引用,后续合法写入使基线失配时返回 unknown 且保持文件不动。不能宣称旧工具会拒绝所有待恢复目标。CLI 不扫描所有 Host 状态目录;v3 转换只核验本库存对应的入口与内容并保留原始证据,NativeRuntime 对本状态目录未完成事务先恢复。 ## 8. 发现反馈与验收 @@ -124,7 +123,7 @@ NativeRuntime.inspectToggle / acceptToggle / executeToggle,沿用既有 operat ### 首期 CLI 消费范围 -CLI 不新增启停命令,需正确读取 v3 并保护停用记录。缺少原生持久事务观察器的 CLI 停用升级安全拒绝,提示使用 DSH 执行;force 不绕过。namespace sync 不更新或 prune 停用安装;Suite 不接管停用安装;doctor 不通过散落别名重建停用归属,保留已有记录。DSH 停用更新与卸载是交付必需能力。 +CLI 不新增启停命令,需读取兼容 v2 库存并从可信入口派生状态、保护停用记录。缺少原生持久事务观察器的 CLI 停用升级安全拒绝,提示使用 DSH 执行;force 不绕过。namespace sync 不更新或 prune 停用安装;Suite 不接管停用安装;doctor 不通过散落别名重建停用归属,保留已有记录。DSH 停用更新与卸载是交付必需能力。 ### 无覆盖入口变更 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 6a6fae077..76350c832 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 @@ -458,3 +458,22 @@ The installed view SHALL check current managed installations only on explicit us - **WHEN** a user checks many installations, including several locations of the same skill - **THEN** a bounded Host-owned scan reads and indexes the trusted inventory once, validates only the requested target and inventory revision per result, and shares remote version resolution for the same source and skill within that scan - **AND** inventory or context changes invalidate the scan, scan resources expire or are released on cancellation, and mutation preparation retains its independent full validation + +### Requirement: Shared state remains compatible with released consumers +New lifecycle operations SHALL retain the strict v2 shared inventory format readable by released CLI 0.2.0. Activation state SHALL be derived from the verified entry file of the exact managed installation, without an additional writable activation authority. Ordinary operation journals SHALL remain readable by released DSH 0.5.0; activation-specific journals SHALL NOT prevent unrelated ordinary operations in that consumer. + +#### Scenario: Old and new consumers alternate ordinary operations +- **WHEN** a new consumer installs, updates, removes, synchronizes or repairs an ordinary installation +- **THEN** the old CLI can still list the same inventory and manage unrelated ordinary installations +- **AND** the operation does not upgrade the shared inventory to an unreadable schema + +#### Scenario: Released consumer explicitly replaces a disabled installation +- **WHEN** the old CLI explicitly force-replaces or removes a disabled installation under its existing overwrite or removal contract +- **THEN** the new consumer observes the resulting real entry, contents and ownership on its next read +- **AND** it does not apply stale disabled state to replacement contents or claim that a removed installation remains present +- **AND** default update, sync and Suite operations are separately tested for unintended re-enablement + +#### Scenario: Activation interruption or ambiguous entry files +- **WHEN** activation is interrupted or both entry names, neither entry, or untrusted entry contents are observed +- **THEN** exact target identity, durable journal stages and verified files govern recovery under the existing lock domain +- **AND** ambiguous contents are not reported as a usable installation or overwritten to make the state appear consistent diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index c72a5cd3b..e6c146691 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -406,3 +406,12 @@ - [x] 46.5 优化统一检查的全量库存重复读取与同技能resolve重复请求,保留精确目标与上下文保护。 - [x] 46.6 完成10/100/500位置规模测量、独立复审和真实DSH新协议回归。 + + +## 47. CLI 0.3.0 / DSH 0.6.0 配套发布收尾 + +- [ ] 47.1 按启停设计 §7 完成严格 v2 兼容修复和发布说明;正常查询/管理兼容 CLI 0.2.0,明确旧版显式覆盖/删除语义,不要求用户同步升级。 +- [ ] 47.2 在原生 Windows/macOS 补技能入口转换、五阶段核心进程强制终止恢复及两版 Host 独立启停场景;明确核心恢复不等于 Host 强杀恢复。 +- [ ] 47.3 补旧对话草稿往返保护的真实浏览器证据,核对候选完整性、旧公开包升级和真实新旧包同 HOME 交替操作、停用默认保护及未发布 v3 安全转换;拒写不计兼容通过。 +- [ ] 47.4 完成发布准备 PR、独立评审与 CI,先发布 CLI 0.3.0 并验证,再发布 DSH 0.6.0;核对公开字节、安装与保留原技能。 +- [ ] 47.5 同步路线图 #998 的已完成、暂缓及后续范围,记录最终发布结果与证据边界。 diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md index edd9cd397..0c390c5da 100644 --- a/openspec/changes/add-dsh-native-plugin/verification.md +++ b/openspec/changes/add-dsh-native-plugin/verification.md @@ -546,3 +546,17 @@ CLI 全量结果与后续专项分开记录,不声称最终新增用例都来 真实DSH最终包两轮12/12扫描通过;等待begin返回时取消,迟到响应不启动检查,随后重扫成功。混合结果/失败重试/详情往返/原升级确认取消及主题窄屏在同逻辑候选包验证,最终补丁只修初始化取消槽位。实际Host跨工作区切换仍未完成,组件竞态验证单独记录。无模型调用、无安装执行,47份本轮基线文件不变;历史release-checklist已在本轮前被调整,未覆盖用户状态。 最终包SHA256 `2befcc2fd11e9da294183afe8b9e5c3f3d9dfb291b0c0072114fc22e76fce130`,97个运行文件与包一致。外部证据 `dsh-update-overview-20261028/performance/summary.md`、`review.md`、`browser/acceptance.md`、`browser/final-validation.md`。未提交、推送或发布。 + + +### 0.6.0 发布前兼容审计与待验收(2026-10-29) + +此前“库存/日志 v3、旧包拒写”记录保留为历史实现证据,不再作为当前兼容验收标准。正式协议已调整为启停设计 §7:严格 v2 库存、可信入口派生状态,普通日志兼容旧 DSH,启停日志隔离影响。正常查询与普通目标管理必须兼容已发布 CLI 0.2.0;旧版显式 force 覆盖和 remove 保留原语义,不承诺其拥有新版启停保护。实现与真实旧包交替验收进行中,发布阻断,尚无修复通过结论。 + +旧候选 HEAD 36adc3b 的插件 647 测试通过不证明后续兼容修复通过。Linux 草稿往返在独立原生分支会话验证取消/选案例不发送及返回旧草稿原字节保持;旧候选运行字节与预览等价,只标复用。全新空白会话受宿主侧栏隐藏规则影响,不扩大为所有空会话验证。 + +Windows Node 24.2.0、DSH 0.2.0-rc.2、Chrome 156 的新隔离 Profile 已通过官方命令安装旧候选并进入技能中心和安装弹窗,但选择“仅此项目”未生效,尚未确认可写上下文或产品原因;未提交安装、未调用模型。启停确认/取消、详情和 NTFS 转换未执行,因此 5.4 Windows 增量不标通过。保留失败证据,未改用户原预览。 + + +兼容审查补充:日志搬迁改为先排他 link 原记录,持久化目标目录后 unlink 源记录并持久化源目录,再写扩展后继;双份需完整相等,原字节 SHA 备份不覆盖。独立定向日志/投影 15 测试、74 断言及 CLI typecheck 通过,不代表全套与真实旧包矩阵完成。无效入口投影为 unreadable,Host 不再默认 enabled,补充卡片与模型上下文测试通过。 + +旧消费者不必理解 activation 日志。中断启用后的合法标准入口仍可能被旧版正常管理;新恢复在写入前必须比较身份、版本、installedAt、指纹与归属,变化即 unknown 并保留现场。该有限保证替代“旧版必能阻止全部待恢复操作”的过度承诺;真实旧包在 moved 阶段写入后的恢复验收仍须单列。CLI 安全转换 v3 不扫描全机 Host 日志,保留原日志与技能文件;NativeRuntime 处理自己日志域的 pending 恢复。 diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index e0c7a320c..173ac76c2 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -12,11 +12,13 @@ Version `0.4.0` uses the independent `dsh-v0.4.0` tag. It adds versioned example `0.5.0` adds read-only skill availability diagnosis in installed cards and conversations, with separate integrity, discovery and invocation-policy results. It retains the compatibility and inventory format of `0.4.0`. +`0.6.0` includes managed enable/disable, unified read-only update checks, and compact horizontal installed cards with a More menu. Shared inventory remains v2 without a coordinated CLI upgrade. Compatibility fixes and real historical-package acceptance are in progress; release remains blocked until they pass. Check npm for public availability. + ## Use an installed skill Market, installed and everyday conversation lists show one card per skill. “使用技能” asks the Host to verify the unique installation actually usable in the current session. Catalog use creates a conversation in the same workspace; conversation use keeps the current session. Choose an example or custom task to insert an editable draft. This never sends a message, invokes a model or overwrites the original draft. -“管理安装” opens skill details, where the inline installation panel shows each location’s version, state and explicit use/update/enable/disable/removal actions. Disabled or unverified skills lead to management. Installation scope remains in advanced confirmation options, with the current-user default unchanged. +In the installed card’s More menu, “管理安装” opens skill details, where the inline installation panel shows each location’s version, state and explicit use/update/enable/disable/removal actions. Disabled or unverified skills lead to management. Installation scope remains in advanced confirmation options, with the current-user default unchanged. An active session is required. Shadowed skills, modified files, disabled user invocation or changed context are reported without modifying the draft. Historical receipts are not proof of current installation. This feature is available from `0.4.0`. @@ -76,8 +78,8 @@ Published `0.1.0` still requires the original Cordis `4.0.4` baseline and does n Query the exact npm version first and install only after confirming availability. Check DSH, Node and a complete Cordis combination listed above, stop the target Web Profile Host, then install the plugin. A verified local tarball can also be installed using the next section: ```sh -npm view @astron-team/skillhub-dsh@0.5.0 version -dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.5.0 --ignore-scripts +npm view @astron-team/skillhub-dsh@0.6.0 version +dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.6.0 --ignore-scripts dsh --profile skillhub-test --host 127.0.0.1 --port 0 --no-open ``` @@ -85,9 +87,9 @@ Replace `skillhub-test` with the intended Web Profile; initial creation is docum ## Upgrade from an older version -`0.4.0` uses inventory schema v2 with per-location versions. The installation core remains bundled, so a separate CLI is unnecessary. If the same user also manages these skills through SkillHub CLI, upgrade every such CLI to `0.2.0` before upgrading the plugin and stop scheduled/background writes from old clients. Confirm availability with `npm view @astron-team/skillhub@0.2.0 version`, then run `npm install -g @astron-team/skillhub@0.2.0`, verify `skillhub version`, then install the exact plugin version above. +Shared inventory remains strict v2 without a persisted `installationState`; activation is derived from trusted `SKILL.md` / `SKILL.md.disabled` entries. A coordinated CLI upgrade is not required. Published CLI `0.2.0` must retain normal queries and ordinary-target management; default update, sync and Suite paths must not unintentionally enable disabled targets. Historical explicit force install, upgrade and sync retain their overwrite semantics and may restore the standard entry; explicit remove retains deletion semantics. New readers must reflect these actual file changes. -Reading v1 inventory does not migrate it. Before the first v2 write, a byte-verified `inventory.v1..backup.json` is retained beside the inventory. User and project copies may retain different versions. Old CLI/v2 inventory mixed writes are unsupported. Once locations have changed independently, restoring the old inventory or downgrading the plugin alone is not a rollback: skill files, installation metadata and operation records must also be reconciled. +Reading v1/v2 does not migrate it. Any v3 inventory from unpublished builds requires identity, entry and pending-transaction validation, an original-byte backup under the same lock, and safe conversion to v2. Never restore a stale backup over newer records. Ordinary journals remain readable by historical DSH; toggle journals isolate compatibility impact. Compatibility implementation and real historical-package acceptance are in progress; release remains blocked until they pass. Authors provide examples at the fixed `.skillhub/examples.json` path inside each skill package. The file keeps its location after installation and follows the skill's version and fingerprint; no separate global example copy is created. Skills without examples use a custom task; invalid declarations remain ordinary files without exposing invalid examples. @@ -211,7 +213,7 @@ Version selection is read-only. A verifiable newer selected version offers an ex `astron_skillhub_list_installed` lists current-source managed records in the current user/project roots, not every skill on the machine. `astron_skillhub_check_update` checks the latest or an explicitly requested newer version. `astron_skillhub_upgrade` and `astron_skillhub_uninstall` require an exact installed reference and real human confirmation, including explicitly selected scopes. Without a native question provider they return a confirmation card. Models cannot authorize operations using paths, plan IDs or confirmation flags. `astron_skillhub_operation` reads the original session-owned receipt without resubmitting a mutation. -New conversational installs default to user scope after an explicit install request. Project installation is explicit/advanced. Updating one location preserves other locations' versions. Local edits and stale references block mutations. CLI installations sharing this inventory must be upgraded together; old CLI/new inventory mixed writes are unsupported. +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. Shared inventory remains compatible with published CLI 0.2.0; historical explicit overwrite and removal retain their original semantics. ### Skill availability diagnosis (0.5.0) @@ -221,7 +223,7 @@ Choose “管理安装 → 检查可用性” on a conversational installed-skil The result distinguishes local integrity, current-session discovery, and user/model invocation policies. Shadowing, local modifications and unknown Host state are separate outcomes. A name-only lookup reports Host observations without proving installation ownership or integrity. Diagnosis never modifies, removes or enables/disables a skill, edits a draft, or sends a message. A historical result is not a guarantee about a later invocation. -## Managed skill enable/disable (unreleased increment) +## Managed skill enable/disable (0.6.0) Open “管理安装” and choose enable or disable on the explicit location row. User and project copies are independent: disabling the user copy does not disable a project copy. UI and conversation mutations require real human confirmation. `astron_skillhub_disable` / `astron_skillhub_enable` accept fresh installation references, never arbitrary paths. @@ -229,7 +231,7 @@ Disabling changes only the entry from `SKILL.md` to `SKILL.md.disabled`, keeping This does not revoke loaded conversation content or stop tasks. Another same-named installation can remain available. Unverified Host cache observations remain unknown; the older RC may need a new session to observe a fallback copy. Removing this plugin does not enable its disabled installations. -The increment writes inventory v3 with per-location `installationState`. Reading v1/v2 is non-mutating; migration preserves the original bytes and a digest-addressed backup. The plugin bundles its compatible core and does not invoke an external CLI. Stop old writers and use the matching compatible CLI build before sharing inventory: published CLI `0.2.0` and `0.1.12` are incompatible. Release versions and order are not assigned yet. An old inventory backup alone is not a downgrade procedure. Native Windows/macOS entry conversion still requires acceptance before release. +Protocol and real historical-package interoperability remain release gates; see above. The detail-header website link uses only the detail API’s optional `webUrl`. Missing, null, blank or unsafe values hide the action without breaking skill details or installation. There is no plugin website setting, route construction or Registry fallback; legacy `websiteUrl` settings are ignored. The open-source server uses its trusted `SKILLHUB_PUBLIC_BASE_URL` to supply an absolute link, or null when no valid website base is configured. SaaS must provide this additive API field separately. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index a1883ea56..435aec020 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -12,11 +12,13 @@ SkillHub 的 DSH 原生插件。在 DSH 侧栏打开 **技能中心**,匿名 `0.5.0` 新增技能卡片及对话中的只读可用性诊断,分别报告文件完整性、宿主发现和调用策略,保留 `0.4.0` 的兼容范围及库存格式。 +`0.6.0` 包含受管理技能启停、统一只读检查更新,以及横向已安装卡片和更多菜单。共享库存继续使用 v2,不要求用户同步升级 CLI;兼容修复及真实旧包验收进行中,未通过前阻断发布。公开可用状态以 npm 为准。 + ## 使用已安装技能 市场、已安装列表和对话中的日常列表均按技能展示。点击“使用技能”,由 Host 核验当前会话实际可用的唯一安装;不会让用户猜安装位置。技能中心入口在同一工作区新建对话,对话内入口保留当前会话。案例显示在原生输入框上方,确认案例或自定义任务后填入可编辑草稿;不会自动发送、调用模型或覆盖原会话草稿。 -“管理安装”进入技能详情,右侧直接展示每个位置的版本和状态,可明确使用此位置、检查更新、启停或卸载。全停用或状态无法确认时先进入管理安装。安装范围放在安装确认的高级选项中,未安装技能仍默认安装到当前用户。 +已安装卡片的更多菜单中,“管理安装”进入技能详情,右侧直接展示每个位置的版本和状态,可明确使用此位置、检查更新、启停或卸载。全停用或状态无法确认时先进入管理安装。安装范围放在安装确认的高级选项中,未安装技能仍默认安装到当前用户。 需要当前活动会话。被项目同名技能覆盖、本地文件变更、禁止用户调用或会话/来源变化时会提示原因;历史回执不能代替当前安装状态。此功能自 `0.4.0` 起提供。 @@ -76,8 +78,8 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output 先查询 npm 上的精确版本,确认可用后安装。核对 DSH、Node 和上表完整 Cordis 组合,停止目标 Web Profile 的 Host,再执行插件安装;也可按下一节安装已验证的本地 tarball: ```sh -npm view @astron-team/skillhub-dsh@0.5.0 version -dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.5.0 --ignore-scripts +npm view @astron-team/skillhub-dsh@0.6.0 version +dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.6.0 --ignore-scripts dsh --profile skillhub-test --host 127.0.0.1 --port 0 --no-open ``` @@ -85,9 +87,9 @@ dsh --profile skillhub-test --host 127.0.0.1 --port 0 --no-open ## 从旧版本升级 -`0.4.0` 使用按安装位置记录版本的库存 v2。插件仍内置安装核心,无需为了使用插件单独安装 CLI;如果同一用户还使用 SkillHub CLI 管理这些技能,先将这些 CLI 升级到 `0.2.0`,再升级插件,并停止旧版 CLI 的定时或后台写入。先用 `npm view @astron-team/skillhub@0.2.0 version` 确认可用,再使用 `npm install -g @astron-team/skillhub@0.2.0`,确认 `skillhub version`,再执行上面的精确插件版本安装命令。 +共享库存保持严格 v2,不写入 `installationState`;启停状态从可信的 `SKILL.md` / `SKILL.md.disabled` 入口派生,不要求 CLI 用户同步升级。已发布 CLI `0.2.0` 的正常查询和普通目标管理必须继续兼容;默认更新、同步及 Suite 不得无意重新启用停用目标。旧 CLI 显式 force 重装、更新、同步仍按原覆盖语义执行,可能恢复标准入口;显式 remove 仍可删除,新版重新读取须反映真实变化。 -读取旧库存不迁移;首次写入 v2 前,在库存同目录保存经字节核对的 `inventory.v1..backup.json`。同一技能的用户与项目副本可保留不同版本。旧 CLI 与 v2 库存混写不受支持;已分位置更新后,不能只恢复旧库存文件或降级插件来回退,必须同时核对实际技能文件、安装元数据和操作记录。 +读取 v1/v2 不迁移。未发布构建产生的 v3 必须在身份、入口与未完成事务校验后,同锁保存原字节备份并安全转换为 v2;不能恢复过时备份覆盖新记录。旧 DSH 普通管理日志保持兼容,启停日志隔离影响。兼容修复及真实旧包验收正在进行,未通过前阻断发布。 案例由作者随技能包提供固定文件 `.skillhub/examples.json`,安装后保持相同位置,跟随技能版本和指纹,不创建另一个全局案例副本。无案例时使用自定义任务;无效声明按普通文件保留,不提供错误的案例入口。 @@ -216,7 +218,7 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh - `astron_skillhub_upgrade` / `astron_skillhub_uninstall`:必须使用列表中的精确引用,并由 Host 发起真实用户确认。模型不能传路径、确认标志或执行计划。原生问答不可用时返回确认卡;取消不修改文件。 - `astron_skillhub_operation`:以原回执查询本会话操作结果,不重复提交操作;历史成功不代表当前仍安装。 -多份安装先选择目标;更新不改变其他位置版本。本地修改、来源/会话变化和失效引用阻止操作。启用逐位置库存后,同机共同管理技能的 CLI 必须同步升级;旧版 CLI 与新库存混用不受支持。 +多份安装先选择目标;更新不改变其他位置版本。本地修改、来源/会话变化和失效引用阻止操作。共享库存兼容已发布 CLI 0.2.0;旧版显式覆盖与删除仍遵循原语义。 ### 技能可用性诊断(0.5.0) @@ -226,7 +228,7 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh 诊断分别核验本地文件、当前会话加载结果,以及用户/模型调用策略。同名技能生效、本地修改和宿主无法核验会给出不同结论。按技能名查询只说明当前宿主观察到的事实,不证明安装来源或内容完整性。检查不会修改、卸载、启停技能或发送消息;历史结果不保证下一次调用仍然可用。 -## 受管理技能启停(未发布增量) +## 受管理技能启停(0.6.0) 在“管理安装”中,对明确位置选择“停用”或“启用”。用户级和项目级副本独立管理;停用用户副本不会关闭项目副本。页面与对话均需真实用户确认,取消不写文件。对话工具 `astron_skillhub_disable` / `astron_skillhub_enable` 使用新查询中的位置引用,不能提交自由路径。 @@ -234,7 +236,7 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh 这不撤回会话已加载的正文,也不终止任务;同名的其他安装可能继续可用。宿主缓存未确认时明确显示未知,不把文件操作成功当作发现已刷新。旧 RC 的同名接替可能需新会话才能观察到。卸载插件不会自动启用这些技能。 -本增量首次写入使用库存 v3(每位置 `installationState`),读取 v1/v2 不改写,迁移保留原始库存字节及摘要备份。插件内置兼容核心,仍不调用外部 CLI;同机使用 CLI 管理相同库存时,须先停止旧写入进程并使用配套兼容构建。现有公开 CLI `0.2.0`/`0.1.12` 不支持此协议,不可混写;配套正式版本与发布顺序另行确定。不能只恢复旧库存文件来降级,须核对入口、安装元数据及操作日志。原生 Windows/macOS 文件转换的实机验收仍是发布前门禁。 +协议与旧包交替操作验收仍是发布门禁,详见上文。 详情标题旁“在官网查看”仅使用详情API返回的可选`webUrl`。字段缺失、null、空白或不安全时隐藏按钮,详情与安装照常使用。插件不再提供网站地址配置、不拼路由、不从Registry回退;旧`websiteUrl`配置忽略。开源服务端复用可信`SKILLHUB_PUBLIC_BASE_URL`生成完整地址,未配置合法网站基址则返回null。SaaS需单独同步此可选字段。 diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 885eea412..f0e270fb1 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub-dsh", - "version": "0.5.0", + "version": "0.6.0", "private": false, "type": "module", "license": "Apache-2.0", diff --git a/plugins/dsh/scripts/probe-host.mjs b/plugins/dsh/scripts/probe-host.mjs index ea0e12a71..be8da8ced 100644 --- a/plugins/dsh/scripts/probe-host.mjs +++ b/plugins/dsh/scripts/probe-host.mjs @@ -1,6 +1,7 @@ +import { safeAvailabilityCheckpoint, safeToggleCheckpoint, safeFixtureLocation, safeOperationObservation, knownHostFailureCodes } from './smoke-native-host.mjs'; import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, realpath, writeFile, access } from 'node:fs/promises'; +import { mkdir, readFile, realpath, writeFile, access, open } from 'node:fs/promises'; import { openSync, closeSync } from 'node:fs'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; @@ -28,6 +29,11 @@ let stage = 'setup'; let selectedProbe; let observerStatusFile; let dependencyStatusFile; +let availabilityCheckpoint; +let toggleCheckpoint; +let fixtureLocation; +let operationObservation; +let hostLogFile; let failureCode = 'PROBE_FAILED'; function fail(code, message) { failureCode = code; throw new Error(message); } async function main() { @@ -77,6 +83,7 @@ await mkdir(run, { mode: 0o700 }); const resultName = `probe-${id}-result.json`; const resultFile = join(root, resultName); const observer = join(run, 'observer.mjs'); +hostLogFile = join(run, 'host.log'); observerStatusFile = join(run, 'observer-status.json'); dependencyStatusFile = join(run, 'dependency-status.json'); const runtimeProbe = values.probe === 'runtime-baseline' || values.probe === 'runtime-unsupported'; @@ -178,6 +185,12 @@ try { catch (error) { if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; } if (report) { if (report.passed !== true) { + if (['skill-availability', 'skill-toggle', 'skill-toggle-watch-off'].includes(values.probe)) operationObservation = safeOperationObservation(report.operation); + if (values.probe === 'skill-availability') availabilityCheckpoint = safeAvailabilityCheckpoint(report.checkpoint); + if (['skill-toggle', 'skill-toggle-watch-off'].includes(values.probe)) { + toggleCheckpoint = safeToggleCheckpoint(report.checkpoint); + fixtureLocation = safeFixtureLocation(report.location); + } stage = 'host-assertion'; fail('HOST_ASSERTION_FAILED', 'Host probe failed; inspect its result file.'); } @@ -204,6 +217,21 @@ try { } main().catch(async () => { const diagnostics = {}; + if (availabilityCheckpoint) diagnostics.availability = availabilityCheckpoint; + if (toggleCheckpoint) diagnostics.toggle = toggleCheckpoint; + if (fixtureLocation) diagnostics.location = fixtureLocation; + if (operationObservation) diagnostics.operation = operationObservation; + if (hostLogFile && ['skill-availability', 'skill-toggle', 'skill-toggle-watch-off'].includes(selectedProbe)) { + let file; + try { + file = await open(hostLogFile, 'r'); + const { size } = await file.stat(); + const bytes = Buffer.alloc(Math.min(size, 1024 * 1024)); + const { bytesRead } = await file.read(bytes, 0, bytes.length, Math.max(0, size - bytes.length)); + diagnostics.observedHostCodes = knownHostFailureCodes(bytes.subarray(0, bytesRead).toString('utf8')); + } catch { /* Diagnostics must not replace the original failure. */ } + finally { await file?.close().catch(() => {}); } + } for (const [key, file] of [['observer', observerStatusFile], ['dependencies', dependencyStatusFile]]) { if (!file) continue; try { diagnostics[key] = JSON.parse(await readFile(file, 'utf8')); } catch { /* Missing marker is itself diagnostic. */ } diff --git a/plugins/dsh/scripts/smoke-native-host.mjs b/plugins/dsh/scripts/smoke-native-host.mjs index ff9b5a832..e32d92911 100644 --- a/plugins/dsh/scripts/smoke-native-host.mjs +++ b/plugins/dsh/scripts/smoke-native-host.mjs @@ -1,9 +1,9 @@ import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { closeSync, openSync } from 'node:fs'; -import { access, mkdir, mkdtemp, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises'; +import { access, chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { delimiter, dirname, join, resolve } from 'node:path'; +import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; @@ -13,6 +13,12 @@ export function selectDshVersion(version, family = 'fresh') { if (version === '0.2.1-rc.2' && family === 'retained') throw new Error('DSH 0.2.1 requires the current Cordis family.'); return version; } +export function scenarioProbes(scenario = 'smoke') { + if (scenario === 'smoke') return ['runtime-baseline', 'plugin-lifecycle', 'skill-availability']; + if (scenario === 'skill-toggle' || scenario === 'skill-toggle-watch-off') return ['runtime-baseline', scenario]; + throw new Error('Unsupported Host scenario'); +} + const PNPM_VERSION = '10.33.0'; export const retainedCordisFamily = { @@ -113,6 +119,50 @@ export async function selectTarball(input) { return target; } +export function safeAvailabilityCheckpoint(value) { + if (!value || typeof value !== 'object') return undefined; + const scenarios = ['setup', 'user-install', 'user-winner', 'project-install', 'project-winner', 'user-shadowed', 'remote', 'by-name', 'content-modified', 'skill-missing', 'directory-missing', 'policy', 'absent', 'incomplete-provider', 'failed-provider']; + const checkpoints = ['setup', 'install', 'install-poll', 'reference', 'before-digest', 'execute', 'assertion', 'after-digest', 'mutation']; + const assertions = ['none', 'isError', 'kind', 'schemaVersion', 'association', 'scope', 'integrity', 'discovery', 'name', 'reason', 'invocation', 'installation', 'namespace', 'slug', 'version', 'fingerprint', 'render', 'privacy', 'unchanged', 'accepted', 'completed', 'reference']; + if (!scenarios.includes(value.scenario) || !checkpoints.includes(value.checkpoint) || !assertions.includes(value.assertion)) return undefined; + return { scenario: value.scenario, checkpoint: value.checkpoint, assertion: value.assertion }; +} + +export function safeToggleCheckpoint(value) { + if (!value || typeof value !== 'object') return undefined; + const scenarios = ['setup', 'install', 'initial-discovery', 'watch-off-disable', 'watch-off-diagnosis', 'watch-off-uninstall', 'cancel', 'disable', 'disabled-files', 'disabled-use', 'enable', 'disable-again', 'disabled-upgrade', 'disabled-uninstall', 'same-name-install', 'same-name-disable', 'same-name-fallback', 'same-name-diagnosis', 'cleanup']; + const assertions = ['none', 'invoke', 'operation', 'discovery', 'equal', 'notEqual', 'deepEqual', 'rejects', 'truthy']; + if (!scenarios.includes(value.scenario) || !assertions.includes(value.assertion)) return undefined; + return { scenario: value.scenario, assertion: value.assertion }; +} + +export function safeFixtureLocation(value) { + if (value?.fixture !== 'skill-toggle-fixture.mjs' || !Number.isInteger(value.line) || value.line < 1 || value.line > 10000) return undefined; + return { fixture: value.fixture, line: value.line }; +} + +export function safeOperationObservation(value) { + if (!value || typeof value !== 'object') return undefined; + const fields = { + phase: ['accepted', 'staging', 'revalidating', 'committing', 'recovering', 'completed', 'failed', 'recovery-required', 'unknown'], + files: ['pending', 'installed', 'already-installed', 'upgraded', 'removed', 'stale-record-removed', 'enabled', 'disabled', 'failed', 'unknown'], + discovery: ['unconfirmed', 'confirmed', 'failed'], recovery: ['not-needed', 'restored', 'incomplete', 'unknown'], + }; + const safe = {}; + for (const [name, allowed] of Object.entries(fields)) if (allowed.includes(value[name])) safe[name] = value[name]; + if (typeof value.deadlineReached === 'boolean') safe.deadlineReached = value.deadlineReached; + return Object.keys(safe).length ? safe : undefined; +} + +const diagnosticCodes = ['EACCES', 'EPERM', 'ENOENT', 'EEXIST', 'ENOSPC', 'EBUSY', 'EXDEV', 'ENOTEMPTY', 'EIO', 'ELOCKED', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'AUTHORIZATION', 'NETWORK', 'FILESYSTEM', 'INVALID_INPUT', 'VALIDATION', 'UNCLASSIFIED']; +export function safeHostFailureCodes(value) { + return Array.isArray(value) ? diagnosticCodes.filter(code => value.includes(code)) : []; +} +export function knownHostFailureCodes(log) { + // Observed codes are diagnostic hints, not attribution of the assertion's cause. + return diagnosticCodes.filter(code => new RegExp('\\b' + code + '\\b').test(log)); +} + export function safeProbeFailure(log, probe) { const stages = ['setup', 'validate-root', 'prepare-fixture', 'fixture-ports', 'registry-ready', 'host-result', 'host-assertion']; const codes = ['PROBE_FAILED', 'REGISTRY_EXITED', 'REGISTRY_TIMEOUT', 'CHILD_EXITED', 'HOST_ASSERTION_FAILED', 'HOST_RESULT_TIMEOUT']; @@ -130,6 +180,20 @@ export function safeProbeFailure(log, probe) { if (['root-realpath', 'temporary-realpath', 'temporary-containment', 'home-identity'].includes(validation)) { safe.validation = validation; } + const availability = safeAvailabilityCheckpoint(result.diagnostics.availability); + if (probe === 'skill-availability' && availability) safe.availability = availability; + if (['skill-toggle', 'skill-toggle-watch-off'].includes(probe)) { + const toggle = safeToggleCheckpoint(result.diagnostics.toggle); + const location = safeFixtureLocation(result.diagnostics.location); + if (toggle) safe.toggle = toggle; + if (location) safe.location = location; + } + if (['skill-availability', 'skill-toggle', 'skill-toggle-watch-off'].includes(probe)) { + const operation = safeOperationObservation(result.diagnostics.operation); + if (operation) safe.operation = operation; + const codes = safeHostFailureCodes(result.diagnostics.observedHostCodes); + if (codes.length) safe.observedHostCodes = codes; + } safe.dependencies = {}; for (const name of ['loader', 'pluginPackages', 'clientModules', 'skillhub']) { const present = result.diagnostics.dependencies?.[name]; @@ -142,6 +206,41 @@ export function safeProbeFailure(log, probe) { return undefined; } +// Only npm's public download content is shared; every Host/config/profile stays fresh. +export async function prepareDownloadCache(path) { + if (!isAbsolute(path)) throw new Error('Download cache must be an absolute temporary directory.'); + const bases = [tmpdir()]; + if (process.env.CI && process.env.RUNNER_TEMP && isAbsolute(process.env.RUNNER_TEMP)) bases.push(process.env.RUNNER_TEMP); + const target = resolve(path); + for (const base of bases) { + const basePath = resolve(base); + const canonicalBase = await realpath(basePath); + // Only the trusted anchor may be canonicalized: never resolve unverified descendants. + const suffix = [basePath, canonicalBase].map(anchor => relative(anchor, target)) + .find(value => value && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value)); + if (!suffix) continue; + let current = canonicalBase; + const parts = suffix.split(sep); + for (const [index, part] of parts.entries()) { + current = join(current, part); + await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error; }); + const info = await lstat(current); + if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid()) + || (process.platform !== 'win32' && index < parts.length - 1 && (info.mode & 0o022) !== 0)) { + throw new Error('Download cache must contain only current-user directories without links.'); + } + } + await chmod(current, 0o700); + const content = join(current, '_cacache'); + const info = await lstat(content).catch(error => { if (error.code !== 'ENOENT') throw error; }); + if (info && (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid()))) { + throw new Error('Invalid download content directory.'); + } + return { path: current, populated: Boolean(info && (await readdir(content)).length) }; + } + throw new Error('Download cache must be below the current temporary directory.'); +} + export function isolatedEnvironment(root, inherited = process.env) { // In particular, do not inherit model credentials, DSH overrides or Node preload hooks. const allowed = /^(PATH|SystemRoot|WINDIR|COMSPEC|PATHEXT|TEMP|TMP|TMPDIR|LANG|LC_ALL|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|CI)$/i; @@ -154,6 +253,7 @@ export function isolatedEnvironment(root, inherited = process.env) { APPDATA: join(root, 'appdata'), LOCALAPPDATA: join(root, 'localappdata'), DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', npm_config_cache: join(root, 'npm-cache'), npm_config_userconfig: join(root, 'empty.npmrc'), + npm_config_logs_dir: join(root, 'logs/npm'), npm_config_globalconfig: join(root, 'empty-global.npmrc'), CI: 'true' }; } @@ -169,14 +269,15 @@ async function findNpm() { } async function main() { - const { values } = parseArgs({ options: { 'dsh-version': { type: 'string', default: '0.2.0-rc.2' }, artifact: { type: 'string' }, report: { type: 'string' }, help: { type: 'boolean' }, 'cordis-family': { type: 'string', default: 'fresh' } } }); + const { values } = parseArgs({ options: { 'download-cache': { type: 'string' }, 'dsh-version': { type: 'string', default: '0.2.0-rc.2' }, artifact: { type: 'string' }, report: { type: 'string' }, help: { type: 'boolean' }, 'cordis-family': { type: 'string', default: 'fresh' }, scenario: { type: 'string', default: 'smoke' } } }); if (values.help) { - console.log('node scripts/smoke-native-host.mjs --artifact --report [--cordis-family fresh|retained] [--dsh-version 0.2.0-rc.2|0.2.1-rc.2]'); + console.log('node scripts/smoke-native-host.mjs --artifact --report [--download-cache ] [--scenario smoke|skill-toggle|skill-toggle-watch-off] [--cordis-family fresh|retained] [--dsh-version 0.2.0-rc.2|0.2.1-rc.2]'); return; } if (!values.artifact || !values.report) throw new Error('Specify --artifact and --report; see --help.'); const [major, minor] = process.versions.node.split('.').map(Number); if (major !== 24 || minor < 2) throw new Error('Use the fixed Node >=24.2.0 <25 baseline.'); + const probes = scenarioProbes(values.scenario); const dshVersion = selectDshVersion(values['dsh-version'], values['cordis-family']); const installManifest = hostInstallManifest(values['cordis-family']); const artifact = await selectTarball(values.artifact); @@ -185,6 +286,8 @@ async function main() { await mkdir(dirname(reportFile), { recursive: true }); const root = await mkdtemp(join(tmpdir(), 'skillhub-native-smoke-')); const env = isolatedEnvironment(root); + const downloads = values['download-cache'] ? await prepareDownloadCache(values['download-cache']) : undefined; + if (downloads) env.npm_config_cache = downloads.path; for (const directory of ['home', 'native-install-home', 'agents', 'workspace', 'logs']) await mkdir(join(root, directory), { mode: 0o700 }); for (const name of ['empty.npmrc', 'empty-global.npmrc']) await writeFile(join(root, name), '', { mode: 0o600 }); await writeFile(join(root, 'package.json'), JSON.stringify(installManifest)); @@ -193,14 +296,22 @@ async function main() { process.once('SIGINT', stop); process.once('SIGTERM', stop); const summary = { passed: false, platform: process.platform, node: process.versions.node, - dsh: dshVersion, pnpm: PNPM_VERSION, cordisFamily: values['cordis-family'], + scenario: values.scenario, dsh: dshVersion, pnpm: PNPM_VERSION, cordisFamily: values['cordis-family'], artifactSha256: createHash('sha256').update(await readFile(artifact)).digest('hex'), - modelCalled: false, probes: [], stage: 'install-host' }; + modelCalled: false, probes: [], preparation: { cacheMode: downloads ? 'shared-public-downloads' : 'isolated', cachePopulated: downloads?.populated ?? false }, stage: 'install-host' }; const run = (name, args, timeoutMs = 300000) => runBounded(args, { cwd: join(root, 'workspace'), env, log: join(root, 'logs', name + '.log'), timeoutMs, signal: abort.signal }); try { - await run('install-host', [npm, 'install', '--prefix', root, '--ignore-scripts', '--no-audit', '--no-fund', - '--registry=https://registry.npmjs.org', `@deepseek-ai/dsh@${dshVersion}`, `pnpm@${PNPM_VERSION}`], 600000); + const installStarted = Date.now(); + try { + await run('install-host', [npm, 'install', '--prefix', root, '--ignore-scripts', '--no-audit', '--no-fund', + '--registry=https://registry.npmjs.org', `@deepseek-ai/dsh@${dshVersion}`, `pnpm@${PNPM_VERSION}`], 600000); + } finally { + env.npm_config_cache = join(root, 'npm-cache'); + summary.preparation.elapsedMs = Date.now() - installStarted; + const installLog = await readFile(join(root, 'logs/install-host.log'), 'utf8').catch(() => ''); + summary.preparation.observedCodes = knownHostFailureCodes(installLog); + } const dsh = join(root, 'node_modules/@deepseek-ai/dsh/lib/bin.js'); await access(dsh); summary.stage = 'initialize-web'; @@ -217,7 +328,7 @@ async function main() { const manifest = JSON.parse(await readFile(join(root, 'home/profiles/skillhub-m0/node_modules/@astron-team/skillhub-dsh/package.json'), 'utf8')); if (manifest.name !== '@astron-team/skillhub-dsh') throw new Error('Unexpected installed candidate identity.'); const probeScript = fileURLToPath(new URL('./probe-host.mjs', import.meta.url)); - for (const probe of ['runtime-baseline', 'plugin-lifecycle', 'skill-availability']) { + for (const probe of probes) { summary.stage = probe; await run(probe, [probeScript, '--root', root, '--probe', probe], 150000); const lines = (await readFile(join(root, 'logs', probe + '.log'), 'utf8')).trim().split('\n'); @@ -238,7 +349,7 @@ async function main() { } catch (error) { summary.failure = ['SPAWN_FAILED', 'DEADLINE_OR_CANCELLED', 'PROCESS_FAILED', 'CLEANUP_DEADLINE'].includes(error.code) ? error.code : 'INVALID_RESULT'; - if (['runtime-baseline', 'plugin-lifecycle', 'skill-availability'].includes(summary.stage)) { + if (probes.includes(summary.stage)) { const log = await readFile(join(root, 'logs', summary.stage + '.log'), 'utf8').catch(() => ''); summary.probeFailure = safeProbeFailure(log, summary.stage); } diff --git a/plugins/dsh/src/host/index.ts b/plugins/dsh/src/host/index.ts index aa68a67ce..a9478cb47 100644 --- a/plugins/dsh/src/host/index.ts +++ b/plugins/dsh/src/host/index.ts @@ -338,7 +338,7 @@ export class SkillHubService extends TypertRemoteService { const items = result.items.map((item, index) => { const itemKey = this.installedItemKey(agent.session.id, cwd, sourceRevision, item); const locationKey = createHmac('sha256', this.installedReferenceKey).update(JSON.stringify([agent.session.id, cwd, sourceRevision, item.namespace, item.slug, item.scope, item.targetPath])).digest('base64url'); - return { ...presentation[index], itemKey, locationKey, installationState: item.installationState ?? 'enabled', namespace: item.namespace, slug: item.slug, version: item.version, scope: item.scope, state: item.state }; + return { ...presentation[index], itemKey, locationKey, installationState: item.installationState, namespace: item.namespace, slug: item.slug, version: item.version, scope: item.scope, state: item.state }; }); return { items, complete: result.complete, issues: result.issues, unavailable }; } @@ -404,7 +404,7 @@ export class SkillHubService extends TypertRemoteService { namespace: selected.item.namespace, slug: selected.item.slug, version: selected.item.version }, target); } } : undefined }); valid(); - return { ...diagnosis, ...(selected ? { installationState: selected.item.installationState ?? 'enabled' } : {}), ...(selected ? { scope: selected.item.scope, installation: { namespace: selected.item.namespace, + return { ...diagnosis, ...(selected ? { installationState: selected.item.installationState } : {}), ...(selected ? { scope: selected.item.scope, installation: { namespace: selected.item.namespace, slug: selected.item.slug, version: selected.item.version, scope: selected.item.scope } } : {}) }; } diff --git a/plugins/dsh/src/host/installation-summary.ts b/plugins/dsh/src/host/installation-summary.ts index 5749c9ee8..1eda95f1d 100644 --- a/plugins/dsh/src/host/installation-summary.ts +++ b/plugins/dsh/src/host/installation-summary.ts @@ -10,6 +10,6 @@ export function summarizeInstallation( complete: !!inventory?.complete && inventory.unavailable.length === 0, unavailable: inventory ? [...inventory.unavailable] : ['user', 'project'], records: inventory?.items.filter(item => item.namespace === identity.namespace && item.slug === identity.slug) - .map(({ scope, version, state, installationState }) => ({ scope, version, state, installationState: installationState ?? 'enabled' })) ?? [], + .map(({ scope, version, state, installationState }) => ({ scope, version, state, installationState })) ?? [], }; } diff --git a/plugins/dsh/src/host/management-context.ts b/plugins/dsh/src/host/management-context.ts index 8e89da0ad..a9c72eaaf 100644 --- a/plugins/dsh/src/host/management-context.ts +++ b/plugins/dsh/src/host/management-context.ts @@ -18,8 +18,8 @@ export function installationContext(list: InstalledSkillList): string { const items: { namespace: string; name: string; scope: string; version: string; state: string }[] = []; let truncated = false; for (const item of list.items) { - const entry = { namespace: item.namespace, name: item.slug, scope: item.scope, version: item.version, state: item.state, installationState: item.installationState ?? 'enabled' }; - if (Object.values(entry).some(value => value.length > 200)) { truncated = true; continue; } + const entry = { namespace: item.namespace, name: item.slug, scope: item.scope, version: item.version, state: item.state, installationState: item.installationState }; + if (Object.values(entry).some(value => value !== undefined && value.length > 200)) { truncated = true; continue; } if (items.length >= MAX_ITEMS || contextJson([...items, entry]).length > MAX_JSON_LENGTH) { truncated = true; break; } items.push(entry); } diff --git a/plugins/dsh/test/integration/skill-availability-fixture.mjs b/plugins/dsh/test/integration/skill-availability-fixture.mjs index 07a40e188..803421514 100644 --- a/plugins/dsh/test/integration/skill-availability-fixture.mjs +++ b/plugins/dsh/test/integration/skill-availability-fixture.mjs @@ -12,9 +12,10 @@ export function apply(ctx, config) { assert(root.startsWith(resolve(tmpdir()) + sep)); assert(fileURLToPath(import.meta.url).startsWith(resolve(tmpdir()) + sep)); assert.equal(process.env.DSH_HOME, join(root, 'home')); + const checkpoint = { scenario: 'setup', checkpoint: 'setup', assertion: 'none' }; const abort = new AbortController(); - const timer = setTimeout(() => run(ctx, root, AbortSignal.any([abort.signal, AbortSignal.timeout(70000)])) - .catch(error => writeFile(join(root, 'skill-availability-result.json'), JSON.stringify({ passed: false, error: error.message }))), 1000); + const timer = setTimeout(() => run(ctx, root, AbortSignal.any([abort.signal, AbortSignal.timeout(70000)]), checkpoint) + .catch(error => writeFile(join(root, 'skill-availability-result.json'), JSON.stringify({ passed: false, error: error.message, checkpoint, operation: checkpoint.operation }))), 1000); return () => { clearTimeout(timer); abort.abort(); }; } @@ -42,7 +43,8 @@ async function digest(directory) { return hash.digest('hex'); } -async function run(ctx, root, signal) { +async function run(ctx, root, signal, checkpoint) { + const mark = (phase, assertion = 'none') => Object.assign(checkpoint, { checkpoint: phase, assertion }); const project = join(root, 'availability-' + randomUUID()); await mkdir(project); const workspace = await ctx.workspaceRegistry.create(project); @@ -56,62 +58,97 @@ async function run(ctx, root, signal) { const records = []; const protectedPaths = [project, join(root, 'home/skills'), join(root, 'home/skillhub'), join(root, 'native-install-home/.skillhub')]; async function check(args, expected) { + mark('before-digest'); const before = await Promise.all(protectedPaths.map(digest)); + mark('execute'); const result = await execute('astron_skillhub_check_availability', args); + mark('assertion', 'isError'); assert.equal(result.isError, false); + mark('assertion', 'kind'); assert.equal(result.value.kind, 'skillhub-availability'); + mark('assertion', 'schemaVersion'); assert.equal(result.value.schemaVersion, 1); - for (const [key, value] of Object.entries(expected)) assert.deepEqual(result.value.diagnosis[key], value, key); + for (const [key, value] of Object.entries(expected)) { mark('assertion', key); assert.deepEqual(result.value.diagnosis[key], value, key); } + mark('assertion', 'installation'); if (args.name) assert.equal(result.value.diagnosis.installation, undefined); else { + mark('assertion', 'namespace'); assert.equal(result.value.diagnosis.installation.namespace, namespace); + mark('assertion', 'slug'); assert.equal(result.value.diagnosis.installation.slug, 'document-helper'); + mark('assertion', 'version'); assert.equal(result.value.diagnosis.installation.version, '1.2.0'); } + mark('assertion', 'version'); assert.equal(result.value.diagnosis.version, undefined); + mark('assertion', 'fingerprint'); assert.equal(result.value.diagnosis.fingerprint, undefined); const rendered = JSON.stringify(result.content); + mark('assertion', 'render'); assert(rendered.includes('skillhub-availability'), 'Real tools.execute rendered the diagnosis'); + mark('assertion', 'privacy'); for (const forbidden of [root, 'AVAILABILITY_PRIVATE_SENTINEL', 'resourceBase', 'metadata', 'provider']) assert(!rendered.includes(forbidden), 'Model render leaked ' + forbidden); + mark('after-digest', 'unchanged'); assert.deepEqual(await Promise.all(protectedPaths.map(digest)), before, 'Diagnosis changed installation/inventory/operation files'); records.push({ query: args.name ? 'name' : 'managed', ...expected }); return result.value.diagnosis; } async function install(scope) { + checkpoint.operation = { deadlineReached: false }; + mark('install'); const result = await execute('astron_skillhub_install', { namespace, slug: 'document-helper', scope }); + mark('assertion', 'isError'); assert.equal(result.isError, false); + mark('assertion', 'accepted'); assert.equal(result.value.status, 'accepted'); const deadline = Date.now() + 15000; let operation; + mark('install-poll'); while (Date.now() < deadline) { operation = await ctx.skillhub.getInstallationOperation(created.sessionId, result.value.operationId); + checkpoint.operation = { phase: operation?.phase, files: operation?.files, recovery: operation?.recovery, + discovery: operation?.discovery, deadlineReached: Date.now() >= deadline }; if (['completed', 'failed', 'unknown', 'recovery-required'].includes(operation?.phase)) break; await delay(100, undefined, { signal }); } + checkpoint.operation.deadlineReached = Date.now() >= deadline; + mark('assertion', 'completed'); assert.equal(operation?.phase, 'completed'); } async function reference(scope) { + mark('reference'); const sourceRevision = ctx.skillhub.getContext().sourceRevision; const list = await ctx.typertGateway.invoke({ peer: ctx.get('connection')?.browserPeer ?? ctx.get('connection')?.operator, namespace: 'skillhub', method: 'listInstalled', args: { agentId: created.sessionId, sourceRevision }, signal }); const item = list.items.find(value => value.namespace === namespace && value.scope === scope); + mark('assertion', 'reference'); assert(item); return { sourceRevision, itemKey: item.itemKey }; } + checkpoint.scenario = 'user-install'; await install('user'); + checkpoint.scenario = 'user-winner'; await check(await reference('user'), { association: 'managed-reference', scope: 'user', integrity: 'verified', discovery: 'winner', name: 'document-helper' }); + checkpoint.scenario = 'project-install'; await install('project'); + checkpoint.scenario = 'project-winner'; await check(await reference('project'), { scope: 'project', integrity: 'verified', discovery: 'winner' }); + checkpoint.scenario = 'user-shadowed'; await check(await reference('user'), { integrity: 'verified', discovery: 'shadowed', reason: 'same-name-winner' }); + checkpoint.scenario = 'remote'; const remote = await ctx.typertGateway.invoke({ peer: ctx.get('connection')?.browserPeer ?? ctx.get('connection')?.operator, namespace: 'skillhub', method: 'checkAvailability', args: { agentId: created.sessionId, ...await reference('project') }, signal }); + mark('assertion', 'discovery'); assert.equal(remote.discovery, 'winner'); + checkpoint.scenario = 'by-name'; await check({ name: 'document-helper' }, { association: 'not-associated', integrity: 'unknown', discovery: 'winner' }); const target = paths.project; const skillPath = join(target, 'SKILL.md'); const original = await readFile(skillPath, 'utf8'); for (const mode of ['content-modified', 'skill-missing', 'directory-missing']) { + checkpoint.scenario = mode; + mark('mutation'); const backup = join(root, 'availability-backup-' + randomUUID()); try { if (mode === 'content-modified') await writeFile(skillPath, original + '\nAVAILABILITY_PRIVATE_SENTINEL'); @@ -122,6 +159,7 @@ async function run(ctx, root, signal) { else await rename(backup, mode === 'skill-missing' ? skillPath : target); } } + checkpoint.scenario = 'policy'; for (const userInvocable of [true, false]) for (const modelInvocable of [true, false]) { const name = `availability-policy-${Number(userInvocable)}-${Number(modelInvocable)}`; const dispose = ctx.skills.register({ name, description: 'Fixture', content: 'AVAILABILITY_PRIVATE_SENTINEL', @@ -130,11 +168,14 @@ async function run(ctx, root, signal) { try { await check({ name }, { association: 'not-associated', integrity: 'unknown', discovery: 'winner', invocation: { userInvocable, modelInvocable } }); } finally { dispose(); } } + checkpoint.scenario = 'absent'; await check({ name: 'availability-absent' }, { discovery: 'not-discovered', integrity: 'unknown' }); + checkpoint.scenario = 'incomplete-provider'; const dispose = ctx.skills.registerProvider(() => ({ name: 'availability-incomplete', list: async () => ({ candidates: [], complete: false }), get: async () => undefined })); try { await check({ name: 'document-helper' }, { discovery: 'unknown', reason: 'snapshot-incomplete' }); } finally { dispose(); } + checkpoint.scenario = 'failed-provider'; const failedProvider = ctx.skills.registerProvider(() => ({ name: 'availability-failed', list: async () => { throw new Error('AVAILABILITY_PRIVATE_SENTINEL'); }, get: async () => undefined })); try { await check({ name: 'document-helper' }, { discovery: 'unknown', reason: 'snapshot-incomplete' }); } diff --git a/plugins/dsh/test/integration/skill-toggle-fixture.mjs b/plugins/dsh/test/integration/skill-toggle-fixture.mjs index 764a38833..26672547b 100644 --- a/plugins/dsh/test/integration/skill-toggle-fixture.mjs +++ b/plugins/dsh/test/integration/skill-toggle-fixture.mjs @@ -17,9 +17,16 @@ export function apply(ctx, config) { assert.equal(process.env.DSH_HOME, join(root, 'home')); assert.equal(process.env.DSH_AGENTS_HOME, join(root, 'agents')); assert.equal(process.env.HOME, join(root, 'native-install-home')); + const checkpoint = { scenario: 'setup', assertion: 'none' }; const abort = new AbortController(); const timer = setTimeout(() => { - run(ctx, root, abort.signal, config.watchOff === true).then(result => ({ passed: true, ...result }), error => ({ passed: false, error: error.message })) + run(ctx, root, abort.signal, config.watchOff === true, checkpoint).then(result => ({ passed: true, ...result }), error => { + // Only this copied fixture's bounded line number may leave the private result. + const frame = String(error.stack ?? '').split('\n').filter(line => line.includes(fileURLToPath(import.meta.url)) || line.includes(import.meta.url)).at(-1); + const line = Number(/:(\d+):\d+\)?$/.exec(frame ?? '')?.[1]); + const location = Number.isInteger(line) && line >= 1 && line <= 10000 ? { fixture: 'skill-toggle-fixture.mjs', line } : undefined; + return { passed: false, error: error.message, checkpoint, location, operation: checkpoint.operation }; + }) .then(result => writeFile(join(root, 'skill-toggle-result.json'), JSON.stringify(result, null, 2))).catch(() => {}); }, 1000); return () => { clearTimeout(timer); abort.abort(); }; @@ -38,7 +45,12 @@ async function hashes(directory) { return result; } -async function run(ctx, root, signal, watchOff) { +async function run(ctx, root, signal, watchOff, checkpoint) { + const checked = Object.assign((...args) => { checkpoint.assertion = 'truthy'; return assert(...args); }, + Object.fromEntries(['equal', 'notEqual', 'deepEqual', 'rejects'].map(name => [name, (...args) => { + checkpoint.assertion = name; return assert[name](...args); + }]))); + const project = join(root, 'toggle-project-' + randomUUID()); await mkdir(join(project, '.git'), { recursive: true }); const workspace = await ctx.workspaceRegistry.create(project); @@ -47,32 +59,39 @@ async function run(ctx, root, signal, watchOff) { if ('error' in resolved) throw resolved.error; let agent = resolved.agent; const options = { scope: agent, cwd: agent.session.header.cwd, signal }; - const invoke = (method, extra = {}) => ctx.typertGateway.invoke({ + const invoke = (method, extra = {}) => { checkpoint.assertion = 'invoke'; return ctx.typertGateway.invoke({ peer: ctx.get('connection')?.browserPeer ?? ctx.get('connection')?.operator, namespace: 'skillhub', method, args: { ...(method === 'getSkillDetail' ? {} : { agentId: agent.session.id }), ...extra }, signal, - }); + }); }; const source = ctx.skillhub.getContext(); const sourceRevision = source.sourceRevision; const detail = await invoke('getSkillDetail', { sourceRevision, namespace: process.env.DSH_FIXTURE_NAMESPACE ?? 'demo', slug: 'document-helper', version: '1.2.0' }); const targets = await invoke('listInstallTargets', { detailToken: detail.detailToken }); - const target = targets.targets.find(item => item.scope === 'project'); assert(target); + const target = targets.targets.find(item => item.scope === 'project'); checked(target); const operations = []; async function complete(receipt) { - assert(receipt); const deadline = Date.now() + 20000; + checkpoint.operation = { deadlineReached: false }; + checkpoint.assertion = 'operation'; + checked(receipt); const deadline = Date.now() + 20000; while (Date.now() < deadline) { const operation = await invoke('getOperation', { operationId: receipt.operationId, hostInstanceId: receipt.context.hostInstanceId }); + checkpoint.operation = { phase: operation?.phase, files: operation?.files, recovery: operation?.recovery, + discovery: operation?.discovery, deadlineReached: Date.now() >= deadline }; if (operation.phase === 'completed') { operations.push({ kind: operation.kind, files: operation.files, discovery: operation.discovery }); return operation; } - assert(!['failed', 'recovery-required', 'unknown'].includes(operation.phase), JSON.stringify(operation)); + checked(!['failed', 'recovery-required', 'unknown'].includes(operation.phase), JSON.stringify(operation)); await delay(50, undefined, { signal }); } + checkpoint.operation.deadlineReached = true; throw new Error('Operation timed out'); } + checkpoint.scenario = 'install'; await complete(await invoke('startInstall', { input: { detailToken: detail.detailToken, targetToken: target.targetToken, hostInstanceId: targets.hostInstanceId, requestKey: randomUUID(), mode: 'new', initiatedFrom: 'plaza' } })); const directory = join(project, '.dsh/skills/document-helper'); const before = await hashes(directory); const list = async () => (await invoke('listInstalled', { sourceRevision })).items.find(item => item.scope === 'project' && item.slug === 'document-helper'); async function discovered(present) { + checkpoint.assertion = 'discovery'; const deadline = Date.now() + 15000; while (Date.now() < deadline) { const snapshot = await ctx.skills.snapshot(options); @@ -82,63 +101,78 @@ async function run(ctx, root, signal, watchOff) { } throw new Error(`Full Host discovery did not become ${present}`); } - const loaded = await discovered(true); assert.equal(loaded.resourceBase.path, directory); + checkpoint.scenario = 'initial-discovery'; + const loaded = await discovered(true); checked.equal(loaded.resourceBase.path, directory); async function mutate(action, item, version = null) { const prepared = await invoke('beginConversationManagement', { sourceRevision, itemKey: item.itemKey, action, version, requestKey: randomUUID() }); - assert.equal(prepared.status, 'awaiting-confirmation', JSON.stringify(prepared)); + checked.equal(prepared.status, 'awaiting-confirmation', JSON.stringify(prepared)); const receipt = await invoke('confirmConversationManagement', { confirmationRef: prepared.confirmationRef, confirmed: true }); - assert.equal(receipt.status, 'accepted'); return complete(receipt.operation); + checked.equal(receipt.status, 'accepted'); return complete(receipt.operation); } const original = await list(); if (watchOff) { + checkpoint.scenario = 'watch-off-disable'; await mutate('disable', original); + checkpoint.scenario = 'watch-off-diagnosis'; const selected = await list(); const diagnosis = await invoke('checkAvailability', { sourceRevision, itemKey: selected.itemKey }); - assert.equal(diagnosis.installationState, 'disabled'); - assert.equal(diagnosis.integrity, 'verified'); - assert.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: selected.itemKey })).reason, 'installation-disabled'); - assert.equal(operations.at(-1).discovery, 'unconfirmed'); + checked.equal(diagnosis.installationState, 'disabled'); + checked.equal(diagnosis.integrity, 'verified'); + checked.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: selected.itemKey })).reason, 'installation-disabled'); + checked.equal(operations.at(-1).discovery, 'unconfirmed'); const snapshot = await ctx.skills.snapshot(options); const cached = await ctx.skills.get('document-helper', options); + checkpoint.scenario = 'watch-off-uninstall'; await mutate('uninstall', selected); return { operations, watchOff: true, diagnosis: { installationState: diagnosis.installationState, discovery: diagnosis.discovery, reason: diagnosis.reason }, snapshotComplete: snapshot.complete, snapshotContainsName: snapshot.skills.some(item => item.name === 'document-helper'), loadedAfterDisable: !!cached, noInstantRevocationClaim: true }; } + checkpoint.scenario = 'cancel'; const cancelled = await invoke('beginConversationManagement', { sourceRevision, itemKey: original.itemKey, action: 'disable', version: null, requestKey: randomUUID() }); - assert.equal((await invoke('confirmConversationManagement', { confirmationRef: cancelled.confirmationRef, confirmed: false })).status, 'cancelled'); - assert.deepEqual(await hashes(directory), before); + checked.equal((await invoke('confirmConversationManagement', { confirmationRef: cancelled.confirmationRef, confirmed: false })).status, 'cancelled'); + checked.deepEqual(await hashes(directory), before); + checkpoint.scenario = 'disable'; await mutate('disable', original); await discovered(false); - let item = await list(); assert.equal(item.installationState, 'disabled'); assert.equal(item.state, 'present'); - assert.equal(item.locationKey, original.locationKey); assert.notEqual(item.itemKey, original.itemKey); + let item = await list(); checked.equal(item.installationState, 'disabled'); checked.equal(item.state, 'present'); + checked.equal(item.locationKey, original.locationKey); checked.notEqual(item.itemKey, original.itemKey); + checkpoint.scenario = 'disabled-files'; const disabled = await hashes(directory); - assert.equal(disabled['SKILL.md.disabled'], before['SKILL.md']); assert(!('SKILL.md' in disabled)); + checked.equal(disabled['SKILL.md.disabled'], before['SKILL.md']); checked(!('SKILL.md' in disabled)); const canonical = { ...disabled, 'SKILL.md': disabled['SKILL.md.disabled'] }; delete canonical['SKILL.md.disabled']; - assert.deepEqual(canonical, before); - assert.equal(await readFile(join(loaded.resourceBase.path, 'references/note.md'), 'utf8'), 'Native installation resource'); - await assert.rejects(invoke('prepareSkillUse', { sourceRevision, itemKey: original.itemKey })); - assert.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: item.itemKey })).reason, 'installation-disabled'); - await mutate('enable', item); await discovered(true); assert.deepEqual(await hashes(directory), before); + checked.deepEqual(canonical, before); + checked.equal(await readFile(join(loaded.resourceBase.path, 'references/note.md'), 'utf8'), 'Native installation resource'); + checkpoint.scenario = 'disabled-use'; + await checked.rejects(invoke('prepareSkillUse', { sourceRevision, itemKey: original.itemKey })); + checked.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: item.itemKey })).reason, 'installation-disabled'); + checkpoint.scenario = 'enable'; + await mutate('enable', item); await discovered(true); checked.deepEqual(await hashes(directory), before); + checkpoint.scenario = 'disable-again'; item = await list(); await mutate('disable', item); await discovered(false); + checkpoint.scenario = 'disabled-upgrade'; item = await list(); await mutate('upgrade', item, '1.3.0'); - item = await list(); assert.equal(item.installationState, 'disabled'); assert.equal(item.version, '1.3.0'); - assert.equal(await readFile(join(directory, 'references/note.md'), 'utf8'), 'Native upgraded resource 1.3.0'); - assert(!('SKILL.md' in await hashes(directory))); await discovered(false); - await mutate('uninstall', item); assert.equal(await list(), undefined); await discovered(false); + item = await list(); checked.equal(item.installationState, 'disabled'); checked.equal(item.version, '1.3.0'); + checked.equal(await readFile(join(directory, 'references/note.md'), 'utf8'), 'Native upgraded resource 1.3.0'); + checked(!('SKILL.md' in await hashes(directory))); await discovered(false); + checkpoint.scenario = 'disabled-uninstall'; + await mutate('uninstall', item); checked.equal(await list(), undefined); await discovered(false); // Independent same-name user/project installations: only the project entry is disabled. const installScope = async scope => { const targets = await invoke('listInstallTargets', { detailToken: detail.detailToken }); - const selected = targets.targets.find(item => item.scope === scope); assert(selected); + const selected = targets.targets.find(item => item.scope === scope); checked(selected); await complete(await invoke('startInstall', { input: { detailToken: detail.detailToken, targetToken: selected.targetToken, hostInstanceId: targets.hostInstanceId, requestKey: randomUUID(), mode: 'new', initiatedFrom: 'plaza' } })); }; + checkpoint.scenario = 'same-name-install'; await installScope('user'); await installScope('project'); await discovered(true); const userDirectory = join(root, 'home/skills/document-helper'); const userBefore = await hashes(userDirectory); + checkpoint.scenario = 'same-name-disable'; await mutate('disable', await list()); + checkpoint.scenario = 'same-name-fallback'; const deadline = Date.now() + 15000; let fallback; do { @@ -150,21 +184,23 @@ async function run(ctx, root, signal, watchOff) { const originalSelected = await list(); const originalDiagnosis = await invoke('checkAvailability', { sourceRevision, itemKey: originalSelected.itemKey }); if (!originalSessionFallback) { - assert.equal(originalDiagnosis.installationState, 'disabled'); - assert.equal(originalDiagnosis.discovery, 'unknown'); + checked.equal(originalDiagnosis.installationState, 'disabled'); + checked.equal(originalDiagnosis.discovery, 'unknown'); const fresh = await ctx.sessionController.create({ workspaceId: workspace.id, agentPreset: 'standard' }); const current = await ctx.sessionController.resolveAgent(fresh.sessionId); if ('error' in current) throw current.error; agent = current.agent; options.scope = agent; await ctx.skills.snapshot(options); fallback = await ctx.skills.get('document-helper', options); } - assert.equal(fallback?.resourceBase?.path, userDirectory); + checked.equal(fallback?.resourceBase?.path, userDirectory); + checkpoint.scenario = 'same-name-diagnosis'; const selected = await list(); const diagnosis = await invoke('checkAvailability', { sourceRevision, itemKey: selected.itemKey }); - assert.equal(diagnosis.installationState, 'disabled'); assert.equal(diagnosis.discovery, 'shadowed'); - assert.equal(diagnosis.reason, 'same-name-winner'); assert.equal(diagnosis.name, 'document-helper'); - assert.deepEqual(await hashes(userDirectory), userBefore); - assert.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: selected.itemKey })).reason, 'installation-disabled'); + checked.equal(diagnosis.installationState, 'disabled'); checked.equal(diagnosis.discovery, 'shadowed'); + checked.equal(diagnosis.reason, 'same-name-winner'); checked.equal(diagnosis.name, 'document-helper'); + checked.deepEqual(await hashes(userDirectory), userBefore); + checked.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: selected.itemKey })).reason, 'installation-disabled'); + checkpoint.scenario = 'cleanup'; await mutate('uninstall', selected); const userItem = (await invoke('listInstalled', { sourceRevision })).items.find(item => item.scope === 'user' && item.slug === 'document-helper'); await mutate('uninstall', userItem); diff --git a/plugins/dsh/test/management-context.test.ts b/plugins/dsh/test/management-context.test.ts index adc40e01a..ed7751923 100644 --- a/plugins/dsh/test/management-context.test.ts +++ b/plugins/dsh/test/management-context.test.ts @@ -6,7 +6,7 @@ import SkillHubService from '@astron-team/skillhub-dsh'; import { installationContext, registerManagementContext } from '../src/host/management-context.js'; import type { InstalledSkillList } from '../src/host/types.js'; -const item = { itemKey: 'hidden-item-key', namespace: 'team', slug: 'writer', version: '1.0.0', scope: 'project' as const, state: 'present' as const }; +const item = { itemKey: 'hidden-item-key', namespace: 'team', slug: 'writer', version: '1.0.0', scope: 'project' as const, state: 'present' as const, installationState: 'enabled' as const }; const list: InstalledSkillList = { items: [item], complete: true, unavailable: [], issues: [] }; function scope(): AssembleContext { const agent = { session: { id: 'test-session', header: { cwd: '/private/project' } } } as unknown as Agent; @@ -106,3 +106,12 @@ test('compiled service injects and disposes its official context listener with s expect((await ctx.systemPrompt.assemble(context)).contexts).toEqual([]); } finally { await service.dispose(); await prompt.dispose(); } }); + + +test('unknown entry state remains unknown in model context', () => { + const value = snapshot(installationContext({ ...list, complete: false, + items: [{ ...item, state: 'unreadable', installationState: undefined }], issues: ['target-unreadable'] })); + expect(value.items[0].state).toBe('unreadable'); + expect(value.items[0]).not.toHaveProperty('installationState'); + expect(value.complete).toBe(false); +}); diff --git a/plugins/dsh/test/probe-diagnostics.test.ts b/plugins/dsh/test/probe-diagnostics.test.ts index f6354fae7..2585082a7 100644 --- a/plugins/dsh/test/probe-diagnostics.test.ts +++ b/plugins/dsh/test/probe-diagnostics.test.ts @@ -28,3 +28,55 @@ test('observer diagnostics whitelist states and exact dependency booleans', () = expect(safeProbeFailure(JSON.stringify({ ...base, diagnostics: { observer: { stage: 'private', validation: 'private-path' }, dependencies: { skillhub: 'secret' } } }), 'runtime-baseline')) .toEqual({ stage: 'host-result', code: 'HOST_RESULT_TIMEOUT', observer: 'not-observed', dependencies: {} }); }); + +test('availability diagnostics retain only fixed checkpoint labels, never raw failure data', () => { + const checkpoint = { scenario: 'user-winner', checkpoint: 'assertion', assertion: 'discovery' }; + const base = { passed: false, probe: 'skill-availability', stage: 'host-assertion', failureCode: 'HOST_ASSERTION_FAILED', + error: '/private/AVAILABILITY_PRIVATE_SENTINEL', diagnostics: { availability: { ...checkpoint, error: 'secret', path: '/private' } } }; + const safe = safeProbeFailure(JSON.stringify(base), 'skill-availability'); + expect(safe.availability).toEqual(checkpoint); + expect(JSON.stringify(safe)).not.toMatch(/private|secret|SENTINEL/); + for (const key of ['scenario', 'checkpoint', 'assertion']) { + expect(safeProbeFailure(JSON.stringify({ ...base, diagnostics: { availability: { ...checkpoint, [key]: '/private/secret' } } }), 'skill-availability').availability).toBeUndefined(); + } + expect(safeProbeFailure(JSON.stringify({ ...base, probe: 'plugin-lifecycle' }), 'plugin-lifecycle').availability).toBeUndefined(); +}); + +test('both toggle probes expose only bounded fixture locations and fixed labels', () => { + for (const probe of ['skill-toggle', 'skill-toggle-watch-off']) { + const base = { passed: false, probe, stage: 'host-assertion', failureCode: 'HOST_ASSERTION_FAILED', + diagnostics: { toggle: { scenario: 'disabled-upgrade', assertion: 'equal', error: 'private-secret' }, + location: { fixture: 'skill-toggle-fixture.mjs', line: 152, stack: '/private/secret' } } }; + const safe = safeProbeFailure(JSON.stringify(base), probe); + expect(safe.toggle).toEqual({ scenario: 'disabled-upgrade', assertion: 'equal' }); + expect(safe.location).toEqual({ fixture: 'skill-toggle-fixture.mjs', line: 152 }); + expect(JSON.stringify(safe)).not.toMatch(/private|secret|stack/); + for (const line of [0, -1, 10001, 1.5, '152', null]) { + expect(safeProbeFailure(JSON.stringify({ ...base, diagnostics: { location: { fixture: 'skill-toggle-fixture.mjs', line } } }), probe).location).toBeUndefined(); + } + expect(safeProbeFailure(JSON.stringify({ ...base, diagnostics: { location: { fixture: '/private/skill-toggle-fixture.mjs', line: 3 } } }), probe).location).toBeUndefined(); + for (const key of ['scenario', 'assertion']) { + expect(safeProbeFailure(JSON.stringify({ ...base, diagnostics: { toggle: { ...base.diagnostics.toggle, [key]: 'private-secret' } } }), probe).toggle).toBeUndefined(); + } + expect(safeProbeFailure(JSON.stringify({ ...base, probe: 'runtime-baseline' }), 'runtime-baseline').location).toBeUndefined(); + } +}); + +test('operation observations and Host codes whitelist every field without revealing operation bodies', async () => { + // @ts-expect-error Development-only Node script has no declarations. + const { knownHostFailureCodes } = await import('../scripts/smoke-native-host.mjs'); + expect(knownHostFailureCodes('private /path EPERM code=EBUSY arbitrary-secret NOT_EIO EPERM')).toEqual(['EPERM', 'EBUSY']); + for (const probe of ['skill-availability', 'skill-toggle', 'skill-toggle-watch-off']) { + const base = { passed: false, probe, stage: 'host-assertion', failureCode: 'HOST_ASSERTION_FAILED', diagnostics: { + operation: { phase: 'failed', files: 'failed', recovery: 'restored', discovery: 'unconfirmed', deadlineReached: false, + operationId: 'secret', targetLabel: '/private', reason: 'private-secret', code: 'SECRET', error: 'raw-secret' }, + observedHostCodes: ['EPERM', '/private', 'EBUSY', 'EPERM', 'SECRET'], + } }; + const safe = safeProbeFailure(JSON.stringify(base), probe); + expect(safe.operation).toEqual({ phase: 'failed', files: 'failed', recovery: 'restored', discovery: 'unconfirmed', deadlineReached: false }); + expect(safe.observedHostCodes).toEqual(['EPERM', 'EBUSY']); + expect(JSON.stringify(safe)).not.toMatch(/private|secret|SECRET|operationId|targetLabel|reason/); + expect(safeProbeFailure(JSON.stringify({ ...base, diagnostics: { operation: { phase: '/secret', files: '/secret', discovery: '/secret', recovery: '/secret', deadlineReached: 'secret' }, observedHostCodes: 'EPERM' } }), probe).operation).toBeUndefined(); + expect(safeProbeFailure(JSON.stringify({ ...base, probe: 'runtime-baseline' }), 'runtime-baseline').operation).toBeUndefined(); + } +}); diff --git a/plugins/dsh/test/search-installation.test.ts b/plugins/dsh/test/search-installation.test.ts index 21fe7f91f..11712aeda 100644 --- a/plugins/dsh/test/search-installation.test.ts +++ b/plugins/dsh/test/search-installation.test.ts @@ -15,12 +15,12 @@ import type { InstalledSkillList } from '../src/host/types.js'; const item = { itemKey: 'private-key', namespace: 'team', slug: 'writer', version: '1.0.0', scope: 'user', state: 'present' } as const; const checkedAt = '2026-10-19T00:00:00.000Z'; test('summary matches full coordinates, preserves scopes and abnormal records, and strips private fields', () => { - const inventory: InstalledSkillList = { complete: true, issues: [], unavailable: [], items: [item, + const inventory: InstalledSkillList = { complete: true, issues: [], unavailable: [], items: [{ ...item, installationState: 'enabled' }, { ...item, namespace: 'other' }, { ...item, slug: 'other' }, { ...item, scope: 'project', state: 'missing', version: '0.9.0' }] }; const value = summarizeInstallation(item, inventory, checkedAt); expect(value).toEqual({ checkedAt, complete: true, unavailable: [], records: [ - { scope: 'user', state: 'present', version: '1.0.0', installationState: 'enabled' }, { scope: 'project', state: 'missing', version: '0.9.0', installationState: 'enabled' }] }); + { scope: 'user', state: 'present', version: '1.0.0', installationState: 'enabled' }, { scope: 'project', state: 'missing', version: '0.9.0', installationState: undefined }] }); expect(JSON.stringify(value)).not.toContain('private-key'); expect(summarizeInstallation(item, { ...inventory, unavailable: ['project'] }, checkedAt).complete).toBe(false); expect(summarizeInstallation(item, undefined, checkedAt)).toEqual({ checkedAt, complete: false, unavailable: ['user', 'project'], records: [] }); diff --git a/plugins/dsh/test/skill-primary-actions.test.tsx b/plugins/dsh/test/skill-primary-actions.test.tsx index ddd4c61e5..294886a67 100644 --- a/plugins/dsh/test/skill-primary-actions.test.tsx +++ b/plugins/dsh/test/skill-primary-actions.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { installedCardBadges, installedCardVersions, installedUpdateKeys, InstalledSkills, type InstalledServices } from '../src/client/installed-skills.js'; import { installedNavigationFor } from '../src/client/installed-navigation.js'; import { UpgradeRequests, RemovalRequests } from '../src/client/upgrade-requests.js'; -import { selectSkillItem } from '../src/client/skill-primary-actions.js'; +import { selectSkillItem, SkillPrimaryActions } from '../src/client/skill-primary-actions.js'; import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol'; const item = { itemKey: 'user', namespace: 'demo', slug: 'writer', scope: 'user' as const, version: '1', state: 'present' as const, installationState: 'disabled' as const }; @@ -80,3 +80,12 @@ test('card update check limits the existing read-only scan to current exact inst expect(installedUpdateKeys([item, project, other], { project: { state: 'failed' } }, true)).toEqual(['project']); expect(installedUpdateKeys([item, project, other], {}, false)).toEqual(['user', 'project', 'other']); }); + + +test('unreadable entry projection never offers use or invents an enabled summary', () => { + const html = renderToStaticMarkup( { throw new Error('must not select'); }} onManage={() => {}} />); + expect(html).not.toContain('使用技能'); + expect(html).toContain('安装状态待核验'); + expect(html).toContain('管理安装'); +}); diff --git a/plugins/dsh/test/smoke-native-host.test.ts b/plugins/dsh/test/smoke-native-host.test.ts index 6d738ee2d..397e8f77d 100644 --- a/plugins/dsh/test/smoke-native-host.test.ts +++ b/plugins/dsh/test/smoke-native-host.test.ts @@ -1,10 +1,10 @@ import { afterEach, expect, test } from 'bun:test'; -import { realpath, access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { realpath, access, chmod, mkdir, stat, symlink, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; // Node-only CI entry; keep its helpers importable without starting a Host or downloading dependencies. // @ts-expect-error The development-only Node entry has no TypeScript declarations. -import { hostInstallManifest, selectDshVersion, supportedDshVersions, isolatedEnvironment, profileInstallManifest, retainedCordisFamily, runBounded, selectTarball } from '../scripts/smoke-native-host.mjs'; +import { prepareDownloadCache, scenarioProbes, hostInstallManifest, selectDshVersion, supportedDshVersions, isolatedEnvironment, profileInstallManifest, retainedCordisFamily, runBounded, selectTarball } from '../scripts/smoke-native-host.mjs'; const roots: string[] = []; async function root() { @@ -173,3 +173,113 @@ test('native smoke accepts only explicit DSH releases and rejects incompatible r } expect(() => selectDshVersion('0.2.1-rc.2', 'retained')).toThrow('requires the current Cordis family'); }); + + +test('installation scenarios select a fresh smoke invocation without availability leftovers', () => { + expect(scenarioProbes()).toEqual(['runtime-baseline', 'plugin-lifecycle', 'skill-availability']); + expect(scenarioProbes('skill-toggle')).toEqual(['runtime-baseline', 'skill-toggle']); + expect(scenarioProbes('skill-toggle-watch-off')).toEqual(['runtime-baseline', 'skill-toggle-watch-off']); + expect(() => scenarioProbes('unknown')).toThrow('Unsupported Host scenario'); +}); + + +test('explicit public download cache shares content but never Host state or credentials', async () => { + const directory = await root(); + const shared = join(directory, 'downloads'); + expect((await prepareDownloadCache(shared)).populated).toBe(false); + await mkdir(join(shared, '_cacache')); + await writeFile(join(shared, '_cacache', 'content'), 'public-tarball'); + expect((await prepareDownloadCache(shared)).populated).toBe(true); + const first = isolatedEnvironment(join(directory, 'first'), { npm_config_cache: '/personal', NPM_TOKEN: 'private', npm_config__auth: 'private' }); + const second = isolatedEnvironment(join(directory, 'second'), first); + for (const key of ['HOME', 'USERPROFILE', 'DSH_HOME', 'DSH_AGENTS_HOME', 'npm_config_userconfig', 'npm_config_logs_dir', 'npm_config_cache']) { + expect(first[key]).not.toBe(second[key]); + expect(first[key]).not.toContain(shared); + } + expect(first.NPM_TOKEN).toBeUndefined(); + expect(first.npm_config__auth).toBeUndefined(); + expect(await readFile(join(shared, '_cacache', 'content'), 'utf8')).toBe('public-tarball'); +}); + +test('download cache rejects relative, temp-root, outside and linked directories', async () => { + const directory = await root(); + await expect(prepareDownloadCache('relative')).rejects.toThrow(); + await expect(prepareDownloadCache(tmpdir())).rejects.toThrow(); + await expect(prepareDownloadCache(process.cwd())).rejects.toThrow(); + const actual = join(directory, 'actual'); + await mkdir(actual); + const linked = join(directory, 'linked'); + await symlink(actual, linked, process.platform === 'win32' ? 'junction' : 'dir'); + await expect(prepareDownloadCache(join(linked, 'nested'))).rejects.toThrow(); + await expect(access(join(actual, 'nested'))).rejects.toThrow(); + const content = join(directory, 'content-cache'); + await mkdir(content); + await symlink(actual, join(content, '_cacache'), process.platform === 'win32' ? 'junction' : 'dir'); + await expect(prepareDownloadCache(content)).rejects.toThrow(); +}); + + +test('explicit CI runner temp is trusted without inheriting it into Host state', async () => { + const directory = await root(); + const previous = { CI: process.env.CI, RUNNER_TEMP: process.env.RUNNER_TEMP, TMPDIR: process.env.TMPDIR, TEMP: process.env.TEMP, TMP: process.env.TMP }; + const alternate = join(directory, 'ordinary-temp'); + await mkdir(alternate); + try { + process.env.CI = 'true'; + process.env.RUNNER_TEMP = directory; + process.env.TMPDIR = alternate; + process.env.TEMP = alternate; + process.env.TMP = alternate; + expect(tmpdir()).toBe(alternate); + const target = join(directory, 'runner-downloads'); + expect((await prepareDownloadCache(target)).path).toBe(target); + expect(isolatedEnvironment(join(directory, 'host')).RUNNER_TEMP).toBeUndefined(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + + +test.skipIf(process.platform === 'win32')('download cache rejects writable intermediate directories without changing their mode', async () => { + const directory = await root(); + const parent = join(directory, 'shared-parent'); + await mkdir(parent); + await chmod(parent, 0o777); + await expect(prepareDownloadCache(join(parent, 'cache'))).rejects.toThrow(); + await expect(access(join(parent, 'cache'))).rejects.toThrow(); + expect((await stat(parent)).mode & 0o777).toBe(0o777); +}); + + +test('trusted temporary base aliases accept lexical and canonical children but never anchors or descendant links', async () => { + const directory = await root(); + const actual = join(directory, 'actual-temp'); + const alias = join(directory, 'temp-alias'); + await mkdir(actual); + await symlink(actual, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const previous = { CI: process.env.CI, RUNNER_TEMP: process.env.RUNNER_TEMP, TMPDIR: process.env.TMPDIR, TEMP: process.env.TEMP, TMP: process.env.TMP }; + try { + process.env.TMPDIR = alias; process.env.TEMP = alias; process.env.TMP = alias; + delete process.env.RUNNER_TEMP; + expect((await prepareDownloadCache(join(actual, 'canonical'))).path).toBe(join(actual, 'canonical')); + expect((await prepareDownloadCache(join(alias, 'lexical'))).path).toBe(join(actual, 'lexical')); + await expect(prepareDownloadCache(alias)).rejects.toThrow(); + await expect(prepareDownloadCache(actual)).rejects.toThrow(); + const descendant = join(actual, 'linked'); + await symlink(directory, descendant, process.platform === 'win32' ? 'junction' : 'dir'); + await expect(prepareDownloadCache(join(alias, 'linked', 'escape'))).rejects.toThrow(); + await expect(prepareDownloadCache(join(actual, 'linked', 'escape'))).rejects.toThrow(); + const unrelated = join(directory, 'other-temp'); await mkdir(unrelated); + process.env.TMPDIR = unrelated; process.env.TEMP = unrelated; process.env.TMP = unrelated; + process.env.CI = 'true'; process.env.RUNNER_TEMP = alias; + expect((await prepareDownloadCache(join(actual, 'runner'))).path).toBe(join(actual, 'runner')); + await expect(prepareDownloadCache(actual)).rejects.toThrow(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } +});