From 90417f4aab8ef7e4082e86618306f79dfcf743d8 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Sat, 12 Sep 2026 23:40:09 -0700 Subject: [PATCH] fix: load edited 0.4.2-0.7.0 Profiles ucp-cli 0.4.2 through 0.7.0 generated 2026-04-08 Profiles with the shopping service entry still set to 2026-01-23. Edited copies remain DIY, so strict snapshot validation rejected them before dispatch. Normalize only that exact generated entry when materializing a DIY Profile. Preserve custom capabilities, scoped headers, URL, active name, and source bytes while leaving near misses and additional dev.ucp.* mismatches on the strict validation path. Keep hosted Profile reads strict so Doctor still reports bytes that an independent Business may reject. Make generic mismatch guidance name the selected Profile and distinguish user-owned URLs from Shopify release defaults. --- skills/ucp/references/REFERENCE.md | 2 +- src/cli/doctor.test.ts | 32 +++- src/cli/session.test.ts | 33 +--- src/core/agent.test.ts | 106 ++++++++++-- src/core/agent.ts | 43 +++-- src/core/legacy-profile.test.ts | 156 ++++++++++++++---- src/core/legacy-profile.ts | 76 +++++++-- test/fixtures/legacy-profiles/PROVENANCE.md | 33 ++-- ...04-08.json => profile-0.4.2-to-0.7.0.json} | 0 ...k-b-2026-08-25.json => profile-0.8.0.json} | 0 ...4-08.json => profile-prepublic-0.1.x.json} | 0 .../managed-profile.integration.test.ts | 86 +++++++++- 12 files changed, 440 insertions(+), 127 deletions(-) rename test/fixtures/legacy-profiles/{stock-a-2026-04-08.json => profile-0.4.2-to-0.7.0.json} (100%) rename test/fixtures/legacy-profiles/{stock-b-2026-08-25.json => profile-0.8.0.json} (100%) rename test/fixtures/legacy-profiles/{stock-a0-prerelease-2026-04-08.json => profile-prepublic-0.1.x.json} (100%) 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()