diff --git a/skills/ucp/references/REFERENCE.md b/skills/ucp/references/REFERENCE.md index 7c27f7a..5870fc3 100644 --- a/skills/ucp/references/REFERENCE.md +++ b/skills/ucp/references/REFERENCE.md @@ -73,7 +73,7 @@ Branch on the full `code`; CTAs are advisory. `PROFILE_FETCH_FAILED` and `PROFIL | `AGENT_PROFILE_UNREACHABLE` | Advertised Profile URL cannot be fetched or used | Run `ucp doctor`; repair hosting or Profile selection in [SETUP](SETUP.md) | | `AGENT_PROFILE_VERSION_UNSUPPORTED` | Profile declares a release unsupported by this CLI build | Select a supported DIY release or another CLI build | | `AGENT_PROFILE_SCHEMA_INVALID` | Profile fails its release schema | Repair the DIY document and hosted copy; managed issues require another build | -| `AGENT_PROFILE_VERSION_MISMATCH` | Internally inconsistent `dev.ucp.*` entries disagree with the Profile's declared UCP release | Align the DIY document and hosted copy; managed issues require another build | +| `AGENT_PROFILE_VERSION_MISMATCH` | Internally inconsistent `dev.ucp.*` entries disagree with the Profile's declared UCP release | Align every `dev.ucp.*` entry and publish the complete corrected document at its configured hosted URL; when moving off a Shopify release-default URL, select the new URL with `--profile-url` or `UCP_AGENT_PROFILE_URL` | | `AGENT_PROFILE_SERVICE_UNDECLARED` | Selected Profile omits an explicitly requested Business service | Use a DIY Profile that declares it, or accept that it is unavailable | | `PROFILE_NOT_FOUND` | An explicitly selected local name or required file is missing/unreadable | Inspect `ucp profile list` and the selection precedence; see [SETUP](SETUP.md) | | `AUTH_REQUIRED` | Merchant requires authentication (HTTP 401) | No merchant auth in this CLI. Hand off using the best prior URL: checkout/cart `continue_url`, then `variant.checkout_url`, then variant/product `url`, then `seller.url`, then the `--business` URL or `https://` | diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index 82efc2d..dd2fa78 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -705,7 +705,7 @@ describe('runDoctor — managed renderings', () => { // fetch and compare. A drift warn here would be doctor reporting a // difference against a file nothing sends. it('audits a named managed profile against the bundled renderings, not its legacy profile.json', async () => { - const dir = await seedLegacy('legacy07', 'stock-a-2026-04-08.json') + const dir = await seedLegacy('legacy07', 'profile-0.4.2-to-0.7.0.json') await writeActive({ profile: 'legacy07' }, { homeDir }) const before = await readFile(join(dir, 'profile.json'), 'utf-8') const { fetch: fetchImpl, calls } = releaseFetch() @@ -1027,6 +1027,36 @@ describe('runDoctor — protocol + profile drift', () => { expect(result.ok).toBe(true) }) + it('keeps hosted validation strict for an edited ucp-cli 0.4.2–0.7.0 Profile', async () => { + const body = publishedProfile('2026-04-08') as PlatformProfile & { + ucp: { + services: Record>> + capabilities: Record + } + } + const shopping = body.ucp.services['dev.ucp.shopping']?.[0] + if (shopping === undefined) throw new Error('published shopping entry missing') + shopping.version = '2026-01-23' + body.ucp.capabilities['com.acme.loyalty'] = [ + { + version: '2026-04-08', + spec: 'https://acme.test/loyalty/spec', + schema: 'https://acme.test/loyalty/schema.json', + }, + ] + + await saveUserProfile({ name: 'edited-042-070', body, meta: DIY_META }, { homeDir }) + await writeActive({ profile: 'edited-042-070' }, { homeDir }) + + const result = await runDoctor({ homeDir, env: {}, fetch: serving(body) }) + + expect(findCheck(result, 'active-profile').status).toBe('ok') + const protocol = findCheck(result, 'protocol') + expect(protocol.status).toBe('fail') + expect(protocol.detail).toContain('AGENT_PROFILE_VERSION_MISMATCH') + expect(result.ok).toBe(false) + }) + it('says NOT latest — and stays ok — for a supported older release', async () => { // A 2026-04-08 profile is VALID: the window is a set, not a floor. This // must never be a failure, or every user pinned to an older release for a diff --git a/src/cli/session.test.ts b/src/cli/session.test.ts index 1d5683f..d2e26fc 100644 --- a/src/cli/session.test.ts +++ b/src/cli/session.test.ts @@ -1,12 +1,10 @@ // resolveSession tests. -import { readFileSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createDiyProfile } from '../core/agent.js' import { PROFILE_FORMAT_VERSION } from '../core/legacy-profile.js' import type { PlatformProfile } from '../core/profile.js' import { saveUserProfile, writeActive } from '../core/profile-store.js' @@ -295,9 +293,8 @@ describe('resolveSession — user profile branch', () => { // The upgraded-legacy path, end to end from disk. The classification itself is // core/legacy-profile.test.ts's job; what matters here is that an operator who // ran `ucp profile init` on 0.7.0 gets a working, multi-rendering session out -// of the same directory — today that profile cannot dispatch at all, because -// its generated body declares dev.ucp.shopping at 2026-01-23 inside a -// 2026-04-08 document and loadAgentProfile's snapshot rule rejects it. +// of the same directory. Migrating an untouched 0.4.2–0.7.0 Profile gives it +// every installed rendering rather than applying the DIY compatibility path. describe('resolveSession — upgraded legacy profile', () => { const FIXTURE_DIR = fileURLToPath( new URL('../../test/fixtures/legacy-profiles/', import.meta.url), @@ -330,7 +327,7 @@ describe('resolveSession — upgraded legacy profile', () => { } it('resolves an untouched v0.7 profile to every installed rendering, under its own name', async () => { - const dir = await seedLegacy('legacy07', 'stock-a-2026-04-08.json') + const dir = await seedLegacy('legacy07', 'profile-0.4.2-to-0.7.0.json') const before = await readFile(join(dir, 'profile.json'), 'utf-8') const session = await resolveSession({ homeDir, env: {}, profile: 'legacy07' }) @@ -353,26 +350,8 @@ describe('resolveSession — upgraded legacy profile', () => { expect(await readFile(join(dir, 'profile.json'), 'utf-8')).toBe(before) }) - it('does not raise AGENT_PROFILE_VERSION_MISMATCH on the v0.7 body', async () => { - await seedLegacy('legacy07', 'stock-a-2026-04-08.json') - - // Guard the premise: that body really is the one the snapshot rule kills. - expect(() => - createDiyProfile({ - name: 'legacy07', - body: JSON.parse( - readFileSync(join(FIXTURE_DIR, 'stock-a-2026-04-08.json'), 'utf-8'), - ) as unknown, - }), - ).toThrow(expect.objectContaining({ code: 'AGENT_PROFILE_VERSION_MISMATCH' })) - - await expect(resolveSession({ homeDir, env: {}, profile: 'legacy07' })).resolves.toMatchObject({ - profile: { source: 'managed' }, - }) - }) - it('resolves an untouched v0.8 profile to managed too', async () => { - await seedLegacy('legacy08', 'stock-b-2026-08-25.json') + await seedLegacy('legacy08', 'profile-0.8.0.json') const session = await resolveSession({ homeDir, env: {}, profile: 'legacy08' }) @@ -407,7 +386,7 @@ describe('resolveSession — upgraded legacy profile', () => { }) it('keeps a stock body with a user-owned URL as a pinned DIY singleton', async () => { - await seedLegacy('hosted', 'stock-b-2026-08-25.json', { + await seedLegacy('hosted', 'profile-0.8.0.json', { ...LEGACY_META, profile_url: 'https://mybot.example.com/.well-known/ucp', } as typeof LEGACY_META) @@ -422,7 +401,7 @@ describe('resolveSession — upgraded legacy profile', () => { it('lets an explicit --profile-url pin an upgraded profile to that one URL', async () => { const warnings: string[] = [] setWarnWriter((message) => warnings.push(message)) - await seedLegacy('legacy07', 'stock-a-2026-04-08.json') + await seedLegacy('legacy07', 'profile-0.4.2-to-0.7.0.json') const session = await resolveSession({ homeDir, diff --git a/src/core/agent.test.ts b/src/core/agent.test.ts index 44275ec..3af42b6 100644 --- a/src/core/agent.test.ts +++ b/src/core/agent.test.ts @@ -18,7 +18,7 @@ import { loadAgentProfile, } from './agent.js' import { LATEST, RELEASES, SUPPORTED_VERSIONS } from './releases.js' -import { setWarnWriter } from './verbose.js' +import { setVerboseWriter, setWarnWriter } from './verbose.js' const SELF_HOSTED = 'https://agent.example.invalid/agent.json' const DEFAULT_0825 = RELEASES['2026-08-25'].defaultAgentProfileUrl @@ -30,6 +30,22 @@ function publishedBody(): { ucp: Record; [k: string]: unknown } } } +function profile042To070Body(): { + ucp: { + services: Record>> + capabilities: Record>> + } + [key: string]: unknown +} { + const body = JSON.parse(RELEASES['2026-04-08'].agentProfileJson) as ReturnType< + typeof profile042To070Body + > + const shopping = body.ucp.services['dev.ucp.shopping']?.[0] + if (shopping === undefined) throw new Error('published shopping entry missing') + shopping.version = '2026-01-23' + return body +} + function captureWarnings(): string[] { const lines: string[] = [] setWarnWriter((msg) => { @@ -39,6 +55,7 @@ function captureWarnings(): string[] { } afterEach(() => { + setVerboseWriter(null) setWarnWriter(null) }) @@ -96,6 +113,32 @@ describe('loadAgentProfile — failure codes are all AGENT_PROFILE_*', () => { }) }) +describe('createDiyProfile — ucp-cli 0.4.2–0.7.0 Profile compatibility', () => { + it('normalizes only the runtime body at the DIY boundary and logs it', () => { + const body = profile042To070Body() + body.ucp.capabilities['com.acme.loyalty'] = [ + { + version: '2026-04-08', + spec: 'https://acme.test/loyalty/spec', + schema: 'https://acme.test/loyalty/schema.json', + }, + ] + const before = structuredClone(body) + const verbose: string[] = [] + setVerboseWriter((line) => verbose.push(line)) + + const profile = createDiyProfile({ name: 'edited-042-070', body, url: SELF_HOSTED }) + const loaded = rendering(profile, '2026-04-08') + + expect(Object.keys(profile.renderings)).toEqual(['2026-04-08']) + expect(loaded).toMatchObject({ source: 'diy', name: 'edited-042-070', url: SELF_HOSTED }) + expect(loaded.capabilities).toContain('com.acme.loyalty') + expect(loaded.body).not.toBe(body) + expect(body).toStrictEqual(before) + expect(verbose.join('')).toContain('source bytes remain unchanged') + }) +}) + // ─── Severity split: whose document is it? ───────────────────────────────── // // These direct-loader fixtures model DIY documents. A `dev.ucp.*` entry off @@ -153,21 +196,38 @@ describe('loadAgentProfile — AGENT_PROFILE_VERSION_MISMATCH', () => { expect(caught?.context).not.toHaveProperty('kind') }) - // Who serves the URL changes nothing about validating the local declaration. - // `ucp doctor` separately detects disagreement with the served document. - it('is fatal on a release-default URL too', () => { + function mismatchError(name: string, url = SELF_HOSTED): UcpError { + try { + loadAgentProfile({ body: mixedVersionBody(), url, source: 'diy', urlOverride: false, name }) + } catch (err) { + const mismatch = err as UcpError + expect(mismatch.code).toBe(ErrorCodes.AGENT_PROFILE_VERSION_MISMATCH) + return mismatch + } + throw new Error('expected profile mismatch') + } + + it('uses the safe Profile name in the inspection command for a custom hosted URL', () => { + const caught = mismatchError('mine') + expect(caught.cta?.description).toContain('configured hosted URL') + expect( + caught.cta?.commands.map((entry) => (typeof entry === 'string' ? entry : entry.command)), + ).toEqual(['ucp profile show mine', 'ucp doctor']) + }) + + it('falls back to bare profile show for an untrusted name', () => { + const caught = mismatchError('mine; rm -rf ~') + expect(caught.cta?.commands[0]).toMatchObject({ command: 'ucp profile show' }) + expect(JSON.stringify(caught.cta)).not.toContain('mine; rm -rf ~') + }) + + it('directs release-default users to host and select the complete corrected document', () => { captureWarnings() - expect(() => - loadAgentProfile({ - body: mixedVersionBody(), - url: DEFAULT_0825, - source: 'diy', - urlOverride: false, - name: 'agent', - }), - ).toThrowError( - expect.objectContaining({ code: 'AGENT_PROFILE_VERSION_MISMATCH' }) as unknown as Error, - ) + const caught = mismatchError('agent', DEFAULT_0825) + expect(caught.cta?.description).toContain('Shopify release-default URL') + expect(caught.cta?.description).toContain('complete corrected document at a URL you control') + expect(caught.cta?.description).toContain('--profile-url or UCP_AGENT_PROFILE_URL') + expect(JSON.stringify(caught.cta)).not.toMatch(/profile init|--force|profile use/) }) // The one place always-fatal could break an install that did nothing wrong: @@ -355,6 +415,22 @@ describe('fetchAgentProfileLive — AGENT_PROFILE_UNREACHABLE carries a reason', expect(live.cacheControl).toBe('public, max-age=300') }) + it('rejects raw hosted ucp-cli 0.4.2–0.7.0 Profile instead of applying local DIY compatibility', async () => { + const fetch = fetchStub( + () => new Response(JSON.stringify(profile042To070Body()), { status: 200 }), + ) + + await expect(fetchAgentProfileLive({ url: SELF_HOSTED, fetch })).rejects.toMatchObject({ + code: ErrorCodes.AGENT_PROFILE_VERSION_MISMATCH, + context: { + url: SELF_HOSTED, + registry: 'services', + key: 'dev.ucp.shopping', + versions: ['2026-01-23'], + }, + }) + }) + it("reason 'network' for a failed connection", async () => { const fetch = fetchStub(() => { throw new Error('connect ECONNREFUSED') diff --git a/src/core/agent.ts b/src/core/agent.ts index 4104e72..815c784 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -21,9 +21,10 @@ import { z } from 'incur' import { ErrorCodes, isUcpError, UcpError } from '../lib/errors.js' -import type { Transport } from '../lib/types.js' +import type { CtaBlock, Transport } from '../lib/types.js' import { formatZodIssues } from '../lib/zod-format.js' import { refusedRedirect, ucpFetch } from './http-client.js' +import { normalizeProfile042To070 } from './legacy-profile.js' import { LATEST, type PlatformProfile, @@ -151,6 +152,27 @@ export interface LoadAgentProfileInput { name?: string } +/** Keep runnable CTA arguments consistent with profile-store's name rule. */ +const RUNNABLE_PROFILE_NAME = /^[a-z0-9][a-z0-9._-]*$/ + +function versionMismatchCta(input: LoadAgentProfileInput): CtaBlock { + const show = + input.name !== undefined && RUNNABLE_PROFILE_NAME.test(input.name) + ? `ucp profile show ${input.name}` + : 'ucp profile show' + const publishing = + releaseByDefaultAgentProfileUrl(input.url) === undefined + ? 'Publish the complete corrected document at its configured hosted URL.' + : 'This is a Shopify release-default URL, which cannot host your edits; publish the complete corrected document at a URL you control, then select it with --profile-url or UCP_AGENT_PROFILE_URL.' + return { + description: `Align every dev.ucp.* entry with the profile's \`ucp.version\`. ${publishing}`, + commands: [ + { command: show, description: 'inspect the raw Profile document' }, + { command: 'ucp doctor', description: 'compare the local document against the URL' }, + ], + } +} + /** * Validate an agent-profile body into an {@link AgentProfile}. Pure — no I/O. * Bundled snapshots, local `profile.json`, and Doctor GETs share one JSON @@ -246,14 +268,7 @@ export function loadAgentProfile(input: LoadAgentProfileInput): AgentProfile { key, versions: off.map((e) => e.version), }, - cta: { - description: - "Align every dev.ucp.* entry with the profile's `ucp.version`. This document is what ucp-cli declares, and the business reads the copy at the profile URL — so the corrected version has to end up in both places.", - commands: [ - { command: 'ucp profile show', description: 'print the active profile document' }, - { command: 'ucp doctor', description: 'compare the local document against the URL' }, - ], - }, + cta: versionMismatchCta(input), }) } } @@ -334,14 +349,20 @@ export interface CreateDiyProfileInput { /** Build a named singleton DIY Profile from one exact body and URL. */ export function createDiyProfile(input: CreateDiyProfileInput): Profile { - const envelope = versionEnvelopeSchema.safeParse(input.body) + const body = normalizeProfile042To070(input.body) + if (body !== input.body) { + vlog( + 'agent-profile: applied compatibility for a Profile generated by ucp-cli 0.4.2–0.7.0; source bytes remain unchanged', + ) + } + const envelope = versionEnvelopeSchema.safeParse(body) const bodyRelease = envelope.success ? release(envelope.data.ucp.version) : undefined const url = parseHttpsUrl( input.url ?? bodyRelease?.defaultAgentProfileUrl ?? RELEASES[LATEST].defaultAgentProfileUrl, 'agent profile URL', ).toString() const agent = loadAgentProfile({ - body: input.body, + body, url, source: 'diy', urlOverride: input.urlOverride ?? false, diff --git a/src/core/legacy-profile.test.ts b/src/core/legacy-profile.test.ts index 4cd6b1d..77e7bab 100644 --- a/src/core/legacy-profile.test.ts +++ b/src/core/legacy-profile.test.ts @@ -19,6 +19,7 @@ import { GENERATED_BODY_FINGERPRINTS, generatedBodyVersion, HISTORICAL_GENERATED_BODY_PROVENANCE, + normalizeProfile042To070, PROFILE_FORMAT_VERSION, profileBodyFingerprint, } from './legacy-profile.js' @@ -28,12 +29,12 @@ import { setWarnWriter } from './verbose.js' const FIXTURE_DIR = fileURLToPath(new URL('../../test/fixtures/legacy-profiles/', import.meta.url)) -/** STOCK-A: npm 0.4.2 … 0.7.0. */ -const STOCK_A = 'stock-a-2026-04-08.json' -/** STOCK-B: npm 0.8.0. */ -const STOCK_B = 'stock-b-2026-08-25.json' -/** STOCK-A0: internal 0.1.x dev builds, never published. */ -const STOCK_A0 = 'stock-a0-prerelease-2026-04-08.json' +/** Generated by npm releases 0.4.2 … 0.7.0. */ +const PROFILE_042_TO_070 = 'profile-0.4.2-to-0.7.0.json' +/** Generated by npm release 0.8.0. */ +const PROFILE_080 = 'profile-0.8.0.json' +/** Generated by internal pre-publication 0.1.x builds. */ +const PROFILE_PREPUBLIC_01X = 'profile-prepublic-0.1.x.json' async function fixtureBytes(name: string): Promise { return readFile(join(FIXTURE_DIR, name), 'utf-8') @@ -43,6 +44,18 @@ async function fixtureBody(name: string): Promise> { return JSON.parse(await fixtureBytes(name)) as Record } +function shoppingEntries(body: Record): Array> { + const ucp = body.ucp as Record + const services = ucp.services as Record + return services['dev.ucp.shopping'] as Array> +} + +function shoppingEntry(body: Record): Record { + const entry = shoppingEntries(body)[0] + if (entry === undefined) throw new Error('shopping entry missing') + return entry +} + /** * `meta.json` as 0.4.2 … 0.8.0 actually wrote it for a stock init: timestamps, * the since-removed `protocol_versions`, and NO `profile_url` (that field was @@ -77,9 +90,9 @@ describe('generated-body fingerprints', () => { // Each fixture hashes to the historical digest recorded for it. If a // fixture is ever re-generated or reformatted, this is what catches it. const expected: Array<[string, string]> = [ - [STOCK_A, '508d145091f0efb805aacd7b21bc738b3dfa108c7b1d59748c66c00fe391b3cd'], - [STOCK_B, '3a75f9cf8e416ecbc716c303b6356dc1c9f6dce702f419654460eda0bf692ff5'], - [STOCK_A0, 'c928a7ed8d841f2da6571203845c8cb87d42c7d94fac28d48b39a65073e55c76'], + [PROFILE_042_TO_070, '508d145091f0efb805aacd7b21bc738b3dfa108c7b1d59748c66c00fe391b3cd'], + [PROFILE_080, '3a75f9cf8e416ecbc716c303b6356dc1c9f6dce702f419654460eda0bf692ff5'], + [PROFILE_PREPUBLIC_01X, 'c928a7ed8d841f2da6571203845c8cb87d42c7d94fac28d48b39a65073e55c76'], ] for (const [file, sha256] of expected) { expect(profileBodyFingerprint(await fixtureBody(file))).toBe(sha256) @@ -93,13 +106,13 @@ describe('generated-body fingerprints', () => { }) it('reports the release each historical body declares', async () => { - expect(generatedBodyVersion(await fixtureBody(STOCK_A))).toBe('2026-04-08') - expect(generatedBodyVersion(await fixtureBody(STOCK_A0))).toBe('2026-04-08') - expect(generatedBodyVersion(await fixtureBody(STOCK_B))).toBe('2026-08-25') + expect(generatedBodyVersion(await fixtureBody(PROFILE_042_TO_070))).toBe('2026-04-08') + expect(generatedBodyVersion(await fixtureBody(PROFILE_PREPUBLIC_01X))).toBe('2026-04-08') + expect(generatedBodyVersion(await fixtureBody(PROFILE_080))).toBe('2026-08-25') }) it('is blind to formatting and key order, and sensitive to values', async () => { - const body = await fixtureBody(STOCK_B) + const body = await fixtureBody(PROFILE_080) expect(profileBodyFingerprint(reverseKeys(body))).toBe(profileBodyFingerprint(body)) // Array order is data, not formatting. @@ -108,11 +121,90 @@ describe('generated-body fingerprints', () => { }) }) +// ─── ucp-cli 0.4.2–0.7.0 runtime compatibility ──────────────────────────── + +describe('normalizeProfile042To070', () => { + it('normalizes the exact generated entry while preserving input and custom declarations', async () => { + const body = await fixtureBody(PROFILE_042_TO_070) + const ucp = body.ucp as Record + const services = ucp.services as Record + const capabilities = ucp.capabilities as Record + services['com.acme.loyalty'] = [{ version: '2026-04-08', transport: 'mcp' }] + capabilities['com.acme.loyalty'] = [ + { version: '2026-04-08', schema: 'https://acme.test/loyalty' }, + ] + const before = structuredClone(body) + + const normalized = normalizeProfile042To070(body) as typeof body + const normalizedUcp = normalized.ucp as Record + const normalizedServices = normalizedUcp.services as Record + const current = JSON.parse(RELEASES['2026-04-08'].agentProfileJson) as typeof body + + expect(normalized).not.toBe(body) + expect(normalizedUcp).not.toBe(ucp) + expect(normalizedServices).not.toBe(services) + expect(normalizedServices['com.acme.loyalty']).toBe(services['com.acme.loyalty']) + expect(normalizedUcp.capabilities).toBe(capabilities) + expect(canonicalJson(shoppingEntries(normalized))).toBe(canonicalJson(shoppingEntries(current))) + expect(body).toStrictEqual(before) + }) + + it.each([ + { + case: 'pre-publication 0.1.x extra endpoint', + mutate: (body: Record) => { + shoppingEntry(body).endpoint = 'https://example.invalid/agent/no-endpoint' + }, + }, + { + case: 'altered entry fields', + mutate: (body: Record) => { + Object.assign(shoppingEntry(body), { + spec: 'https://example.test/not-stock', + transport: 'rest', + schema: 'https://example.test/not-stock.json', + }) + }, + }, + { + case: 'missing entry field', + mutate: (body: Record) => { + delete shoppingEntry(body).spec + }, + }, + { + case: 'second shopping entry', + mutate: (body: Record) => { + shoppingEntries(body).push(structuredClone(shoppingEntry(body))) + }, + }, + { + case: 'wrong root version', + mutate: (body: Record) => { + const ucp = body.ucp as Record + ucp.version = '2026-08-25' + }, + }, + { + case: 'wrong service key', + mutate: (body: Record) => { + const services = (body.ucp as Record).services as Record + services['dev.ucp.shopping.other'] = services['dev.ucp.shopping'] + delete services['dev.ucp.shopping'] + }, + }, + ])('returns the original reference for $case', async ({ mutate }) => { + const body = await fixtureBody(PROFILE_042_TO_070) + mutate(body) + expect(normalizeProfile042To070(body)).toBe(body) + }) +}) + // ─── the pure classifier ────────────────────────────────────────────────── describe('classifyStoredProfile', () => { it('reads an untouched generated body with no URL as managed', async () => { - for (const file of [STOCK_A, STOCK_B, STOCK_A0]) { + for (const file of [PROFILE_042_TO_070, PROFILE_080, PROFILE_PREPUBLIC_01X]) { expect(classifyStoredProfile(await fixtureBody(file), LEGACY_META)).toEqual({ kind: 'managed', needsMarker: true, @@ -123,18 +215,18 @@ describe('classifyStoredProfile', () => { it('keeps a generated body managed under its own release default URL', async () => { // The one URL a managed rendering would advertise for these bytes anyway. expect( - classifyStoredProfile(await fixtureBody(STOCK_A), { + classifyStoredProfile(await fixtureBody(PROFILE_042_TO_070), { profile_url: RELEASES['2026-04-08'].defaultAgentProfileUrl, }).kind, ).toBe('managed') expect( - classifyStoredProfile(await fixtureBody(STOCK_B), { + classifyStoredProfile(await fixtureBody(PROFILE_080), { profile_url: RELEASES['2026-08-25'].defaultAgentProfileUrl, }).kind, ).toBe('managed') // Trailing-slash / case-of-host normalization is not an edit either. expect( - classifyStoredProfile(await fixtureBody(STOCK_B), { + classifyStoredProfile(await fixtureBody(PROFILE_080), { profile_url: RELEASES['2026-08-25'].defaultAgentProfileUrl.replace( 'shopify.dev', 'SHOPIFY.dev', @@ -144,7 +236,7 @@ describe('classifyStoredProfile', () => { }) it('treats any other URL beside a stock body as DIY', async () => { - const body = await fixtureBody(STOCK_A) + const body = await fixtureBody(PROFILE_042_TO_070) for (const profile_url of [ 'https://mybot.example.com/.well-known/ucp', // A different release's default URL is still a decision: a managed @@ -156,7 +248,7 @@ describe('classifyStoredProfile', () => { }) it('treats one semantic edit as DIY', async () => { - const body = await fixtureBody(STOCK_A) + const body = await fixtureBody(PROFILE_042_TO_070) const ucp = body.ucp as Record const capabilities = { ...(ucp.capabilities as Record) } delete capabilities['dev.shopify.catalog.global'] @@ -166,7 +258,7 @@ describe('classifyStoredProfile', () => { }) it('honors an explicit marker over any fingerprint, in both directions', async () => { - const stock = await fixtureBody(STOCK_B) + const stock = await fixtureBody(PROFILE_080) expect(classifyStoredProfile(stock, { kind: 'diy', format_version: 2 })).toEqual({ kind: 'diy', needsMarker: false, @@ -179,7 +271,7 @@ describe('classifyStoredProfile', () => { it('re-stamps a kind written without a format_version', async () => { // Hand-edited or half-written marker: honor the stated kind, but finish // the marker so the next read is a plain lookup. - expect(classifyStoredProfile(await fixtureBody(STOCK_B), { kind: 'diy' })).toEqual({ + expect(classifyStoredProfile(await fixtureBody(PROFILE_080), { kind: 'diy' })).toEqual({ kind: 'diy', needsMarker: true, }) @@ -187,7 +279,7 @@ describe('classifyStoredProfile', () => { it('accepts a marker from a newer format_version without downgrading it', async () => { expect( - classifyStoredProfile(await fixtureBody(STOCK_B), { + classifyStoredProfile(await fixtureBody(PROFILE_080), { kind: 'diy', format_version: PROFILE_FORMAT_VERSION + 1, }), @@ -223,7 +315,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { } it('upgrades an untouched v0.7 profile to managed and leaves profile.json alone', async () => { - const bytes = await fixtureBytes(STOCK_A) + const bytes = await fixtureBytes(PROFILE_042_TO_070) const dir = await seedLegacy('legacy07', bytes) const read = await readUserProfile('legacy07', { homeDir }) @@ -244,7 +336,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { }) it('upgrades an untouched v0.8 profile to managed', async () => { - const dir = await seedLegacy('legacy08', await fixtureBytes(STOCK_B)) + const dir = await seedLegacy('legacy08', await fixtureBytes(PROFILE_080)) const read = await readUserProfile('legacy08', { homeDir }) @@ -302,7 +394,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { { damage: 'missing', code: 'PROFILE_NOT_FOUND' }, { damage: 'corrupt', code: 'SCHEMA_VALIDATION_FAILED' }, ])('rejects an unmarked legacy entry whose profile.json is $damage', async ({ damage, code }) => { - const dir = await seedLegacy('unmarked-damage', await fixtureBytes(STOCK_A)) + const dir = await seedLegacy('unmarked-damage', await fixtureBytes(PROFILE_042_TO_070)) const bodyPath = join(dir, 'profile.json') if (damage === 'missing') await rm(bodyPath) else await writeFile(bodyPath, '', 'utf-8') @@ -323,7 +415,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { }) it('marks one semantic edit DIY and preserves the edited body and user meta', async () => { - const body = await fixtureBody(STOCK_A) + const body = await fixtureBody(PROFILE_042_TO_070) const ucp = body.ucp as Record const services = ucp.services as Record const edited = { @@ -360,7 +452,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { }) it('marks a stock body under a custom URL DIY', async () => { - await seedLegacy('hosted', await fixtureBytes(STOCK_B), { + await seedLegacy('hosted', await fixtureBytes(PROFILE_080), { ...LEGACY_META, profile_url: 'https://mybot.example.com/.well-known/ucp', }) @@ -372,7 +464,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { }) it('leaves an explicitly marked DIY stock body DIY, and writes nothing', async () => { - const dir = await seedLegacy('pinned', await fixtureBytes(STOCK_B), { + const dir = await seedLegacy('pinned', await fixtureBytes(PROFILE_080), { ...LEGACY_META, format_version: PROFILE_FORMAT_VERSION, kind: 'diy', @@ -391,7 +483,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { }) it('never touches headers.json', async () => { - const dir = await seedLegacy('withheaders', await fixtureBytes(STOCK_A)) + const dir = await seedLegacy('withheaders', await fixtureBytes(PROFILE_042_TO_070)) // Deliberately ugly bytes: tabs, trailing newline-less end, key order the // formatter would change. Byte equality is the assertion. const headers = '{\n\t"default": {"X-Trace": "keep-me"},\n\t"businesses": {}\n}' @@ -403,7 +495,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { }) it('is idempotent: the second read neither writes nor changes anything', async () => { - const dir = await seedLegacy('twice', await fixtureBytes(STOCK_A)) + const dir = await seedLegacy('twice', await fixtureBytes(PROFILE_042_TO_070)) let writes = 0 const writeMeta = async (path: string, content: string) => { writes += 1 @@ -423,7 +515,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { it('classifies an exploratory scan without stamping metadata or warning on a blocked write', async () => { const warnings: string[] = [] setWarnWriter((msg) => warnings.push(msg)) - const dir = await seedLegacy('scanned', await fixtureBytes(STOCK_A)) + const dir = await seedLegacy('scanned', await fixtureBytes(PROFILE_042_TO_070)) const untouched = await readFile(join(dir, 'meta.json'), 'utf-8') let writes = 0 @@ -447,7 +539,7 @@ describe('readUserProfile — one-time legacy upgrade', () => { it('keeps working when the marker cannot be written', async () => { const warnings: string[] = [] setWarnWriter((msg) => warnings.push(msg)) - const dir = await seedLegacy('readonly', await fixtureBytes(STOCK_A)) + const dir = await seedLegacy('readonly', await fixtureBytes(PROFILE_042_TO_070)) const untouched = await readFile(join(dir, 'meta.json'), 'utf-8') const read = await readUserProfile('readonly', { diff --git a/src/core/legacy-profile.ts b/src/core/legacy-profile.ts index bb7fb81..44cfbf5 100644 --- a/src/core/legacy-profile.ts +++ b/src/core/legacy-profile.ts @@ -3,20 +3,20 @@ // ── What this exists for ────────────────────────────────────────────────── // // Before local Profiles gained stored kinds, `ucp profile init` wrote ONE -// generated body plus a `meta.json` with no discriminator. Read through -// today's session resolver that document becomes a singleton DIY Profile -// pinned to one release — and for every 0.4.2 … 0.7.0 install it is worse -// than pinned: that generated body declares `dev.ucp.shopping` at UCP -// 2026-01-23 inside a 2026-04-08 profile, which `loadAgentProfile`'s snapshot -// rule rejects outright (`AGENT_PROFILE_VERSION_MISMATCH`). An untouched -// generated body is not an authored declaration; it is an old spelling of -// "give me whatever this CLI ships". So the upgrade re-reads it as the -// multi-rendering managed Profile, and negotiation may select any release -// installed in this binary. +// generated body plus a `meta.json` with no discriminator. Read through the +// session resolver that document becomes a singleton DIY Profile pinned to +// one release — and for every 0.4.2 … 0.7.0 install it is worse than pinned: +// that generated body declares `dev.ucp.shopping` at UCP 2026-01-23 inside a +// 2026-04-08 profile. An untouched generated body is not an authored +// declaration; it is an old spelling of "give me whatever this CLI ships". +// So the upgrade re-reads it as the multi-rendering managed Profile, and +// negotiation may select any release installed in this binary. // // An EDITED body, or a body paired with a URL the user owns, is the opposite: // somebody decided what this agent claims and where the counterparty reads -// it. Those stay DIY — singleton, pinned, byte-preserved. +// it. Those stay DIY — singleton, pinned, byte-preserved. The compatibility +// normalizer below handles the exact service entry generated by ucp-cli +// 0.4.2 … 0.7.0 in a runtime clone. // // ── Why a fingerprint, and why canonical JSON ───────────────────────────── // @@ -33,8 +33,8 @@ // Published npm versions of @shopify/ucp-cli: 0.4.2, 0.4.3, 0.5.0, 0.6.0, // 0.6.1, 0.6.2, 0.6.3, 0.7.0, 0.8.0. Evaluating `localAgentProfileBody()` // with each release's build defines yields exactly two distinct published -// bodies (STOCK-A, STOCK-B) plus one pre-publication body (STOCK-A0) that -// only ever existed in dev builds of the internal 0.1.x tree. Frozen copies +// bodies: one from 0.4.2 … 0.7.0 and one from 0.8.0. A third, pre-publication +// body existed only in dev builds of the internal 0.1.x tree. Frozen copies // and the full derivation live in test/fixtures/legacy-profiles/. // // Those releases never wrote `meta.profile_url` on their own: `profile init` @@ -48,8 +48,9 @@ // // Pure. No I/O, no filesystem, no knowledge of where the bytes were read // from: core/profile-store.ts owns the read, the atomic marker write, and the -// warn-and-continue policy. Nothing in agent/profile/discover/operation may -// grow a legacy branch — the whole legacy vocabulary is this module. +// warn-and-continue policy. The DIY Profile factory calls the normalizer; the +// strict loader used for hosted bytes does not. The whole legacy vocabulary +// and exact matcher remain in this module. import { createHash } from 'node:crypto' @@ -115,18 +116,19 @@ const HISTORICAL_GENERATED_BODIES: readonly GeneratedBody[] = Object.freeze([ version: '2026-04-08', sha256: '508d145091f0efb805aacd7b21bc738b3dfa108c7b1d59748c66c00fe391b3cd', provenance: - 'STOCK-A — localAgentProfileBody() in npm 0.4.2 … 0.7.0 (git v0.7.0:src/core/profile.ts). Declares dev.ucp.shopping at 2026-01-23 inside a 2026-04-08 profile.', + 'PROFILE_042_TO_070 — localAgentProfileBody() in npm 0.4.2 … 0.7.0 (git v0.7.0:src/core/profile.ts). Declares dev.ucp.shopping at 2026-01-23 inside a 2026-04-08 profile.', }, { version: '2026-08-25', sha256: '3a75f9cf8e416ecbc716c303b6356dc1c9f6dce702f419654460eda0bf692ff5', - provenance: 'STOCK-B — localAgentProfileBody() in npm 0.8.0 (git v0.8.0:src/core/profile.ts).', + provenance: + 'PROFILE_080 — localAgentProfileBody() in npm 0.8.0 (git v0.8.0:src/core/profile.ts).', }, { version: '2026-04-08', sha256: 'c928a7ed8d841f2da6571203845c8cb87d42c7d94fac28d48b39a65073e55c76', provenance: - 'STOCK-A0 — localAgentProfileBody() in the internal 0.1.x tree (git 89f0074:src/core/profile.ts, branch local/init-history). Never published to npm; recognized because dev builds of that tree wrote it, and it pins dev.ucp.shopping to an unreachable example.invalid endpoint that is useless as a DIY declaration.', + 'PROFILE_PREPUBLIC_01X — localAgentProfileBody() in the internal 0.1.x tree (git 89f0074:src/core/profile.ts, branch local/init-history). Never published to npm; recognized because dev builds of that tree wrote it, and it pins dev.ucp.shopping to an unreachable example.invalid endpoint that is useless as a DIY declaration.', }, ]) @@ -149,6 +151,44 @@ export function canonicalJson(value: unknown): string { return JSON.stringify(value) ?? 'null' } +// Exact `dev.ucp.shopping` value generated by ucp-cli 0.4.2 through 0.7.0. +const PROFILE_042_TO_070 = [ + { + version: '2026-01-23', + spec: 'https://ucp.dev/2026-04-08/specification/overview', + transport: 'mcp', + schema: 'https://ucp.dev/2026-04-08/services/shopping/mcp.openrpc.json', + }, +] as const + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Normalize the one off-version service entry generated by ucp-cli 0.4.2 … 0.7.0. + * Exact matching keeps every near miss on the normal strict-validation path. + * A match returns a copy-on-write runtime body; source bytes stay untouched. + */ +export function normalizeProfile042To070(body: unknown): unknown { + if (!isRecord(body) || !isRecord(body.ucp)) return body + const ucp = body.ucp + if (ucp.version !== '2026-04-08' || !isRecord(ucp.services)) return body + const services = ucp.services + if (canonicalJson(services['dev.ucp.shopping']) !== canonicalJson(PROFILE_042_TO_070)) return body + + return { + ...body, + ucp: { + ...ucp, + services: { + ...services, + 'dev.ucp.shopping': [{ ...PROFILE_042_TO_070[0], version: '2026-04-08' }], + }, + }, + } +} + /** sha256 of {@link canonicalJson} — the identity used for classification. */ export function profileBodyFingerprint(body: unknown): string { return createHash('sha256').update(canonicalJson(body)).digest('hex') diff --git a/test/fixtures/legacy-profiles/PROVENANCE.md b/test/fixtures/legacy-profiles/PROVENANCE.md index 593a4c7..574ac4b 100644 --- a/test/fixtures/legacy-profiles/PROVENANCE.md +++ b/test/fixtures/legacy-profiles/PROVENANCE.md @@ -26,26 +26,31 @@ Published npm versions of `@shopify/ucp-cli` (registry.npmjs.org, verified): | file | id | git ref | shipped in | sha256 (canonical JSON) | | --- | --- | --- | --- | --- | -| `stock-a-2026-04-08.json` | STOCK-A | `v0.7.0:src/core/profile.ts` | npm 0.4.2 … 0.7.0 (identical body in all nine builds of that range) | `508d145091f0efb805aacd7b21bc738b3dfa108c7b1d59748c66c00fe391b3cd` | -| `stock-b-2026-08-25.json` | STOCK-B | `v0.8.0:src/core/profile.ts` | npm 0.8.0 | `3a75f9cf8e416ecbc716c303b6356dc1c9f6dce702f419654460eda0bf692ff5` | -| `stock-a0-prerelease-2026-04-08.json` | STOCK-A0 | `89f0074:src/core/profile.ts` (branch `local/init-history`) | never published; dev builds of the internal 0.1.x tree | `c928a7ed8d841f2da6571203845c8cb87d42c7d94fac28d48b39a65073e55c76` | +| `profile-0.4.2-to-0.7.0.json` | ucp-cli 0.4.2–0.7.0 | `v0.7.0:src/core/profile.ts` | npm 0.4.2 … 0.7.0 (identical body in all nine builds of that range) | `508d145091f0efb805aacd7b21bc738b3dfa108c7b1d59748c66c00fe391b3cd` | +| `profile-0.8.0.json` | ucp-cli 0.8.0 | `v0.8.0:src/core/profile.ts` | npm 0.8.0 | `3a75f9cf8e416ecbc716c303b6356dc1c9f6dce702f419654460eda0bf692ff5` | +| `profile-prepublic-0.1.x.json` | pre-publication 0.1.x | `89f0074:src/core/profile.ts` (branch `local/init-history`) | never published; dev builds of the internal 0.1.x tree | `c928a7ed8d841f2da6571203845c8cb87d42c7d94fac28d48b39a65073e55c76` | Notes on each: -- **STOCK-A** declares `dev.ucp.shopping` at UCP `2026-01-23` inside a - `2026-04-08` profile. Today's `loadAgentProfile` snapshot rule rejects that - with `AGENT_PROFILE_VERSION_MISMATCH`, so without the upgrade every profile - created by 0.4.2 … 0.7.0 fails at dispatch. This is the case the migration - exists for. -- **STOCK-B** is snapshot-clean and loads today; it upgrades for reach - (managed offers both installed releases), not for repair. -- **STOCK-A0** differs from STOCK-A by exactly one field — an +- **ucp-cli 0.4.2–0.7.0 Profile** declares `dev.ucp.shopping` at UCP + `2026-01-23` inside a `2026-04-08` profile. Untouched copies upgrade to the + managed Profile. For edited DIY copies, `createDiyProfile` recognizes only + that complete published service entry and changes its version to + `2026-04-08` in a runtime clone; the fixture and the user's source bytes + remain unchanged. Strict hosted validation does not apply this local + normalization. +- **ucp-cli 0.8.0 Profile** is snapshot-clean and loads directly; it upgrades + for reach (managed offers both installed releases), not for repair. +- **Pre-publication 0.1.x Profile** differs from the ucp-cli 0.4.2–0.7.0 + Profile by exactly one field: an `endpoint: "https://example.invalid/agent/no-endpoint"` on the `dev.ucp.shopping` service entry. It predates the OSS release and reached no npm user, but the repo was internal through 0.4.1 and dev builds of that tree - wrote this document. Recognized deliberately: as a DIY declaration it is - useless (it pins an unreachable endpoint), and no hand-authored profile is - going to collide with it byte-for-byte. + wrote this document. Its untouched fingerprint safely upgrades to managed: + the endpoint is unusable and a hand-authored document cannot plausibly + collide with the full canonical fingerprint. Its extra endpoint deliberately + excludes an edited or explicitly DIY pre-publication document from the + runtime matcher. ## Current templates are NOT frozen here diff --git a/test/fixtures/legacy-profiles/stock-a-2026-04-08.json b/test/fixtures/legacy-profiles/profile-0.4.2-to-0.7.0.json similarity index 100% rename from test/fixtures/legacy-profiles/stock-a-2026-04-08.json rename to test/fixtures/legacy-profiles/profile-0.4.2-to-0.7.0.json diff --git a/test/fixtures/legacy-profiles/stock-b-2026-08-25.json b/test/fixtures/legacy-profiles/profile-0.8.0.json similarity index 100% rename from test/fixtures/legacy-profiles/stock-b-2026-08-25.json rename to test/fixtures/legacy-profiles/profile-0.8.0.json diff --git a/test/fixtures/legacy-profiles/stock-a0-prerelease-2026-04-08.json b/test/fixtures/legacy-profiles/profile-prepublic-0.1.x.json similarity index 100% rename from test/fixtures/legacy-profiles/stock-a0-prerelease-2026-04-08.json rename to test/fixtures/legacy-profiles/profile-prepublic-0.1.x.json diff --git a/test/integration/managed-profile.integration.test.ts b/test/integration/managed-profile.integration.test.ts index 06e0fb4..37e806f 100644 --- a/test/integration/managed-profile.integration.test.ts +++ b/test/integration/managed-profile.integration.test.ts @@ -96,18 +96,16 @@ async function removeHomeAndClose(home: string, mock: MockUcpShopping): Promise< await rm(home, { recursive: true, force: true }) } -const HISTORICAL_STOCK = [ +const HISTORICAL_GENERATED_PROFILES = [ { - label: 'v0.7 STOCK-A', + label: 'ucp-cli 0.4.2–0.7.0 Profile', path: fileURLToPath( - new URL('../fixtures/legacy-profiles/stock-a-2026-04-08.json', import.meta.url), + new URL('../fixtures/legacy-profiles/profile-0.4.2-to-0.7.0.json', import.meta.url), ), }, { - label: 'v0.8 STOCK-B', - path: fileURLToPath( - new URL('../fixtures/legacy-profiles/stock-b-2026-08-25.json', import.meta.url), - ), + label: 'ucp-cli 0.8.0 Profile', + path: fileURLToPath(new URL('../fixtures/legacy-profiles/profile-0.8.0.json', import.meta.url)), }, ] as const @@ -232,7 +230,79 @@ describe('managed Profile: compiled integration journeys', () => { } }) - it.each(HISTORICAL_STOCK)( + it('loads an active edited ucp-cli 0.4.2–0.7.0 Profile as DIY with zero recovery action', async () => { + const mock = await startMockUcpShopping() + const home = await mkdtemp(join(tmpdir(), 'ucp-edited-042-070-')) + const name = 'edited-042-070' + const dir = join(home, 'profiles', name) + const profilePath = join(dir, 'profile.json') + const metaPath = join(dir, 'meta.json') + const headersPath = join(dir, 'headers.json') + const activePath = join(home, 'active.yaml') + const storedProfileUrl = 'https://agent.example.test/edited-042-070.json' + try { + const body = JSON.parse(await readFile(HISTORICAL_GENERATED_PROFILES[0].path, 'utf-8')) as { + ucp: { capabilities: Record } + } + body.ucp.capabilities['com.acme.loyalty'] = [ + { + version: VERSION_04, + spec: 'https://example.com/ucp/loyalty/spec', + schema: 'https://example.com/ucp/loyalty/schema.json', + }, + ] + const profileBytes = `${JSON.stringify(body, null, 2)}\n` + const originalMeta = { + created_at: '2026-01-01T00:00:00.000Z', + profile_url: storedProfileUrl, + protocol_versions: { min: '2026-01-23', max: VERSION_04 }, + } + const headersBytes = `${JSON.stringify({ + default: { 'Integration-Marker': 'from-profile' }, + })}\n` + const activeBytes = `profile: ${name}\nbusiness: ${mock.url}\n` + await mkdir(dir, { recursive: true }) + await writeFile(profilePath, profileBytes, 'utf-8') + await writeFile(metaPath, `${JSON.stringify(originalMeta)}\n`, 'utf-8') + await writeFile(headersPath, headersBytes, 'utf-8') + await writeFile(activePath, activeBytes, 'utf-8') + + const discovered = await runCli(home, ['discover', '--verbose']) + expect(discovered.code, diagnostic(discovered)).toBe(0) + expect(discovery(discovered).protocol).toEqual({ + version: VERSION_04, + source: 'supported_versions', + agentProfileUrl: storedProfileUrl, + businessProfileUrl: `${mock.url}${MOCK_LEGACY_PROFILE_PATH}`, + }) + expect(discovered.stderr).toContain('source bytes remain unchanged') + expect(discovered.stderr).toContain('Integration-Marker: from-profile') + // The mock records the advertised URL on tools/list; it does not dereference it. + expect(toolsListRequests(mock)).toEqual([ + expect.objectContaining({ agentProfileUrl: storedProfileUrl }), + ]) + + const shown = await runCli(home, ['profile', 'show']) + expect(shown.code, diagnostic(shown)).toBe(0) + expect(shown.json).toMatchObject({ + name, + kind: 'diy', + active: true, + renderings: [{ version: VERSION_04, profile_url: storedProfileUrl }], + body: { + ucp: { capabilities: { 'com.acme.loyalty': body.ucp.capabilities['com.acme.loyalty'] } }, + }, + meta: { ...originalMeta, format_version: 2, kind: 'diy' }, + }) + expect(await readFile(activePath, 'utf-8')).toBe(activeBytes) + expect(await readFile(profilePath, 'utf-8')).toBe(profileBytes) + expect(await readFile(headersPath, 'utf-8')).toBe(headersBytes) + } finally { + await removeHomeAndClose(home, mock) + } + }) + + it.each(HISTORICAL_GENERATED_PROFILES)( '$label is marked managed once, gains every installed rendering, and preserves user bytes', async ({ path }) => { const mock = await startMockUcpShopping()