diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md index 059b192b..f584f20a 100644 --- a/openspec/changes/support-non-asset-files/tasks.md +++ b/openspec/changes/support-non-asset-files/tasks.md @@ -78,15 +78,15 @@ ## 8. Lockfile, Receipt, and Materialization — Research -- [ ] 8.1 Explore: Trace lockfile loading/writing and every place resolved entries are inherited, minted, compared, or carried forward -- [ ] 8.2 Explore: Trace receipt loading, bootstrapping, project isolation, drift removal, tri-write commit, and rollback ordering -- [ ] 8.3 Explore: Trace materialization, skip-if-identical behavior, journaling, deletion, drift reporting, and archive-to-adapter data flow -- [ ] 8.4 Propose: Define the migration and transaction approach for per-file integrity, untrusted receipt ownership, atomic skill bundles, normal legacy migration, and frozen legacy behavior +- [x] 8.1 Explore: Trace lockfile loading/writing and every place resolved entries are inherited, minted, compared, or carried forward +- [x] 8.2 Explore: Trace receipt loading, bootstrapping, project isolation, drift removal, tri-write commit, and rollback ordering +- [x] 8.3 Explore: Trace materialization, skip-if-identical behavior, journaling, deletion, drift reporting, and archive-to-adapter data flow +- [x] 8.4 Propose: Define the migration and transaction approach for per-file integrity, untrusted receipt ownership, atomic skill bundles, normal legacy migration, and frozen legacy behavior ## 9. Lockfile, Receipt, and Materialization — Implementation -- [ ] 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 -- [ ] 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 +- [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 diff --git a/packages/cli/src/commands/install/__tests__/install-cli.test.ts b/packages/cli/src/commands/install/__tests__/install-cli.test.ts index 7356e313..0e05dbfa 100644 --- a/packages/cli/src/commands/install/__tests__/install-cli.test.ts +++ b/packages/cli/src/commands/install/__tests__/install-cli.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, import { tmpdir } from 'node:os' import { join } from 'node:path' import { ADAPTER_API_VERSION } from '@agent-facets/adapter/api-version' +import { CURRENT_LOCKFILE_VERSION } from '@agent-facets/protocol' import { captureStderr } from '../../../__tests__/helpers/capture-std.ts' import { withTTY } from '../../../__tests__/helpers/with-tty.ts' import { installCommand } from '../index.ts' @@ -123,10 +124,19 @@ describe('facet install — CLI happy path', () => { const lockPath = join(projectRoot, 'facets.lock') expect(existsSync(lockPath)).toBe(true) const lockfile = JSON.parse(readFileSync(lockPath, 'utf8')) - expect(lockfile.lockfileVersion).toBe(1) + // A fresh install writes the current (`0.2`) lockfile schema with + // per-materialized-file integrity records inside each asset. + expect(lockfile.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION) expect(lockfile.facets['viper-plans']).toMatchObject({ version: '0.1.0', - assets: [{ scope: 'project', type: 'skill', name: 'planning' }], + assets: [ + { + scope: 'project', + type: 'skill', + name: 'planning', + files: [{ path: 'skills/planning/SKILL.md', integrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) }], + }, + ], }) expect(lockfile.facets['viper-plans'].integrity).toMatch(/^sha256:/) diff --git a/packages/engine/src/__tests__/run-install.test.ts b/packages/engine/src/__tests__/run-install.test.ts index c01e498d..cc713bbe 100644 --- a/packages/engine/src/__tests__/run-install.test.ts +++ b/packages/engine/src/__tests__/run-install.test.ts @@ -5,8 +5,10 @@ import { join } from 'node:path' import type { Adapter } from '@agent-facets/adapter' import { ADAPTER_API_VERSION, deleteAssetFile, installAssetFile, readAssetFile } from '@agent-facets/adapter' import type { BuildManifest, Lockfile } from '@agent-facets/protocol' -import { computeContentHash } from '@agent-facets/protocol' +import { CURRENT_LOCKFILE_VERSION, CurrentLockfileSchema, computeContentHash } from '@agent-facets/protocol' +import { type } from 'arktype' import { type CacheIdentity, cachePath, cachePutVerified, computeDirIntegrity } from '../cache/index.ts' +import { loadLockfile } from '../install/lockfile-io.ts' import { runInstall } from '../install/run-install.ts' import type { StageEvent } from '../install/types.ts' @@ -294,6 +296,38 @@ describe('runInstall — local source success path', () => { expect(result.summary.installed).toBe(1) expect(result.lockfile.facets['viper-plans']?.version).toBe('0.1.0') }) + + // 9.1/9.2: a fresh normal install records the current (`0.2`) lockfile + // with per-materialized-file integrity records derived from the verified + // build, not identity-only assets. + test('a fresh install writes a 0.2 lockfile with recomputed per-file records', async () => { + const local = buildLocalFixture('viper-plans') + const relPath = `./${local.split('/').pop()}` + writeFileSync(join(projectRoot, 'facets.json'), JSON.stringify({ facets: { 'viper-plans': relPath } })) + + const result = await runInstall({ projectRoot, adapters: [buildFakeAdapter('test')] }) + expect(result.ok).toBe(true) + if (!result.ok) expect.unreachable() + + expect(result.lockfile.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION) + + // The written lockfile round-trips: reloading it under exact 0.2 dispatch + // succeeds (an identity-only 0.2 entry would fail the CurrentLockfile + // schema on reload) and reports the current version. + const reloaded = loadLockfile(projectRoot) + if (!reloaded.ok) expect.unreachable() + expect(reloaded.version).toBe(CURRENT_LOCKFILE_VERSION) + + // Validate the written bytes against the current schema and inspect the + // per-file records off the validated (current) shape. + 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.find((a) => a.type === 'skill' && a.name === 'planning') + if (asset === undefined) expect.unreachable() + expect(asset.files).toEqual([ + { path: 'skills/planning/SKILL.md', integrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) }, + ]) + }) }) describe('runInstall — registry source surfaces REGISTRY_ERROR on resolution failure', () => { @@ -887,6 +921,22 @@ describe('runInstall — git cache hit short-circuits clone', () => { url: `https://github.com/example/${facetName}.git`, commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', }) + + // 9.1/9.2 migration: the seeded lockfile was legacy-alpha `1` with + // identity-only assets. A normal (non-frozen) install migrates it to the + // current `0.2` schema, re-deriving per-file records from the verified + // slot while keeping the locked identity untouched. + expect(result.lockfile.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION) + const written = CurrentLockfileSchema(JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8'))) + if (written instanceof type.errors) expect.unreachable() + expect(written.facets[facetName]?.assets).toEqual([ + { + scope: 'project', + type: 'skill', + name: 'planning', + files: [{ path: 'skills/planning/SKILL.md', integrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) }], + }, + ]) }) test('returns CACHE_INTEGRITY_MISMATCH when sidecar disagrees with lockfile', async () => { diff --git a/packages/engine/src/install/__tests__/lockfile-io.test.ts b/packages/engine/src/install/__tests__/lockfile-io.test.ts index ab6c19e8..f684a88c 100644 --- a/packages/engine/src/install/__tests__/lockfile-io.test.ts +++ b/packages/engine/src/install/__tests__/lockfile-io.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { CURRENT_LOCKFILE_VERSION, LEGACY_LOCKFILE_VERSION } from '@agent-facets/protocol' import { FACETS_LOCK_FILE, loadLockfile, writeLockfile } from '../lockfile-io.ts' let projectRoot: string @@ -15,20 +16,21 @@ afterEach(() => { }) describe('loadLockfile — empty/missing', () => { - test('missing file returns empty lockfile with existed=false', () => { + test('missing file returns a current (0.2) empty lockfile with existed=false', () => { const result = loadLockfile(projectRoot) expect(result.ok).toBe(true) - if (result.ok) { - expect(result.existed).toBe(false) - expect(result.data.facets).toEqual({}) - } + if (!result.ok) expect.unreachable() + expect(result.existed).toBe(false) + expect(result.data.facets).toEqual({}) + expect(result.data.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION) + expect(result.version).toBe(CURRENT_LOCKFILE_VERSION) }) }) describe('loadLockfile — round-trip', () => { - test('writes and reads back an identical lockfile', () => { + test('writes and reads back an identical current (0.2) lockfile', () => { const lockfile = { - lockfileVersion: 1 as const, + lockfileVersion: CURRENT_LOCKFILE_VERSION as typeof CURRENT_LOCKFILE_VERSION, facets: { 'viper-plans': { source: { @@ -38,14 +40,47 @@ describe('loadLockfile — round-trip', () => { }, version: '0.1.0', integrity: 'sha256:deadbeef', - assets: [{ scope: 'project' as const, type: 'skill' as const, name: 'planning' }], + assets: [ + { + scope: 'project' as const, + type: 'skill' as const, + name: 'planning', + files: [{ path: 'skills/planning/SKILL.md', integrity: `sha256:${'0'.repeat(64)}` }], + }, + ], }, }, } writeLockfile(projectRoot, lockfile) const loaded = loadLockfile(projectRoot) expect(loaded.ok).toBe(true) - if (loaded.ok) expect(loaded.data).toEqual(lockfile) + if (!loaded.ok) expect.unreachable() + expect(loaded.data).toEqual(lockfile) + expect(loaded.version).toBe(CURRENT_LOCKFILE_VERSION) + }) + + test('loads a legacy-alpha (1) lockfile under the legacy schema during the compatibility window', () => { + const legacy = { + lockfileVersion: LEGACY_LOCKFILE_VERSION as typeof LEGACY_LOCKFILE_VERSION, + facets: { + 'viper-plans': { + source: { + kind: 'git' as const, + url: 'github:agent-facets/viper-plans#main', + commit: 'abc123def0123456789abc123def0123456789ab', + }, + version: '0.1.0', + integrity: 'sha256:deadbeef', + assets: [{ scope: 'project' as const, type: 'skill' as const, name: 'planning' }], + }, + }, + } + writeLockfile(projectRoot, legacy) + const loaded = loadLockfile(projectRoot) + expect(loaded.ok).toBe(true) + if (!loaded.ok) expect.unreachable() + expect(loaded.data).toEqual(legacy) + expect(loaded.version).toBe(LEGACY_LOCKFILE_VERSION) }) }) @@ -68,25 +103,59 @@ describe('loadLockfile — error paths', () => { }) }) -// F9 — forward-compat guard. A lockfile from a future CLI must produce a -// clear "upgrade the CLI" message, not a generic arktype mismatch. -describe('loadLockfile — F9 forward-compat guard', () => { - test('lockfileVersion > LOCKFILE_VERSION fails with an actionable error', () => { +// Exact version dispatch (design D10). An unsupported/unknown version must +// produce an actionable "upgrade the CLI" message, not a generic arktype +// mismatch — and dispatch is by exact equality, never numeric ordering. +describe('loadLockfile — exact version dispatch', () => { + test('an unsupported lockfileVersion fails with an actionable error', () => { writeFileSync(join(projectRoot, FACETS_LOCK_FILE), JSON.stringify({ lockfileVersion: 99, facets: {} })) const result = loadLockfile(projectRoot) expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error).toContain('newer facet CLI') - expect(result.error).toContain('lockfileVersion 99') - expect(result.error).toContain('Upgrade the CLI') - } + if (result.ok) expect.unreachable() + expect(result.error).toContain('unsupported lockfileVersion') + expect(result.error).toContain('99') + expect(result.error).toContain('Upgrade the CLI') }) - test('lockfileVersion equal to LOCKFILE_VERSION loads normally', () => { + test('legacy-alpha version 1 loads under the legacy schema', () => { writeFileSync(join(projectRoot, FACETS_LOCK_FILE), JSON.stringify({ lockfileVersion: 1, facets: {} })) const result = loadLockfile(projectRoot) expect(result.ok).toBe(true) - if (result.ok) expect(result.existed).toBe(true) + if (!result.ok) expect.unreachable() + expect(result.existed).toBe(true) + expect(result.version).toBe(LEGACY_LOCKFILE_VERSION) + }) + + test('current version 0.2 loads under the current schema', () => { + writeFileSync(join(projectRoot, FACETS_LOCK_FILE), JSON.stringify({ lockfileVersion: 0.2, facets: {} })) + const result = loadLockfile(projectRoot) + expect(result.ok).toBe(true) + if (!result.ok) expect.unreachable() + expect(result.existed).toBe(true) + expect(result.version).toBe(CURRENT_LOCKFILE_VERSION) + }) + + test('a malformed 0.2 lockfile is not reinterpreted as legacy 1', () => { + // `files` is required on 0.2 asset entries; omitting it is a 0.2 schema + // violation, never a fallback to the legacy identity-only shape. + writeFileSync( + join(projectRoot, FACETS_LOCK_FILE), + JSON.stringify({ + lockfileVersion: 0.2, + facets: { + x: { + source: { kind: 'registry', registry: 'https://example.com' }, + version: '1.0.0', + integrity: 'sha256:deadbeef', + assets: [{ scope: 'project', type: 'skill', name: 'planning' }], + }, + }, + }), + ) + const result = loadLockfile(projectRoot) + expect(result.ok).toBe(false) + if (result.ok) expect.unreachable() + expect(result.error).toContain('lockfileVersion 0.2') }) }) diff --git a/packages/engine/src/install/__tests__/run-remove.test.ts b/packages/engine/src/install/__tests__/run-remove.test.ts index 5363b11b..31723c93 100644 --- a/packages/engine/src/install/__tests__/run-remove.test.ts +++ b/packages/engine/src/install/__tests__/run-remove.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Adapter } from '@agent-facets/adapter' import { ADAPTER_API_VERSION } from '@agent-facets/adapter/api-version' +import { CURRENT_LOCKFILE_VERSION } from '@agent-facets/protocol' /** * Tests for the `facet remove` orchestrator (`runRemove`). @@ -279,9 +280,11 @@ describe('runRemove — last facet', () => { expect(Object.keys(readFacets())).toHaveLength(0) expect(Object.keys(readLockfileFacets())).toHaveLength(0) - // Lockfile is still structurally valid (declares a version). + // Lockfile is still structurally valid: a normal install writes the + // current (`0.2`) schema. Version dispatch is exact, not ordered, so + // `0.2` is the current version even though it is numerically < 1. const lock = JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8')) - expect(lock.lockfileVersion).toBeGreaterThanOrEqual(1) + expect(lock.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION) }) }) diff --git a/packages/engine/src/install/commit/finalize-facet.ts b/packages/engine/src/install/commit/finalize-facet.ts index c1332074..414d7b51 100644 --- a/packages/engine/src/install/commit/finalize-facet.ts +++ b/packages/engine/src/install/commit/finalize-facet.ts @@ -1,4 +1,4 @@ -import type { LockfileSource, ResolvedFacetManifest } from '@agent-facets/protocol' +import type { FacetManifest, LockfileSource, ResolvedFacetManifest } from '@agent-facets/protocol' import { loadManifest, resolvePrompts } from '../../loaders/facet.ts' import { getRegistryBaseUrl } from '../../registry/index.ts' import type { Source } from '../../sources/facet/types.ts' @@ -7,10 +7,17 @@ import type { RunInstallFailure, StageEvent } from '../types.ts' /** * Result of loading and validating a facet's content from a resolved * source directory. On success carries the prompt-resolved manifest - * (what `materialize` consumes) and the facet's server declarations. + * (what `materialize` consumes), the validated raw manifest (what the + * verified-asset-plan derivation consumes for archive classification), and + * the facet's server declarations. */ export type LoadFacetContentResult = - | { ok: true; resolved: ResolvedFacetManifest; serversDeclared: ReadonlyArray } + | { + ok: true + manifest: FacetManifest + resolved: ResolvedFacetManifest + serversDeclared: ReadonlyArray + } | { ok: false; failure: RunInstallFailure } /** @@ -63,7 +70,7 @@ export async function loadFacetContent( } } - return { ok: true, resolved: resolved.data, serversDeclared } + return { ok: true, manifest: rawManifest.data, resolved: resolved.data, serversDeclared } } /** diff --git a/packages/engine/src/install/commit/resolve-facet.ts b/packages/engine/src/install/commit/resolve-facet.ts index 2ca0bc23..4cb28e0c 100644 --- a/packages/engine/src/install/commit/resolve-facet.ts +++ b/packages/engine/src/install/commit/resolve-facet.ts @@ -59,9 +59,9 @@ export async function resolveFacet(args: ResolveFacetArgs): Promise /** See `ResolveRegistryFacetArgs.effectiveLocked`. */ effectiveLocked: LockfileFacet | undefined + /** See `ResolveRegistryFacetArgs.frozenLockfile`. */ + frozenLockfile: boolean onStage: (event: StageEvent) => void onLog: OnLog } @@ -42,7 +44,7 @@ export interface ResolveGitFacetArgs { * integrity confirmation does not apply (design non-goal). */ export async function resolveGitFacet(args: ResolveGitFacetArgs): Promise { - const { facetName, source, effectiveLocked, onStage, onLog } = args + const { facetName, source, effectiveLocked, frozenLockfile, onStage, onLog } = args onStage({ kind: 'facet-stage', facet: facetName, stage: 'resolve' }) @@ -96,12 +98,29 @@ export async function resolveGitFacet(args: ResolveGitFacetArgs): Promise `[verbose] cached ${facetName}@${buildResult.data.version} from clone`) + // The verified content lives in the durable cache slot (`sourceDir` + // was reassigned to `putResult.path` above), so per-file `files[]` + // records are derived from there on both the reproduction (migration) + // and fresh paths. Frozen reproduction is the only path that inherits + // verbatim, and it never reaches this build branch (an audited hit is + // required for frozen reproduction; a frozen rebuild would fail the + // one-check reproduction guard rather than rewrite). + const plan = buildVerifiedAssetPlan(content.manifest, sourceDir) + if (!plan.ok) { + return { ok: false, failure: { code: 'BUILD_FAILED', facet: facetName, errors: plan.errors } } + } if (effectiveLocked !== undefined) { - // The build just reproduced the locked integrity; the lockfile - // is the source of truth, so the entry is inherited verbatim. + // The build just reproduced the locked integrity; identity is the + // lockfile's, but `files[]` is (re)derived so a legacy entry migrates. entry = { source: effectiveLocked.source, version: effectiveLocked.version, integrity: effectiveLocked.integrity, - assets: effectiveLocked.assets, + assets: plan.plan.assets, } } else { const buildSource = buildLockfileSource(facetName, source, clonedCommit) @@ -182,7 +212,7 @@ export async function resolveGitFacet(args: ResolveGitFacetArgs): Promise void onLog: OnLog } @@ -56,7 +64,7 @@ export interface ResolveRegistryFacetArgs { * is the hard `CACHE_INTEGRITY_MISMATCH` (never a silent re-download). */ export async function resolveRegistryFacet(args: ResolveRegistryFacetArgs): Promise { - const { facetName, source, effectiveLocked, onStage, onLog } = args + const { facetName, source, effectiveLocked, frozenLockfile, onStage, onLog } = args onStage({ kind: 'facet-stage', facet: facetName, stage: 'resolve' }) @@ -184,25 +192,43 @@ export async function resolveRegistryFacet(args: ResolveRegistryFacetArgs): Prom const content = await loadFacetContent(facetName, result.slotPath, onStage) if (!content.ok) return content - const entry: LockfileFacet = - effectiveLocked !== undefined - ? { - // Locked paths inherit the entry verbatim: the chain just - // proved the content reproduces it, and the lockfile is the - // source of truth — never rewritten on reproduction. - source: effectiveLocked.source, - version: effectiveLocked.version, - integrity: effectiveLocked.integrity, - assets: effectiveLocked.assets, - } - : { - // Confirming paths create the entry from the resolved exact - // version and the chain's verified integrity. - source: { kind: 'registry', registry: getRegistryBaseUrl() }, - version: exactVersion, - integrity: result.integrity, - assets: computeAssetList(content.resolved), - } + let entry: LockfileFacet + 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. + entry = { + source: effectiveLocked.source, + version: effectiveLocked.version, + integrity: effectiveLocked.integrity, + assets: effectiveLocked.assets, + } + } else { + // Normal-mode reproduction (migration) and confirming (fresh) paths + // both derive per-file `files[]` records from the verified slot, so a + // legacy lockfile is migrated to `0.2` and a fresh entry is recorded at + // `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 } } + } + entry = + effectiveLocked !== undefined + ? { + source: effectiveLocked.source, + version: effectiveLocked.version, + integrity: effectiveLocked.integrity, + assets: plan.plan.assets, + } + : { + source: { kind: 'registry', registry: getRegistryBaseUrl() }, + version: exactVersion, + integrity: result.integrity, + assets: plan.plan.assets, + } + } return { ok: true, value: { entry, resolved: content.resolved, serversDeclared: content.serversDeclared } } } diff --git a/packages/engine/src/install/lockfile-io.ts b/packages/engine/src/install/lockfile-io.ts index af8d8593..e8d45267 100644 --- a/packages/engine/src/install/lockfile-io.ts +++ b/packages/engine/src/install/lockfile-io.ts @@ -1,14 +1,29 @@ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { atomicWriteFileSync } from '@agent-facets/common' -import { LOCKFILE_VERSION, type Lockfile, LockfileSchema } from '@agent-facets/protocol' -import { type } from 'arktype' +import { + CURRENT_LOCKFILE_VERSION, + type LEGACY_LOCKFILE_VERSION, + type Lockfile, + type LockfileParseFailure, + parseLockfileDocument, + SUPPORTED_LOCKFILE_VERSIONS, +} from '@agent-facets/protocol' import { jsonFileText } from '../json-file-text.ts' /** * Bytes-level I/O for facets.lock. Keeps JSON parse/serialize in one place * so the orchestrator only deals with validated Lockfile values. * + * Version dispatch is EXACT (design D10): `loadLockfile` delegates to + * protocol's `parseLockfileDocument`, which selects the legacy-alpha `1` + * schema or the current `0.2` schema by exact equality — never numeric + * ordering. A future/unknown version is a structured + * `unsupported-lockfile-version` rejection, and a malformed `0.2` lockfile + * is never reinterpreted as legacy `1`. The loaded version is surfaced on + * the result so the orchestrator can migrate a legacy lockfile to `0.2` + * in normal mode while retaining it verbatim in frozen mode. + * * F4 note — closed-alpha posture: every persisted facet entry carries an * `integrity` field, but `loadLockfile` does NOT re-verify it against a * newly-resolved source. The lockfile is an audit log, not a tamper check. @@ -18,12 +33,24 @@ import { jsonFileText } from '../json-file-text.ts' export const FACETS_LOCK_FILE = 'facets.lock' -export type LoadLockfileResult = { ok: true; data: Lockfile; existed: boolean } | { ok: false; error: string } +/** + * The exact schema version a lockfile was loaded under. Legacy `1` carries + * identity-only asset entries; current `0.2` carries per-materialized-file + * integrity records. The orchestrator dispatches migration behavior on this + * discriminant rather than re-parsing the version out of `data`. + */ +export type LoadedLockfileVersion = typeof LEGACY_LOCKFILE_VERSION | typeof CURRENT_LOCKFILE_VERSION + +export type LoadLockfileResult = + | { ok: true; data: Lockfile; existed: boolean; version: LoadedLockfileVersion } + | { ok: false; error: string } export function loadLockfile(projectRoot: string): LoadLockfileResult { const path = join(projectRoot, FACETS_LOCK_FILE) if (!existsSync(path)) { - return { ok: true, data: emptyLockfile(), existed: false } + // A missing lockfile bootstraps the current (`0.2`) empty shape — new + // projects never start on the legacy schema. + return { ok: true, data: emptyLockfile(), existed: false, version: CURRENT_LOCKFILE_VERSION } } let raw: string try { @@ -34,31 +61,49 @@ export function loadLockfile(projectRoot: string): LoadLockfileResult { error: `failed to read ${FACETS_LOCK_FILE}: ${err instanceof Error ? err.message : String(err)}`, } } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (err) { - return { - ok: false, - error: `${FACETS_LOCK_FILE} is malformed JSON: ${err instanceof Error ? err.message : String(err)}`, - } + + const parsed = parseLockfileDocument(raw) + if (!parsed.ok) { + return { ok: false, error: describeLockfileFailure(parsed.failure) } } - // F9 — forward-compat guard. If the file is from a newer CLI that bumped - // `lockfileVersion`, stop before schema validation so the user sees an - // actionable message instead of a confusing arktype mismatch. A lockfile - // with a matching or older version falls through to the schema below. - const parsedVersion = (parsed as { lockfileVersion?: unknown } | null)?.lockfileVersion - if (typeof parsedVersion === 'number' && parsedVersion > LOCKFILE_VERSION) { - return { - ok: false, - error: `${FACETS_LOCK_FILE} is from a newer facet CLI (lockfileVersion ${parsedVersion}, this CLI supports ${LOCKFILE_VERSION}). Upgrade the CLI, or delete ${FACETS_LOCK_FILE} to regenerate.`, - } + + // `CurrentLockfile` asset entries are a structural superset of the + // permissive `Lockfile` (identity fields plus `files`), so a current + // document reads correctly through the still-legacy downstream install + // paths; the per-file records are threaded through explicitly by the + // lockfile `0.2` materialization work. + return { + ok: true, + data: parsed.data.lockfile as Lockfile, + existed: true, + version: parsed.data.lockfileVersion, } - const validated = LockfileSchema(parsed) - if (validated instanceof type.errors) { - return { ok: false, error: `${FACETS_LOCK_FILE} is invalid: ${validated.summary}` } +} + +/** + * Render protocol's tagged `LockfileParseFailure` into the actionable + * string the orchestrator surfaces today. Structured install-failure + * variants replace this string channel in the per-file reconciliation work. + */ +function describeLockfileFailure(failure: LockfileParseFailure): string { + switch (failure.code) { + case 'invalid-json': + return `${FACETS_LOCK_FILE} is malformed JSON: ${summarizeErrors(failure.errors)}` + case 'duplicate-members': + return `${FACETS_LOCK_FILE} contains duplicate object member names: ${summarizeErrors(failure.errors)}` + case 'unsupported-lockfile-version': + return ( + `${FACETS_LOCK_FILE} declares an unsupported lockfileVersion ` + + `(${failure.observed ?? 'missing'}, this CLI supports ${SUPPORTED_LOCKFILE_VERSIONS.join(', ')}). ` + + `Upgrade the CLI, or delete ${FACETS_LOCK_FILE} to regenerate.` + ) + case 'schema-violation': + return `${FACETS_LOCK_FILE} is invalid (lockfileVersion ${failure.lockfileVersion}): ${summarizeErrors(failure.errors)}` } - return { ok: true, data: validated as Lockfile, existed: true } +} + +function summarizeErrors(errors: ReadonlyArray<{ message: string }>): string { + return errors.map((e) => e.message).join('; ') } /** @@ -78,6 +123,11 @@ export function writeLockfile(projectRoot: string, lockfile: Lockfile): void { atomicWriteFileSync(path, jsonFileText(canonical)) } +/** + * Bootstrap an empty lockfile at the current (`0.2`) schema version. New + * projects and fresh normal installs start current; legacy `1` is only ever + * an input format read from disk. + */ export function emptyLockfile(): Lockfile { - return { lockfileVersion: LOCKFILE_VERSION, facets: {} } + return { lockfileVersion: CURRENT_LOCKFILE_VERSION, facets: {} } } diff --git a/packages/engine/src/install/run-install.ts b/packages/engine/src/install/run-install.ts index ae944c6b..3e865660 100644 --- a/packages/engine/src/install/run-install.ts +++ b/packages/engine/src/install/run-install.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import type { FacetsJson, Lockfile } from '@agent-facets/protocol' +import { CURRENT_LOCKFILE_VERSION, type FacetsJson, type Lockfile } from '@agent-facets/protocol' import { type AdapterCompatibilityFailure, compatibilityFailureFor } from '../adapters/api-compatibility.ts' import { loadFacetsJson } from '../manifest/project-files.ts' import { applyManifestWritePolicy, mergeDeltaIntoManifest } from './commit/delta.ts' @@ -173,8 +173,16 @@ export async function runInstall(opts: RunInstallOptions): Promise/SKILL.md` plus every declared + * companion; an agent or command owns exactly its single conventional + * primary file. The `files` array matches the `CurrentLockfileAsset` shape + * (sorted by path, at least one record) so it can be written directly into a + * `0.2` lockfile entry. + */ +export interface VerifiedAsset { + scope: 'system' | 'user' | 'project' + type: 'skill' | 'agent' | 'command' + name: string + files: VerifiedAssetFile[] +} + +/** One owned file: canonical inner-archive path plus its recomputed hash. */ +export interface VerifiedAssetFile { + path: string + integrity: string +} + +export type BuildVerifiedAssetPlanResult = + | { ok: true; plan: VerifiedAssetPlan } + | { ok: false; errors: ValidationError[] } + +/** + * Derive the verified asset plan for a facet whose content lives in a + * verified directory (a registry/git cache slot or a built local dir). + * + * All four resolve paths funnel through this one producer, so classification + * and hashing happen in exactly one place regardless of source kind. + * + * `verifiedDir` MUST already be verified by the caller (cache self-audit, + * three-check, or one-check) — this function recomputes per-file hashes over + * its bytes but does not itself establish trust in the directory. + */ +export function buildVerifiedAssetPlan(manifest: FacetManifest, verifiedDir: string): BuildVerifiedAssetPlanResult { + // 1. Classify every inner-archive entry via the shared protocol plan. + const planResult = planArchiveEntries(manifest) + if (!planResult.ok) { + return { ok: false, errors: planResult.errors } + } + + // 2. Recompute per-file hashes over the verified bytes for every entry + // except the embedded manifest (facet.json is not a materialized asset + // file and never appears in an asset's owned set). + const hashPaths = planResult.data.filter((entry) => entry.kind !== 'manifest').map((entry) => entry.path) + const recomputed = computeDirIntegrity(verifiedDir, hashPaths) + if (!recomputed.ok) { + return { + ok: false, + errors: [ + { + path: recomputed.path, + message: + recomputed.reason === 'unreadable' + ? `verified asset file could not be read: ${recomputed.path}` + : `verified asset file has an unsafe path: ${recomputed.path}`, + expected: recomputed.reason === 'unreadable' ? 'a readable file' : 'a safe canonical path', + actual: recomputed.reason, + }, + ], + } + } + const hashFor = (path: string): string => { + const hash = recomputed.assetHashes[path] + // `computeDirIntegrity` succeeded over exactly `hashPaths`, so every + // planned non-manifest path has a hash. This guard is defensive. + if (hash === undefined) { + throw new Error(`internal: missing recomputed hash for planned path "${path}"`) + } + return hash + } + + // 3. Group primary assets with their owned companions; collect + // archive-only entries separately. + const assetsByKey = new Map() + const skillKeyByName = new Map() + const archiveOnly: VerifiedAssetFile[] = [] + + for (const entry of planResult.data) { + switch (entry.kind) { + case 'manifest': + break + case 'primary-asset': { + const key = `${entry.assetType}:${entry.name}` + const asset: VerifiedAsset = { + scope: 'project', + type: entry.assetType, + name: entry.name, + files: [{ path: entry.path, integrity: hashFor(entry.path) }], + } + assetsByKey.set(key, asset) + if (entry.assetType === 'skill') { + skillKeyByName.set(entry.name, key) + } + break + } + case 'skill-companion': { + const key = skillKeyByName.get(entry.skill) + // `planArchiveEntries` only emits a skill-companion for a declared + // skill, so the owning primary asset was already collected above. + if (key === undefined) { + throw new Error(`internal: companion "${entry.path}" references undeclared skill "${entry.skill}"`) + } + const owner = assetsByKey.get(key) + if (owner === undefined) { + throw new Error(`internal: missing owning asset for companion "${entry.path}"`) + } + owner.files.push({ path: entry.path, integrity: hashFor(entry.path) }) + break + } + case 'archive-only': + archiveOnly.push({ path: entry.path, integrity: hashFor(entry.path) }) + break + } + } + + // 4. Sort each asset's files by path (the `CurrentLockfileAsset` narrow + // requires strict lexicographic ordering and rejects duplicates) and + // the asset list deterministically (skills → agents → commands, then by + // name) so lockfile diffs stay stable. + const assets = [...assetsByKey.values()].sort(assetOrder) + for (const asset of assets) { + asset.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + } + archiveOnly.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + + return { ok: true, plan: { assets, archiveOnly } } +} + +const TYPE_ORDER: Record = { skill: 0, agent: 1, command: 2 } + +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 +}