Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/ucp/references/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<seller.domain>` |
Expand Down
32 changes: 31 additions & 1 deletion src/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<string, Array<Record<string, unknown>>>
capabilities: Record<string, unknown>
}
}
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
Expand Down
33 changes: 6 additions & 27 deletions src/cli/session.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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' })
Expand All @@ -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' })

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
106 changes: 91 additions & 15 deletions src/core/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +30,22 @@ function publishedBody(): { ucp: Record<string, unknown>; [k: string]: unknown }
}
}

function profile042To070Body(): {
ucp: {
services: Record<string, Array<Record<string, unknown>>>
capabilities: Record<string, Array<Record<string, unknown>>>
}
[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) => {
Expand All @@ -39,6 +55,7 @@ function captureWarnings(): string[] {
}

afterEach(() => {
setVerboseWriter(null)
setWarnWriter(null)
})

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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')
Expand Down
43 changes: 32 additions & 11 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
})
}
}
Expand Down Expand Up @@ -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,
Expand Down
Loading