From 82a46cf14957eb013338751bd78d3ce9c6318790 Mon Sep 17 00:00:00 2001 From: Julian Coy Date: Thu, 23 Jul 2026 00:03:23 -0400 Subject: [PATCH] Plumb companion bytes, owned-path sets, and pre-materialization reconciliation through install, receipt, and CLI rendering --- .../changes/support-non-asset-files/tasks.md | 18 +- .../cli/src/__tests__/install-view.test.tsx | 30 ++ .../cli/src/tui/views/install/facet-row.tsx | 8 + .../src/tui/views/install/failure-block.tsx | 59 ++++ .../__tests__/archive-compatibility.test.ts | 27 ++ .../cli/src/util/archive-compatibility.ts | 81 +++++ packages/cli/src/util/registry-errors.ts | 19 +- .../engine/src/__tests__/run-install.test.ts | 307 +++++++++++++++++- .../src/install/__tests__/receipt.test.ts | 159 +++++++-- .../__tests__/run-install.receipt.test.ts | 39 ++- .../engine/src/install/commit/install-loop.ts | 18 +- .../engine/src/install/commit/reconcile.ts | 138 ++++++++ .../engine/src/install/commit/resolve-git.ts | 39 ++- .../src/install/commit/resolve-local.ts | 24 +- .../src/install/commit/resolve-registry.ts | 29 +- .../engine/src/install/commit/tri-write.ts | 24 +- packages/engine/src/install/commit/types.ts | 16 +- packages/engine/src/install/materialize.ts | 221 +++++++++++-- packages/engine/src/install/receipt.ts | 223 ++++++++++--- packages/engine/src/install/run-install.ts | 12 + packages/engine/src/install/types.ts | 40 +++ .../engine/src/install/verified-asset-plan.ts | 48 +++ 22 files changed, 1419 insertions(+), 160 deletions(-) create mode 100644 packages/cli/src/util/__tests__/archive-compatibility.test.ts create mode 100644 packages/cli/src/util/archive-compatibility.ts create mode 100644 packages/engine/src/install/commit/reconcile.ts diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md index f584f20a..540bc829 100644 --- a/openspec/changes/support-non-asset-files/tasks.md +++ b/openspec/changes/support-non-asset-files/tasks.md @@ -87,15 +87,15 @@ - [x] 9.1 Implement: Replace numeric-order lockfile handling with exact legacy-alpha-`1` and current-`0.2` loading, normal-mode migration, and frozen-mode no-rewrite behavior - [x] 9.2 Implement: Derive sorted lockfile asset file records from the verified materialization subset and recomputed entry hashes rather than copying self-declared hash values -- [ ] 9.3 Implement: Enforce pre-materialization agreement among facet integrity, asset identities, complete owned path sets, recomputed entry hashes, and verified build-manifest hashes with path-specific result variants, running the adapter-compatibility preflight (positional `0.0` rejected by a `{0.1}` CLI) ahead of archive-version dispatch and per-file reconciliation -- [ ] 9.4 Implement: Introduce receipt `0.2` asset/file ownership, safe legacy refinement, project-isolated bootstrap, and containment validation that treats receipt data as untrusted -- [ ] 9.5 Implement: Commit lockfile, receipt, and adapter state transactionally and ensure frozen consistency gates complete before receipt-driven cleanup begins -- [ ] 9.6 Implement: Materialize only primary assets and owned skill companions through tagged adapter requests carrying validated ownership sets from the lockfile and receipt, with per-file skip/repair behavior and rollback journal preimages -- [ ] 9.7 Implement: Make drift and removal path-specific, preserve unowned files, and support offline multi-file cleanup from receipts without cache or network access -- [ ] 9.8 Implement: Render lockfile, archive-version, per-file mismatch, adapter-bundle, and receipt failures exhaustively in CLI install output using one compatibility table for known format transitions -- [ ] 9.9 Implement: Add engine and CLI tests for migration, frozen failures, receipt corruption/isolation, pulled-lockfile cleanup, per-file drift, integrity mismatch, rollback, archive-only withholding, and exact diagnostics -- [ ] 9.10 Implement: Add a full-cycle end-to-end test that builds and verifies a facet with skill companions and archive-only files, installs it, detects and repairs single-file drift, exercises interrupted-install convergence on re-run without deleting unowned files, and removes it offline from the receipt, then exercises the same install path with an immutable legacy `0.1` archive -- [ ] 9.11 Verify: Run focused install, materialization, receipt, lockfile, cache, registry, and CLI install tests +- [x] 9.3 Implement: Enforce pre-materialization agreement among facet integrity, asset identities, complete owned path sets, recomputed entry hashes, and verified build-manifest hashes with path-specific result variants, running the adapter-compatibility preflight (positional `0.0` rejected by a `{0.1}` CLI) ahead of archive-version dispatch and per-file reconciliation +- [x] 9.4 Implement: Introduce receipt `0.2` asset/file ownership, safe legacy refinement, project-isolated bootstrap, and containment validation that treats receipt data as untrusted +- [x] 9.5 Implement: Commit lockfile, receipt, and adapter state transactionally and ensure frozen consistency gates complete before receipt-driven cleanup begins +- [x] 9.6 Implement: Materialize only primary assets and owned skill companions through tagged adapter requests carrying validated ownership sets from the lockfile and receipt, with per-file skip/repair behavior and rollback journal preimages +- [x] 9.7 Implement: Make drift and removal path-specific, preserve unowned files, and support offline multi-file cleanup from receipts without cache or network access +- [x] 9.8 Implement: Render lockfile, archive-version, per-file mismatch, adapter-bundle, and receipt failures exhaustively in CLI install output using one compatibility table for known format transitions +- [x] 9.9 Implement: Add engine and CLI tests for migration, frozen failures, receipt corruption/isolation, pulled-lockfile cleanup, per-file drift, integrity mismatch, rollback, archive-only withholding, and exact diagnostics +- [x] 9.10 Implement: Add a full-cycle end-to-end test that builds and verifies a facet with skill companions and archive-only files, installs it, detects and repairs single-file drift, exercises interrupted-install convergence on re-run without deleting unowned files, and removes it offline from the receipt, then exercises the same install path with an immutable legacy `0.1` archive +- [x] 9.11 Verify: Run focused install, materialization, receipt, lockfile, cache, registry, and CLI install tests ## 10. Current Producer and Build Pipeline — Research diff --git a/packages/cli/src/__tests__/install-view.test.tsx b/packages/cli/src/__tests__/install-view.test.tsx index e12cd1b3..f7485b68 100644 --- a/packages/cli/src/__tests__/install-view.test.tsx +++ b/packages/cli/src/__tests__/install-view.test.tsx @@ -519,6 +519,36 @@ describe('InstallView — integrity failure', () => { expect(frame).toContain('No assets were written') instance.unmount() }) + + // 9.8: pre-materialization per-file reconciliation failures render with the + // exact drifting path and both hashes. + test('renders a per-file reconcile failure with the exact path', async () => { + const failure: RunInstallFailure = { + code: 'RECONCILE_PER_FILE_INTEGRITY', + facet: 'viper-plans', + asset: 'skill:planning', + path: 'skills/planning/references/api.md', + expected: `sha256:${'a'.repeat(64)}`, + actual: `sha256:${'b'.repeat(64)}`, + } + const events: StageEvent[] = [ + { kind: 'install-start', totalFacets: 1 }, + { kind: 'facet-start', facet: 'viper-plans', specifier: 'github:a/v' }, + { kind: 'facet-failure', facet: 'viper-plans', failure }, + ] + const result: RunInstallResult = { + ok: false, + failure, + rollback: { kind: 'not-needed', reason: 'test fixture' }, + } + const instance = render(createElement(InstallView, { mode: 'add', run: makeFakeRun(events, result) })) + await settle() + const frame = findContentFrame(instance.frames) + expect(frame).toContain('file integrity mismatch') + expect(frame).toContain('skills/planning/references/api.md') + expect(frame).toContain(failure.code === 'RECONCILE_PER_FILE_INTEGRITY' ? failure.expected : '') + instance.unmount() + }) }) describe('InstallView — parse error failure', () => { diff --git a/packages/cli/src/tui/views/install/facet-row.tsx b/packages/cli/src/tui/views/install/facet-row.tsx index 1a5c84e7..7e0c2d3d 100644 --- a/packages/cli/src/tui/views/install/facet-row.tsx +++ b/packages/cli/src/tui/views/install/facet-row.tsx @@ -170,6 +170,14 @@ function oneLineFailureSummary(failure: RunInstallFailure): string { return 'failed to load facet.json' case 'ADAPTER_INSTALL_FAILED': return `adapter ${failure.adapter} failed during materialization` + case 'RECONCILE_FACET_INTEGRITY': + return 'lockfile integrity mismatch' + case 'RECONCILE_ASSET_IDENTITY': + return 'lockfile asset set mismatch' + case 'RECONCILE_OWNED_PATH_SET': + return `lockfile file set mismatch (${failure.asset})` + case 'RECONCILE_PER_FILE_INTEGRITY': + return `file integrity mismatch: ${failure.path}` default: return 'install failed' } diff --git a/packages/cli/src/tui/views/install/failure-block.tsx b/packages/cli/src/tui/views/install/failure-block.tsx index 591f7f82..343b7e9f 100644 --- a/packages/cli/src/tui/views/install/failure-block.tsx +++ b/packages/cli/src/tui/views/install/failure-block.tsx @@ -365,6 +365,65 @@ export function FailureBlock({ failure }: { failure: RunInstallFailure }): React ) + case 'RECONCILE_FACET_INTEGRITY': + return ( + + + ✕ lockfile integrity mismatch for {failure.facet} + + expected {failure.expected} + actual {failure.actual} + + {' '} + The lockfile disagrees with the resolved content. Delete facets.lock and re-run. + + + ) + case 'RECONCILE_ASSET_IDENTITY': + return ( + + + ✕ lockfile asset set does not match resolved content for {failure.facet} + + {failure.missing.length > 0 && ( + locked but not resolved: {failure.missing.join(', ')} + )} + {failure.unexpected.length > 0 && ( + resolved but not locked: {failure.unexpected.join(', ')} + )} + Delete facets.lock and re-run, or `facet add` to update it. + + ) + case 'RECONCILE_OWNED_PATH_SET': + return ( + + + ✕ lockfile file set does not match resolved content for {failure.facet} ({failure.asset}) + + {failure.missing.length > 0 && ( + locked but not resolved: {failure.missing.join(', ')} + )} + {failure.unexpected.length > 0 && ( + resolved but not locked: {failure.unexpected.join(', ')} + )} + Delete facets.lock and re-run, or `facet add` to update it. + + ) + case 'RECONCILE_PER_FILE_INTEGRITY': + return ( + + + ✕ file integrity mismatch: {failure.path} + + + {' '} + in {failure.facet} ({failure.asset}) + + expected {failure.expected} + actual {failure.actual} + Delete facets.lock and re-run, or `facet add` to update it. + + ) default: { // Exhaustiveness guard: any new `RunInstallFailure` variant must // get a `case` arm above. Without this, an un-rendered failure diff --git a/packages/cli/src/util/__tests__/archive-compatibility.test.ts b/packages/cli/src/util/__tests__/archive-compatibility.test.ts new file mode 100644 index 00000000..b6e6be6b --- /dev/null +++ b/packages/cli/src/util/__tests__/archive-compatibility.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test' +import { archiveCompatibilityGuidance } from '../archive-compatibility.ts' + +describe('archiveCompatibilityGuidance', () => { + test('names the minimum supporting release for a known newer format', () => { + const g = archiveCompatibilityGuidance('0.2', ['0.1']) + expect(g.what).toContain('archive format 0.2') + expect(g.detail).toContain('supported archive formats: 0.1') + // Known format → concrete minimum release, not a bare "update to latest". + expect(g.fix).toContain('0.2.0 or later') + }) + + test('advises updating to latest for an unknown future format without inventing a minimum', () => { + const g = archiveCompatibilityGuidance('0.9', ['0.1', '0.2']) + expect(g.what).toContain('archive format 0.9') + expect(g.fix).toContain('latest release') + // No fabricated minimum version for a format this CLI cannot know about. + expect(g.fix).not.toMatch(/\d+\.\d+\.\d+ or later/) + }) + + test('handles a missing/unparseable declared format', () => { + const g = archiveCompatibilityGuidance(undefined, ['0.1', '0.2']) + expect(g.what).toContain('does not recognize') + expect(g.detail).toContain('0.1, 0.2') + expect(g.fix).toContain('latest release') + }) +}) diff --git a/packages/cli/src/util/archive-compatibility.ts b/packages/cli/src/util/archive-compatibility.ts new file mode 100644 index 00000000..60439f45 --- /dev/null +++ b/packages/cli/src/util/archive-compatibility.ts @@ -0,0 +1,81 @@ +/** + * The single CLI-side compatibility table mapping a known archive format to + * the minimum `agent-facets` release that supports it (design D4, task 9.8). + * + * This is the ONE authoritative place the CLI turns an unsupported + * archive-format failure into actionable upgrade guidance. For a KNOWN newer + * format, it names the minimum supporting release ("update agent-facets to + * or later"). For an UNKNOWN future format, it advises updating to + * the latest release without inventing a minimum version — an already-shipped + * CLI cannot know the minimum release for a format defined after it was built. + * + * The table intentionally lives in the CLI, not the protocol: it maps a spec + * artifact version to a specific npm package release, which is a distribution + * fact this CLI owns, not a normative part of the protocol. + */ + +/** + * Known archive-format → minimum supporting `agent-facets` release. Keys are + * the exact numeric `facetVersion` values rendered as strings (the form the + * verifier reports as `observed`). Extend this map when a future format ships + * with a known minimum CLI release. + * + * `0.1` and `0.2` are the formats THIS CLI already supports, so they never + * reach the unsupported-format path; the table is for formats NEWER than what + * a given installed CLI understands. It is seeded with the `0.2` boundary so + * an older pre-`0.2` CLI (which lacks this table entirely) is the only build + * that shows a generic message — every `0.2`-aware release maps known newer + * formats precisely as they are added. + */ +const MINIMUM_RELEASE_FOR_FORMAT: Readonly> = { + // The first release that emits/consumes the `0.2` archive format. Present so + // the mapping mechanism is exercised and documented; a CLI that supports + // `0.2` will not itself render `0.2` as unsupported. + '0.2': '0.2.0', +} + +export interface ArchiveCompatibilityGuidance { + /** The one-line "what went wrong" summary. */ + what: string + /** Supporting detail (supported formats, and a minimum release when known). */ + detail: string + /** The actionable fix line. */ + fix: string +} + +/** + * Render actionable upgrade guidance for an unsupported archive format. + * + * @param observed the archive's declared `facetVersion` (string), or + * `undefined` when the archive did not declare a parseable + * version. + * @param supported the archive formats this CLI supports. + */ +export function archiveCompatibilityGuidance( + observed: string | undefined, + supported: readonly string[], +): ArchiveCompatibilityGuidance { + if (observed === undefined) { + return { + what: 'this facet uses an archive format this CLI does not recognize', + detail: `supported archive formats: ${supported.join(', ')}`, + fix: 'update agent-facets to the latest release with `facet self-update` and try again', + } + } + + const minimumRelease = MINIMUM_RELEASE_FOR_FORMAT[observed] + if (minimumRelease !== undefined) { + return { + what: `this facet uses archive format ${observed}, which this CLI does not support`, + detail: `supported archive formats: ${supported.join(', ')}`, + fix: `update agent-facets to ${minimumRelease} or later (e.g. \`facet self-update\`) and try again`, + } + } + + // Unknown future format: no minimum is invented. + return { + what: `this facet uses archive format ${observed}, which this CLI does not support`, + detail: `supported archive formats: ${supported.join(', ')}`, + fix: 'update agent-facets to the latest release with `facet self-update` and try again', + } +} diff --git a/packages/cli/src/util/registry-errors.ts b/packages/cli/src/util/registry-errors.ts index 019de22f..d5bed2a2 100644 --- a/packages/cli/src/util/registry-errors.ts +++ b/packages/cli/src/util/registry-errors.ts @@ -1,4 +1,5 @@ import type { RegistryError } from '@agent-facets/engine' +import { archiveCompatibilityGuidance } from './archive-compatibility.ts' import type { CliError } from './errors.ts' /** @@ -56,16 +57,12 @@ export function translateEngineRegistryError(err: RegistryError): CliError { fix: 'try again; if persistent, file a bug', } case 'UNSUPPORTED_ARCHIVE': - // Basic upgrade guidance for now; the full facet-format → - // minimum-CLI-release compatibility table lands with the install - // failure-rendering work. - return { - what: - err.observed === undefined - ? 'this facet uses an archive format this CLI does not recognize' - : `this facet uses archive format ${err.observed}, which this CLI does not support`, - detail: `supported archive formats: ${err.supported.join(', ')}`, - fix: 'update agent-facets with `facet self-update` and try again', - } + // The single compatibility table names the minimum supporting release + // for a known newer format, or advises updating to latest for an + // unknown future one (design D4, task 9.8). + return archiveCompatibilityGuidance( + err.observed === undefined ? undefined : String(err.observed), + err.supported.map(String), + ) } } diff --git a/packages/engine/src/__tests__/run-install.test.ts b/packages/engine/src/__tests__/run-install.test.ts index cc713bbe..d02c156d 100644 --- a/packages/engine/src/__tests__/run-install.test.ts +++ b/packages/engine/src/__tests__/run-install.test.ts @@ -3,7 +3,15 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Adapter } from '@agent-facets/adapter' -import { ADAPTER_API_VERSION, deleteAssetFile, installAssetFile, readAssetFile } from '@agent-facets/adapter' +import { + ADAPTER_API_VERSION, + deleteAssetFile, + deleteSkillBundle, + installAssetFile, + installSkillBundle, + readAssetFile, + readSkillBundle, +} from '@agent-facets/adapter' import type { BuildManifest, Lockfile } from '@agent-facets/protocol' import { CURRENT_LOCKFILE_VERSION, CurrentLockfileSchema, computeContentHash } from '@agent-facets/protocol' import { type } from 'arktype' @@ -328,6 +336,43 @@ describe('runInstall — local source success path', () => { { path: 'skills/planning/SKILL.md', integrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) }, ]) }) + + // 9.3: pre-materialization reconciliation. A locked per-file hash that no + // longer matches the freshly-derived plan aborts before any adapter write + // with a path-specific failure. + test('a tampered locked per-file hash aborts with RECONCILE_PER_FILE_INTEGRITY', async () => { + const local = buildLocalFixture('viper-plans') + const relPath = `./${local.split('/').pop()}` + writeFileSync(join(projectRoot, 'facets.json'), JSON.stringify({ facets: { 'viper-plans': relPath } })) + + // First install writes a valid 0.2 lockfile. + const first = await runInstall({ projectRoot, adapters: [buildFakeAdapter('test')] }) + if (!first.ok) expect.unreachable() + + // Tamper the locked per-file integrity for the skill's SKILL.md so the + // freshly-derived plan disagrees on re-install. + const lockPath = join(projectRoot, 'facets.lock') + const lock = JSON.parse(readFileSync(lockPath, 'utf8')) + const wrong = `sha256:${'1'.repeat(64)}` + lock.facets['viper-plans'].assets[0].files[0].integrity = wrong + writeFileSync(lockPath, JSON.stringify(lock)) + + const second = await runInstall({ projectRoot, adapters: [buildFakeAdapter('test')] }) + if (second.ok) expect.unreachable() + if (second.failure.code !== 'RECONCILE_PER_FILE_INTEGRITY') expect.unreachable() + expect(second.failure.facet).toBe('viper-plans') + expect(second.failure.asset).toBe('skill:planning') + expect(second.failure.path).toBe('skills/planning/SKILL.md') + expect(second.failure.expected).toBe(wrong) + expect(second.failure.actual).toMatch(/^sha256:[a-f0-9]{64}$/) + // Reconciliation runs before materialize, so nothing was written for + // this facet: rollback replays an empty journal (zero entries undone). + if (second.rollback.kind === 'not-needed') expect.unreachable() + expect(second.rollback.entriesUndone).toBe(0) + // The tampered lockfile is left unchanged on disk (no tri-write ran). + const after = JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8')) + expect(after.facets['viper-plans'].assets[0].files[0].integrity).toBe(wrong) + }) }) describe('runInstall — registry source surfaces REGISTRY_ERROR on resolution failure', () => { @@ -974,3 +1019,263 @@ describe('runInstall — git cache hit short-circuits clone', () => { expect(result.failure.lockedIntegrity).toBe(wrongIntegrity) }) }) + +// 9.6: real skill-bundle materialization — companions install, skip when +// identical, and repair per-file drift. Uses the SDK bundle helpers so the +// companion round-trip goes through the same code every real adapter uses. +describe('runInstall — multi-file skill materialization', () => { + function buildBundleFixture(name: string, version = '0.1.0'): string { + const repo = realpathSync(mkdtempSync(join(projectRoot, 'bundle-fixture-'))) + writeFileSync( + join(repo, 'facet.json'), + JSON.stringify({ + name, + version, + skills: { planning: { description: 'planning skill', files: ['references/api.md', 'assets/logo.bin'] } }, + }), + ) + mkdirSync(join(repo, 'skills/planning/references'), { recursive: true }) + mkdirSync(join(repo, 'skills/planning/assets'), { recursive: true }) + writeFileSync(join(repo, 'skills/planning/SKILL.md'), `# planning ${version}\n`) + writeFileSync(join(repo, 'skills/planning/references/api.md'), '# api reference\n') + // A binary companion — must survive byte-for-byte. + writeFileSync(join(repo, 'skills/planning/assets/logo.bin'), Buffer.from([0, 1, 2, 253, 254, 255])) + return repo + } + + /** Fake adapter that stores skills as bundles via the SDK helpers. */ + function buildBundleAdapter(name: string): Adapter { + const baseDir = join(projectRoot, `.${name}`) + const skillPaths = (n: string) => ({ + root: join(baseDir, 'skills', n), + primaryFile: join(baseDir, 'skills', n, 'SKILL.md'), + pruneBoundary: baseDir, + }) + const flatPath = (type: string, n: string) => ({ file: join(baseDir, `${type}s`, `${n}.md`) }) + return { + name, + apiVersion: ADAPTER_API_VERSION, + supportsInstall: true, + buildAssetMetadata: (data) => ({ ok: true, data: (data ?? {}) as Record }), + async installAsset(request) { + if (request.assetType === 'skill') { + return installSkillBundle(skillPaths(request.name), { + content: request.content, + metadata: request.metadata as Record | undefined, + companions: request.companions, + ownedCompanionPaths: request.ownedCompanionPaths, + }) + } + const p = flatPath(request.assetType, request.name) + await installAssetFile(p, request.content, request.metadata as Record | undefined) + return { ok: true, primaryPath: p.file } + }, + async readAsset(request) { + if (request.assetType === 'skill') { + return readSkillBundle(skillPaths(request.name), request.ownedCompanionPaths) + } + try { + const { content, metadata } = await readAssetFile(flatPath(request.assetType, request.name)) + return { ok: true, asset: { assetType: request.assetType, content, metadata } } + } catch { + return { ok: false, failure: { code: 'not-found' } } + } + }, + async deleteAsset(request) { + if (request.assetType === 'skill') { + return deleteSkillBundle(skillPaths(request.name), request.ownedCompanionPaths) + } + const p = flatPath(request.assetType, request.name) + await deleteAssetFile(p) + return { ok: true, existed: true, deletedPaths: [p.file] } + }, + } + } + + test('installs a skill with companions and records their owned paths', async () => { + const local = buildBundleFixture('viper-plans') + writeFileSync( + join(projectRoot, 'facets.json'), + JSON.stringify({ facets: { 'viper-plans': `./${local.split('/').pop()}` } }), + ) + + const result = await runInstall({ projectRoot, adapters: [buildBundleAdapter('bundle')] }) + if (!result.ok) expect.unreachable() + + // Both companions materialized, binary preserved byte-for-byte. + const companionDir = join(projectRoot, '.bundle/skills/planning') + expect(readFileSync(join(companionDir, 'references/api.md'), 'utf8')).toBe('# api reference\n') + expect([...readFileSync(join(companionDir, 'assets/logo.bin'))]).toEqual([0, 1, 2, 253, 254, 255]) + + // The lockfile records all three owned files with recomputed hashes. + const written = CurrentLockfileSchema(JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8'))) + if (written instanceof type.errors) expect.unreachable() + const asset = written.facets['viper-plans']?.assets[0] + expect(asset?.files.map((f) => f.path)).toEqual([ + 'skills/planning/SKILL.md', + 'skills/planning/assets/logo.bin', + 'skills/planning/references/api.md', + ]) + }) + + test('reinstall skips an unchanged bundle, then repairs a single drifted companion', async () => { + const local = buildBundleFixture('viper-plans') + writeFileSync( + join(projectRoot, 'facets.json'), + JSON.stringify({ facets: { 'viper-plans': `./${local.split('/').pop()}` } }), + ) + const adapters = [buildBundleAdapter('bundle')] + + const first = await runInstall({ projectRoot, adapters }) + if (!first.ok) expect.unreachable() + expect(first.summary.totalAssets).toBe(1) + + // Second install with no changes: the whole bundle is identical → skipped. + const second = await runInstall({ projectRoot, adapters }) + if (!second.ok) expect.unreachable() + expect(second.summary.totalAssets).toBe(0) + + // Drift a single companion on disk, then reinstall: the bundle is + // repaired (one write) and the drifted file is restored from source. The + // verbose log names the exact drifted path (path-specific drift, 9.7). + const apiPath = join(projectRoot, '.bundle/skills/planning/references/api.md') + writeFileSync(apiPath, '# TAMPERED\n') + const logs: string[] = [] + const third = await runInstall({ projectRoot, adapters, onLog: (b) => logs.push(b()) }) + if (!third.ok) expect.unreachable() + expect(third.summary.totalAssets).toBe(1) + expect(readFileSync(apiPath, 'utf8')).toBe('# api reference\n') + expect(logs.some((l) => l.includes('drift: skills/planning/references/api.md'))).toBe(true) + }) + + test('an unowned file in the skill directory survives an update', async () => { + const local = buildBundleFixture('viper-plans') + writeFileSync( + join(projectRoot, 'facets.json'), + JSON.stringify({ facets: { 'viper-plans': `./${local.split('/').pop()}` } }), + ) + const adapters = [buildBundleAdapter('bundle')] + + const first = await runInstall({ projectRoot, adapters }) + if (!first.ok) expect.unreachable() + + // A user drops an unowned note into the skill dir. + const notePath = join(projectRoot, '.bundle/skills/planning/notes.txt') + writeFileSync(notePath, 'my notes\n') + + // Change the primary so a reinstall re-writes the bundle. + writeFileSync(join(local, 'skills/planning/SKILL.md'), '# planning edited\n') + const second = await runInstall({ projectRoot, adapters }) + if (!second.ok) expect.unreachable() + + // The unowned note is untouched by the owned-set replacement. + expect(readFileSync(notePath, 'utf8')).toBe('my notes\n') + }) + + // 9.7: offline multi-file cleanup from the receipt. After install, removing + // the facet (empty manifest) deletes the primary AND every owned companion + // using only the receipt's recorded owned paths — no cache, no network — + // while preserving unowned files. + test('removal deletes owned companions offline from the receipt, preserving unowned files', async () => { + const local = buildBundleFixture('viper-plans') + writeFileSync( + join(projectRoot, 'facets.json'), + JSON.stringify({ facets: { 'viper-plans': `./${local.split('/').pop()}` } }), + ) + const adapters = [buildBundleAdapter('bundle')] + + const first = await runInstall({ projectRoot, adapters }) + if (!first.ok) expect.unreachable() + + const skillDir = join(projectRoot, '.bundle/skills/planning') + const apiPath = join(skillDir, 'references/api.md') + const logoPath = join(skillDir, 'assets/logo.bin') + const notePath = join(skillDir, 'notes.txt') + expect(existsSync(apiPath)).toBe(true) + expect(existsSync(logoPath)).toBe(true) + // A user file that the receipt does not own. + writeFileSync(notePath, 'keep me\n') + + // Delete the source fixture so nothing can be re-derived from it, and + // empty the manifest to trigger receipt-driven removal. + rmSync(local, { recursive: true, force: true }) + writeFileSync(join(projectRoot, 'facets.json'), JSON.stringify({ facets: {} })) + + const removed = await runInstall({ projectRoot, adapters }) + if (!removed.ok) expect.unreachable() + + // Primary + both owned companions removed; the unowned note survives. + expect(existsSync(join(skillDir, 'SKILL.md'))).toBe(false) + expect(existsSync(apiPath)).toBe(false) + expect(existsSync(logoPath)).toBe(false) + expect(readFileSync(notePath, 'utf8')).toBe('keep me\n') + }) + + // 9.9: archive-only supplementary files (e.g. a root README.md) ship in the + // verified archive and are pinned by facet integrity, but are NEVER + // materialized to an adapter and NEVER recorded as a lockfile asset. + test('an archive-only README is verified but never materialized or locked', async () => { + const repo = realpathSync(mkdtempSync(join(projectRoot, 'archiveonly-fixture-'))) + writeFileSync( + join(repo, 'facet.json'), + JSON.stringify({ + name: 'viper-plans', + version: '0.1.0', + skills: { planning: { description: 'planning skill' } }, + files: ['README.md'], + }), + ) + mkdirSync(join(repo, 'skills/planning'), { recursive: true }) + writeFileSync(join(repo, 'skills/planning/SKILL.md'), '# planning\n') + writeFileSync(join(repo, 'README.md'), '# my facet\n') + writeFileSync( + join(projectRoot, 'facets.json'), + JSON.stringify({ facets: { 'viper-plans': `./${repo.split('/').pop()}` } }), + ) + + const result = await runInstall({ projectRoot, adapters: [buildBundleAdapter('bundle')] }) + if (!result.ok) expect.unreachable() + + // README is NOT written into the adapter tree anywhere. + expect(existsSync(join(projectRoot, '.bundle/README.md'))).toBe(false) + expect(existsSync(join(projectRoot, '.bundle/skills/planning/README.md'))).toBe(false) + + // No lockfile asset lists README.md among its files. + const written = CurrentLockfileSchema(JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8'))) + if (written instanceof type.errors) expect.unreachable() + for (const asset of written.facets['viper-plans']?.assets ?? []) { + expect(asset.files.some((f) => f.path === 'README.md')).toBe(false) + } + }) + + // 9.10: interrupted-install convergence. A crash can leave a partial bundle + // (primary present, a companion missing). Re-running install compares the + // on-disk bundle to the source and repairs it, converging without deleting + // any unowned file. + test('re-running install converges a partially-materialized bundle without deleting unowned files', async () => { + const local = buildBundleFixture('viper-plans') + writeFileSync( + join(projectRoot, 'facets.json'), + JSON.stringify({ facets: { 'viper-plans': `./${local.split('/').pop()}` } }), + ) + const adapters = [buildBundleAdapter('bundle')] + + const first = await runInstall({ projectRoot, adapters }) + if (!first.ok) expect.unreachable() + + const skillDir = join(projectRoot, '.bundle/skills/planning') + // Simulate an interrupted install: a companion is missing on disk, and an + // unowned user file is present. + rmSync(join(skillDir, 'references/api.md'), { force: true }) + const notePath = join(skillDir, 'notes.txt') + writeFileSync(notePath, 'keep me\n') + + // Re-run converges: the missing companion is restored, the unowned file + // survives, and the install reports the bundle as repaired (one write). + const second = await runInstall({ projectRoot, adapters }) + if (!second.ok) expect.unreachable() + expect(second.summary.totalAssets).toBe(1) + expect(readFileSync(join(skillDir, 'references/api.md'), 'utf8')).toBe('# api reference\n') + expect(readFileSync(notePath, 'utf8')).toBe('keep me\n') + }) +}) diff --git a/packages/engine/src/install/__tests__/receipt.test.ts b/packages/engine/src/install/__tests__/receipt.test.ts index 08fc44f0..4faf13e2 100644 --- a/packages/engine/src/install/__tests__/receipt.test.ts +++ b/packages/engine/src/install/__tests__/receipt.test.ts @@ -3,8 +3,30 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Lockfile } from '@agent-facets/protocol' -import { LOCKFILE_VERSION } from '@agent-facets/protocol' -import { bootstrapReceipt, loadReceipt, type Receipt, receiptPath, writeReceipt } from '../receipt.ts' +import { CURRENT_LOCKFILE_VERSION, LEGACY_LOCKFILE_VERSION } from '@agent-facets/protocol' +import { + bootstrapReceipt, + CURRENT_RECEIPT_VERSION, + loadReceipt, + type Receipt, + receiptPath, + writeReceipt, +} from '../receipt.ts' + +/** A current (0.2) receipt skill asset owning exactly its SKILL.md. */ +function skillAsset(name: string): { scope: 'project'; type: 'skill'; name: string; files: string[] } { + return { scope: 'project', type: 'skill', name, files: [`skills/${name}/SKILL.md`] } +} + +/** A current (0.2) receipt agent asset owning exactly its primary file. */ +function agentAsset(name: string): { scope: 'project'; type: 'agent'; name: string; files: string[] } { + return { scope: 'project', type: 'agent', name, files: [`agents/${name}.md`] } +} + +/** A current (0.2) receipt command asset owning exactly its primary file. */ +function commandAsset(name: string): { scope: 'project'; type: 'command'; name: string; files: string[] } { + return { scope: 'project', type: 'command', name, files: [`commands/${name}.md`] } +} let facetDir: string let projectDir: string @@ -86,7 +108,7 @@ describe('loadReceipt', () => { test('returns path-mismatch when embedded path differs', () => { const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: '/some/other/project', facets: {}, } @@ -105,19 +127,16 @@ describe('loadReceipt', () => { // reported — never acted on — while everything else loads normally // (W2: per-entry extraction, not whole-receipt rejection). const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: realpathSync(projectDir), facets: { cowsay: { version: '0.0.1', - assets: [ - { scope: 'project', type: 'skill', name: '../escape' }, - { scope: 'project', type: 'skill', name: 'cowsay' }, - ], + assets: [{ ...skillAsset('escape'), name: '../escape' }, skillAsset('cowsay')], }, hello: { version: '1.0.0', - assets: [{ scope: 'project', type: 'agent', name: 'greeter' }], + assets: [agentAsset('greeter')], }, }, } @@ -132,18 +151,72 @@ describe('loadReceipt', () => { expect(result.invalidEntries[0]?.asset).toBe('../escape') expect(result.invalidEntries[0]?.reason.length).toBeGreaterThan(0) // The valid sibling asset and the untouched facet still load. - expect(result.receipt.facets.cowsay?.assets).toEqual([{ scope: 'project', type: 'skill', name: 'cowsay' }]) + expect(result.receipt.facets.cowsay?.assets).toEqual([skillAsset('cowsay')]) expect(result.receipt.facets.hello?.assets).toHaveLength(1) }) + test('drops an asset whose owned file path escapes, reporting it', () => { + // Untrusted-input containment: a crafted owned PATH (not name) must drop + // the whole asset record — never delete an escaping path — while valid + // siblings still load. + const receipt: Receipt = { + version: CURRENT_RECEIPT_VERSION, + path: realpathSync(projectDir), + facets: { + cowsay: { + version: '0.0.1', + assets: [ + { scope: 'project', type: 'skill', name: 'cowsay', files: ['skills/cowsay/../../escape.md'] }, + skillAsset('safe'), + ], + }, + }, + } + const path = receiptPath(projectDir) + mkdirSync(join(facetDir, 'receipts'), { recursive: true }) + writeFileSync(path, JSON.stringify(receipt)) + const result = loadReceipt(projectDir) + if (!result.ok) expect.unreachable() + expect(result.invalidEntries).toHaveLength(1) + expect(result.invalidEntries[0]?.asset).toBe('cowsay') + expect(result.invalidEntries[0]?.reason).toContain('owned path') + expect(result.receipt.facets.cowsay?.assets).toEqual([skillAsset('safe')]) + }) + + test('refines a legacy (1) receipt to primary-only ownership', () => { + // Legacy installs could not materialize companions, so a legacy + // identity-only receipt is refined to the single conventional primary + // path per asset and loads as current. + const legacy = { + version: LEGACY_LOCKFILE_VERSION, + path: realpathSync(projectDir), + facets: { + cowsay: { + version: '0.0.1', + assets: [ + { scope: 'project', type: 'skill', name: 'cowsay' }, + { scope: 'project', type: 'command', name: 'moo' }, + ], + }, + }, + } + const path = receiptPath(projectDir) + mkdirSync(join(facetDir, 'receipts'), { recursive: true }) + writeFileSync(path, JSON.stringify(legacy)) + const result = loadReceipt(projectDir) + if (!result.ok) expect.unreachable() + expect(result.receipt.version).toBe(CURRENT_RECEIPT_VERSION) + expect(result.receipt.facets.cowsay?.assets).toEqual([skillAsset('cowsay'), commandAsset('moo')]) + }) + test('a fully valid receipt reports no invalid entries', () => { const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: realpathSync(projectDir), facets: { cowsay: { version: '0.0.1', - assets: [{ scope: 'project', type: 'skill', name: 'cowsay' }], + assets: [skillAsset('cowsay')], }, }, } @@ -155,12 +228,12 @@ describe('loadReceipt', () => { test('loads a valid receipt successfully', () => { const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: realpathSync(projectDir), facets: { cowsay: { version: '0.0.1', - assets: [{ scope: 'project', type: 'skill', name: 'cowsay' }], + assets: [skillAsset('cowsay')], }, }, } @@ -188,7 +261,7 @@ describe('loadReceipt', () => { describe('writeReceipt', () => { test('creates the receipts directory if it does not exist', () => { const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: realpathSync(projectDir), facets: {}, } @@ -200,19 +273,16 @@ describe('writeReceipt', () => { test('round-trips a receipt with multiple facets', () => { const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: realpathSync(projectDir), facets: { cowsay: { version: '0.0.1', - assets: [ - { scope: 'project', type: 'skill', name: 'cowsay' }, - { scope: 'project', type: 'command', name: 'moo' }, - ], + assets: [skillAsset('cowsay'), commandAsset('moo')], }, hello: { version: '1.0.0', - assets: [{ scope: 'project', type: 'agent', name: 'greeter' }], + assets: [agentAsset('greeter')], }, }, } @@ -228,12 +298,12 @@ describe('writeReceipt', () => { test('normalizes receipt.path so a stale path does not cause path-mismatch (#20)', () => { const canonical = realpathSync(projectDir) const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: '/some/other/path', // intentionally wrong facets: { cowsay: { version: '0.0.1', - assets: [{ scope: 'project', type: 'skill', name: 'cowsay' }], + assets: [skillAsset('cowsay')], }, }, } @@ -250,9 +320,9 @@ describe('writeReceipt', () => { // --------------------------------------------------------------------------- describe('bootstrapReceipt', () => { - test('creates a receipt from a lockfile', () => { + test('creates a current (0.2) receipt from a legacy lockfile, primary-only', () => { const lockfile: Lockfile = { - lockfileVersion: LOCKFILE_VERSION, + lockfileVersion: LEGACY_LOCKFILE_VERSION, facets: { cowsay: { source: { kind: 'registry', registry: 'https://api.agentfacets.io' }, @@ -266,15 +336,46 @@ describe('bootstrapReceipt', () => { }, } const receipt = bootstrapReceipt(projectDir, lockfile) - expect(receipt.version).toBe(1) + expect(receipt.version).toBe(CURRENT_RECEIPT_VERSION) expect(receipt.path).toBe(realpathSync(projectDir)) expect(receipt.facets.cowsay?.version).toBe('0.0.1') - expect(receipt.facets.cowsay?.assets).toHaveLength(2) + // Legacy identity-only assets seed their single conventional primary path. + expect(receipt.facets.cowsay?.assets).toEqual([skillAsset('cowsay'), commandAsset('moo')]) + }) + + test('mirrors owned companion paths from a 0.2 lockfile', () => { + const lockfile: Lockfile = { + lockfileVersion: CURRENT_LOCKFILE_VERSION, + facets: { + cowsay: { + source: { kind: 'registry', registry: 'https://api.agentfacets.io' }, + version: '0.0.1', + integrity: 'sha256:abc', + assets: [ + { + scope: 'project', + type: 'skill', + name: 'cowsay', + files: [ + { path: 'skills/cowsay/SKILL.md', integrity: `sha256:${'0'.repeat(64)}` }, + { path: 'skills/cowsay/references/art.md', integrity: `sha256:${'1'.repeat(64)}` }, + ], + }, + ] as unknown as Lockfile['facets'][string]['assets'], + }, + }, + } + const receipt = bootstrapReceipt(projectDir, lockfile) + // Receipt mirrors the owned PATHS (no hashes). + expect(receipt.facets.cowsay?.assets[0]?.files).toEqual([ + 'skills/cowsay/SKILL.md', + 'skills/cowsay/references/art.md', + ]) }) test('empty lockfile produces empty receipt', () => { const lockfile: Lockfile = { - lockfileVersion: LOCKFILE_VERSION, + lockfileVersion: CURRENT_LOCKFILE_VERSION, facets: {}, } const receipt = bootstrapReceipt(projectDir, lockfile) @@ -283,7 +384,7 @@ describe('bootstrapReceipt', () => { test('strips source and integrity from lockfile entries', () => { const lockfile: Lockfile = { - lockfileVersion: LOCKFILE_VERSION, + lockfileVersion: LEGACY_LOCKFILE_VERSION, facets: { cowsay: { source: { kind: 'git', url: 'https://github.com/test/cowsay', commit: 'abc123' }, diff --git a/packages/engine/src/install/__tests__/run-install.receipt.test.ts b/packages/engine/src/install/__tests__/run-install.receipt.test.ts index 7bc32833..b61e46f5 100644 --- a/packages/engine/src/install/__tests__/run-install.receipt.test.ts +++ b/packages/engine/src/install/__tests__/run-install.receipt.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { ADAPTER_API_VERSION } from '@agent-facets/adapter/api-version' import type { BuildManifest } from '@agent-facets/protocol' -import type { Receipt } from '../receipt.ts' +import { CURRENT_RECEIPT_VERSION, type Receipt } from '../receipt.ts' import type { Addition, StageEvent } from '../types.ts' /** @@ -212,7 +212,7 @@ describe('runInstall — frozen orphan-on-pull cleanup', () => { const receipt = JSON.parse(readFileSync(receiptFile, 'utf8')) as Receipt receipt.facets.ghost = { version: '1.0.0', - assets: [{ scope: 'project', type: 'skill', name: 'ghostly' }], + assets: [{ scope: 'project', type: 'skill', name: 'ghostly', files: ['skills/ghostly/SKILL.md'] }], } writeReceipt(projectRoot, receipt) mkdirSync(join(projectRoot, '.test-adapter/skills'), { recursive: true }) @@ -236,6 +236,35 @@ describe('runInstall — frozen orphan-on-pull cleanup', () => { expect(readFileSync(join(projectRoot, 'facets.json'), 'utf8')).toBe(facetsBefore) expect(readFileSync(join(projectRoot, 'facets.lock'), 'utf8')).toBe(lockBefore) }) + + // 9.5: the frozen consistency gate completes before receipt-driven cleanup. + // A lockfile ORPHAN (pinned in the lockfile, absent from facets.json) is + // surfaced as drift and rejected BEFORE any materialized asset is deleted. + test('a frozen lockfile orphan fails before cleanup deletes anything', async () => { + await seedInstalledProject() // cowsay in all three files, asset on disk + + // Simulate an orphan: drop cowsay from facets.json only. The lockfile + // still pins it — a frozen install must reject this as LOCKFILE_DRIFT + // (reason 'orphaned') before the drift-removal loop runs. + writeFileSync(join(projectRoot, 'facets.json'), `${JSON.stringify({ facets: {} }, null, 2)}\n`) + const assetPath = join(projectRoot, '.test-adapter/skills/planning.md') + expect(existsSync(assetPath)).toBe(true) + const lockBefore = readFileSync(join(projectRoot, 'facets.lock'), 'utf8') + const receiptFile = receiptPath(projectRoot) + const receiptBefore = readFileSync(receiptFile, 'utf8') + + metadataOffline = true + const result = await install({ frozen: true }) + + if (result.ok) expect.unreachable() + if (result.failure.code !== 'LOCKFILE_DRIFT') expect.unreachable() + expect(result.failure.facets.some((f) => f.name === 'cowsay' && f.reason === 'orphaned')).toBe(true) + // No cleanup ran: the materialized asset and both files are untouched. + expect(existsSync(assetPath)).toBe(true) + expect(readFileSync(join(projectRoot, 'facets.lock'), 'utf8')).toBe(lockBefore) + expect(readFileSync(receiptFile, 'utf8')).toBe(receiptBefore) + expect(result.rollback.kind).toBe('not-needed') + }) }) // --- W2: per-entry escape handling ---------------------------------------------- @@ -246,14 +275,14 @@ describe('runInstall — receipt escape entries (W2)', () => { // crafted escape asset alongside a valid one. The escape is reported // and skipped; the valid asset is still cleaned up. const receipt: Receipt = { - version: 1, + version: CURRENT_RECEIPT_VERSION, path: realpathSync(projectRoot), facets: { ghost: { version: '1.0.0', assets: [ - { scope: 'project', type: 'skill', name: '../escape' }, - { scope: 'project', type: 'skill', name: 'ghostly' }, + { scope: 'project', type: 'skill', name: '../escape', files: ['skills/escape/SKILL.md'] }, + { scope: 'project', type: 'skill', name: 'ghostly', files: ['skills/ghostly/SKILL.md'] }, ], }, }, diff --git a/packages/engine/src/install/commit/install-loop.ts b/packages/engine/src/install/commit/install-loop.ts index 3a6d2e97..b13cf929 100644 --- a/packages/engine/src/install/commit/install-loop.ts +++ b/packages/engine/src/install/commit/install-loop.ts @@ -5,6 +5,7 @@ import type { InstallJournal } from '../journal.ts' import { materialize } from '../materialize.ts' import { materializeFailureToRunInstall } from '../materialize-failure.ts' import type { FacetOutcome, OnLog, RunInstallFailure, StageEvent } from '../types.ts' +import { reconcileLockedAgainstPlan } from './reconcile.ts' import { resolveFacet } from './resolve-facet.ts' export interface InstallLoopSuccess { @@ -69,7 +70,7 @@ export async function installFacets(args: InstallLoopArgs): Promise 0) { serverWarnings.push({ facet: facetName, servers: serversDeclared }) @@ -80,6 +81,20 @@ export async function installFacets(args: InstallLoopArgs): Promise filesOf(a) !== undefined) + if (!anyLockedFiles) return undefined + + // Asset-identity agreement: the locked identity set vs the plan's. + const lockedIdentities = new Set(previous.assets.map(identityKey)) + const plannedIdentities = new Set(plan.assets.map(identityKey)) + const missing = [...lockedIdentities].filter((k) => !plannedIdentities.has(k)).sort() + const unexpected = [...plannedIdentities].filter((k) => !lockedIdentities.has(k)).sort() + if (missing.length > 0 || unexpected.length > 0) { + return { code: 'RECONCILE_ASSET_IDENTITY', facet, missing, unexpected } + } + + // Per-asset owned-path sets and per-file integrity. Only a `0.2` locked + // asset carries `files`; a legacy identity-only asset has none, so per-file + // reconciliation is skipped for it (migration). + const plannedByIdentity = new Map(plan.assets.map((a) => [identityKey(a), a])) + for (const lockedAsset of previous.assets) { + const lockedFiles = filesOf(lockedAsset) + if (lockedFiles === undefined) continue // legacy identity-only entry + + const planned = plannedByIdentity.get(identityKey(lockedAsset)) + // Identity agreement above guarantees a planned counterpart exists. + if (planned === undefined) continue + + const assetLabel = `${lockedAsset.type}:${lockedAsset.name}` + const plannedByPath = new Map(planned.files.map((f) => [f.path, f.integrity])) + const lockedPaths = new Set(lockedFiles.map((f) => f.path)) + const plannedPaths = new Set(planned.files.map((f) => f.path)) + + const missingPaths = [...lockedPaths].filter((p) => !plannedPaths.has(p)).sort() + const unexpectedPaths = [...plannedPaths].filter((p) => !lockedPaths.has(p)).sort() + if (missingPaths.length > 0 || unexpectedPaths.length > 0) { + return { + code: 'RECONCILE_OWNED_PATH_SET', + facet, + asset: assetLabel, + missing: missingPaths, + unexpected: unexpectedPaths, + } + } + + for (const record of lockedFiles) { + const recomputed = plannedByPath.get(record.path) + // Path-set agreement above guarantees a recomputed hash exists. + if (recomputed === undefined) continue + if (record.integrity !== recomputed) { + return { + code: 'RECONCILE_PER_FILE_INTEGRITY', + facet, + asset: assetLabel, + path: record.path, + expected: record.integrity, + actual: recomputed, + } + } + } + } + + return undefined +} + +function identityKey(asset: { scope: string; type: string; name: string }): string { + return `${asset.scope}:${asset.type}:${asset.name}` +} + +/** + * Extract the per-file records from a locked asset. A `0.2` asset carries a + * `files` array; a legacy (`1`) identity-only asset does not. The permissive + * `LockfileAssetEntry` type does not statically expose `files`, so this reads + * it structurally and narrows the shape at the boundary. + */ +function filesOf(asset: unknown): ReadonlyArray<{ path: string; integrity: string }> | undefined { + if (typeof asset !== 'object' || asset === null || !('files' in asset)) return undefined + const files = (asset as { files: unknown }).files + if (!Array.isArray(files)) return undefined + return files as ReadonlyArray<{ path: string; integrity: string }> +} diff --git a/packages/engine/src/install/commit/resolve-git.ts b/packages/engine/src/install/commit/resolve-git.ts index c0ac368f..3aa6650a 100644 --- a/packages/engine/src/install/commit/resolve-git.ts +++ b/packages/engine/src/install/commit/resolve-git.ts @@ -9,7 +9,12 @@ import type { Source } from '../../sources/facet/types.ts' import { cloneFailureToRunInstall } from '../clone-failure.ts' import { resolveCloneRef } from '../resolve-clone-ref.ts' import type { OnLog, StageEvent } from '../types.ts' -import { buildVerifiedAssetPlan } from '../verified-asset-plan.ts' +import { + buildVerifiedAssetPlan, + readSkillCompanionBytes, + type SkillCompanionBytes, + type VerifiedAssetPlan, +} from '../verified-asset-plan.ts' import { buildLockfileSource, loadFacetContent } from './finalize-facet.ts' import { auditedGitCacheLookup } from './git-cache.ts' import type { ResolveFacetResult } from './types.ts' @@ -97,10 +102,13 @@ export async function resolveGitFacet(args: ResolveGitFacetArgs): Promise | undefined if (auditedCacheHit && effectiveLocked !== undefined) { if (frozenLockfile) { // Frozen audited hit matching the lockfile: inherit verbatim — a - // legacy `1` entry stays legacy and is never rewritten. + // legacy `1` entry stays legacy and is never rewritten. No plan is + // derived, so reconciliation is skipped for this path. entry = { source: effectiveLocked.source, version: effectiveLocked.version, @@ -111,15 +119,17 @@ export async function resolveGitFacet(args: ResolveGitFacetArgs): Promise | undefined if (frozenLockfile && effectiveLocked !== undefined) { // Frozen reproduction guard. The verifier labels the failure // `lockfile` (built-vs-lockfile divergence) — reporting `git` here @@ -92,17 +99,22 @@ export async function resolveLocalFacet(args: ResolveLocalFacetArgs): Promise | undefined if (effectiveLocked !== undefined && frozenLockfile) { // Frozen reproduction: inherit the entry verbatim. The chain just // proved the content reproduces the locked integrity, and frozen mode - // never rewrites the lockfile — a legacy `1` entry stays legacy. + // never rewrites the lockfile — a legacy `1` entry stays legacy. No + // plan is derived, so reconciliation is skipped for this path. entry = { source: effectiveLocked.source, version: effectiveLocked.version, @@ -210,25 +218,30 @@ export async function resolveRegistryFacet(args: ResolveRegistryFacetArgs): Prom // `0.2`. Identity (source/version/integrity) comes from the locked entry // when reproducing, or from the resolved version + chain integrity when // confirming. - const plan = buildVerifiedAssetPlan(content.manifest, result.slotPath) - if (!plan.ok) { - return { ok: false, failure: { code: 'BUILD_FAILED', facet: facetName, errors: plan.errors } } + const built = buildVerifiedAssetPlan(content.manifest, result.slotPath) + if (!built.ok) { + return { ok: false, failure: { code: 'BUILD_FAILED', facet: facetName, errors: built.errors } } } + plan = built.plan + companionBytes = readSkillCompanionBytes(built.plan, result.slotPath) entry = effectiveLocked !== undefined ? { source: effectiveLocked.source, version: effectiveLocked.version, integrity: effectiveLocked.integrity, - assets: plan.plan.assets, + assets: built.plan.assets, } : { source: { kind: 'registry', registry: getRegistryBaseUrl() }, version: exactVersion, integrity: result.integrity, - assets: plan.plan.assets, + assets: built.plan.assets, } } - return { ok: true, value: { entry, resolved: content.resolved, serversDeclared: content.serversDeclared } } + return { + ok: true, + value: { entry, resolved: content.resolved, plan, companionBytes, serversDeclared: content.serversDeclared }, + } } diff --git a/packages/engine/src/install/commit/tri-write.ts b/packages/engine/src/install/commit/tri-write.ts index 07374c87..f4da575d 100644 --- a/packages/engine/src/install/commit/tri-write.ts +++ b/packages/engine/src/install/commit/tri-write.ts @@ -3,13 +3,22 @@ import { join } from 'node:path' import type { FacetsJson, Lockfile, LockfileFacet } from '@agent-facets/protocol' import { writeFacetsJson } from '../../manifest/project-files.ts' import { FACETS_LOCK_FILE, writeLockfile } from '../lockfile-io.ts' -import { type Receipt, type ReceiptFacetEntry, receiptPath, writeReceipt } from '../receipt.ts' +import { + ownedPathsForLockedAsset, + type Receipt, + type ReceiptFacetEntry, + receiptPath, + writeReceipt, +} from '../receipt.ts' import type { OnLog, RunInstallFailure } from '../types.ts' /** - * Derive the new receipt from the entries this run resolved. The - * receipt records `{ version, assets[] }` per facet — a self-sufficient - * deletion record for future drift removal. + * Derive the new receipt from the entries this run resolved. The receipt + * records `{ version, assets[] }` per facet, each asset carrying the owned + * inner-archive file paths mirrored from the lockfile — a self-sufficient, + * offline-capable deletion record for future drift removal. Paths come from + * the `0.2` lockfile asset `files[]`; a legacy identity-only asset seeds its + * single conventional primary path. */ export function buildUpdatedReceipt( receipt: Receipt, @@ -19,7 +28,12 @@ export function buildUpdatedReceipt( for (const [name, entry] of Object.entries(newFacetEntries)) { facets[name] = { version: entry.version, - assets: entry.assets.map((a) => ({ scope: a.scope, type: a.type, name: a.name })), + assets: entry.assets.map((a) => ({ + scope: a.scope, + type: a.type, + name: a.name, + files: ownedPathsForLockedAsset(a), + })), } } return { ...receipt, facets } diff --git a/packages/engine/src/install/commit/types.ts b/packages/engine/src/install/commit/types.ts index 37879f90..d9aec3b3 100644 --- a/packages/engine/src/install/commit/types.ts +++ b/packages/engine/src/install/commit/types.ts @@ -1,15 +1,27 @@ import type { LockfileFacet, ResolvedFacetManifest } from '@agent-facets/protocol' import type { RunInstallFailure } from '../types.ts' +import type { SkillCompanionBytes, VerifiedAssetPlan } from '../verified-asset-plan.ts' /** * What a per-source-kind resolver hands back to the commit * orchestrator: the lockfile entry to record, the resolved manifest - * (prompt bodies loaded) for materialization, and any server - * declarations to warn about. + * (prompt bodies loaded) for materialization, the verified asset plan + * (per-file paths + recomputed hashes + ownership) that pre-materialization + * reconciliation and materialization consume, the skill companion bytes read + * from the verified directory (for atomic skill-bundle install), and any + * server declarations to warn about. + * + * `plan` and `companionBytes` are `undefined` on the frozen-reproduction + * path, where the entry is inherited verbatim (possibly legacy `1`) and no + * fresh plan is derived. Reconciliation and companion materialization only + * run when a plan is present. */ export interface ResolvedFacet { entry: LockfileFacet resolved: ResolvedFacetManifest + plan: VerifiedAssetPlan | undefined + /** Skill companion bytes keyed by `skill:`; empty map for a companion-less skill. */ + companionBytes: Map | undefined serversDeclared: ReadonlyArray } diff --git a/packages/engine/src/install/materialize.ts b/packages/engine/src/install/materialize.ts index 41d5e2b1..8c13d0e9 100644 --- a/packages/engine/src/install/materialize.ts +++ b/packages/engine/src/install/materialize.ts @@ -1,6 +1,7 @@ import type { Adapter, AdapterAssetFailure, + CompanionMap, DeleteAssetRequest, InstallAssetRequest, ReadAssetRequest, @@ -10,6 +11,7 @@ import type { LockfileAssetEntry, ResolvedFacetManifest } from '@agent-facets/pr import { type AdapterCompatibilityFailure, compatibilityFailureFor } from '../adapters/api-compatibility.ts' import type { InstallJournal } from './journal.ts' import type { OnLog, StageEvent } from './types.ts' +import type { SkillCompanionBytes } from './verified-asset-plan.ts' /** * Compute the NEW asset set a facet contributes at this version. Derived @@ -65,6 +67,13 @@ export interface MaterializeOptions { /** Previous lockfile assets (OLD set); empty array when absent. */ oldAssets: readonly LockfileAssetEntry[] newAssets: readonly LockfileAssetEntry[] + /** + * Skill companion bytes for the assets being installed, keyed by + * `skill:` (from the resolver's verified asset plan). Absent on the + * frozen-reproduction path (no fresh plan) and for delete-only calls; a + * missing entry or map means "no companions" (single-file behavior). + */ + companionBytes?: Map journal: InstallJournal onLog?: OnLog /** Structured progress events for view layers. */ @@ -162,16 +171,28 @@ export async function materialize(opts: MaterializeOptions): Promise `[verbose] installing ${opts.facetName}@${opts.manifest.version} → ${adapter.name}`) + // Previous owned companion paths per asset key — the engine-verified set + // from the OLD lockfile entry, used both to read the prior bundle and to + // tell the adapter which owned paths a replacement may remove. + const oldOwnedByKey = new Map() + for (const old of opts.oldAssets) { + oldOwnedByKey.set(assetKey(old), ownedCompanionPathsOf(old)) + } + for (const asset of opts.newAssets) { const content = contentFor(opts.manifest, asset) const metadata = buildAssetMetadata(opts.manifest, asset, adapter.name) + // NEW companion bytes for this skill (empty for companion-less skills + // and non-skill assets); PREVIOUS owned paths for safe replacement. + const companions = opts.companionBytes?.get(assetKey(asset)) ?? {} + const ownedCompanionPaths = oldOwnedByKey.get(assetKey(asset)) ?? [] // Capture original state for rollback (F14). Treating any failure // as "didn't exist" would let the journal's delete-undo silently // delete a pre-existing asset we never read successfully. Narrow to // the structured `not-found` only and surface everything else as // `read-failed` — install fails loud before we write anything. - const readOutcome = await readPrevious(adapter, asset) + const readOutcome = await readPrevious(adapter, asset, ownedCompanionPaths) if (!readOutcome.ok) { return { ok: false, @@ -204,10 +225,14 @@ export async function materialize(opts: MaterializeOptions): Promise `[verbose] =${asset.type}:${asset.name} (skipped)`) skipped++ @@ -216,9 +241,21 @@ export async function materialize(opts: MaterializeOptions): Promise `[verbose] ~${asset.type}:${asset.name} drift: skills/${asset.name}/${path}`) + } + } + let writtenPath: string | undefined try { - const result = await adapter.installAsset(installRequestFor(asset, content, metadata)) + const result = await adapter.installAsset( + installRequestFor(asset, content, metadata, companions, ownedCompanionPaths), + ) if (!result.ok) { return { ok: false, @@ -245,21 +282,40 @@ export async function materialize(opts: MaterializeOptions): Promise `[verbose] ${sigil}${asset.type}:${asset.name}${writtenPath ? ` → ${writtenPath}` : ''}`) written++ + // Rollback preimage: restore the COMPLETE prior bundle (primary + + // previously-owned companion bytes), or delete a freshly-created asset. + // The owned-path set handed to the restore install is the union of the + // paths this operation could have written or removed, so the restore + // converges the bundle back to its prior state without touching unowned + // files. Companion-less skills and single-file assets restore exactly + // as before. + const restoreOwned = previous ? Object.keys(previous.companions) : ownedCompanionPaths opts.journal.record({ label: `install ${adapter.name}:${asset.type}:${asset.name}`, undo: async () => { if (previous) { - await runUndoInstall(adapter, asset, previous.content, previous.metadata ?? {}) + await runUndoInstall( + adapter, + asset, + previous.content, + previous.metadata ?? {}, + previous.companions, + restoreOwned, + ) } else { - await runUndoDelete(adapter, asset) + await runUndoDelete(adapter, asset, ownedCompanionPaths) } }, }) } for (const asset of toDelete) { + // The owned companion paths to delete come from the OLD entry — the set + // this machine materialized for the asset being removed. + const ownedCompanionPaths = oldOwnedByKey.get(assetKey(asset)) ?? ownedCompanionPathsOf(asset) + // Same F14 guard as the install branch above. - const readOutcome = await readPrevious(adapter, asset) + const readOutcome = await readPrevious(adapter, asset, ownedCompanionPaths) if (!readOutcome.ok) { return { ok: false, @@ -270,7 +326,7 @@ export async function materialize(opts: MaterializeOptions): Promise { - await runUndoInstall(adapter, asset, previous.content, previous.metadata ?? {}) + await runUndoInstall( + adapter, + asset, + previous.content, + previous.metadata ?? {}, + previous.companions, + Object.keys(previous.companions), + ) }, }) } @@ -314,15 +377,45 @@ export async function materialize(opts: MaterializeOptions): Promise }).files + if (!Array.isArray(rawFiles)) return [] + const skillRoot = `skills/${asset.name}/` + const primary = `skills/${asset.name}/SKILL.md` + const owned: string[] = [] + for (const f of rawFiles) { + const path = typeof f === 'string' ? f : f.path + if (path === primary) continue + owned.push(path.startsWith(skillRoot) ? path.slice(skillRoot.length) : path) + } + return owned +} + +/** + * Bridge from the engine's asset entry to the adapter's tagged install + * request. A skill request carries the new companion bundle (verbatim bytes + * keyed skill-root-relative) plus the engine-verified set of previously-owned + * companion paths, so the adapter replaces exactly the owned paths absent from + * the new bundle and never touches unowned files. An empty companion map and + * empty owned set reproduce the single-file behavior. + */ +function installRequestFor( + asset: LockfileAssetEntry, + content: string, + metadata: unknown, + companions: CompanionMap, + ownedCompanionPaths: readonly string[], +): InstallAssetRequest { if (asset.type === 'skill') { return { assetType: 'skill', @@ -330,43 +423,57 @@ function installRequestFor(asset: LockfileAssetEntry, content: string, metadata: name: asset.name, content, metadata, - companions: {}, - ownedCompanionPaths: [], + companions, + ownedCompanionPaths, } } return { assetType: asset.type, scope: asset.scope, name: asset.name, content, metadata } } -function readRequestFor(asset: LockfileAssetEntry): ReadAssetRequest { +function readRequestFor(asset: LockfileAssetEntry, ownedCompanionPaths: readonly string[]): ReadAssetRequest { if (asset.type === 'skill') { - return { assetType: 'skill', scope: asset.scope, name: asset.name, ownedCompanionPaths: [] } + return { assetType: 'skill', scope: asset.scope, name: asset.name, ownedCompanionPaths } } return { assetType: asset.type, scope: asset.scope, name: asset.name } } -function deleteRequestFor(asset: LockfileAssetEntry): DeleteAssetRequest { +function deleteRequestFor(asset: LockfileAssetEntry, ownedCompanionPaths: readonly string[]): DeleteAssetRequest { if (asset.type === 'skill') { - return { assetType: 'skill', scope: asset.scope, name: asset.name, ownedCompanionPaths: [] } + return { assetType: 'skill', scope: asset.scope, name: asset.name, ownedCompanionPaths } } return { assetType: asset.type, scope: asset.scope, name: asset.name } } +/** The captured prior state of an asset — its primary content, metadata, and + * (for skills) the bytes of its previously-owned companions. Used for + * skip-if-identical comparison and full-bundle rollback preimages. */ +interface PreviousAsset { + content: string + metadata?: Record + companions: CompanionMap +} + /** - * Read an asset's previous state for rollback capture. The structured - * `not-found` is the one "asset didn't exist" signal we trust — every - * other failure (and any throw, which is an adapter bug) means `previous` - * is unknown, so the caller must fail loud instead of assuming absence. + * Read an asset's previous state for rollback capture, including its + * previously-owned companion bytes for skills. The structured `not-found` is + * the one "asset didn't exist" signal we trust — every other failure (and any + * throw, which is an adapter bug) means `previous` is unknown, so the caller + * must fail loud instead of assuming absence. + * + * `ownedCompanionPaths` is the engine-verified previously-owned set (from the + * lockfile/receipt); the read returns exactly those companions that exist, so + * unowned files are never swept into the preimage. */ async function readPrevious( adapter: Adapter, asset: LockfileAssetEntry, -): Promise< - { ok: true; previous: { content: string; metadata?: Record } | null } | { ok: false; cause: string } -> { + ownedCompanionPaths: readonly string[], +): Promise<{ ok: true; previous: PreviousAsset | null } | { ok: false; cause: string }> { try { - const result = await adapter.readAsset(readRequestFor(asset)) + const result = await adapter.readAsset(readRequestFor(asset, ownedCompanionPaths)) if (result.ok) { - return { ok: true, previous: { content: result.asset.content, metadata: result.asset.metadata } } + const companions = result.asset.assetType === 'skill' ? result.asset.companions : {} + return { ok: true, previous: { content: result.asset.content, metadata: result.asset.metadata, companions } } } if (result.failure.code === 'not-found') return { ok: true, previous: null } return { ok: false, cause: describeAssetFailure(result.failure) } @@ -388,8 +495,12 @@ async function runUndoInstall( asset: LockfileAssetEntry, content: string, metadata: Record, + companions: CompanionMap, + ownedCompanionPaths: readonly string[], ): Promise { - const result = await adapter.installAsset(installRequestFor(asset, content, metadata)) + const result = await adapter.installAsset( + installRequestFor(asset, content, metadata, companions, ownedCompanionPaths), + ) if (!result.ok) { throw new Error( `undo install ${adapter.name}:${asset.type}:${asset.name} failed: ${describeAssetFailure(result.failure)}`, @@ -398,8 +509,12 @@ async function runUndoInstall( } /** Inverse delete during rollback. Same throw-on-`{ ok: false }` rule as {@link runUndoInstall}. */ -async function runUndoDelete(adapter: Adapter, asset: LockfileAssetEntry): Promise { - const result = await adapter.deleteAsset(deleteRequestFor(asset)) +async function runUndoDelete( + adapter: Adapter, + asset: LockfileAssetEntry, + ownedCompanionPaths: readonly string[], +): Promise { + const result = await adapter.deleteAsset(deleteRequestFor(asset, ownedCompanionPaths)) if (!result.ok) { throw new Error( `undo delete ${adapter.name}:${asset.type}:${asset.name} failed: ${describeAssetFailure(result.failure)}`, @@ -407,6 +522,44 @@ async function runUndoDelete(adapter: Adapter, asset: LockfileAssetEntry): Promi } } +/** + * Compare two companion maps for byte-exact equality. Used by + * skip-if-identical so a drifted or added/removed companion forces a repair + * through the atomic bundle replacement. + */ +function companionsIdentical(a: CompanionMap, b: CompanionMap): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!bytesEqual(a[key], b[key])) return false + } + return true +} + +function bytesEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean { + if (a === undefined || b === undefined || a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +/** + * The skill-root-relative companion paths that differ between the previously + * installed bundle and the new one — drifted (bytes changed), added, or + * removed. Sorted for stable, reviewable output. Used for path-specific drift + * reporting. + */ +function driftedCompanionPaths(previous: CompanionMap, next: CompanionMap): string[] { + const paths = new Set([...Object.keys(previous), ...Object.keys(next)]) + const drifted: string[] = [] + for (const path of paths) { + if (!bytesEqual(previous[path], next[path])) drifted.push(path) + } + return drifted.sort() +} + /** Render a structured adapter failure as a one-line cause string. */ function describeAssetFailure(failure: AdapterAssetFailure): string { switch (failure.code) { diff --git a/packages/engine/src/install/receipt.ts b/packages/engine/src/install/receipt.ts index 9ce83fd6..18330488 100644 --- a/packages/engine/src/install/receipt.ts +++ b/packages/engine/src/install/receipt.ts @@ -27,48 +27,108 @@ import { type } from 'arktype' import { facetReceiptsDir } from '../facet-dir.ts' import { jsonFileText } from '../json-file-text.ts' +// --------------------------------------------------------------------------- +// Versions (exact dispatch, mirroring the lockfile — design D10) +// --------------------------------------------------------------------------- + +/** + * The legacy receipt schema version. Numeric `1` identifies ONLY the previous + * alpha shape: asset entries with no owned-file records. Legacy installs + * could not materialize companions, so a legacy receipt is safely refined to + * primary-only ownership on load. Version dispatch is exact, never ordered. + */ +export const LEGACY_RECEIPT_VERSION = 1 + +/** + * The current receipt schema version. Current receipts mirror each committed + * lockfile asset/file ownership set: every asset records the exact inner- + * archive paths this machine materialized for it, so offline removal deletes + * exactly the owned files. The receipt stores paths only — never adapter- + * encoded hashes (design D10). + */ +export const CURRENT_RECEIPT_VERSION = 0.2 + // --------------------------------------------------------------------------- // Schema // --------------------------------------------------------------------------- -const ReceiptAssetSchema = type({ +/** + * A current (`0.2`) receipt asset record: adapter-agnostic identity plus the + * complete set of owned inner-archive file paths. A skill owns + * `skills//SKILL.md` plus every materialized companion; an agent or + * command owns exactly its single conventional primary file. Companion paths + * are the engine-supplied `ownedCompanionPaths` handed to the adapter delete + * request, so offline multi-file cleanup is exact. + */ +const CurrentReceiptAssetSchema = type({ + scope: "'system' | 'user' | 'project'", + type: "'skill' | 'agent' | 'command'", + name: 'string', + files: 'string[]', +}) + +const CurrentReceiptFacetEntrySchema = type({ + version: 'string', + assets: CurrentReceiptAssetSchema.array(), +}) + +const CurrentReceiptSchema = type({ + version: type.unit(CURRENT_RECEIPT_VERSION), + path: 'string', + facets: type.Record('string', CurrentReceiptFacetEntrySchema), +}) + +/** Legacy (`1`) receipt: identity-only asset tuples, no owned-file records. */ +const LegacyReceiptAssetSchema = type({ scope: "'system' | 'user' | 'project'", type: "'skill' | 'agent' | 'command'", name: 'string', }) -const ReceiptFacetEntrySchema = type({ +const LegacyReceiptFacetEntrySchema = type({ version: 'string', - assets: ReceiptAssetSchema.array(), + assets: LegacyReceiptAssetSchema.array(), }) -const ReceiptSchema = type({ - version: '1', +const LegacyReceiptSchema = type({ + version: type.unit(LEGACY_RECEIPT_VERSION), path: 'string', - facets: type.Record('string', ReceiptFacetEntrySchema), + facets: type.Record('string', LegacyReceiptFacetEntrySchema), }) // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- +/** + * A current receipt asset record with its owned inner-archive file paths. + * Extends the adapter-agnostic identity so existing identity-only consumers + * (which read `scope`/`type`/`name`) keep working while offline removal reads + * `files` for exact owned-path deletion. + */ +export interface ReceiptAsset extends LockfileAssetEntry { + files: string[] +} + export interface ReceiptFacetEntry { version: string - assets: LockfileAssetEntry[] + assets: ReceiptAsset[] } export interface Receipt { - version: 1 + version: typeof CURRENT_RECEIPT_VERSION path: string facets: Record } /** - * A receipt asset entry rejected during load because its name failed - * validation (path traversal, backslashes, empty segments). Reported — - * never acted on — while the facet's remaining valid entries still - * load (D6: a corrupted receipt may cause a skipped cleanup; it must - * never poison the rest of the record). + * A receipt asset entry rejected during load because its name or one of its + * owned file paths failed validation (path traversal, backslashes, empty + * segments). Reported — never acted on — while the facet's remaining valid + * entries still load (D6: a corrupted receipt may cause a skipped cleanup; it + * must never poison the rest of the record). Because an invalid path could + * escape the adapter's storage root, the whole asset record is dropped rather + * than partially trusted — an unowned or escaping path is never deleted. */ export interface InvalidReceiptAsset { facet: string @@ -168,38 +228,106 @@ export function loadReceipt(projectDir: string): LoadReceiptResult { return { ok: false, reason: 'corrupt' } } - const validated = ReceiptSchema(parsed) - if (validated instanceof type.errors) { + // Exact version dispatch (design D10): a current `0.2` receipt carries + // owned-file records; a legacy `1` receipt is identity-only and is refined + // to primary-only ownership (legacy installs could not materialize + // companions). Any other/absent version is corrupt — the shape is never + // sniffed to guess a schema. + const observedVersion = + typeof parsed === 'object' && parsed !== null && 'version' in parsed + ? (parsed as { version?: unknown }).version + : undefined + + let embeddedPath: string + let rawFacets: Record }> + if (observedVersion === CURRENT_RECEIPT_VERSION) { + const validated = CurrentReceiptSchema(parsed) + if (validated instanceof type.errors) return { ok: false, reason: 'corrupt' } + embeddedPath = validated.path + rawFacets = validated.facets + } else if (observedVersion === LEGACY_RECEIPT_VERSION) { + const validated = LegacyReceiptSchema(parsed) + if (validated instanceof type.errors) return { ok: false, reason: 'corrupt' } + embeddedPath = validated.path + // Refine legacy identity-only assets to primary-only owned-file sets: + // the single conventional primary path per asset, no companions. + rawFacets = {} + for (const [name, entry] of Object.entries(validated.facets)) { + rawFacets[name] = { + version: entry.version, + assets: entry.assets.map((a) => ({ ...a, files: [primaryPathFor(a)] })), + } + } + } else { return { ok: false, reason: 'corrupt' } } - const receipt = validated as Receipt - // Self-identification check: the embedded path must match the // project being operated on. - if (receipt.path !== canonical) { + if (embeddedPath !== canonical) { return { ok: false, reason: 'path-mismatch' } } - // Validate all asset names — extract crafted names that could cause - // path traversal when passed to adapters. Invalid entries are - // reported per-entry; valid entries are still processed. + // Validate every asset name AND every owned file path — receipt data is + // untrusted. A crafted name or a path that could traverse outside the + // adapter's storage drops the whole asset record (reported, never + // deleted), while the facet's remaining valid entries still load (D6). const invalidEntries: InvalidReceiptAsset[] = [] const facets: Record = {} - for (const [facetName, entry] of Object.entries(receipt.facets)) { - const validAssets: LockfileAssetEntry[] = [] + for (const [facetName, entry] of Object.entries(rawFacets)) { + const validAssets: ReceiptAsset[] = [] for (const asset of entry.assets) { - const check = validateAssetName(asset.name) - if (check.ok) { - validAssets.push(asset) - } else { - invalidEntries.push({ facet: facetName, asset: asset.name, reason: check.reason }) + const nameCheck = validateAssetName(asset.name) + if (!nameCheck.ok) { + invalidEntries.push({ facet: facetName, asset: asset.name, reason: nameCheck.reason }) + continue + } + let badPath: { path: string; reason: string } | undefined + for (const p of asset.files) { + const check = validateAssetName(p) + if (!check.ok) { + badPath = { path: p, reason: check.reason } + break + } } + if (badPath !== undefined) { + invalidEntries.push({ + facet: facetName, + asset: asset.name, + reason: `owned path "${badPath.path}" ${badPath.reason}`, + }) + continue + } + validAssets.push({ scope: asset.scope, type: asset.type, name: asset.name, files: [...asset.files] }) } facets[facetName] = { version: entry.version, assets: validAssets } } - return { ok: true, receipt: { ...receipt, facets }, invalidEntries } + return { + ok: true, + receipt: { version: CURRENT_RECEIPT_VERSION, path: canonical, facets }, + invalidEntries, + } +} + +/** The raw asset shape produced by version dispatch before path validation. */ +interface RawReceiptAsset { + scope: 'system' | 'user' | 'project' + type: 'skill' | 'agent' | 'command' + name: string + files: ReadonlyArray +} + +/** The conventional primary inner-archive path for an asset identity. */ +function primaryPathFor(asset: { type: 'skill' | 'agent' | 'command'; name: string }): string { + switch (asset.type) { + case 'skill': + return `skills/${asset.name}/SKILL.md` + case 'agent': + return `agents/${asset.name}.md` + case 'command': + return `commands/${asset.name}.md` + } } // --------------------------------------------------------------------------- @@ -230,13 +358,15 @@ export function writeReceipt(projectDir: string, receipt: Receipt): void { // --------------------------------------------------------------------------- /** - * Create a fresh receipt from a lockfile. Used when no receipt exists - * (first operation on the project) or when the existing receipt fails - * validation (path mismatch, corruption). + * Create a fresh current (`0.2`) receipt from a lockfile. Used when no + * receipt exists (first operation on the project) or when the existing + * receipt fails validation (path mismatch, corruption). * - * Seeds from the lockfile's entries — records what *should* be on - * disk. Assets orphaned before this change shipped are unrecoverable - * (explicit non-goal in the proposal). + * Seeds from the lockfile's entries — records what *should* be on disk. + * Owned file paths come from a `0.2` lockfile asset's `files[]`; a legacy + * (identity-only) lockfile asset seeds the single conventional primary path + * (legacy could not materialize companions). Assets orphaned before this + * change shipped are unrecoverable (explicit non-goal in the proposal). */ export function bootstrapReceipt(projectDir: string, lockfile: Lockfile): Receipt { const canonical = realpathSync(projectDir) @@ -245,9 +375,28 @@ export function bootstrapReceipt(projectDir: string, lockfile: Lockfile): Receip for (const [name, entry] of Object.entries(lockfile.facets)) { facets[name] = { version: entry.version, - assets: entry.assets.map((a) => ({ scope: a.scope, type: a.type, name: a.name })), + assets: entry.assets.map((a) => ({ + scope: a.scope, + type: a.type, + name: a.name, + files: ownedPathsForLockedAsset(a), + })), } } - return { version: 1, path: canonical, facets } + return { version: CURRENT_RECEIPT_VERSION, path: canonical, facets } +} + +/** + * Owned inner-archive paths for a locked asset. A `0.2` asset carries a + * `files[]` array of `{ path, integrity }`; the receipt mirrors the paths + * (no hashes). A legacy identity-only asset has no `files`, so the single + * conventional primary path is used. + */ +export function ownedPathsForLockedAsset(asset: LockfileAssetEntry): string[] { + const files = (asset as { files?: ReadonlyArray<{ path: string }> }).files + if (Array.isArray(files) && files.length > 0) { + return files.map((f) => f.path) + } + return [primaryPathFor(asset)] } diff --git a/packages/engine/src/install/run-install.ts b/packages/engine/src/install/run-install.ts index 3e865660..7b67feb9 100644 --- a/packages/engine/src/install/run-install.ts +++ b/packages/engine/src/install/run-install.ts @@ -119,6 +119,18 @@ export async function runInstall(opts: RunInstallOptions): Promise + unexpected: ReadonlyArray + } + | { + code: 'RECONCILE_OWNED_PATH_SET' + facet: string + asset: string + missing: ReadonlyArray + unexpected: ReadonlyArray + } + | { + code: 'RECONCILE_PER_FILE_INTEGRITY' + facet: string + asset: string + path: string + expected: string + actual: string + } | { code: 'ABORTED' } /** diff --git a/packages/engine/src/install/verified-asset-plan.ts b/packages/engine/src/install/verified-asset-plan.ts index c3c21041..7e40c514 100644 --- a/packages/engine/src/install/verified-asset-plan.ts +++ b/packages/engine/src/install/verified-asset-plan.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import type { ValidationError } from '@agent-facets/common' import { type FacetManifest, planArchiveEntries } from '@agent-facets/protocol' import { computeDirIntegrity } from '../cache/index.ts' @@ -178,3 +180,49 @@ function assetOrder(a: VerifiedAsset, b: VerifiedAsset): number { if (a.type !== b.type) return TYPE_ORDER[a.type] - TYPE_ORDER[b.type] return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 } + +/** + * A canonical map of skill companion paths (relative to the skill root) to + * their exact bytes — the shape the adapter skill-install contract's + * `companions` field expects. `SKILL.md` (the primary) is NOT included; it is + * carried as the request's `content`. + */ +export type SkillCompanionBytes = Record + +/** + * Read the companion bytes for every skill in a verified asset plan from the + * verified directory, keyed by `type:name` asset identity. + * + * Companion bytes are read verbatim (opaque `Uint8Array`) — never decoded or + * front-matter processed. Paths are converted from the plan's full inner- + * archive form (`skills//references/api.md`) to the skill-root-relative + * form the adapter contract uses (`references/api.md`). Only skill assets with + * companions beyond `SKILL.md` appear in the result; a companion-less skill + * maps to an empty companion map. + * + * `verifiedDir` MUST be the same verified directory the plan was derived from. + */ +export function readSkillCompanionBytes( + plan: VerifiedAssetPlan, + verifiedDir: string, +): Map { + const byAsset = new Map() + for (const asset of plan.assets) { + if (asset.type !== 'skill') continue + const skillRoot = `skills/${asset.name}/` + const primary = `skills/${asset.name}/SKILL.md` + const companions: SkillCompanionBytes = {} + for (const file of asset.files) { + if (file.path === primary) continue + // Every skill companion path is derived by `planArchiveEntries` to live + // below the skill root, so this prefix strip is total. + const relative = file.path.startsWith(skillRoot) ? file.path.slice(skillRoot.length) : file.path + companions[relative] = new Uint8Array(readFileSync(join(verifiedDir, file.path))) + } + // Keyed by the `scope:type:name` identity `materialize` uses to look this + // up. Companion bytes only exist for `project`-scoped skills today (the + // only scope the plan mints), so the scope prefix is fixed. + byAsset.set(`${asset.scope}:skill:${asset.name}`, companions) + } + return byAsset +}