From 8c973980c0d0238f7d23e066e8fe2d542e16a0ae Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Wed, 29 Jul 2026 00:47:40 -0400 Subject: [PATCH 1/2] feat(core): add Manifest v2 trust boundary --- apps/desktop/src/main/main.mjs | 41 +- apps/desktop/src/main/service-host.mjs | 34 +- .../content/docs/docs/reference/manifest.md | 127 ++- package.json | 2 +- packages/core/src/manifest.mjs | 834 ++++++++++++++++++ packages/core/src/pack-manager.mjs | 127 +-- packages/core/src/service.mjs | 117 +-- packages/core/src/sources/cache.mjs | 30 +- packages/core/src/sources/index.mjs | 8 +- packages/core/tests/manifest.test.mjs | 491 +++++++++++ packages/core/tests/pack-test.sh | 21 + specs/contextcake-project-profiles/design.md | 2 +- specs/contextcake-project-profiles/spec.md | 2 +- 13 files changed, 1642 insertions(+), 194 deletions(-) create mode 100644 packages/core/src/manifest.mjs create mode 100644 packages/core/tests/manifest.test.mjs diff --git a/apps/desktop/src/main/main.mjs b/apps/desktop/src/main/main.mjs index bf0c88a..7e0e0ab 100644 --- a/apps/desktop/src/main/main.mjs +++ b/apps/desktop/src/main/main.mjs @@ -189,28 +189,26 @@ function safeProfiles(profiles) { } function applyPulledManifest(settings) { - const current = readManifestConfig() const currentUserId = authManager?.getUserId?.() ?? null const incomingSources = Array.isArray(settings?.sources) ? settings.sources : null - const layers = incomingSources ? incomingSources.filter(sourceIsRunnable) : current.layers - const pendingSources = incomingSources ? incomingSources.filter((source) => !sourceIsRunnable(source)) : current.pendingSources const profiles = safeProfiles(settings?.profiles) - const next = { - ...current, - ...(layers ? { layers } : {}), - ...(pendingSources?.length ? { pendingSources, pendingSourcesOwnerUserId: currentUserId } : {}), - ...(profiles ? { profiles, profilesOwnerUserId: currentUserId } : {}), - } - if (!pendingSources?.length) { - delete next.pendingSources - delete next.pendingSourcesOwnerUserId - } - if (isDeepStrictEqual(current, next)) return - const serialized = `${JSON.stringify(next, null, 2)}\n` - const temporary = `${manifestPath()}.tmp` - fs.writeFileSync(temporary, serialized, { mode: 0o600 }) - fs.renameSync(temporary, manifestPath()) - lastAppliedManifest = serialized + const mutation = service.mutateManifest((current) => { + const layers = incomingSources ? incomingSources.filter(sourceIsRunnable) : current.layers + const pendingSources = incomingSources ? incomingSources.filter((source) => !sourceIsRunnable(source)) : current.pendingSources + const next = { + ...current, + ...(layers ? { layers } : {}), + ...(pendingSources?.length ? { pendingSources, pendingSourcesOwnerUserId: currentUserId } : {}), + ...(profiles ? { profiles, profilesOwnerUserId: currentUserId } : {}), + } + if (!pendingSources?.length) { + delete next.pendingSources + delete next.pendingSourcesOwnerUserId + } + return isDeepStrictEqual(current, next) ? null : next + }) + if (!mutation.changed) return + lastAppliedManifest = mutation.serialized service?.reload?.() } @@ -370,7 +368,10 @@ async function initializeAccounts() { let wasSignedIn = currentAuthState().signedIn authManager.on('session-changed', (state) => { sendToRenderer('auth:session-changed', state) - if (state.signedIn && !wasSignedIn) syncAfterSignIn() + // Startup can finish an OAuth deep link before the engine service exists. + // The post-createWindow bootstrap below performs that first pull; only + // already-running app sessions sync immediately from this event. + if (state.signedIn && !wasSignedIn && service) syncAfterSignIn() wasSignedIn = state.signedIn }) settingsSync.on('status-changed', (state) => sendToRenderer('settings:sync-status', state)) diff --git a/apps/desktop/src/main/service-host.mjs b/apps/desktop/src/main/service-host.mjs index 3c0a15c..632a1d5 100644 --- a/apps/desktop/src/main/service-host.mjs +++ b/apps/desktop/src/main/service-host.mjs @@ -12,6 +12,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { pathToFileURL } from 'node:url' import { utilityProcess } from 'electron' import { enginePaths, manifestPath, configDir } from './paths.mjs' @@ -23,13 +24,15 @@ const here = path.dirname(fileURLToPath(import.meta.url)) const BOOT_TIMEOUT_MS = 20_000 const RELOAD_TIMEOUT_MS = 5_000 -function ensureConfig() { +function ensureConfig(withManifestLock, writeContextManifest) { fs.mkdirSync(configDir(), { recursive: true }) - if (!fs.existsSync(manifestPath())) { - // Valid empty manifest: the console shows its first-run SetupWizard when - // the cascade has zero sources and writes layers through /api/sources. - fs.writeFileSync(manifestPath(), JSON.stringify({ layers: [] }, null, 2) + '\n') - } + withManifestLock(manifestPath(), () => { + if (!fs.existsSync(manifestPath())) { + // Valid empty manifest: the console shows its first-run SetupWizard when + // the cascade has zero sources and writes layers through /api/sources. + writeContextManifest(manifestPath(), { layers: [] }) + } + }) } /** @@ -40,8 +43,14 @@ function ensureConfig() { * engine, so the caller treats it as fatal. */ export async function startEngineService({ onCrash } = {}) { - ensureConfig() const { serviceModule, consoleDist } = enginePaths() + const manifestModule = path.join(path.dirname(serviceModule), 'manifest.mjs') + const { + readContextManifest, + withManifestLock, + writeContextManifest, + } = await import(pathToFileURL(manifestModule).href) + ensureConfig(withManifestLock, writeContextManifest) const child = utilityProcess.fork( path.join(here, 'engine-process.mjs'), @@ -109,6 +118,17 @@ export async function startEngineService({ onCrash } = {}) { } }) }, + mutateManifest(buildCandidate) { + return withManifestLock(manifestPath(), () => { + const current = readContextManifest(manifestPath(), { allowMissing: false }) + const candidate = buildCandidate(current) + if (candidate === null) { + return { changed: false, serialized: `${JSON.stringify(current, null, 2)}\n` } + } + writeContextManifest(manifestPath(), candidate, { allowTransitional: true }) + return { changed: true, serialized: `${JSON.stringify(candidate, null, 2)}\n` } + }) + }, close() { if (closing) return closing = true diff --git a/apps/site/src/content/docs/docs/reference/manifest.md b/apps/site/src/content/docs/docs/reference/manifest.md index aa2211c..9182f35 100644 --- a/apps/site/src/content/docs/docs/reference/manifest.md +++ b/apps/site/src/content/docs/docs/reference/manifest.md @@ -1,14 +1,20 @@ --- -title: layers.json manifest -description: Layer names, levels, and sources — the complete manifest schema. +title: Manifest reference +description: Profiles, project mappings, layer sources, precedence, and compatibility. --- -The manifest is a single JSON file that declares your layer stack. Every command -that resolves knowledge (`resolver.mjs`, `mcp-server.mjs`, `write.mjs`) is pointed -at one with `--manifest`. It is the one file that defines what layers exist, in -what order they take precedence, and where each layer's knowledge comes from. +The manifest is a single local JSON file that declares ContextCake's profiles, +project mappings, layer stacks, and Pack assignments. Every command that resolves +knowledge is pointed at one with `--manifest`. -## Schema +ContextCake still reads the original flat `layers` shape without rewriting it. +The Project Profiles rollout adds a canonical v2 shape with a required `default` +profile. The shared manifest and Pack code understand v2 now; automatic profile +selection in `resolver.mjs` and `mcp-server.mjs` arrives in the next implementation +slice. Until that wiring ships, keep agent-facing manifests flat rather than +manually converting them. + +## Current flat schema ```json { @@ -20,9 +26,102 @@ what order they take precedence, and where each layer's knowledge comes from. } ``` -The main top-level key is `layers`, an ordered array of layer objects. The local Pack -manager may also maintain a `packs` registry of installed versions and assignments. That -registry is bookkeeping for rollback; the resolver reads only the explicit layer entries. +The main top-level key is `layers`, an array of layer objects. The local Pack manager +may also maintain a `packs` registry of installed versions and assignments. That +registry is bookkeeping for rollback; resolution reads only explicit layer entries. + +Reading a flat manifest creates an in-memory virtual profile with id `default`. It +does not alter the file. Creating the first additional profile is the deliberate +migration point described below. + +## Manifest v2: Project Profiles + +Canonical v2 moves every runnable layer into one profile and stores local project +folder mappings separately: + +```json +{ + "profiles": { + "default": { + "label": "Default", + "layers": [ + { "name": "personal", "level": 3, "source": "files", "path": "/Users/you/Notes" } + ] + }, + "payments": { + "label": "Payments", + "layers": [ + { "name": "repo", "level": 3, "source": "github", "repo": "acme/payments", "paths": ["docs/**"] }, + { "name": "team-pack", "level": 0, "source": "okf-local", "path": "packs/team/1.0.0" } + ], + "pendingSources": [] + } + }, + "projects": { + "/Users/you/Code/payments": "payments" + }, + "packs": {} +} +``` + +The v2 rules are intentionally strict: + +- `profiles.default` is required and cannot be deleted. +- Profile ids are stable lowercase slugs of at most 63 characters. Changing a + visible `label` does not change the id. +- Each profile owns a complete `layers` array. Layer names need to be unique only + within that profile. +- `projects` maps absolute, machine-local folders to profile ids. The paths are + never uploaded by settings sync. +- `pendingSources` holds synced descriptors that are incomplete or not yet + trusted on this machine. They are visible repair tasks, never runnable layers. +- Canonical v2 has `profiles` and no top-level `layers`. + +### Selection order + +Profile-aware commands use one deterministic order: + +1. An explicit `--profile ` wins. +2. Otherwise, the deepest canonical project folder containing the process working + directory wins. Matching uses path segments, not a raw string prefix. +3. With no match, the required `default` profile wins. + +An explicit or matched unknown profile fails closed. It never falls through to +unrelated default context. Symlink aliases are resolved to real paths; two equally +specific aliases that name different profiles are a configuration error. + +### Migration and transitional manifests + +Existing flat manifests are not migrated on read. Creating the first additional +profile performs one locked transaction: + +1. Re-read and validate the latest manifest. +2. Write and verify a mode-`0600` backup whose filename contains a UTC timestamp + and SHA-256 of the original bytes. +3. Move the exact flat layer array to `profiles.default.layers`. +4. Convert default-stack Pack assignments to profile `default` and quarantine + incomplete synced source descriptors in `pendingSources`. +5. Validate the complete candidate and atomically replace the manifest. + +Some current settings-sync and Pack combinations can contain both top-level +`layers` and profile metadata. ContextCake recognizes that as a transitional +shape and continues running the flat stack until explicit normalization. General +writers reject newly created split-brain documents; only compatibility operations +may update a shipped transitional file. + +Profile and source mutations share one adjacent lock file, so concurrent Pack, +source, mapping, and profile operations cannot overwrite one another. Pack +assignment, active version, precedence, origin, and layer references are validated +as one contract before an atomic write. + +### Cache identity + +Profile-aware cache entries use an opaque SHA-256 fingerprint derived from the +profile id and canonical source configuration. The fingerprint includes source +kind and name plus its local root, repository/ref/path selection, endpoint, MCP +command/arguments, and adapter options as applicable. Raw local paths do not appear +in the cache namespace. Equal layer names in two profiles, renamed sources, and the +same repository at different refs therefore cannot share cached content. | Field | Required | Applies to | Meaning | |-------|----------|------------|---------| @@ -43,8 +142,9 @@ registry is bookkeeping for rollback; the resolver reads only the explicit layer When a concept exists in more than one layer, the resolver merges it per section: the highest `level` that speaks to a given section wins that section, and everything else is inherited from below. Levels are integers you choose — higher is more -authoritative. Precedence is decided by level alone: two layers at the same level -keep the first one listed. +authoritative. When two contributors have the same level, the most recently +updated contributor wins that horizontal tie; array order is not an extra +precedence rule. See [Merge semantics](/docs/concepts/merge-semantics) and [Layer cake](/docs/concepts/layer-cake) for how precedence plays out across sections. @@ -191,7 +291,8 @@ that records the Pack identity and active version: Do not edit installed Pack directories. Put personal or team changes in a separate, higher-precedence layer. Updates switch only the Pack-managed layer path; rollback points it at a retained version; removal detaches it. None of those operations overwrite or -delete another layer. +delete another layer. In v2, every Pack assignment names its profile explicitly; +the same retained immutable version may be attached to more than one profile. ## The bundled demo manifest diff --git a/package.json b/package.json index 7539bf6..6531bc3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Federated team knowledge with cascading layer precedence — OKF-compatible, MCP-ready.", "type": "module", "scripts": { - "test": "bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh", + "test": "bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && node --test packages/core/tests/manifest.test.mjs && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh", "mcp": "node mcp-server.mjs", "playground": "node apps/playground/server.mjs", "console:live": "npm --prefix apps/console run build:live && node apps/playground/server.mjs --console apps/console/dist", diff --git a/packages/core/src/manifest.mjs b/packages/core/src/manifest.mjs new file mode 100644 index 0000000..8029ec7 --- /dev/null +++ b/packages/core/src/manifest.mjs @@ -0,0 +1,834 @@ +// Shared ContextCake manifest boundary. +// +// This module owns schema-mode detection, profile selection, migration, and +// serialized atomic writes. Callers may inspect a complete manifest here, but +// runtime adapters should receive only the selected layer array returned by +// selectManifestProfile(). The dependency-free core deliberately uses only +// Node.js built-ins. + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +export const PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/; +export const MANIFEST_LOCK_TIMEOUT_MS = 15_000; +export const MANIFEST_LOCK_STALE_MS = 60_000; + +const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const RUNNABLE_SOURCE_KINDS = new Set(["okf-local", "files", "github", "mcp"]); +const CREDENTIAL_PATTERN = /(?:github_pat_|gh[pousr]_|sk-[A-Za-z0-9]|bearer\s+[A-Za-z0-9._-])/i; +const CREDENTIAL_KEY_PATTERN = /^(?:(?:[a-z0-9]+_)?token|access[_-]?token|refresh[_-]?token|password|passwd|secret|client[_-]?secret|private[_-]?key|api[_-]?key|credential|authorization|cookie)$/i; +const CREDENTIAL_VALUE_PATTERN = /(?:github_pat_[A-Za-z0-9_]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|glpat-[A-Za-z0-9_-]{12,}|npm_[A-Za-z0-9]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|(?:AKIA|ASIA)[A-Z0-9]{16}|sk-[A-Za-z0-9_-]{12,}|bearer\s+[A-Za-z0-9._-]{12,}|eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|https?:\/\/[^/@\s]+:[^/@\s]+@)/i; +const CREDENTIAL_ASSIGNMENT_PATTERN = /(?:^|[\s?&#:_'"-])(?:access[_-]?token|token|api[_-]?key|client[_-]?secret|private[_-]?key|secret|password|credential|authorization|signature|sig)\s*(?:=|:)/i; + +export function classifyManifest(manifest) { + assertObject(manifest, "ContextCake manifest"); + const hasLayers = Object.hasOwn(manifest, "layers"); + const hasProfiles = Object.hasOwn(manifest, "profiles"); + if (hasLayers && hasProfiles) return "transitional"; + if (hasProfiles) return "v2"; + return "legacy"; +} + +export function validateContextManifest(manifest, { validatePacks = true } = {}) { + assertSafeKeys(manifest); + rejectCredentialFields(manifest, "ContextCake manifest"); + rejectCredentialValues(manifest, "ContextCake manifest", { allowScrubbed: true }); + const mode = classifyManifest(manifest); + const warnings = []; + + if (mode === "legacy" || mode === "transitional") { + if (manifest.layers !== undefined && !Array.isArray(manifest.layers)) throw new Error("ContextCake manifest layers must be an array."); + validateLayers(manifest.layers ?? [], "legacy default"); + } + + if (mode === "v2" || mode === "transitional") { + assertObject(manifest.profiles, "ContextCake manifest profiles"); + if (mode === "v2" && !Object.hasOwn(manifest.profiles, "default")) { + throw new Error("Manifest v2 requires profiles.default."); + } + for (const [profileId, profile] of Object.entries(manifest.profiles)) { + assertProfileId(profileId); + assertObject(profile, `Profile ${profileId}`); + if (profile.label !== undefined) normalizeProfileLabel(profile.label); + if (!Array.isArray(profile.layers)) throw new Error(`Profile ${profileId} does not have a layers array.`); + if (mode === "transitional") validateTransitionalProfileLayers(profile.layers, `profile ${profileId}`); + else validateLayers(profile.layers, `profile ${profileId}`); + if (profile.pendingSources !== undefined) { + if (!Array.isArray(profile.pendingSources)) throw new Error(`Profile ${profileId} pendingSources must be an array.`); + validatePendingSources(profile.pendingSources, `profile ${profileId}`); + } + } + } + + if (manifest.pendingSources !== undefined) { + if (!Array.isArray(manifest.pendingSources)) throw new Error("ContextCake manifest pendingSources must be an array."); + validatePendingSources(manifest.pendingSources, "legacy default"); + } + + if (manifest.projects !== undefined) { + assertObject(manifest.projects, "ContextCake manifest projects"); + for (const [root, profileId] of Object.entries(manifest.projects)) { + if (!path.isAbsolute(root)) throw new Error(`Project mapping must use an absolute path: ${root}`); + assertProfileId(profileId); + if (mode !== "v2" || !Object.hasOwn(manifest.profiles, profileId)) { + warnings.push({ code: "dangling-project-profile", root, profileId }); + } + } + } + + if (validatePacks) validatePackRegistry(manifest, mode, warnings); + return { mode, warnings }; +} + +export function readContextManifest(manifestPath, { allowMissing = true, validatePacks = true } = {}) { + const resolved = path.resolve(manifestPath); + if (!fs.existsSync(resolved)) { + if (allowMissing) return { layers: [] }; + throw new Error(`ContextCake manifest does not exist: ${resolved}`); + } + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(resolved, "utf8")); + } catch (error) { + throw new Error(`ContextCake manifest is not valid JSON: ${error.message}`); + } + validateContextManifest(manifest, { validatePacks }); + return manifest; +} + +export function getManifestProfileLayers(manifest, profile = null) { + const { mode } = validateContextManifest(manifest); + if (mode === "legacy") { + if (profile !== null && profile !== "default") throw new Error(`Unknown ContextCake profile: ${profile}`); + manifest.layers ??= []; + return manifest.layers; + } + if (mode === "transitional") { + if (profile === null || profile === "default") return manifest.layers; + if (!Object.hasOwn(manifest.profiles, profile)) throw new Error(`Unknown ContextCake profile: ${profile}`); + return manifest.profiles[profile].layers; + } + const profileId = profile ?? "default"; + if (!Object.hasOwn(manifest.profiles, profileId)) throw new Error(`Unknown ContextCake profile: ${profileId}`); + return manifest.profiles[profileId].layers; +} + +export function selectManifestProfile(manifest, { + requestedProfile = null, + cwd = process.cwd(), + realpath = fs.realpathSync.native, +} = {}) { + const { mode, warnings } = validateContextManifest(manifest); + if (mode === "legacy" || mode === "transitional") { + if (requestedProfile !== null && requestedProfile !== "default") { + throw new Error(`Profile ${requestedProfile} requires migration to Manifest v2.`); + } + return { + mode, + profileId: "default", + profileLabel: "Default", + layers: manifest.layers ?? [], + reason: requestedProfile === "default" ? "explicit" : "legacy-default", + matchedProjectRoot: null, + warnings, + }; + } + + if (requestedProfile !== null) { + assertProfileId(requestedProfile); + return selectedV2Profile(manifest, requestedProfile, "explicit", null, warnings); + } + + const canonicalCwd = canonicalExistingPath(cwd, realpath, "Working directory"); + const matches = []; + for (const [configuredRoot, profileId] of Object.entries(manifest.projects ?? {})) { + let canonicalRoot; + try { + canonicalRoot = canonicalExistingPath(configuredRoot, realpath, "Project mapping"); + } catch { + warnings.push({ code: "stale-project-root", root: configuredRoot, profileId }); + continue; + } + if (!containsPath(canonicalRoot, canonicalCwd)) continue; + matches.push({ configuredRoot, canonicalRoot, profileId, depth: pathDepth(canonicalRoot) }); + } + matches.sort((a, b) => b.depth - a.depth || b.canonicalRoot.length - a.canonicalRoot.length); + if (matches.length) { + const winner = matches[0]; + const conflict = matches.find((candidate) => ( + candidate !== winner + && candidate.depth === winner.depth + && candidate.canonicalRoot === winner.canonicalRoot + && candidate.profileId !== winner.profileId + )); + if (conflict) throw new Error(`Project mappings resolve the same canonical root to different profiles: ${winner.configuredRoot} and ${conflict.configuredRoot}`); + return selectedV2Profile(manifest, winner.profileId, "project", winner.canonicalRoot, warnings); + } + return selectedV2Profile(manifest, "default", "default", null, warnings); +} + +export function listManifestProfiles(manifest) { + const { mode, warnings } = validateContextManifest(manifest); + if (mode === "legacy") { + return [{ id: "default", label: "Default", sourceCount: manifest.layers?.length ?? 0, mappingCount: 0, mode, valid: true }]; + } + if (mode === "transitional") { + const virtual = [{ id: "default", label: "Default", sourceCount: manifest.layers.length, mappingCount: 0, mode, valid: true }]; + return virtual.concat(Object.entries(manifest.profiles) + .filter(([id]) => id !== "default") + .map(([id, profile]) => profileSummary(id, profile, manifest.projects, mode, warnings))); + } + return Object.entries(manifest.profiles).map(([id, profile]) => profileSummary(id, profile, manifest.projects, mode, warnings)); +} + +export function normalizeProfileLabel(value) { + if (typeof value !== "string") throw new Error("Profile label must be a string."); + const label = value.normalize("NFC").trim(); + if (label.length < 1 || [...label].length > 80) throw new Error("Profile label must contain 1 to 80 characters."); + if (/\p{Cc}|\p{Cf}/u.test(label) || /[\r\n]/.test(label)) throw new Error("Profile label cannot contain control characters or line breaks."); + if (CREDENTIAL_PATTERN.test(label)) throw new Error("Profile label looks like a credential and was rejected."); + return label; +} + +export function createProfileId(label, existingIds = []) { + const normalized = normalizeProfileLabel(label) + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63) + .replace(/-+$/g, "") || "profile"; + const occupied = new Set(existingIds); + if (!occupied.has(normalized)) return normalized; + for (let suffix = 2; suffix < 1_000_000; suffix += 1) { + const tail = `-${suffix}`; + const candidate = `${normalized.slice(0, 63 - tail.length).replace(/-+$/g, "")}${tail}`; + if (!occupied.has(candidate)) return candidate; + } + throw new Error("Could not allocate a unique profile id."); +} + +export function manifestRevision(manifest) { + return crypto.createHash("sha256").update(stableJson(manifest)).digest("hex"); +} + +export function sourceConfigFingerprint(profileId, layer, manifestDir = process.cwd()) { + assertProfileId(profileId); + validateLayer(layer, `profile ${profileId}`); + const kind = layer.source ?? "okf-local"; + const localRoot = (kind === "okf-local" || kind === "files") && typeof layer.path === "string" + ? canonicalConfiguredPath(manifestDir, layer.path) + : null; + const mcpArgs = kind === "mcp" + ? (layer.args ?? []).map((argument) => ( + argument.startsWith("./") || argument.startsWith("../") + ? canonicalConfiguredPath(manifestDir, argument) + : argument + )) + : null; + const config = { + profileId, + name: layer.name, + kind, + root: localRoot, + repo: layer.repo ?? null, + ref: layer.ref ?? null, + paths: layer.paths ?? null, + apiBase: layer.apiBase ?? null, + auth: layer.auth ?? null, + command: kind === "mcp" ? layer.command ?? null : null, + args: mcpArgs, + cache: layer.cache ? { ttlSeconds: layer.cache.ttlSeconds ?? null } : null, + git: layer.git ?? null, + }; + return crypto.createHash("sha256").update(stableJson(config)).digest("hex"); +} + +export function writeContextManifest(manifestPath, manifest, { + allowLegacy = true, + allowTransitional = false, +} = {}) { + const { mode } = validateContextManifest(manifest); + if (mode === "legacy" && !allowLegacy) throw new Error("A legacy manifest cannot be written by this operation."); + if (mode === "transitional" && !allowTransitional) { + throw new Error("A transitional manifest can only be written by an explicit compatibility operation or normalized to v2."); + } + writeAtomicJson(path.resolve(manifestPath), manifest); +} + +export function withManifestLock(manifestPath, mutate, { + timeoutMs = MANIFEST_LOCK_TIMEOUT_MS, + staleMs = MANIFEST_LOCK_STALE_MS, +} = {}) { + const resolved = path.resolve(manifestPath); + const lockPath = `${resolved}.lock`; + fs.mkdirSync(path.dirname(resolved), { recursive: true, mode: 0o700 }); + const deadline = Date.now() + timeoutMs; + let token = tryAcquireManifestLock(lockPath, staleMs); + while (token === null) { + if (Date.now() >= deadline) throw new Error(`Timed out acquiring the ContextCake manifest lock at ${lockPath}.`); + sleepSync(50); + token = tryAcquireManifestLock(lockPath, staleMs); + } + try { + return mutate(); + } finally { + releaseManifestLock(lockPath, token); + } +} + +export function mutateContextManifest(manifestPath, mutate, { + allowMissing = true, + allowLegacy = true, + allowTransitional = false, +} = {}) { + const resolved = path.resolve(manifestPath); + return withManifestLock(resolved, () => { + const manifest = readContextManifest(resolved, { allowMissing }); + const result = mutate(manifest); + writeContextManifest(resolved, manifest, { allowLegacy, allowTransitional }); + return result; + }); +} + +export function migrateManifestToV2(manifestPath, { + newProfile = null, + projectPath = null, + now = () => new Date(), + realpath = fs.realpathSync.native, +} = {}) { + const resolved = path.resolve(manifestPath); + return withManifestLock(resolved, () => { + const raw = fs.readFileSync(resolved); + const manifest = readContextManifest(resolved, { allowMissing: false }); + const beforeMode = classifyManifest(manifest); + if (newProfile) validateNewProfile(newProfile, manifest); + + if (beforeMode === "v2") { + const candidate = structuredClone(manifest); + applyNewProfile(candidate, newProfile, projectPath, realpath); + if (newProfile) writeContextManifest(resolved, candidate, { allowLegacy: false }); + return { action: newProfile ? "profile-created" : "already-v2", mode: "v2", backupPath: null, backupHash: null }; + } + + const candidate = normalizeToV2(manifest); + applyNewProfile(candidate, newProfile, projectPath, realpath); + validateContextManifest(candidate); + + const backupHash = crypto.createHash("sha256").update(raw).digest("hex"); + const stamp = formatUtcTimestamp(now()); + const backupPath = `${resolved}.pre-profiles.${stamp}.${backupHash}.json`; + writeVerifiedBackup(backupPath, raw, backupHash); + writeContextManifest(resolved, candidate, { allowLegacy: false }); + return { action: "migrated", mode: "v2", backupPath, backupHash }; + }); +} + +export function verifyManifestBackup(backupPath, expectedHash) { + if (!/^([a-f0-9]{64})$/.test(expectedHash)) throw new Error("Expected backup hash must be a SHA-256 hex digest."); + const actual = crypto.createHash("sha256").update(fs.readFileSync(backupPath)).digest("hex"); + if (actual !== expectedHash) throw new Error(`Manifest backup hash mismatch: expected ${expectedHash}, received ${actual}.`); + return true; +} + +function selectedV2Profile(manifest, profileId, reason, matchedProjectRoot, warnings) { + if (!Object.hasOwn(manifest.profiles, profileId)) throw new Error(`Unknown ContextCake profile: ${profileId}`); + const profile = manifest.profiles[profileId]; + return { + mode: "v2", + profileId, + profileLabel: profile.label ?? (profileId === "default" ? "Default" : profileId), + layers: profile.layers, + reason, + matchedProjectRoot, + warnings, + }; +} + +function profileSummary(id, profile, projects, mode, warnings) { + return { + id, + label: profile.label ?? (id === "default" ? "Default" : id), + sourceCount: profile.layers.length, + pendingSourceCount: profile.pendingSources?.length ?? 0, + mappingCount: Object.values(projects ?? {}).filter((profileId) => profileId === id).length, + mode, + valid: !warnings.some((warning) => warning.profileId === id), + }; +} + +function validateLayers(layers, owner) { + const names = new Set(); + let liveCount = 0; + for (const layer of layers) { + validateLayer(layer, owner); + if (names.has(layer.name)) throw new Error(`${owner} contains duplicate layer name: ${layer.name}`); + names.add(layer.name); + if (layer.live === true) liveCount += 1; + } + if (liveCount > 1) throw new Error(`${owner} contains more than one live layer.`); +} + +function validateTransitionalProfileLayers(layers, owner) { + const names = new Set(); + for (const layer of layers) { + assertObject(layer, `Layer in ${owner}`); + rejectCredentialFields(layer, `Layer in ${owner}`); + rejectCredentialValues(layer, `Layer in ${owner}`, { allowScrubbed: true }); + if (typeof layer.name !== "string" || !layer.name.trim()) throw new Error(`Layer in ${owner} must have a non-empty name.`); + if (names.has(layer.name)) throw new Error(`${owner} contains duplicate layer name: ${layer.name}`); + names.add(layer.name); + if (isRunnableLayer(layer)) validateLayer(layer, owner); + else validateAuthReference(layer.auth, `Pending source ${layer.name}`, { allowScrubbed: true }); + } +} + +function validateLayer(layer, owner) { + assertObject(layer, `Layer in ${owner}`); + rejectCredentialFields(layer, `Layer in ${owner}`); + rejectCredentialValues(layer, `Layer in ${owner}`); + if (typeof layer.name !== "string" || !layer.name.trim()) throw new Error(`Layer in ${owner} must have a non-empty name.`); + if (!Number.isInteger(Number(layer.level))) throw new Error(`Layer ${layer.name} in ${owner} must have an integer level.`); + const kind = layer.source ?? "okf-local"; + if (!RUNNABLE_SOURCE_KINDS.has(kind)) throw new Error(`Layer ${layer.name} has unsupported source kind: ${kind}`); + if ((kind === "okf-local" || kind === "files") && typeof layer.path !== "string") { + throw new Error(`Layer ${layer.name} requires a path.`); + } + if (kind === "github" && typeof layer.repo !== "string") throw new Error(`Layer ${layer.name} requires a GitHub repo.`); + if (kind === "mcp" && typeof layer.command !== "string") throw new Error(`Layer ${layer.name} requires an MCP command.`); + if (layer.args !== undefined && (!Array.isArray(layer.args) || layer.args.some((value) => typeof value !== "string"))) { + throw new Error(`Layer ${layer.name} args must be an array of strings.`); + } + validateAuthReference(layer.auth, `Layer ${layer.name}`); +} + +function validatePendingSources(sources, owner) { + const names = new Set(); + for (const source of sources) { + assertObject(source, `Pending source in ${owner}`); + rejectCredentialFields(source, `Pending source in ${owner}`); + rejectCredentialValues(source, `Pending source in ${owner}`, { allowScrubbed: true }); + if (typeof source.name !== "string" || !source.name.trim()) throw new Error(`Pending source in ${owner} must have a non-empty name.`); + if (names.has(source.name)) throw new Error(`${owner} contains duplicate pending source name: ${source.name}`); + names.add(source.name); + validateAuthReference(source.auth, `Pending source ${source.name}`, { allowScrubbed: true }); + } +} + +function validatePackRegistry(manifest, mode, warnings) { + if (manifest.packs === undefined) return; + assertObject(manifest.packs, "ContextCake manifest packs registry"); + const assignedLayers = new Set(); + for (const [packId, record] of Object.entries(manifest.packs)) { + assertObject(record, `Pack registry entry ${packId}`); + if (record.id !== undefined && record.id !== packId) throw new Error(`Pack registry key ${packId} does not match record id ${record.id}.`); + if (!Array.isArray(record.installedVersions) || !Array.isArray(record.assignments)) { + throw new Error(`Pack registry entry is missing version or assignment arrays: ${packId}`); + } + const versions = new Set(); + for (const entry of record.installedVersions) { + assertObject(entry, `Installed Pack version ${packId}`); + if (typeof entry.version !== "string" || !entry.version) throw new Error(`Pack ${packId} has an invalid installed version.`); + if (versions.has(entry.version)) throw new Error(`Pack ${packId} contains duplicate retained version ${entry.version}.`); + versions.add(entry.version); + } + const assignmentProfiles = new Set(); + for (const assignment of record.assignments) { + assertObject(assignment, `Pack assignment ${packId}`); + const profileId = assignment.profile ?? null; + if (profileId !== null) assertProfileId(profileId); + if (typeof assignment.layerName !== "string" || !assignment.layerName) throw new Error(`Pack ${packId} assignment is missing layerName.`); + if (typeof assignment.activeVersion !== "string" || !assignment.activeVersion) throw new Error(`Pack ${packId} assignment is missing activeVersion.`); + if (!Number.isInteger(Number(assignment.level))) throw new Error(`Pack ${packId} assignment has an invalid level.`); + const normalizedProfileId = mode === "v2" && profileId === null ? "default" : (profileId ?? "default"); + if (assignmentProfiles.has(normalizedProfileId)) throw new Error(`Pack ${packId} contains a duplicate assignment for profile ${normalizedProfileId}.`); + assignmentProfiles.add(normalizedProfileId); + if (mode === "v2" && profileId === null) warnings.push({ code: "legacy-null-pack-profile", packId, profileId: "default" }); + let layers; + try { + layers = getLayersWithoutValidation(manifest, mode, profileId); + } catch (error) { + warnings.push({ code: "dangling-pack-profile", packId, profileId, message: error.message }); + continue; + } + if (!versions.has(assignment.activeVersion)) throw new Error(`Pack ${packId} assignment references missing version ${assignment.activeVersion}.`); + const matches = layers.filter((layer) => layer.name === assignment.layerName); + if (matches.length !== 1) throw new Error(`Pack ${packId} assignment must match exactly one layer named ${assignment.layerName}.`); + const layer = matches[0]; + const expectedOrigin = `pack:${packId}@${assignment.activeVersion}`; + if (layer.origin !== expectedOrigin || Number(layer.level) !== Number(assignment.level)) { + throw new Error(`Pack ${packId} assignment does not match layer ${assignment.layerName}.`); + } + assignedLayers.add(`${normalizedProfileId}\0${layer.name}\0${layer.origin}`); + } + } + for (const [profileId, layers] of allRunnableLayerSets(manifest, mode)) { + for (const layer of layers) { + if (typeof layer.origin !== "string" || !layer.origin.startsWith("pack:")) continue; + if (!assignedLayers.has(`${profileId}\0${layer.name}\0${layer.origin}`)) { + throw new Error(`Pack layer ${layer.name} in profile ${profileId} has no matching registry assignment.`); + } + } + } +} + +function getLayersWithoutValidation(manifest, mode, profile) { + if (mode === "legacy") { + if (profile !== null && profile !== "default") throw new Error(`Unknown ContextCake profile: ${profile}`); + return manifest.layers ?? []; + } + if (mode === "transitional") { + if (profile === null || profile === "default") return manifest.layers; + if (!Object.hasOwn(manifest.profiles, profile)) throw new Error(`Unknown ContextCake profile: ${profile}`); + return manifest.profiles[profile].layers; + } + const profileId = profile ?? "default"; + if (!Object.hasOwn(manifest.profiles, profileId)) throw new Error(`Unknown ContextCake profile: ${profileId}`); + return manifest.profiles[profileId].layers; +} + +function allRunnableLayerSets(manifest, mode) { + if (mode === "legacy") return [["default", manifest.layers ?? []]]; + if (mode === "transitional") { + return [["default", manifest.layers], ...Object.entries(manifest.profiles).filter(([id]) => id !== "default").map(([id, profile]) => [id, profile.layers])]; + } + return Object.entries(manifest.profiles).map(([id, profile]) => [id, profile.layers]); +} + +function normalizeToV2(manifest) { + const existingProfiles = structuredClone(manifest.profiles ?? {}); + const output = structuredClone(manifest); + delete output.layers; + delete output.pendingSources; + delete output.pendingSourcesOwnerUserId; + const profiles = {}; + + const existingDefault = existingProfiles.default ?? {}; + const legacyLayers = manifest.layers ?? []; + const defaultIdentities = new Set(legacyLayers.map(sourceIdentity)); + const defaultPending = [ + ...(existingDefault.pendingSources ?? []), + ...(manifest.pendingSources ?? []), + ].filter((source) => !defaultIdentities.has(sourceIdentity(source))); + for (const layer of existingDefault.layers ?? []) { + if (!defaultIdentities.has(sourceIdentity(layer))) defaultPending.push(layer); + } + profiles.default = { + ...existingDefault, + label: normalizeProfileLabel(existingDefault.label ?? "Default"), + layers: structuredClone(legacyLayers), + ...nonEmptyPending(defaultPending), + ...(manifest.pendingSourcesOwnerUserId ? { pendingSourcesOwnerUserId: manifest.pendingSourcesOwnerUserId } : {}), + }; + + for (const [profileId, profile] of Object.entries(existingProfiles)) { + if (profileId === "default") continue; + const runnable = []; + const pending = [...(profile.pendingSources ?? [])]; + for (const layer of profile.layers ?? []) { + if (isRunnableLayer(layer)) runnable.push(layer); + else pending.push(layer); + } + const runnableIdentities = new Set(runnable.map(sourceIdentity)); + profiles[profileId] = { + ...profile, + label: normalizeProfileLabel(profile.label ?? profileId), + layers: runnable, + ...nonEmptyPending(pending.filter((source) => !runnableIdentities.has(sourceIdentity(source)))), + }; + } + output.profiles = profiles; + output.projects ??= {}; + for (const record of Object.values(output.packs ?? {})) { + for (const assignment of record.assignments ?? []) { + if (assignment.profile === null || assignment.profile === undefined) assignment.profile = "default"; + } + } + return output; +} + +function applyNewProfile(manifest, newProfile, projectPath, realpath) { + if (!newProfile) { + if (projectPath) throw new Error("A project mapping requires a new profile."); + return; + } + const id = newProfile.id ?? createProfileId(newProfile.label, Object.keys(manifest.profiles)); + assertProfileId(id); + if (Object.hasOwn(manifest.profiles, id)) throw new Error(`ContextCake profile already exists: ${id}`); + manifest.profiles[id] = { + label: normalizeProfileLabel(newProfile.label), + layers: structuredClone(newProfile.layers ?? []), + ...(newProfile.pendingSources?.length ? { pendingSources: structuredClone(newProfile.pendingSources) } : {}), + }; + if (projectPath) { + const canonical = canonicalExistingPath(projectPath, realpath, "Project mapping"); + for (const [existingRoot, existingId] of Object.entries(manifest.projects ?? {})) { + let existingCanonical; + try { existingCanonical = canonicalExistingPath(existingRoot, realpath, "Project mapping"); } catch { continue; } + if (existingCanonical === canonical && existingId !== id) throw new Error(`Project mapping already belongs to profile ${existingId}: ${existingRoot}`); + } + manifest.projects ??= {}; + manifest.projects[canonical] = id; + } +} + +function validateNewProfile(profile, manifest) { + assertObject(profile, "New profile"); + normalizeProfileLabel(profile.label); + if (profile.id !== undefined) assertProfileId(profile.id); + if (profile.id && Object.hasOwn(manifest.profiles ?? {}, profile.id)) throw new Error(`ContextCake profile already exists: ${profile.id}`); + validateLayers(profile.layers ?? [], "new profile"); + if (profile.pendingSources) validatePendingSources(profile.pendingSources, "new profile"); +} + +function nonEmptyPending(sources) { + const unique = []; + const seen = new Set(); + for (const source of sources) { + if (!source || typeof source !== "object") continue; + const identity = sourceIdentity(source); + if (seen.has(identity)) continue; + seen.add(identity); + unique.push(structuredClone(source)); + } + return unique.length ? { pendingSources: unique } : {}; +} + +function isRunnableLayer(layer) { + if (!layer || typeof layer !== "object" || Array.isArray(layer)) return false; + const kind = layer.source ?? "okf-local"; + if ((kind === "okf-local" || kind === "files") && typeof layer.path !== "string") return false; + if (kind === "mcp" && typeof layer.command !== "string") return false; + if (kind === "github" && typeof layer.repo !== "string") return false; + return RUNNABLE_SOURCE_KINDS.has(kind); +} + +function sourceIdentity(source) { + return `${source?.source ?? "okf-local"}\0${source?.name ?? ""}`; +} + +function rejectCredentialFields(value, label) { + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2"); + if (CREDENTIAL_KEY_PATTERN.test(normalizedKey)) { + throw new Error(`${label} contains forbidden raw credential field: ${key}`); + } + rejectCredentialFields(child, label); + } +} + +function rejectCredentialValues(value, label, { allowScrubbed = false } = {}, key = "") { + if (allowScrubbed && isScrubMarker(value)) return; + if (typeof value === "string") { + if (key === "auth" && value.startsWith("keychain:")) return; + if (CREDENTIAL_VALUE_PATTERN.test(value) || CREDENTIAL_ASSIGNMENT_PATTERN.test(value)) { + throw new Error(`${label} contains a value that looks like a raw credential.`); + } + return; + } + if (Array.isArray(value)) { + for (const entry of value) rejectCredentialValues(entry, label, { allowScrubbed }, key); + return; + } + if (!value || typeof value !== "object") return; + for (const [childKey, childValue] of Object.entries(value)) { + rejectCredentialValues(childValue, label, { allowScrubbed }, childKey); + } +} + +function validateAuthReference(auth, label, { allowScrubbed = false } = {}) { + if (auth === undefined || auth === null) return; + if (allowScrubbed && isScrubMarker(auth)) return; + if (typeof auth === "string" && /^keychain:[A-Za-z0-9._/-]+$/.test(auth)) return; + if ( + auth && typeof auth === "object" && !Array.isArray(auth) + && Object.keys(auth).length === 1 + && typeof auth.tokenEnv === "string" + && /^[A-Za-z_][A-Za-z0-9_]*$/.test(auth.tokenEnv) + ) return; + throw new Error(`${label} auth must be a keychain alias or a tokenEnv reference, never a raw credential.`); +} + +function isScrubMarker(value) { + return value && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === 1 && typeof value.__scrubbed === "string"; +} + +function writeVerifiedBackup(backupPath, bytes, expectedHash) { + if (fs.existsSync(backupPath)) { + verifyManifestBackup(backupPath, expectedHash); + return; + } + writeAtomicBytes(backupPath, bytes, { exclusiveTarget: true }); + verifyManifestBackup(backupPath, expectedHash); +} + +function writeAtomicJson(filePath, value) { + writeAtomicBytes(filePath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`)); +} + +function writeAtomicBytes(filePath, bytes, { exclusiveTarget = false } = {}) { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + if (exclusiveTarget && fs.existsSync(filePath)) throw new Error(`Refusing to overwrite existing file: ${filePath}`); + const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`; + let descriptor = null; + try { + descriptor = fs.openSync(temporary, "wx", 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + if (exclusiveTarget) { + try { + // An exclusive hard link is the no-overwrite counterpart to rename: + // the completed bytes become visible atomically, while a target that + // appeared after our preflight check is never replaced. + fs.linkSync(temporary, filePath); + } catch (error) { + if (error.code === "EEXIST") throw new Error(`Refusing to overwrite existing file: ${filePath}`); + throw error; + } + fs.rmSync(temporary); + } else { + fs.renameSync(temporary, filePath); + } + } catch (error) { + if (descriptor !== null) fs.closeSync(descriptor); + fs.rmSync(temporary, { force: true }); + throw error; + } +} + +function readManifestLock(lockPath) { + try { + const stat = fs.lstatSync(lockPath); + const value = JSON.parse(fs.readFileSync(lockPath, "utf8")); + return { ...value, dev: stat.dev, ino: stat.ino, mtimeMs: stat.mtimeMs }; + } catch { + try { + const stat = fs.lstatSync(lockPath); + return { dev: stat.dev, ino: stat.ino, mtimeMs: stat.mtimeMs }; + } catch { return null; } + } +} + +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === "EPERM"; + } +} + +function manifestLockIsStale(lock, staleMs) { + if (!lock) return false; + if (processIsAlive(lock.pid)) return false; + const timestamp = Number.isFinite(lock.createdAt) ? lock.createdAt : lock.mtimeMs; + const age = Date.now() - timestamp; + return age > staleMs || age < -staleMs; +} + +function tryAcquireManifestLock(lockPath, staleMs) { + const token = crypto.randomUUID(); + const payload = { pid: process.pid, createdAt: Date.now(), token }; + try { + fs.writeFileSync(lockPath, JSON.stringify(payload), { flag: "wx", mode: 0o600 }); + return token; + } catch (error) { + if (error.code !== "EEXIST") throw error; + } + + const observed = readManifestLock(lockPath); + if (!manifestLockIsStale(observed, staleMs)) return null; + const parked = `${lockPath}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}.stale`; + try { + fs.renameSync(lockPath, parked); + } catch { + return null; + } + + // Another contender may have replaced the stale lock between our read and + // rename. Restore that newer owner instead of treating it as the stale inode + // we observed. + const parkedLock = readManifestLock(parked); + if (!parkedLock || parkedLock.dev !== observed?.dev || parkedLock.ino !== observed?.ino) { + try { fs.renameSync(parked, lockPath); } catch { /* lock path was restored elsewhere */ } + return null; + } + fs.rmSync(parked, { force: true }); + try { + fs.writeFileSync(lockPath, JSON.stringify(payload), { flag: "wx", mode: 0o600 }); + return token; + } catch (error) { + if (error.code === "EEXIST") return null; + throw error; + } +} + +function releaseManifestLock(lockPath, token) { + try { + // A delayed former holder must never delete a replacement lock. + if (readManifestLock(lockPath)?.token !== token) return; + fs.rmSync(lockPath, { force: true }); + } catch { + // Releasing an advisory lock must not hide the mutation result. + } +} + +function sleepSync(milliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function canonicalExistingPath(value, realpath, label) { + if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`${label} must be an absolute path.`); + return path.normalize(realpath(value)); +} + +function canonicalConfiguredPath(manifestDir, value) { + const resolved = path.resolve(manifestDir, value); + try { return fs.realpathSync.native(resolved); } catch { return resolved; } +} + +function containsPath(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function pathDepth(value) { + return path.normalize(value).split(path.sep).filter(Boolean).length; +} + +function formatUtcTimestamp(value) { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) throw new Error("Migration timestamp is invalid."); + return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (!value || typeof value !== "object") return JSON.stringify(value); + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; +} + +function assertProfileId(value) { + if (typeof value !== "string" || !PROFILE_ID_PATTERN.test(value) || FORBIDDEN_KEYS.has(value)) { + throw new Error(`Invalid ContextCake profile id: ${String(value)}`); + } +} + +function assertObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object.`); +} + +function assertSafeKeys(value, location = "manifest") { + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if (FORBIDDEN_KEYS.has(key)) throw new Error(`ContextCake manifest uses reserved key ${key} at ${location}.`); + assertSafeKeys(child, `${location}.${key}`); + } +} diff --git a/packages/core/src/pack-manager.mjs b/packages/core/src/pack-manager.mjs index 39817eb..2aec666 100644 --- a/packages/core/src/pack-manager.mjs +++ b/packages/core/src/pack-manager.mjs @@ -8,6 +8,14 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { + MANIFEST_LOCK_STALE_MS, + classifyManifest, + getManifestProfileLayers, + readContextManifest, + withManifestLock, + writeContextManifest, +} from "./manifest.mjs"; const ALLOWED_EXTENSIONS = new Set([".md", ".yaml", ".yml", ".json", ".txt"]); const MAX_FILE_BYTES = 5 * 1024 * 1024; @@ -141,7 +149,8 @@ export function previewPackUpdate({ const manifest = readContextManifest(resolvedManifestPath); const record = readPackRecord(manifest, candidate.id); if (!record) throw new Error(`Pack is not installed: ${candidate.id}`); - const assignment = record.assignments.find((entry) => entry.profile === (profile ?? null)); + const profileKey = packProfileKey(manifest, profile); + const assignment = findPackAssignment(record, manifest, profileKey); if (!assignment) throw new Error(`Pack ${candidate.id} is not attached to ${profile ? `profile ${profile}` : "the default stack"}.`); const active = record.installedVersions.find((entry) => entry.version === assignment.activeVersion); if (!active) throw new Error(`Pack registry is missing the active version ${candidate.id}@${assignment.activeVersion}.`); @@ -150,7 +159,7 @@ export function previewPackUpdate({ return { action: "update-preview", id: candidate.id, - profile: profile ?? null, + profile: profileKey, fromVersion: current.version, toVersion: candidate.version, currentChecksum: current.checksum, @@ -175,7 +184,7 @@ export function installPack({ const resolvedPacksDir = path.resolve(packsDir); return withManifestLock(resolvedManifestPath, () => { const manifest = readContextManifest(resolvedManifestPath); - const layers = selectProfileLayers(manifest, profile); + const layers = getManifestProfileLayers(manifest, profile); const registry = ensurePackRegistry(manifest); const record = registry[pack.id] ? readPackRecord(manifest, pack.id) : { id: pack.id, @@ -187,8 +196,9 @@ export function installPack({ }; registry[pack.id] = record; - const profileKey = profile ?? null; - const existingAssignment = record.assignments.find((entry) => entry.profile === profileKey); + const profileKey = packProfileKey(manifest, profile); + const existingAssignment = findPackAssignment(record, manifest, profileKey); + if (existingAssignment && classifyManifest(manifest) === "v2" && existingAssignment.profile == null) existingAssignment.profile = "default"; const priorVersion = existingAssignment?.activeVersion ?? null; const numericLevel = level === null || level === undefined ? Number(existingAssignment?.level ?? 0) : Number(level); if (!Number.isInteger(numericLevel) || numericLevel < -100 || numericLevel > 100) { @@ -215,7 +225,7 @@ export function installPack({ }); } - let assignment = record.assignments.find((entry) => entry.profile === profileKey); + let assignment = findPackAssignment(record, manifest, profileKey); if (!assignment) { assignment = { profile: profileKey, @@ -230,7 +240,7 @@ export function installPack({ } upsertPackLayer(layers, assignment, pack.id, versionRoot, resolvedManifestPath); - writeContextManifest(resolvedManifestPath, manifest); + writeContextManifest(resolvedManifestPath, manifest, { allowTransitional: true }); return { action: priorVersion && priorVersion !== pack.version ? "updated" : installed ? "installed" : "attached", @@ -252,7 +262,8 @@ export function rollbackPack({ manifestPath, packId, profile = null, version = n const manifest = readContextManifest(resolvedManifestPath); const record = readPackRecord(manifest, packId); if (!record) throw new Error(`Pack is not installed: ${packId}`); - const assignment = record.assignments?.find((entry) => entry.profile === (profile ?? null)); + const profileKey = packProfileKey(manifest, profile); + const assignment = findPackAssignment(record, manifest, profileKey); if (!assignment) throw new Error(`Pack ${packId} is not attached to ${profile ? `profile ${profile}` : "the default stack"}.`); const candidates = record.installedVersions.filter((entry) => entry.version !== assignment.activeVersion); @@ -263,12 +274,13 @@ export function rollbackPack({ manifestPath, packId, profile = null, version = n const versionRoot = safeChildPath(resolvedPacksDir, packId, selected.version); const inspected = inspectPack(versionRoot, { expectedChecksum: selected.checksum }); - const layers = selectProfileLayers(manifest, profile); + const layers = getManifestProfileLayers(manifest, profile); const priorVersion = assignment.activeVersion; + if (classifyManifest(manifest) === "v2" && assignment.profile == null) assignment.profile = "default"; assignment.activeVersion = selected.version; upsertPackLayer(layers, assignment, packId, versionRoot, resolvedManifestPath); - writeContextManifest(resolvedManifestPath, manifest); - return { action: "rolled-back", pack: inspected, profile: profile ?? null, priorVersion }; + writeContextManifest(resolvedManifestPath, manifest, { allowTransitional: true }); + return { action: "rolled-back", pack: inspected, profile: profileKey, priorVersion }; }); } @@ -279,15 +291,15 @@ export function removePack({ manifestPath, packId, profile = null }) { const manifest = readContextManifest(resolvedManifestPath); const record = readPackRecord(manifest, packId); if (!record) throw new Error(`Pack is not installed: ${packId}`); - const profileKey = profile ?? null; - const assignmentIndex = record.assignments?.findIndex((entry) => entry.profile === profileKey) ?? -1; + const profileKey = packProfileKey(manifest, profile); + const assignmentIndex = record.assignments?.findIndex((entry) => packProfileKey(manifest, entry.profile) === profileKey) ?? -1; if (assignmentIndex < 0) throw new Error(`Pack ${packId} is not attached to ${profile ? `profile ${profile}` : "the default stack"}.`); + const layers = getManifestProfileLayers(manifest, profile); const [assignment] = record.assignments.splice(assignmentIndex, 1); - const layers = selectProfileLayers(manifest, profile); const layerIndex = layers.findIndex((layer) => layer.name === assignment.layerName && isPackOrigin(layer.origin, packId)); if (layerIndex >= 0) layers.splice(layerIndex, 1); - writeContextManifest(resolvedManifestPath, manifest); + writeContextManifest(resolvedManifestPath, manifest, { allowTransitional: true }); return { action: "detached", id: packId, @@ -466,88 +478,21 @@ function sweepStaleStaging(idDir, versionName) { } } -function readContextManifest(manifestPath) { - if (!fs.existsSync(manifestPath)) return { layers: [] }; - const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("ContextCake manifest must be a JSON object."); - if (parsed.layers !== undefined && !Array.isArray(parsed.layers)) throw new Error("ContextCake manifest layers must be an array."); - parsed.layers ??= []; - return parsed; -} - -function writeContextManifest(manifestPath, manifest) { - fs.mkdirSync(path.dirname(manifestPath), { recursive: true, mode: 0o700 }); - const temporary = `${manifestPath}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`; - try { - fs.writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600, flag: "wx" }); - fs.renameSync(temporary, manifestPath); - fs.chmodSync(manifestPath, 0o600); - } catch (error) { - fs.rmSync(temporary, { force: true }); - throw error; - } -} - -const MANIFEST_LOCK_TIMEOUT_MS = 15_000; -const MANIFEST_LOCK_STALE_MS = 60_000; - -// Serialize the manifest read-modify-write so two concurrent `pack` commands -// cannot clobber each other's registry edits (writeContextManifest already -// guards against torn writes; this guards against lost updates). Advisory -// lockfile next to the manifest, with stale-lock takeover after a crash. -function withManifestLock(manifestPath, mutate) { - const lockPath = `${manifestPath}.lock`; - fs.mkdirSync(path.dirname(manifestPath), { recursive: true, mode: 0o700 }); - const deadline = Date.now() + MANIFEST_LOCK_TIMEOUT_MS; - let fd = null; - while (fd === null) { - try { - fd = fs.openSync(lockPath, "wx", 0o600); - } catch (error) { - if (error.code !== "EEXIST") throw error; - if (reapStaleLock(lockPath)) continue; - if (Date.now() >= deadline) throw new Error(`Timed out acquiring the Pack manifest lock at ${lockPath}.`); - sleepSync(50); - } - } - try { - fs.writeSync(fd, `${process.pid}\n`); - return mutate(); - } finally { - fs.closeSync(fd); - fs.rmSync(lockPath, { force: true }); - } -} - -function reapStaleLock(lockPath) { - try { - const stat = fs.lstatSync(lockPath); - if (Date.now() - stat.mtimeMs < MANIFEST_LOCK_STALE_MS) return false; - fs.rmSync(lockPath, { force: true }); - return true; - } catch { - return false; - } -} - -function sleepSync(milliseconds) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); -} - -function selectProfileLayers(manifest, profile) { - if (!profile) return manifest.layers; - if (FORBIDDEN_KEYS.has(profile) || !manifest.profiles || !Object.hasOwn(manifest.profiles, profile)) throw new Error(`Unknown ContextCake profile: ${profile}`); - const selected = manifest.profiles[profile]; - if (!Array.isArray(selected.layers)) throw new Error(`Profile ${profile} does not have a layers array.`); - return selected.layers; -} - function ensurePackRegistry(manifest) { if (manifest.packs === undefined) manifest.packs = {}; if (!manifest.packs || typeof manifest.packs !== "object" || Array.isArray(manifest.packs)) throw new Error("ContextCake manifest packs registry must be an object."); return manifest.packs; } +function packProfileKey(manifest, profile) { + if (classifyManifest(manifest) === "v2") return profile ?? "default"; + return profile === "default" ? null : (profile ?? null); +} + +function findPackAssignment(record, manifest, profileKey) { + return record.assignments?.find((entry) => packProfileKey(manifest, entry.profile) === profileKey); +} + function readPackRecord(manifest, packId) { if (!manifest.packs || !Object.hasOwn(manifest.packs, packId)) return null; const record = manifest.packs[packId]; diff --git a/packages/core/src/service.mjs b/packages/core/src/service.mjs index e13ff8e..706f767 100644 --- a/packages/core/src/service.mjs +++ b/packages/core/src/service.mjs @@ -38,6 +38,12 @@ import { import { layerRootMap, listFilesApi, readFileApi, serveRawApi, writeFileApi, writeSectionApi, } from "./layer-files.mjs"; +import { + classifyManifest, + getManifestProfileLayers, + mutateContextManifest, + readContextManifest, +} from "./manifest.mjs"; // Re-exported so hosts (apps/playground/server.mjs) keep importing the shared // HTTP internals from the service, wherever they are actually defined. @@ -152,6 +158,19 @@ function snapshotView(source, snap) { const sleep = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.(); }); +function defaultProfileContainer(manifest) { + return classifyManifest(manifest) === "v2" ? manifest.profiles.default : manifest; +} + +function removePendingSource(container, name) { + if (!Array.isArray(container.pendingSources)) return; + container.pendingSources = container.pendingSources.filter((pending) => pending?.name !== name); + if (container.pendingSources.length === 0) { + delete container.pendingSources; + delete container.pendingSourcesOwnerUserId; + } +} + // ---- the service ------------------------------------------------------------- export function createEngineService({ @@ -198,11 +217,7 @@ export function createEngineService({ } function readManifest() { - return JSON.parse(fs.readFileSync(MANIFEST, "utf8")); - } - - function writeManifest(manifest) { - fs.writeFileSync(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + return readContextManifest(MANIFEST, { allowMissing: false }); } // A rebuild (manifest edit, or a CRUD/sync route) must not close the adapter @@ -694,15 +709,15 @@ export function createEngineService({ } catch (err) { throw httpError(400, err.message); } - const manifest = readManifest(); - const next = { ...(manifest.settings ?? {}) }; - for (const key of Object.keys(body.settings ?? body)) { - if (clean[key] === undefined) delete next[key]; // null = reset to default - else next[key] = clean[key]; - } - if (Object.keys(next).length === 0) delete manifest.settings; - else manifest.settings = next; - writeManifest(manifest); + mutateContextManifest(MANIFEST, (manifest) => { + const next = { ...(manifest.settings ?? {}) }; + for (const key of Object.keys(body.settings ?? body)) { + if (clean[key] === undefined) delete next[key]; // null = reset to default + else next[key] = clean[key]; + } + if (Object.keys(next).length === 0) delete manifest.settings; + else manifest.settings = next; + }, { allowMissing: false, allowTransitional: true }); // New limits change how sources are read, so their indexes are stale: the // settings are part of the index key, so reload() rebuilds them. reload(); @@ -725,9 +740,8 @@ export function createEngineService({ const b = parseJson(rawBody); const name = String(b.name ?? "").trim(); if (!/^[a-zA-Z0-9 _-]{1,40}$/.test(name)) throw httpError(400, "Name: letters/numbers/space/_/- (max 40)"); - const manifest = readManifest(); - manifest.layers = manifest.layers ?? []; - if (manifest.layers.some((l) => l.name === name)) throw httpError(409, `A source named "${name}" already exists`); + const initialManifest = readManifest(); + if (getManifestProfileLayers(initialManifest).some((l) => l.name === name)) throw httpError(409, `A source named "${name}" already exists`); const level = Number.isFinite(+b.level) ? +b.level : 1; let layer; @@ -772,19 +786,16 @@ export function createEngineService({ throw httpError(400, `Unknown source kind: ${b.kind}`); } - manifest.layers.push(layer); - // A synced source whose machine-local path/command was scrubbed waits in - // pendingSources. Configuring that source locally promotes it to a runnable - // layer without leaving a duplicate metadata-only record behind. - if (Array.isArray(manifest.pendingSources)) { - manifest.pendingSources = manifest.pendingSources.filter((pending) => pending?.name !== name); - if (manifest.pendingSources.length === 0) { - delete manifest.pendingSources; - delete manifest.pendingSourcesOwnerUserId; - } - } - writeManifest(manifest); - reload(); // starts this source's background index + mutateContextManifest(MANIFEST, (manifest) => { + const layers = getManifestProfileLayers(manifest); + if (layers.some((candidate) => candidate.name === name)) throw httpError(409, `A source named "${name}" already exists`); + layers.push(layer); + // A synced source whose machine-local path/command was scrubbed waits in + // pendingSources. Configuring that source locally promotes it to a runnable + // layer without leaving a duplicate metadata-only record behind. + removePendingSource(defaultProfileContainer(manifest), name); + }, { allowMissing: false, allowTransitional: true }); + reload(); return { ok: true, added: name, @@ -828,37 +839,35 @@ export function createEngineService({ function removeSourceApi(name) { if (!name) throw httpError(400, "Provide ?name="); - const manifest = readManifest(); - const before = (manifest.layers ?? []).length; - const pendingBefore = (manifest.pendingSources ?? []).length; - manifest.layers = (manifest.layers ?? []).filter((l) => l.name !== name); - if (Array.isArray(manifest.pendingSources)) { - manifest.pendingSources = manifest.pendingSources.filter((pending) => pending?.name !== name); - if (manifest.pendingSources.length === 0) { - delete manifest.pendingSources; - delete manifest.pendingSourcesOwnerUserId; + mutateContextManifest(MANIFEST, (manifest) => { + const layers = getManifestProfileLayers(manifest); + const before = layers.length; + const container = defaultProfileContainer(manifest); + const pendingBefore = container.pendingSources?.length ?? 0; + const retained = layers.filter((layer) => layer.name !== name); + layers.splice(0, layers.length, ...retained); + removePendingSource(container, name); + if (layers.length === before && (container.pendingSources?.length ?? 0) === pendingBefore) { + throw httpError(404, `No source named "${name}"`); } - } - if (manifest.layers.length === before && (manifest.pendingSources ?? []).length === pendingBefore) { - throw httpError(404, `No source named "${name}"`); - } - writeManifest(manifest); + }, { allowMissing: false, allowTransitional: true }); reload(); return { ok: true, removed: name }; } function patchSourceApi(rawBody) { const b = parseJson(rawBody); - const manifest = readManifest(); - const layer = (manifest.layers ?? []).find((l) => l.name === b.name); - if (!layer) throw httpError(404, `No source named "${b.name}"`); - if (b.level !== undefined && Number.isFinite(+b.level)) layer.level = +b.level; - if (b.newName && b.newName !== b.name) { - if (!/^[a-zA-Z0-9 _-]{1,40}$/.test(b.newName)) throw httpError(400, "Invalid new name"); - if (manifest.layers.some((l) => l.name === b.newName)) throw httpError(409, "Name already exists"); - layer.name = b.newName; - } - writeManifest(manifest); + mutateContextManifest(MANIFEST, (manifest) => { + const layers = getManifestProfileLayers(manifest); + const layer = layers.find((candidate) => candidate.name === b.name); + if (!layer) throw httpError(404, `No source named "${b.name}"`); + if (b.level !== undefined && Number.isFinite(+b.level)) layer.level = +b.level; + if (b.newName && b.newName !== b.name) { + if (!/^[a-zA-Z0-9 _-]{1,40}$/.test(b.newName)) throw httpError(400, "Invalid new name"); + if (layers.some((candidate) => candidate.name === b.newName)) throw httpError(409, "Name already exists"); + layer.name = b.newName; + } + }, { allowMissing: false, allowTransitional: true }); reload(); return { ok: true }; } diff --git a/packages/core/src/sources/cache.mjs b/packages/core/src/sources/cache.mjs index 510aba3..e598cb0 100644 --- a/packages/core/src/sources/cache.mjs +++ b/packages/core/src/sources/cache.mjs @@ -7,11 +7,19 @@ import fs from "node:fs"; import path from "node:path"; -export function withCache(source, { ttlMs = 300000, cacheDir = null } = {}) { +export function withCache(source, { ttlMs = 300000, cacheDir = null, namespace = null } = {}) { const memory = new Map(); // cache key -> { value, storedAt } // Per-source subdir; encodeURIComponent keeps ids (which may contain "/") // as single safe filenames — nothing can traverse out of cacheDir. - const dir = cacheDir ? path.join(cacheDir, encodeURIComponent(source.name)) : null; + // Profile-aware callers add an opaque source fingerprint before the display + // name. Legacy/direct callers retain the original on-disk layout. + const dir = cacheDir + ? path.join(cacheDir, ...(namespace ? [safeCacheSegment(namespace)] : []), safeCacheSegment(source.name)) + : null; + + function memoryKey(key) { + return namespace ? `${namespace}\0${key}` : key; + } function diskPath(key) { return path.join(dir, `${encodeURIComponent(key)}.json`); @@ -41,15 +49,16 @@ export function withCache(source, { ttlMs = 300000, cacheDir = null } = {}) { } async function cached(key, load) { - const hit = memory.get(key); + const scopedKey = memoryKey(key); + const hit = memory.get(scopedKey); if (hit && Date.now() - hit.storedAt < ttlMs) return hit.value; const disk = readDisk(key); if (disk) { - memory.set(key, disk); + memory.set(scopedKey, disk); return disk.value; } const value = await load(); - memory.set(key, { value, storedAt: Date.now() }); + memory.set(scopedKey, { value, storedAt: Date.now() }); writeDisk(key, value); return value; } @@ -86,3 +95,14 @@ export function withCache(source, { ttlMs = 300000, cacheDir = null } = {}) { }; return wrapped; } + +// encodeURIComponent leaves dots untouched, so a complete segment of "." or +// ".." would regain filesystem meaning when passed to path.join. Encode those +// two cases explicitly while keeping ordinary source-name cache paths stable. +function safeCacheSegment(value) { + const encoded = encodeURIComponent(String(value)); + if (encoded === "") return "%00"; + if (encoded === ".") return "%2E"; + if (encoded === "..") return "%2E%2E"; + return encoded; +} diff --git a/packages/core/src/sources/index.mjs b/packages/core/src/sources/index.mjs index aaa8876..a7064c6 100644 --- a/packages/core/src/sources/index.mjs +++ b/packages/core/src/sources/index.mjs @@ -17,14 +17,19 @@ import { createMcpSource } from "./mcp.mjs"; import { withCache } from "./cache.mjs"; import { withGitSync } from "./git-sync.mjs"; import { resolveSettings, walkLimitsFrom } from "../settings.mjs"; +import { sourceConfigFingerprint } from "../manifest.mjs"; -export function buildSources(manifest, manifestDir, { tokens = {} } = {}) { +export function buildSources(manifest, manifestDir, { tokens = {}, profileId = null } = {}) { // Indexing limits are user settings, so they must reach the adapters that // enforce them — changing the limit in the app and reloading has to matter. const limits = walkLimitsFrom(resolveSettings(manifest)); return (manifest.layers ?? []).map((layer) => { const kind = layer.source ?? "okf-local"; const base = { name: layer.name, level: Number(layer.level) }; + // Existing flat-manifest callers omit profileId and retain their exact + // cache layout. Profile-aware callers opt into the isolated fingerprint + // namespace after selecting one stack. + const fingerprint = profileId === null ? null : sourceConfigFingerprint(profileId, layer, manifestDir); let source; if (kind === "okf-local") { source = createOkfLocalSource({ ...base, root: path.resolve(manifestDir, layer.path), limits }); @@ -52,6 +57,7 @@ export function buildSources(manifest, manifestDir, { tokens = {} } = {}) { source = withCache(source, { ...(layer.cache.ttlSeconds != null ? { ttlMs: Number(layer.cache.ttlSeconds) * 1000 } : {}), cacheDir: layer.cache.dir ? path.resolve(manifestDir, layer.cache.dir) : null, + namespace: fingerprint, }); } if (layer.git) { diff --git a/packages/core/tests/manifest.test.mjs b/packages/core/tests/manifest.test.mjs new file mode 100644 index 0000000..308731c --- /dev/null +++ b/packages/core/tests/manifest.test.mjs @@ -0,0 +1,491 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + classifyManifest, + createProfileId, + getManifestProfileLayers, + listManifestProfiles, + manifestRevision, + migrateManifestToV2, + mutateContextManifest, + normalizeProfileLabel, + readContextManifest, + selectManifestProfile, + sourceConfigFingerprint, + validateContextManifest, + verifyManifestBackup, + withManifestLock, + writeContextManifest, +} from "../src/manifest.mjs"; +import { withCache } from "../src/sources/cache.mjs"; +import { buildSources } from "../src/sources/index.mjs"; +import { mergeSyncedSettings, prepareSyncPayload } from "../../../apps/desktop/src/main/settings-sync.mjs"; + +const testFile = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(testFile), "../../.."); +const manifestModule = path.join(repoRoot, "packages/core/src/manifest.mjs"); +const packManagerModule = path.join(repoRoot, "packages/core/src/pack-manager.mjs"); + +function temporaryDirectory(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "contextcake-manifest-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function profile(label = "Default", layers = []) { + return { label, layers }; +} + +test("legacy manifests remain virtual, explicit-default compatible, and unmodified", () => { + const manifest = { layers: [{ name: "local", level: 3, path: "notes" }] }; + const before = JSON.stringify(manifest); + assert.equal(classifyManifest(manifest), "legacy"); + assert.deepEqual(selectManifestProfile(manifest, { cwd: process.cwd() }), { + mode: "legacy", + profileId: "default", + profileLabel: "Default", + layers: manifest.layers, + reason: "legacy-default", + matchedProjectRoot: null, + warnings: [], + }); + assert.equal(selectManifestProfile(manifest, { requestedProfile: "default" }).reason, "explicit"); + assert.throws(() => selectManifestProfile(manifest, { requestedProfile: "work" }), /requires migration/); + assert.equal(JSON.stringify(manifest), before); + assert.equal(getManifestProfileLayers(manifest), manifest.layers); +}); + +test("v2 selection uses explicit override, deepest canonical mapping, and default fallback", (t) => { + const root = temporaryDirectory(t); + const project = path.join(root, "project"); + const nested = path.join(project, "packages", "api"); + const nestedSource = path.join(nested, "src"); + const sibling = path.join(root, "project-old"); + fs.mkdirSync(nestedSource, { recursive: true }); + fs.mkdirSync(sibling); + const manifest = { + profiles: { + default: profile(), + project: profile("Project"), + api: profile("API"), + }, + projects: { + [project]: "project", + [nested]: "api", + }, + }; + + const selected = selectManifestProfile(manifest, { cwd: nestedSource }); + assert.equal(selected.profileId, "api"); + assert.equal(selected.reason, "project"); + assert.equal(selected.matchedProjectRoot, fs.realpathSync.native(nested)); + assert.equal(selectManifestProfile(manifest, { requestedProfile: "project", cwd: nested }).reason, "explicit"); + assert.equal(selectManifestProfile(manifest, { cwd: sibling }).profileId, "default"); + assert.equal(selectManifestProfile(manifest, { cwd: sibling }).reason, "default"); +}); + +test("selection rejects alias conflicts and intended mappings to unknown profiles", (t) => { + const root = temporaryDirectory(t); + const project = path.join(root, "project"); + const alias = path.join(root, "project-alias"); + fs.mkdirSync(project); + fs.symlinkSync(project, alias, "dir"); + const conflict = { + profiles: { default: profile(), one: profile("One"), two: profile("Two") }, + projects: { [project]: "one", [alias]: "two" }, + }; + assert.throws(() => selectManifestProfile(conflict, { cwd: project }), /same canonical root/); + + const dangling = { + profiles: { default: profile() }, + projects: { [project]: "missing" }, + }; + assert.throws(() => selectManifestProfile(dangling, { cwd: project }), /Unknown ContextCake profile: missing/); +}); + +test("stale mappings warn without blocking an unrelated default selection", (t) => { + const root = temporaryDirectory(t); + const current = path.join(root, "current"); + const missing = path.join(root, "missing"); + fs.mkdirSync(current); + const selection = selectManifestProfile({ + profiles: { default: profile(), work: profile("Work") }, + projects: { [missing]: "work" }, + }, { cwd: current }); + assert.equal(selection.profileId, "default"); + assert(selection.warnings.some((warning) => warning.code === "stale-project-root")); +}); + +test("manifest validation rejects unsafe keys, duplicate layers, invalid labels, and Pack drift", () => { + const polluted = JSON.parse('{"profiles":{"default":{"label":"Default","layers":[]}},"__proto__":{"polluted":true}}'); + assert.throws(() => validateContextManifest(polluted), /reserved key/); + assert.throws(() => validateContextManifest({ + profiles: { default: profile("Default", [{ name: "same", level: 1, path: "a" }, { name: "same", level: 0, path: "b" }]) }, + }), /duplicate layer name/); + assert.throws(() => normalizeProfileLabel("github_pat_abcdefghijklmnopqrstuvwxyz"), /credential/); + assert.equal(createProfileId("Déjà Vu", ["deja-vu"]), "deja-vu-2"); + assert.throws(() => validateContextManifest({ + profiles: { default: profile("Default", [{ name: "repo", level: 1, source: "github", repo: "acme/docs", token: "secret" }]) }, + }), /raw credential field/); + assert.throws(() => validateContextManifest({ + profiles: { default: profile("Default", [{ name: "repo", level: 1, source: "github", repo: "acme/docs", cache: { clientSecret: "plain" } }]) }, + }), /raw credential field/); + assert.throws(() => validateContextManifest({ + profiles: { default: profile() }, + metadata: { endpoint: "https://example.invalid/docs?token=abcdefghijklmnop" }, + }), /looks like a raw credential/); + assert.throws(() => validateContextManifest({ + profiles: { default: profile("Default", [{ name: "repo", level: 1, source: "github", repo: "acme/docs", auth: "ghp_not-a-reference" }]) }, + }), /keychain alias or a tokenEnv reference/); + assert.throws(() => validateContextManifest({ + profiles: { default: profile("Default", [{ name: "remote", level: 1, source: "mcp", command: "node", args: ["--header", "Bearer abcdefghijklmnopqrstuvwxyz"] }]) }, + }), /looks like a raw credential/); + + assert.throws(() => validateContextManifest({ + profiles: { + default: profile("Default", [{ name: "pack-demo", level: 1, path: "packs/demo/1.0.0", origin: "pack:demo@1.0.0" }]), + }, + packs: { + demo: { + installedVersions: [{ version: "1.0.0", checksum: "sha256:test" }], + assignments: [{ profile: "default", layerName: "pack-demo", activeVersion: "1.0.0", level: 0 }], + }, + }, + }), /does not match layer/); + assert.throws(() => validateContextManifest({ + profiles: { + default: profile("Default", [{ name: "pack-demo", level: 0, path: "packs/demo/1.0.0", origin: "pack:demo@1.0.0" }]), + }, + packs: { + demo: { + installedVersions: [{ version: "1.0.0" }], + assignments: [ + { profile: "default", layerName: "pack-demo", activeVersion: "1.0.0", level: 0 }, + { profile: "default", layerName: "pack-demo", activeVersion: "1.0.0", level: 0 }, + ], + }, + }, + }), /duplicate assignment/); +}); + +test("transitional migration preserves runnable data, quarantines incomplete sources, and creates a verified backup", (t) => { + const root = temporaryDirectory(t); + const manifestPath = path.join(root, "manifest.json"); + const projectPath = path.join(root, "new-project"); + fs.mkdirSync(projectPath); + const original = { + layers: [ + { name: "personal", level: 3, source: "files", path: "notes" }, + { name: "pack-demo", level: 0, path: "packs/demo/1.0.0", origin: "pack:demo@1.0.0" }, + ], + profiles: { + default: { + label: "Remote default", + layers: [ + { name: "remote-mcp", level: 1, source: "mcp", command: { __scrubbed: "execution" } }, + { name: "personal", level: 3, source: "files", path: { __scrubbed: "path" } }, + ], + }, + work: { + label: "Work", + layers: [ + { name: "work-docs", level: 2, source: "files", path: "work" }, + { name: "remote-files", level: 1, source: "files", path: { __scrubbed: "path" } }, + ], + }, + }, + profilesOwnerUserId: "user-1", + pendingSources: [ + { name: "pending", level: 1, source: "files" }, + { name: "personal", level: 3, source: "files", path: { __scrubbed: "path" } }, + ], + pendingSourcesOwnerUserId: "user-1", + packs: { + demo: { + installedVersions: [{ version: "1.0.0", checksum: "sha256:test" }], + assignments: [{ profile: null, layerName: "pack-demo", activeVersion: "1.0.0", level: 0 }], + }, + }, + }; + const raw = `${JSON.stringify(original, null, 2)}\n`; + fs.writeFileSync(manifestPath, raw, { mode: 0o600 }); + + const result = migrateManifestToV2(manifestPath, { + newProfile: { id: "new-project", label: "New Project" }, + projectPath, + now: () => new Date("2026-07-29T12:34:56.000Z"), + }); + assert.equal(result.action, "migrated"); + assert.equal(result.backupHash, crypto.createHash("sha256").update(raw).digest("hex")); + assert.match(result.backupPath, /\.pre-profiles\.20260729T123456Z\.[a-f0-9]{64}\.json$/); + assert.equal(verifyManifestBackup(result.backupPath, result.backupHash), true); + assert.equal(fs.statSync(result.backupPath).mode & 0o777, 0o600); + + const migrated = readContextManifest(manifestPath); + assert.equal(classifyManifest(migrated), "v2"); + assert.equal(Object.hasOwn(migrated, "layers"), false); + assert.deepEqual(migrated.profiles.default.layers, original.layers); + assert.deepEqual(migrated.profiles.work.layers.map((layer) => layer.name), ["work-docs"]); + assert.deepEqual(migrated.profiles.work.pendingSources.map((source) => source.name), ["remote-files"]); + assert.deepEqual(new Set(migrated.profiles.default.pendingSources.map((source) => source.name)), new Set(["remote-mcp", "pending"])); + assert.equal(migrated.profiles.default.pendingSourcesOwnerUserId, "user-1"); + assert.equal(migrated.profilesOwnerUserId, "user-1"); + assert.equal(migrated.packs.demo.assignments[0].profile, "default"); + assert.deepEqual(migrated.profiles["new-project"], { label: "New Project", layers: [] }); + assert.equal(migrated.projects[fs.realpathSync.native(projectPath)], "new-project"); + assert.equal(fs.statSync(manifestPath).mode & 0o777, 0o600); + + const beforeSecondRun = fs.readFileSync(manifestPath, "utf8"); + const second = migrateManifestToV2(manifestPath); + assert.equal(second.action, "already-v2"); + assert.equal(second.backupPath, null); + assert.equal(fs.readFileSync(manifestPath, "utf8"), beforeSecondRun); +}); + +test("migration accepts the transitional profile shape emitted by desktop settings sync", (t) => { + const root = temporaryDirectory(t); + const manifestPath = path.join(root, "manifest.json"); + const remote = prepareSyncPayload({ + profiles: { + default: profile("Default", [{ name: "remote-default", level: 1, source: "files", path: "/Users/remote/notes" }]), + company: profile("Company", [{ name: "remote-mcp", level: 0, source: "mcp", command: "node", args: ["./server.mjs"] }]), + }, + }); + const newMachine = mergeSyncedSettings({}, remote); + assert.doesNotMatch(JSON.stringify(newMachine), /Users\/remote|server\.mjs|"node"/); + writeContextManifest(manifestPath, { + layers: [{ name: "local", level: 3, source: "files", path: "notes" }], + profiles: newMachine.profiles, + profilesOwnerUserId: "user-1", + }, { allowTransitional: true }); + + migrateManifestToV2(manifestPath); + const migrated = readContextManifest(manifestPath); + assert.deepEqual(migrated.profiles.default.layers.map((layer) => layer.name), ["local"]); + assert.deepEqual(migrated.profiles.default.pendingSources.map((source) => source.name), ["remote-default"]); + assert.deepEqual(migrated.profiles.company.layers, []); + assert.deepEqual(migrated.profiles.company.pendingSources.map((source) => source.name), ["remote-mcp"]); + assert.equal(migrated.profilesOwnerUserId, "user-1"); +}); + +test("flat migration preserves every layer field and refuses a stale or corrupt backup", (t) => { + const root = temporaryDirectory(t); + const manifestPath = path.join(root, "manifest.json"); + const manifest = { + layers: [ + { name: "team", level: 2, source: "files", path: "team", customAdapterOption: { keep: true } }, + { name: "live", level: 1, path: "live", live: true, git: { pullTtlSeconds: 90, retentionDays: 14 } }, + ], + }; + const raw = `${JSON.stringify(manifest, null, 2)}\n`; + fs.writeFileSync(manifestPath, raw, { mode: 0o600 }); + const hash = crypto.createHash("sha256").update(raw).digest("hex"); + const backupPath = `${manifestPath}.pre-profiles.20260729T010203Z.${hash}.json`; + fs.writeFileSync(backupPath, "corrupt", { mode: 0o600 }); + + assert.throws(() => migrateManifestToV2(manifestPath, { + newProfile: { id: "work", label: "Work" }, + now: () => new Date("2026-07-29T01:02:03.000Z"), + }), /backup hash mismatch/i); + assert.equal(fs.readFileSync(manifestPath, "utf8"), raw); + + fs.rmSync(backupPath); + migrateManifestToV2(manifestPath, { + newProfile: { id: "work", label: "Work" }, + now: () => new Date("2026-07-29T01:02:03.000Z"), + }); + const migrated = readContextManifest(manifestPath); + assert.deepEqual(migrated.profiles.default.layers, manifest.layers); + assert.deepEqual(migrated.profiles.work.layers, []); +}); + +test("writes reject accidental split-brain manifests unless a compatibility caller opts in", (t) => { + const root = temporaryDirectory(t); + const manifestPath = path.join(root, "manifest.json"); + const transitional = { layers: [], profiles: { work: profile("Work") } }; + assert.throws(() => writeContextManifest(manifestPath, transitional), /transitional manifest/); + writeContextManifest(manifestPath, transitional, { allowTransitional: true }); + assert.equal(classifyManifest(readContextManifest(manifestPath)), "transitional"); +}); + +test("source fingerprints and cache namespaces isolate profile, auth, and ref identity without moving legacy caches", async (t) => { + const root = temporaryDirectory(t); + const layer = { name: "repo", level: 1, source: "github", repo: "acme/docs", ref: "main", cache: { ttlSeconds: 60 } }; + const first = sourceConfigFingerprint("default", layer, root); + assert.equal(first, sourceConfigFingerprint("default", structuredClone(layer), root)); + assert.notEqual(first, sourceConfigFingerprint("work", layer, root)); + assert.notEqual(first, sourceConfigFingerprint("default", { ...layer, ref: "release" }, root)); + assert.notEqual(first, sourceConfigFingerprint("default", { ...layer, auth: { tokenEnv: "PRIVATE_GITHUB_TOKEN" } }, root)); + + const docsDir = path.join(root, "docs"); + fs.mkdirSync(docsDir); + fs.writeFileSync(path.join(docsDir, "note.md"), "# Note\n\nLegacy cache location.\n"); + const sourceManifest = { + layers: [{ name: "notes", level: 1, source: "files", path: "docs", cache: { dir: "build-cache", ttlSeconds: 60 } }], + }; + const [legacySource] = buildSources(sourceManifest, root); + await legacySource.loadConcept("note"); + assert(fs.existsSync(path.join(root, "build-cache", "notes", "concept%3Anote.json"))); + const [profileSource] = buildSources(sourceManifest, root, { profileId: "work" }); + await profileSource.loadConcept("note"); + const profileFingerprint = sourceConfigFingerprint("work", sourceManifest.layers[0], root); + assert(fs.existsSync(path.join(root, "build-cache", profileFingerprint, "notes", "concept%3Anote.json"))); + + const cacheDir = path.join(root, "cache"); + const makeSource = (value) => ({ + name: "same", + level: 1, + async loadConcept() { return value; }, + async listConceptIds() { return []; }, + close() {}, + }); + const one = withCache(makeSource({ profile: "one" }), { cacheDir, namespace: "one-fingerprint" }); + const two = withCache(makeSource({ profile: "two" }), { cacheDir, namespace: "two-fingerprint" }); + assert.deepEqual(await one.loadConcept("id"), { profile: "one" }); + assert.deepEqual(await two.loadConcept("id"), { profile: "two" }); + assert.deepEqual(fs.readdirSync(cacheDir).sort(), ["one-fingerprint", "two-fingerprint"]); + + const hostile = withCache({ ...makeSource({ safe: true }), name: ".." }, { cacheDir, namespace: ".." }); + assert.deepEqual(await hostile.loadConcept("id"), { safe: true }); + assert(fs.existsSync(path.join(cacheDir, "%2E%2E", "%2E%2E", "concept%3Aid.json"))); + assert.equal(fs.existsSync(path.join(root, "concept%3Aid.json")), false); + hostile.sync(); + assert(fs.existsSync(path.join(cacheDir, "one-fingerprint"))); +}); + +test("manifest mutation locking preserves concurrent updates and times out safely", async (t) => { + const root = temporaryDirectory(t); + const manifestPath = path.join(root, "manifest.json"); + writeContextManifest(manifestPath, { layers: [] }); + const script = ` + import { mutateContextManifest } from ${JSON.stringify(pathToFileURL(manifestModule).href)}; + const [manifestPath, label, hold] = process.argv.slice(1); + mutateContextManifest(manifestPath, (manifest) => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(hold)); + manifest.order = [...(manifest.order ?? []), label]; + }); + `; + await Promise.all([ + runNode(script, [manifestPath, "one", "100"]), + runNode(script, [manifestPath, "two", "0"]), + ]); + assert.deepEqual(new Set(readContextManifest(manifestPath).order), new Set(["one", "two"])); + + fs.writeFileSync(`${manifestPath}.lock`, "held\n", { mode: 0o600 }); + assert.throws(() => withManifestLock(manifestPath, () => {}, { timeoutMs: 25, staleMs: 60_000 }), /Timed out acquiring/); + fs.rmSync(`${manifestPath}.lock`); + + fs.writeFileSync(`${manifestPath}.lock`, JSON.stringify({ pid: 999_999, createdAt: 1, token: "stale" }), { mode: 0o600 }); + const staleRaceScript = ` + import { withManifestLock } from ${JSON.stringify(pathToFileURL(manifestModule).href)}; + const [manifestPath] = process.argv.slice(1); + try { + withManifestLock(manifestPath, () => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 180); + }, { timeoutMs: 75, staleMs: 1 }); + process.stdout.write("WON"); + } catch (error) { + process.stdout.write(error.message.includes("Timed out") ? "BUSY" : "ERROR"); + } + `; + const staleResults = await Promise.all([ + runNodeOutput(staleRaceScript, [manifestPath]), + runNodeOutput(staleRaceScript, [manifestPath]), + ]); + assert.equal(staleResults.filter((result) => result === "WON").length, 1); + assert.equal(staleResults.filter((result) => result === "BUSY").length, 1); + + withManifestLock(manifestPath, () => { + fs.writeFileSync(`${manifestPath}.lock`, JSON.stringify({ pid: process.pid, createdAt: Date.now(), token: "replacement" })); + }); + assert.equal(JSON.parse(fs.readFileSync(`${manifestPath}.lock`, "utf8")).token, "replacement"); + fs.rmSync(`${manifestPath}.lock`); +}); + +test("concurrent source, Pack, and first-profile mutations cannot lose an update", async (t) => { + const root = temporaryDirectory(t); + const manifestPath = path.join(root, "manifest.json"); + const packsDir = path.join(root, "packs"); + const sourcePack = path.join(repoRoot, "specs/contextcake-packs/packs/contextcake"); + writeContextManifest(manifestPath, { layers: [] }); + + const migrateScript = ` + import { migrateManifestToV2 } from ${JSON.stringify(pathToFileURL(manifestModule).href)}; + const [manifestPath] = process.argv.slice(1); + migrateManifestToV2(manifestPath, { newProfile: { id: "work", label: "Work" } }); + `; + const packScript = ` + import { installPack } from ${JSON.stringify(pathToFileURL(packManagerModule).href)}; + const [manifestPath, sourceRoot, packsDir] = process.argv.slice(1); + installPack({ manifestPath, sourceRoot, packsDir }); + `; + const sourceScript = ` + import { getManifestProfileLayers, mutateContextManifest } from ${JSON.stringify(pathToFileURL(manifestModule).href)}; + const [manifestPath] = process.argv.slice(1); + mutateContextManifest(manifestPath, (manifest) => { + getManifestProfileLayers(manifest).push({ name: "notes", level: 2, source: "files", path: "notes" }); + }, { allowTransitional: true }); + `; + await Promise.all([ + runNode(migrateScript, [manifestPath]), + runNode(packScript, [manifestPath, sourcePack, packsDir]), + runNode(sourceScript, [manifestPath]), + ]); + + const manifest = readContextManifest(manifestPath); + assert.equal(classifyManifest(manifest), "v2"); + assert.deepEqual(manifest.profiles.work, { label: "Work", layers: [] }); + assert(manifest.profiles.default.layers.some((layer) => layer.origin === "pack:contextcake@0.1.0")); + assert(manifest.profiles.default.layers.some((layer) => layer.name === "notes")); + assert.equal(manifest.packs.contextcake.assignments[0].profile, "default"); +}); + +test("profile summaries and revisions are deterministic without opening sources", () => { + const manifest = { + profiles: { + default: profile(), + work: { label: "Work", layers: [], pendingSources: [{ name: "remote" }] }, + }, + projects: { "/tmp/work": "work" }, + }; + const summaries = listManifestProfiles(manifest); + assert.equal(summaries.find((entry) => entry.id === "work").mappingCount, 1); + assert.equal(summaries.find((entry) => entry.id === "work").pendingSourceCount, 1); + assert.equal(manifestRevision(manifest), manifestRevision(structuredClone(manifest))); + assert.notEqual(manifestRevision(manifest), manifestRevision({ ...manifest, projects: {} })); +}); + +function runNode(script, args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--input-type=module", "-e", script, ...args], { stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`child exited ${code}: ${stderr}`)); + }); + }); +} + +function runNodeOutput(script, args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--input-type=module", "-e", script, ...args], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(stdout.trim()); + else reject(new Error(`child exited ${code}: ${stderr}`)); + }); + }); +} diff --git a/packages/core/tests/pack-test.sh b/packages/core/tests/pack-test.sh index 67b67fc..655e030 100644 --- a/packages/core/tests/pack-test.sh +++ b/packages/core/tests/pack-test.sh @@ -113,6 +113,27 @@ fi if node "$pack_cli" install "$source_pack" --manifest "$tmpdir/manifest.json" --packs-dir "$tmpdir/packs" --profile missing >/dev/null 2>&1; then fail "unknown profile was silently created" fi + +# Canonical v2 uses the explicit default profile id in both the registry and +# the profile layer stack; the legacy null assignment must not leak forward. +cat > "$tmpdir/manifest-v2.json" <<'EOF' +{ + "profiles": { + "default": { "label": "Default", "layers": [] } + }, + "projects": {} +} +EOF +node "$pack_cli" install "$source_pack" --manifest "$tmpdir/manifest-v2.json" --packs-dir "$tmpdir/packs-v2" --level 0 >/dev/null +node - "$tmpdir/manifest-v2.json" <<'NODE' || fail "v2 default Pack assignment was not canonical" +const fs = require('node:fs') +const m = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) +const assignment = m.packs.contextcake.assignments[0] +if (assignment.profile !== 'default') process.exit(1) +if (!m.profiles.default.layers.some((layer) => layer.origin === 'pack:contextcake@0.1.0')) process.exit(1) +if (Object.hasOwn(m, 'layers')) process.exit(1) +NODE + mkdir "$tmpdir/outside-store" ln -s "$tmpdir/outside-store" "$tmpdir/symlink-store" if node "$pack_cli" install "$source_pack" --manifest "$tmpdir/manifest.json" --packs-dir "$tmpdir/symlink-store" >/dev/null 2>&1; then diff --git a/specs/contextcake-project-profiles/design.md b/specs/contextcake-project-profiles/design.md index 1cb1562..2ba27b3 100644 --- a/specs/contextcake-project-profiles/design.md +++ b/specs/contextcake-project-profiles/design.md @@ -5,7 +5,7 @@ dependency-free engine, MCP server, engine service, Mac app, Console, Packs, team sync, docs, and release validation. **Date:** 2026-07-28 -**Status:** Proposed — adversarial review complete; awaiting product approval +**Status:** Approved — implementation authorized 2026-07-29 **Spec:** `specs/contextcake-project-profiles/spec.md` **Estimated shape:** five implementation PRs plus a seven-day dogfood gate diff --git a/specs/contextcake-project-profiles/spec.md b/specs/contextcake-project-profiles/spec.md index decfcad..f30563e 100644 --- a/specs/contextcake-project-profiles/spec.md +++ b/specs/contextcake-project-profiles/spec.md @@ -7,7 +7,7 @@ project's profile automatically and exposes only its sources, Packs, live captures, provenance, and conflicts. **Date:** 2026-07-28 -**Status:** Proposed — adversarial review complete; awaiting product approval +**Status:** Approved — implementation authorized 2026-07-29 **Workflow:** Requirements-First delivery slice of the approved profiles work in `specs/contextcake-integrations/spec.md` **Depends on:** `specs/contextcake-core/design.md`, From 97e8b9775832473beb9a2158468aeab5ee8af94f Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Wed, 29 Jul 2026 00:52:57 -0400 Subject: [PATCH 2/2] fix(core): scope Pack drift to selected profile --- .../content/docs/docs/reference/manifest.md | 9 +-- packages/core/src/manifest.mjs | 63 +++++++++++++++---- packages/core/tests/manifest.test.mjs | 25 ++++++++ 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/apps/site/src/content/docs/docs/reference/manifest.md b/apps/site/src/content/docs/docs/reference/manifest.md index 9182f35..5db5944 100644 --- a/apps/site/src/content/docs/docs/reference/manifest.md +++ b/apps/site/src/content/docs/docs/reference/manifest.md @@ -30,9 +30,9 @@ The main top-level key is `layers`, an array of layer objects. The local Pack ma may also maintain a `packs` registry of installed versions and assignments. That registry is bookkeeping for rollback; resolution reads only explicit layer entries. -Reading a flat manifest creates an in-memory virtual profile with id `default`. It -does not alter the file. Creating the first additional profile is the deliberate -migration point described below. +The shared profile selector treats a flat manifest as an in-memory virtual profile +with id `default`. Selection does not alter the file. Creating the first additional +profile is the deliberate migration point described below. ## Manifest v2: Project Profiles @@ -74,7 +74,8 @@ The v2 rules are intentionally strict: - `projects` maps absolute, machine-local folders to profile ids. The paths are never uploaded by settings sync. - `pendingSources` holds synced descriptors that are incomplete or not yet - trusted on this machine. They are visible repair tasks, never runnable layers. + trusted on this machine. The later profile UI will present them as repair tasks; + the engine never treats them as runnable layers. - Canonical v2 has `profiles` and no top-level `layers`. ### Selection order diff --git a/packages/core/src/manifest.mjs b/packages/core/src/manifest.mjs index 8029ec7..627a92e 100644 --- a/packages/core/src/manifest.mjs +++ b/packages/core/src/manifest.mjs @@ -52,11 +52,16 @@ export function validateContextManifest(manifest, { validatePacks = true } = {}) assertObject(profile, `Profile ${profileId}`); if (profile.label !== undefined) normalizeProfileLabel(profile.label); if (!Array.isArray(profile.layers)) throw new Error(`Profile ${profileId} does not have a layers array.`); - if (mode === "transitional") validateTransitionalProfileLayers(profile.layers, `profile ${profileId}`); - else validateLayers(profile.layers, `profile ${profileId}`); + if (mode === "transitional") { + validateTransitionalProfileLayers(profile.layers, `profile ${profileId}`); + for (const layer of profile.layers) { + if (!isRunnableLayer(layer)) warnings.push({ code: "pending-source", profileId, sourceName: layer.name }); + } + } else validateLayers(profile.layers, `profile ${profileId}`); if (profile.pendingSources !== undefined) { if (!Array.isArray(profile.pendingSources)) throw new Error(`Profile ${profileId} pendingSources must be an array.`); validatePendingSources(profile.pendingSources, `profile ${profileId}`); + for (const source of profile.pendingSources) warnings.push({ code: "pending-source", profileId, sourceName: source.name }); } } } @@ -64,6 +69,7 @@ export function validateContextManifest(manifest, { validatePacks = true } = {}) if (manifest.pendingSources !== undefined) { if (!Array.isArray(manifest.pendingSources)) throw new Error("ContextCake manifest pendingSources must be an array."); validatePendingSources(manifest.pendingSources, "legacy default"); + for (const source of manifest.pendingSources) warnings.push({ code: "pending-source", profileId: "default", sourceName: source.name }); } if (manifest.projects !== undefined) { @@ -119,11 +125,12 @@ export function selectManifestProfile(manifest, { cwd = process.cwd(), realpath = fs.realpathSync.native, } = {}) { - const { mode, warnings } = validateContextManifest(manifest); + const { mode, warnings } = validateContextManifest(manifest, { validatePacks: false }); if (mode === "legacy" || mode === "transitional") { if (requestedProfile !== null && requestedProfile !== "default") { throw new Error(`Profile ${requestedProfile} requires migration to Manifest v2.`); } + validatePackRegistry(manifest, mode, warnings, { strict: false, selectedProfile: "default" }); return { mode, profileId: "default", @@ -169,7 +176,8 @@ export function selectManifestProfile(manifest, { } export function listManifestProfiles(manifest) { - const { mode, warnings } = validateContextManifest(manifest); + const { mode, warnings } = validateContextManifest(manifest, { validatePacks: false }); + validatePackRegistry(manifest, mode, warnings, { strict: false }); if (mode === "legacy") { return [{ id: "default", label: "Default", sourceCount: manifest.layers?.length ?? 0, mappingCount: 0, mode, valid: true }]; } @@ -335,6 +343,7 @@ export function verifyManifestBackup(backupPath, expectedHash) { function selectedV2Profile(manifest, profileId, reason, matchedProjectRoot, warnings) { if (!Object.hasOwn(manifest.profiles, profileId)) throw new Error(`Unknown ContextCake profile: ${profileId}`); + validatePackRegistry(manifest, "v2", warnings, { strict: false, selectedProfile: profileId }); const profile = manifest.profiles[profileId]; return { mode: "v2", @@ -355,7 +364,7 @@ function profileSummary(id, profile, projects, mode, warnings) { pendingSourceCount: profile.pendingSources?.length ?? 0, mappingCount: Object.values(projects ?? {}).filter((profileId) => profileId === id).length, mode, - valid: !warnings.some((warning) => warning.profileId === id), + valid: !warnings.some((warning) => warning.profileId === id && warning.code !== "pending-source"), }; } @@ -417,7 +426,7 @@ function validatePendingSources(sources, owner) { } } -function validatePackRegistry(manifest, mode, warnings) { +function validatePackRegistry(manifest, mode, warnings, { strict = true, selectedProfile = null } = {}) { if (manifest.packs === undefined) return; assertObject(manifest.packs, "ContextCake manifest packs registry"); const assignedLayers = new Set(); @@ -450,16 +459,38 @@ function validatePackRegistry(manifest, mode, warnings) { try { layers = getLayersWithoutValidation(manifest, mode, profileId); } catch (error) { - warnings.push({ code: "dangling-pack-profile", packId, profileId, message: error.message }); + reportPackInvariant(error.message, { code: "dangling-pack-profile", packId, profileId: normalizedProfileId }, warnings, { strict, selectedProfile }); + continue; + } + if (!versions.has(assignment.activeVersion)) { + reportPackInvariant( + `Pack ${packId} assignment references missing version ${assignment.activeVersion}.`, + { code: "missing-pack-version", packId, profileId: normalizedProfileId }, + warnings, + { strict, selectedProfile }, + ); continue; } - if (!versions.has(assignment.activeVersion)) throw new Error(`Pack ${packId} assignment references missing version ${assignment.activeVersion}.`); const matches = layers.filter((layer) => layer.name === assignment.layerName); - if (matches.length !== 1) throw new Error(`Pack ${packId} assignment must match exactly one layer named ${assignment.layerName}.`); + if (matches.length !== 1) { + reportPackInvariant( + `Pack ${packId} assignment must match exactly one layer named ${assignment.layerName}.`, + { code: "missing-pack-layer", packId, profileId: normalizedProfileId, layerName: assignment.layerName }, + warnings, + { strict, selectedProfile }, + ); + continue; + } const layer = matches[0]; const expectedOrigin = `pack:${packId}@${assignment.activeVersion}`; if (layer.origin !== expectedOrigin || Number(layer.level) !== Number(assignment.level)) { - throw new Error(`Pack ${packId} assignment does not match layer ${assignment.layerName}.`); + reportPackInvariant( + `Pack ${packId} assignment does not match layer ${assignment.layerName}.`, + { code: "pack-layer-drift", packId, profileId: normalizedProfileId, layerName: assignment.layerName }, + warnings, + { strict, selectedProfile }, + ); + continue; } assignedLayers.add(`${normalizedProfileId}\0${layer.name}\0${layer.origin}`); } @@ -468,12 +499,22 @@ function validatePackRegistry(manifest, mode, warnings) { for (const layer of layers) { if (typeof layer.origin !== "string" || !layer.origin.startsWith("pack:")) continue; if (!assignedLayers.has(`${profileId}\0${layer.name}\0${layer.origin}`)) { - throw new Error(`Pack layer ${layer.name} in profile ${profileId} has no matching registry assignment.`); + reportPackInvariant( + `Pack layer ${layer.name} in profile ${profileId} has no matching registry assignment.`, + { code: "orphan-pack-layer", profileId, layerName: layer.name }, + warnings, + { strict, selectedProfile }, + ); } } } } +function reportPackInvariant(message, details, warnings, { strict, selectedProfile }) { + if (strict || (selectedProfile !== null && details.profileId === selectedProfile)) throw new Error(message); + warnings.push({ ...details, message }); +} + function getLayersWithoutValidation(manifest, mode, profile) { if (mode === "legacy") { if (profile !== null && profile !== "default") throw new Error(`Unknown ContextCake profile: ${profile}`); diff --git a/packages/core/tests/manifest.test.mjs b/packages/core/tests/manifest.test.mjs index 308731c..e9d01ed 100644 --- a/packages/core/tests/manifest.test.mjs +++ b/packages/core/tests/manifest.test.mjs @@ -175,6 +175,29 @@ test("manifest validation rejects unsafe keys, duplicate layers, invalid labels, }), /duplicate assignment/); }); +test("inactive Pack drift warns while selecting the affected profile fails closed", () => { + const manifest = { + profiles: { + default: profile(), + work: profile("Work", [{ name: "pack-demo", level: 1, path: "packs/demo/1.0.0", origin: "pack:demo@1.0.0" }]), + }, + packs: { + demo: { + installedVersions: [{ version: "1.0.0" }], + assignments: [{ profile: "work", layerName: "pack-demo", activeVersion: "1.0.0", level: 0 }], + }, + }, + }; + assert.throws(() => validateContextManifest(manifest), /does not match layer/); + const healthy = selectManifestProfile(manifest, { requestedProfile: "default" }); + assert.equal(healthy.profileId, "default"); + assert(healthy.warnings.some((warning) => warning.code === "pack-layer-drift" && warning.profileId === "work")); + assert.throws(() => selectManifestProfile(manifest, { requestedProfile: "work" }), /does not match layer/); + const summaries = listManifestProfiles(manifest); + assert.equal(summaries.find((entry) => entry.id === "default").valid, true); + assert.equal(summaries.find((entry) => entry.id === "work").valid, false); +}); + test("transitional migration preserves runnable data, quarantines incomplete sources, and creates a verified backup", (t) => { const root = temporaryDirectory(t); const manifestPath = path.join(root, "manifest.json"); @@ -458,6 +481,8 @@ test("profile summaries and revisions are deterministic without opening sources" const summaries = listManifestProfiles(manifest); assert.equal(summaries.find((entry) => entry.id === "work").mappingCount, 1); assert.equal(summaries.find((entry) => entry.id === "work").pendingSourceCount, 1); + assert.equal(summaries.find((entry) => entry.id === "work").valid, true); + assert(selectManifestProfile(manifest, { requestedProfile: "work" }).warnings.some((warning) => warning.code === "pending-source")); assert.equal(manifestRevision(manifest), manifestRevision(structuredClone(manifest))); assert.notEqual(manifestRevision(manifest), manifestRevision({ ...manifest, projects: {} })); });