From ce721f042ae15ecc9a0c48552697a8a2892e42cd Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 11 Sep 2026 22:21:58 -0700 Subject: [PATCH] feat: make managed Profile negotiation the default Change Profile resolution from requiring an initialized local Profile to using a managed Profile by default. This improves CLI ergonomics: catalog, cart, checkout, and order commands work immediately while preserving exact release negotiation and explicit DIY Profiles for customization. Derive one managed rendering from every installed RELEASES entry. Discover the Business offer first, select the newest exact release shared by both sides, and advertise only that rendering's URL. Compatibility remains an exact release intersection; selected document, endpoint, and operation failures surface directly instead of triggering an implicit downgrade. Establish one runtime Profile contract: - managed Profiles expose all installed release renderings - DIY Profiles retain one exact authored document and release - Profile body and URL provenance resolve independently, so a URL override never replaces a named DIY Profile's capabilities or services - explicit missing, corrupt, or inconsistent selections fail closed rather than unexpectedly changing identity Keep Profile authoring intentional. `profile init` creates an exact DIY snapshot, activates it only with `--activate`, preserves owned URLs during forced regeneration when possible, and validates the complete runtime Profile before changing active state. Profile names resolve through per-call arguments, environment, persisted selection, then the managed default; Profile URL overrides follow their own precedence chain. Recognize untouched Profiles generated by earlier releases as named managed aliases. Preserve their names, active selection, defaults, headers, and historical files while adding classification metadata best-effort. Once metadata marks an alias managed, current runtime resolution no longer depends on its historical profile.json; edited, custom-hosted, and explicitly DIY Profiles remain authored snapshots. Keep MCP routing isolated from operator-global state by ignoring Profile and Business values in active.yaml while continuing to honor explicit tool arguments and environment variables. Partition persisted Business discovery by canonical full Profile URL and verify each cache envelope owns the requested URL. Read legacy origin-keyed entries only when the primary entry is absent or invalid, never when a valid primary entry merely expired. Continue to honor Business Cache-Control and the separate tools/list TTL. Make Profile commands and Doctor reflect the same effective runtime state. Expose the managed selection through list, show, and use --managed; keep inactive Profile scans read-only; audit every managed rendering with bounded parallel probes; and preserve authoritative recovery CTAs through Doctor output. Attach schema-error provenance so only operation-input failures recommend --input-schema. Align the README, agent skill, setup reference, and release notes with the new default while keeping DIY authoring, precedence, persistent headers, and release pinning in the advanced references. Keep version stability explicit rather than persisting hidden resource-to-rendering affinity or silently walking older releases. Workflows that must remain on one release can create a DIY pin before the resource and pass it explicitly on every subsequent operation. Validated with schema regeneration, typecheck, lint, 962 unit tests, and 33 compiled integration tests with one expected live test skipped. --- .changeset/profile-driven-negotiation.md | 57 +- README.md | 40 +- biome.json | 3 +- skills/ucp/SKILL.md | 17 +- skills/ucp/references/CATALOG.md | 4 +- skills/ucp/references/REFERENCE.md | 74 +- skills/ucp/references/SETUP.md | 140 +-- src/cli-envelope.test.ts | 6 +- src/cli-errors.test.ts | 368 +++++- src/cli.test.ts | 156 ++- src/cli.ts | 159 +-- src/cli/doctor.test.ts | 931 +++++++++++--- src/cli/doctor.ts | 876 +++++++++---- src/cli/profile-hint.test.ts | 105 +- src/cli/profile-hint.ts | 110 +- src/cli/profile.test.ts | 1106 ++++++++++++++++- src/cli/profile.ts | 442 +++++-- src/cli/session.test.ts | 368 +++++- src/cli/session.ts | 202 +-- src/core/agent.test.ts | 280 +++-- src/core/agent.ts | 363 +++--- src/core/cache.test.ts | 211 +++- src/core/cache.ts | 61 +- src/core/discover.test.ts | 434 +++++-- src/core/discover.ts | 209 ++-- src/core/legacy-profile.test.ts | 471 +++++++ src/core/legacy-profile.ts | 232 ++++ src/core/negotiate.test.ts | 97 +- src/core/operation.test.ts | 94 +- src/core/operation.ts | 78 +- src/core/profile-store.test.ts | 93 +- src/core/profile-store.ts | 312 ++++- src/core/profile.test.ts | 71 +- src/core/profile.ts | 174 ++- src/core/releases.test.ts | 26 +- src/core/releases.ts | 22 + src/lib/errors.ts | 82 +- src/services/shopping.test.ts | 14 +- src/test-utils.ts | 83 +- test/fixtures/legacy-profiles/PROVENANCE.md | 60 + .../legacy-profiles/stock-a-2026-04-08.json | 99 ++ .../stock-a0-prerelease-2026-04-08.json | 100 ++ .../legacy-profiles/stock-b-2026-08-25.json | 99 ++ test/fixtures/mock-business.ts | 12 + test/fixtures/mock-ucp-shopping.ts | 49 +- test/fixtures/subprocess-env.ts | 25 + .../catalog-live.integration.test.ts | 31 +- .../managed-profile.integration.test.ts | 379 ++++++ .../purchase-journey.integration.test.ts | 51 +- test/integration/smoke.integration.test.ts | 215 ++-- 50 files changed, 7766 insertions(+), 1925 deletions(-) create mode 100644 src/core/legacy-profile.test.ts create mode 100644 src/core/legacy-profile.ts create mode 100644 test/fixtures/legacy-profiles/PROVENANCE.md create mode 100644 test/fixtures/legacy-profiles/stock-a-2026-04-08.json create mode 100644 test/fixtures/legacy-profiles/stock-a0-prerelease-2026-04-08.json create mode 100644 test/fixtures/legacy-profiles/stock-b-2026-08-25.json create mode 100644 test/fixtures/subprocess-env.ts create mode 100644 test/integration/managed-profile.integration.test.ts diff --git a/.changeset/profile-driven-negotiation.md b/.changeset/profile-driven-negotiation.md index 02a74c8..91d8ecc 100644 --- a/.changeset/profile-driven-negotiation.md +++ b/.changeset/profile-driven-negotiation.md @@ -2,53 +2,16 @@ "@shopify/ucp-cli": minor --- -**A profile speaks exactly one UCP release, and the business must offer that release.** +`ucp-cli` now negotiates the newest exact UCP release shared with each Business, using the managed Profile by default. This build supports **2026-04-08** and **2026-08-25**; extension-key validation follows the selected release. -`ucp-cli` supports UCP **2026-04-08** and **2026-08-25**. This explicit set replaces the `2026-01-23`–`2026-08-25` range that `0.8.0` accepted; the profile selects which release it speaks when you create it: +Untouched Profiles generated by earlier releases continue working as named managed Profiles. Edited or custom-hosted Profiles retain their authored document and URL. `ucp profile init` remains available for intentional customization or release pinning, accepts one exact `--version`, and activates only with `--activate`; the former range flags and inert `profile publish` command are removed. -```sh -ucp profile init --name legacy --version 2026-04-08 -ucp discover shop.example.com --profile legacy -``` +Other observable changes: -Upgrading also raises the runtime floor to Node.js `22.19.0` (breaking change 7). - -## Breaking changes - -1. **The selected profile's release must exactly match a version the business offers.** `0.8.0` accepted a business service entry anywhere in its `2026-01-23`–`2026-08-25` range. Now a `2026-08-25` profile against a business publishing only `2026-04-08` fails with `PROTOCOL_VERSION_INCOMPATIBLE` before any commerce operation is sent — and when another local profile speaks a version the business offers, the error names that profile. A business offering neither supported release (for example, only `2026-01-23`) can no longer negotiate at all. - - The match can also come through `supported_versions`: whenever the top-level `/.well-known/ucp` document is at any different version and its `supported_versions` entry links a document for your profile's release, `ucp-cli` fetches and validates that leaf. Extension-key validation follows the selected release: `2026-04-08` rejects hyphens and digit-leading reverse-domain segments; `2026-08-25` behavior is unchanged. - -2. **A profile's local `profile.json` controls negotiation, whether its profile URL is a release default or a URL you own.** Its `ucp.version`, services, and capabilities are what `ucp-cli` uses. Requests advertise the profile URL, the business reads the copy served there, and `ucp doctor` compares the two. If an operation reports `AGENT_PROFILE_SERVICE_UNDECLARED`, declare that service in `profile.json` and make the profile URL serve the corrected file — switching to a URL you own if the current one cannot change. - - Upgrading does not rewrite an existing `profile.json`, so a stock `0.8.0` file can report a non-failing `profile-drift` warning against the currently served document. `ucp profile init --name --version --force` replaces the file and discards local edits; pass `--profile-url` again when the URL is yours. - -3. **`ucp profile init` takes `--version `; the removed `--protocol-min` / `--protocol-max` flags now fail as unknown flags.** A stale `meta.protocol_versions` field in existing profiles is tolerated but ignored. - - **`ucp profile publish` is removed** and invoking it fails as an unknown command. The `0.8.0` command never uploaded a document; publish `profile.json` through your hosting workflow. - -4. **Bare `ucp discover` negotiates only the services both your agent profile and the business document declare.** Business-only services stay visible in the returned `profile`, are not contacted, get no `negotiated` entry — and no longer fail a bare `ucp discover` merely because they cannot negotiate. Explicitly requesting an undeclared service still raises `AGENT_PROFILE_SERVICE_UNDECLARED`. - - Output gains `protocol` — fields `version`, `source` (`well-known` or `supported_versions`), and `businessProfileUrl` — plus `expectedCapabilities`, the intersection of agent profile and business declarations. `expectedCapabilities` is advisory; each response's `ucp.capabilities` remains authoritative. - -5. **`ucp doctor` exits 1 when any check fails; warnings alone exit 0.** `--skip-network` skips exactly four checks: `protocol`, `profile-redirect`, `profile-drift`, and `profile-cache-control`. - - Those four replace `0.8.0`'s `profile-url` check id — update scripts that match check ids. An unreachable profile URL or a version disagreement is a failure; non-version document drift and cache-control issues are warnings. - -6. **`ucp --version` prints `ucp (UCP 2026-04-08, 2026-08-25)`** instead of `0.8.0`'s bare semver. Update scripts to read `` from the second field. - -7. **Node.js `22.19.0` or later is required.** npm's default behavior reports the `engines` mismatch as an `EBADENGINE` warning and completes the install, so a successful install is not proof of compatibility. - - `ucp doctor`'s `runtime` check now compares the full `major.minor.patch` and fails below the floor; `0.8.0` compared the major version only. Upgrade Node before invoking the CLI. - -8. **Redirect responses are refused: UCP requires profile, identity, and schema documents to be served directly, with no redirect to follow.** `ucp-cli` refuses `301`, `302`, `303`, `307`, and `308` on every outbound UCP fetch, and applies the same policy to negotiated service endpoints, so the endpoint it calls is the endpoint the business document declares. - - Direct refusals raise `TRANSPORT_REDIRECT_REFUSED`, naming the status and the refused `Location`. A redirecting agent profile URL surfaces in `ucp doctor` as a single failing `profile-redirect` check. Serve documents directly and put each final HTTPS URL in the declaration that names it. - -Fixed `checkout complete` body handling so it accepts input consistently with other write operations. - -## Error codes - -Eight codes are added relative to `0.8.0`, and none are removed: `AGENT_PROFILE_SCHEMA_INVALID`, `AGENT_PROFILE_SERVICE_UNDECLARED`, `AGENT_PROFILE_UNREACHABLE`, `AGENT_PROFILE_VERSION_MISMATCH`, `AGENT_PROFILE_VERSION_UNSUPPORTED`, `PROFILE_VERSION_MISMATCH`, `SERVICE_VERSION_INCOMPATIBLE`, and `TRANSPORT_REDIRECT_REFUSED`. - -Within this added set, the `AGENT_PROFILE_*` codes identify your agent document — the one your requests advertise. `PROFILE_VERSION_MISMATCH` identifies the business document. `SERVICE_VERSION_INCOMPATIBLE` means the two sides' service declarations share no common version. `TRANSPORT_REDIRECT_REFUSED` is a redirect refused directly at the transport (breaking change 8). +- `ucp discover` reports the selected protocol and services declared by both Profiles. +- MCP mode ignores Profile and Business selections in `active.yaml`; explicit arguments and environment variables still apply. +- Business discovery and `tools/list` are cached separately between runs. `--refresh` bypasses both caches; `--dry-run` skips only the operation call. +- Doctor enforces the Node.js `22.19.0` runtime floor, exits nonzero on failed checks, and separately reports Profile redirects, drift, and cache policy. +- UCP document and declared endpoint redirects raise `TRANSPORT_REDIRECT_REFUSED` rather than being followed. +- Profile, version, and service failures now distinguish Profile-side and Business-side causes; see the error-code reference for recovery. +- `ucp checkout complete` now accepts and sends its required completion body like other write operations. diff --git a/README.md b/README.md index 7d5c49a..1ae6d9f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ Designed agent-first. Structured JSON I/O on every command. Schema introspection ```sh $> npm install -g @shopify/ucp-cli $> ucp skills add -$> ucp profile init --name shopper ``` ### 1. Find products @@ -52,7 +51,7 @@ The merchant returns a cart with confirmed pricing and `continue_url` that the b Need a shipping-cost preview before checkout? Inspect `ucp cart update --input-schema`; if the merchant accepts fulfillment destinations on cart update, send the buyer destination there to get merchant-provided estimates. Use checkout for the complete shipping/pickup option map and final selectable options. ### 3. Convert to checkout, configure handoff, complete -Some checkouts require additional buyer input or review that the agent can't negotiate on the buyer's behalf. When the merchant returns an escalation status with a continue URL, configure a custom hook and the CLI will call it to handle the handoff. +Some checkouts require additional buyer input or review that the agent can't negotiate on the buyer's behalf. When the merchant returns an escalation status with a continue URL, configure a custom hook and the CLI will call it to handle the handoff. ```sh export UCP_ON_ESCALATION='jq -r .url | xargs open' # macOS; xdg-open on Linux @@ -92,41 +91,20 @@ The skill packages the agent-facing operating model — when to search vs discov A **business** is a URL — `https://shop.example.com`. The CLI fetches the business's UCP profile (cached on disk per spec TTL), negotiates the protocol version + transport, and dispatches operations against its endpoint. UCP CLI abstracts transport, service + capability negotiation, ..., and error handling. -### Choosing a protocol version +### Protocol releases ```sh -ucp --version # ucp 0.7.0 (UCP 2026-04-08, 2026-08-25) +ucp --version # ucp 0.9.0 (UCP 2026-04-08, 2026-08-25) ``` -The parenthetical lists the UCP releases this CLI supports. Which one you speak is set by your **active profile** — a profile declares exactly one `ucp.version`, and merchants validate that exact version on every request. So switching version means switching profile: - -```sh -ucp profile init --name legacy --version 2026-04-08 # create a profile pinned to an older release -ucp discover shop.example.com --profile legacy # ...and use it -``` - -Each profile has two halves, and one contract between them: - -- `~/.ucp/profiles//profile.json` — the document the CLI declares. Its `ucp.version` is the release you speak; its services and capabilities are what you offer to negotiate. -- `meta.profile_url` — where that document lives on the web. The CLI sends this URL on every request, merchants fetch it, and they negotiate against whatever it serves. - -**The two must say the same thing.** Keeping them equal is your job — `ucp doctor` is what checks: `protocol` fails when the URL is unreachable or serves a different `ucp.version` than you send, `profile-drift` warns when the rest of the document differs. `profile init` starts them in agreement by writing the release's published document to disk. - -To advertise a capability set of your own, put the document at a URL you control. Nothing is signed, so **whoever controls that URL controls this agent's identity.** - -```sh -ucp profile init --name mine --profile-url https://you.example/agent.json -# edit ~/.ucp/profiles/mine/profile.json -# upload it to that URL yourself — scp, S3, whatever you host with -ucp doctor # confirms the two agree -``` +The parenthetical lists the UCP releases this CLI supports. For each Business, the CLI uses the newest release both sides support. Intentional release pinning and custom Profiles are covered in [`skills/ucp/references/SETUP.md`](skills/ucp/references/SETUP.md). Profile documents are unsigned, so **whoever controls the advertised URL controls the agent's identity.** Two scopes for picking which business an operation targets: - **Global catalog (no `--business`)** — for product discovery across thousands of merchants. Each result names its merchant via `seller.domain`. -- **Per-merchant (`--business `)** — for cart, checkout, order, or catalog operations scoped to a single merchant. +- **Per-merchant (`--business `)** — for cart, checkout, order, or catalog operations scoped to a single merchant. -**Live introspection so the agent never guesses.** Both `discover` and `--input-schema` make a real network call to the merchant; they're not static doc lookups. The schema you get back is whatever the merchant currently advertises — including extensions they've added since you last shopped there. Merchants stay in authoritative control of their own schemas; they can evolve, deprecate, or extend without coordinated releases against the CLI or the agent. Capability negotiation is real: the agent and merchant agree on what to use based on what's actually offered right now. +**Live introspection so the agent never guesses.** Both `discover` and `--input-schema` read the merchant's own advertised schemas rather than static docs; results are cached briefly, and `--refresh` forces a fresh fetch. The schema you get back is whatever the merchant currently advertises — including extensions they've added since you last shopped there. Merchants stay in authoritative control of their own schemas; they can evolve, deprecate, or extend without coordinated releases against the CLI or the agent. Capability negotiation is real: the agent and merchant agree on what to use based on what's actually offered right now. ```sh ucp discover --business https:// # what operations are offered @@ -135,7 +113,7 @@ ucp cart update --input-schema --business https:// # cart-s ucp checkout update --input-schema --business https:// # full fulfillment option map/final fields ``` -**Every response carries a `cta`.** The CLI is context-aware — it tracks where you are in the flow and surfaces the next-best step(s) as structured recommendations the agent should consider. Successful responses point forward (cart created → here are the checkout / refine / search-more commands); error responses point at recovery (schema validation failed → here's the `--input-schema` command to introspect first). The agent doesn't have to memorize the operating model; the CLI threads it through. +**Most responses carry a `cta`.** The CLI is context-aware — it tracks where you are in the flow and surfaces the next-best step(s) as structured recommendations the agent should consider. Successful responses point forward (cart created → here are the checkout / refine / search-more commands); recoverable errors point at recovery (schema validation failed → here's the `--input-schema` command to introspect first). The agent doesn't have to memorize the operating model; the CLI threads it through. Errors themselves stay flat — `code` and `message` always, `retryable` and `cta` optional — so branch on the full `code` and read a CTA as advice. ```sh $ ucp cart create --business https://shop.example.com \ @@ -238,7 +216,7 @@ ucp cart update --business https:// \ --dry-run ``` -Builds and validates the request, prints the exact payload that would hit the wire (including auto-injected `meta.idempotency-key` and `meta.ucp-agent`), skips the network call. Cart and checkout updates are full-replace: carry forward request-shaped line items, using `line_items[].id` only for existing lines and `line_items[].item.id` for the underlying item/variant. Useful for debugging payloads or confirming a mutation before issuing it. +Builds and validates the request, prints the exact payload that would hit the wire (including auto-injected `meta.idempotency-key` and `meta.ucp-agent`), and skips the operation call — discovery still runs first, so a cold or `--refresh`ed cache can still hit the network. Cart and checkout updates are full-replace: carry forward request-shaped line items, using `line_items[].id` only for existing lines and `line_items[].item.id` for the underlying item/variant. Useful for debugging payloads or confirming a mutation before issuing it. ### Custom request headers (auth, tenancy, tracing) @@ -251,7 +229,7 @@ UCP requests attach a built-in `User-Agent: @shopify/ucp-cli/`. Overrid Higher source wins per header name (case-insensitive); non-conflicting headers from every source ship. Empty values unset for that scope. Framing headers the dispatcher owns (`Content-Type`, `Accept`, `Host`, `Connection`, hop-by-hop, `MCP-Protocol-Version`) are silently dropped from all user sources. Sensitive header values (`Authorization`, `Cookie`, and any name ending in `-Token`, `-Key`, `-Secret`, `-Password`) are redacted in verbose traces (`UCP_VERBOSE=1`). -Persistent setup, modeled on git's `[http]` / `[http ""]`: +Persistent setup needs a named Profile (see [`skills/ucp/references/SETUP.md`](skills/ucp/references/SETUP.md)), and is modeled on git's `[http]` / `[http ""]`: ```json { diff --git a/biome.json b/biome.json index daa09d0..7aad818 100644 --- a/biome.json +++ b/biome.json @@ -10,7 +10,8 @@ "!**/.husky", "!**/coverage", "!**/local", - "!**/src/core/generated" + "!**/src/core/generated", + "!**/test/fixtures/legacy-profiles" ] }, "formatter": { diff --git a/skills/ucp/SKILL.md b/skills/ucp/SKILL.md index c346b35..b53f844 100644 --- a/skills/ucp/SKILL.md +++ b/skills/ucp/SKILL.md @@ -9,8 +9,6 @@ command: ucp When a buyer expresses commercial intent — wanting to find, buy, or track products — this is your toolkit. You can search across thousands of merchants via a bundled global catalog, build carts and complete checkouts against any UCP-supporting merchant, and follow up on orders. For merchants that don't support direct transactions, hand off gracefully to the merchant's own flow. -> **Setup**: Run `ucp profile init --name agent` at the start of any session. It's idempotent — re-running with an existing profile no-ops (`created: false`, exit 0) — so call it unconditionally rather than checking state first. This creates the local CLI identity required for all UCP operations; it's not a merchant onboarding step or a Catalog API key. See `references/SETUP.md` for installation paths and `ucp doctor`. - ## How to decide what to do | Buyer says... | Do this | @@ -18,7 +16,7 @@ When a buyer expresses commercial intent — wanting to find, buy, or track prod | "Find me X", "I need X for Y", "what's a good X under $Z" — no merchant named | `ucp catalog search` against the global catalog. Each result names its merchant via `seller.domain`. | | "Show me this" — buyer pastes a product/variant link or wants a specific product's full PDP/options matrix | `ucp catalog get_product ` — single call, returns `result.product` (singular). Omit `--business` for global Catalog IDs. | | "Are these still available?" — refreshing prices/stock/validity for known IDs (saved lists, wish lists, stale carts) | `ucp catalog lookup` with the IDs (up to 50). To distinguish OOS from delisted, pass `filters.available: false` — the default filters to in-stock only, so OOS and delisted both look like absence. | -| "Buy this from \" — buyer names a specific merchant | `ucp discover --business ` first; if it succeeds, transact via `--business `. If it fails, the merchant doesn't speak UCP — tell the buyer and offer alternatives. | +| "Buy this from \" — buyer names a specific merchant | `ucp discover --business ` first; if it succeeds, transact via `--business `. If it fails with `PROFILE_FETCH_FAILED`, the merchant doesn't speak UCP — tell the buyer and offer alternatives; any other code is a different failure, so branch on the full `code`. | | "Track my order" | `ucp order get --business ` | **Rule of thumb:** broad product discovery → global catalog (no `--business` needed). Business-scoped operations — cart, checkout, order, or catalog scoped to a specific merchant — → pass `--business `. Reach for one or the other based on the buyer's intent. @@ -40,9 +38,11 @@ The merchant decides what it accepts and what it exposes. These introspection co 2. **Operation input schema** — `ucp --input-schema --business ` returns the inputSchema for a specific tool from that merchant — including buyer-supplied destination fields, payment methods, discount handling, business-specific extension keys, etc. Use before composing any non-trivial payload (delivery info, payment, discount, fulfillment). -3. **What hits the wire** — `ucp [args] --dry-run` builds and validates the request, then prints the exact MCP envelope (`tool`, `arguments`, auto-injected `meta.idempotency-key` and `meta.ucp-agent`) without dispatching. Use when debugging a payload, confirming a mutation before issuing it, or learning the protocol shape (e.g. while building your own UCP-aware app). The printed `arguments` are the canonical MCP call; the CLI additionally wraps signing and web-bot-auth at the transport layer — if you build a client that calls MCP directly, you own that wrapping. +3. **What hits the wire** — `ucp [args] --dry-run` builds and validates the request, then prints the exact MCP envelope (`tool`, `arguments`, auto-injected `meta.idempotency-key` and `meta.ucp-agent`) without dispatching it. Discovery still runs first, so a cold or `--refresh`ed cache may hit the network. Use when debugging a payload, confirming a mutation before issuing it, or learning the protocol shape (e.g. while building your own UCP-aware app). The printed `arguments` are the canonical MCP call; the CLI additionally wraps signing and web-bot-auth at the transport layer — if you build a client that calls MCP directly, you own that wrapping. + +Discovery is cached, so `discover` and `--input-schema` may be answered from local state; pass `--refresh` when you need a synchronous recheck. -The CLI rejects unknown plain keys client-side before sending; if you hit `SCHEMA_VALIDATION_FAILED`, the error's CTA tells you the exact `--input-schema` command to run. Spec-canonical fields (per the UCP `Context` and `Buyer` types) may still be rejected if a specific merchant doesn't advertise them — the merchant's advertised schema is authoritative. +The CLI rejects unknown plain keys client-side before sending; if you hit `SCHEMA_VALIDATION_FAILED`, run the exact command in the error's CTA — recovery depends on what failed validation. Spec-canonical fields (per the UCP `Context` and `Buyer` types) may still be rejected if a specific merchant doesn't advertise them — the merchant's advertised schema is authoritative. Bundled global catalog operations — `search` for discovery, `lookup` for refreshing saved or bookmarked product/variant IDs (carts, wish lists, deep links), and `get_product` for full PDP detail — take well-known inputs covered below and in `references/CATALOG.md`; you don't need to introspect before basic use. Reach for `--input-schema` when adding Shopify-specific extension fields (`like`, `saved_catalog_slug`, server-side `view`, taxonomy attributes, rating, price tier, etc.), when live schema differs, or when composing checkout payloads. @@ -204,15 +204,16 @@ ucp checkout create --business https:// --input '{ }' ``` -**Checkout fulfillment is the complete, selectable flow.** Run `ucp checkout update --input-schema --business ` before composing buyer, payment, discount, or fulfillment payloads. Do not assume shipping: present all merchant-returned `fulfillment.methods[]` unless the buyer already chose a method. For shipping, provide address destinations; for pickup, select returned retail-location destinations. Use real `result.line_items[].id` values in `line_item_ids`, then ask or confirm before selecting returned `fulfillment.methods[].groups[].options[]` with `groups[].selected_option_id` unless the buyer's preference is already clear. Full examples live in `references/FULFILLMENT.md`. +**Checkout fulfillment is the complete, selectable flow.** Run `ucp checkout update --input-schema --business ` before composing buyer, discount, or fulfillment payloads. Do not assume shipping: present all merchant-returned `fulfillment.methods[]` unless the buyer already chose a method. For shipping, provide address destinations; for pickup, select returned retail-location destinations. Use real `result.line_items[].id` values in `line_item_ids`, then ask or confirm before selecting returned `fulfillment.methods[].groups[].options[]` with `groups[].selected_option_id` unless the buyer's preference is already clear. Full examples live in `references/FULFILLMENT.md`. ### Complete ```sh -ucp checkout complete --business https:// +ucp checkout complete --input-schema --business https:// +ucp checkout complete --business https:// --input @complete.json ``` -Read `result.status`: +Completion takes its own body: compose `complete.json` from that live schema and the `ucp.payment_handlers` the current checkout response advertises. Then read `result.status`: | Status | Meaning | |---|---| diff --git a/skills/ucp/references/CATALOG.md b/skills/ucp/references/CATALOG.md index 66fe0c7..671fa6e 100644 --- a/skills/ucp/references/CATALOG.md +++ b/skills/ucp/references/CATALOG.md @@ -291,7 +291,7 @@ There is no general Admin Product ID → Catalog UPID lookup. Source UPIDs from ## Auth tiers and headers -Global Catalog works tokenless for prototypes and low-RPS use once the CLI has a local agent profile (`ucp profile init --name agent`). That local profile is CLI identity setup, not merchant onboarding and not a Catalog API key. +Global Catalog works tokenless for prototypes and low-RPS use. When you need production attribution, higher rate limits, authenticated pagination, or future buyer-linked personalization, pass a Catalog token as a normal UCP header: @@ -301,7 +301,7 @@ ucp catalog search \ --input '{"query":"running shoes","pagination":{"limit":10}}' ``` -For repeated use, store it in `~/.ucp/profiles//headers.json` scoped to the global Catalog origin: +For repeated use, store it in `~/.ucp/profiles//headers.json` scoped to the global Catalog origin — persistent headers need a named Profile (see `SETUP.md`); otherwise keep passing `--header` per call: ```json { diff --git a/skills/ucp/references/REFERENCE.md b/skills/ucp/references/REFERENCE.md index e19eba2..7c27f7a 100644 --- a/skills/ucp/references/REFERENCE.md +++ b/skills/ucp/references/REFERENCE.md @@ -17,43 +17,65 @@ Successful UCP operation responses have this shape: } ``` -- **`business` / `endpoint` / `transport`** — dispatch identity. Compare `business` against the active profile's `meta.defaults.catalog` to tell whether a catalog response came from the global catalog vs a specific merchant. +- **`business` / `endpoint` / `transport`** — the Business and exact negotiated endpoint used for this dispatch. A catalog command without `--business` may resolve `business` from the catalog default. - **`result`** — the operation payload (products, cart, checkout, order). -- **`ucp`** — protocol metadata: negotiated capabilities and dynamic payment handlers. Read `ucp.payment_handlers` when composing a `payment` object for checkout. +- **`ucp`** — protocol metadata: negotiated capabilities and dynamic payment handlers. Read `ucp.payment_handlers` when composing the completion body against `ucp checkout complete --input-schema --business `. - **`cta`** — what to do next. Always read `cta.description` first — it tells you what's possible and what to weigh, not what to mechanically execute. Pick the command that serves the buyer's goal. If none fit, stop or ask. -Errors: `{ "code": "...", "message": "...", "cta": {...} }` (no dispatch identity — failure may pre-date contact). Successful commands exit `0` (including `requires_escalation`); errors exit `1`. +Errors are flat objects: `code` and `message` are required, while `retryable` and `cta` are independently optional. -`ucp discover` additionally returns `protocol` and `expectedCapabilities`: +```json +{ "code": "PROFILE_FETCH_FAILED", "message": "...", "retryable": true, "cta": { "description": "...", "commands": [...] } } +``` -- **`protocol.version`** — the negotiated UCP version. Always the version the **active agent profile** declares; `ucp-cli` only bounds which releases are *possible* (`ucp --version` prints the ones it supports). Switch versions by switching profiles. -- **`protocol.source`** — `well-known` when the merchant's top-level document was at that version, `supported_versions` when a version-specific leaf was followed. There is deliberately no second date to compare: do **not** infer compatibility from date order. -- **`protocol.businessProfileUrl`** — the merchant document that was parsed. (`profileUrl` everywhere else means the *agent's* URL.) -- **`expectedCapabilities`** — `your profile ∩ merchant` — a **prediction** for pre-call planning, not an authority. The CLI computes it from `~/.ucp/profiles//profile.json`, the document it declares; the merchant fetches your profile URL instead and reports what it actually negotiated in every response's `ucp.capabilities`; that field wins. `[]` means "nothing in common (or none published)", never "nothing works". A persistent gap between the two means the local document and the one at the profile URL disagree — operator fix (`ucp doctor` names it), not something to retry. -- **`negotiated`** is only the services **both** sides declare. Services the merchant offers that your profile does not declare are not negotiated; they stay visible in the lossless `profile` field and `--verbose` says which were skipped. +Errors carry no dispatch identity. Branch on the full `code`; treat `retryable` as a hint and `cta` as advice, reading `cta.description` before any command. Successful commands exit `0` (including `requires_escalation`); errors exit `1`. -**The error envelope carries `code`, `message`, and `cta` only** — there is no `context` field on the wire. Everything you need to recover is in those three. Branch on `code`; read `message` for the specific values (version sets, declared/offered service ids, the failing URL). +`ucp discover` returns these fields under `result`: -## Error codes +- **`protocol.version`** — exact UCP release selected for this Business. +- **`protocol.source`** — `well-known` for the Business Profile at its standard URL or `supported_versions` for a release-specific Business Profile. +- **`protocol.agentProfileUrl`** — Profile URL advertised for this exchange. +- **`protocol.businessProfileUrl`** — Business Profile used for this exchange. +- **`expectedCapabilities`** — advisory intersection of both Profiles' capability declarations. The operation response's `ucp.capabilities` is authoritative. +- **`negotiated`** — services declared by both Profiles. Business-only services remain in `profile` and are reported as skipped under `--verbose`. + +There is no `context` field on the wire; branch on `code`, `message`, and `cta`, and read `message` for diagnostic values. -Branch on `code` first; CTAs (when present) carry recovery suggestions. +## Profile selection + +Commands use the managed Profile unless a named Profile or Profile URL override wins. With a named DIY Profile, a URL override changes the advertised URL but commands still use its local document. MCP ignores `active.yaml`. See [SETUP](SETUP.md) for intentional configuration. + +| Value | Highest to lowest precedence | +|---|---| +| Profile name | `--profile` → `UCP_PROFILE` → `active.yaml.profile` → managed | +| Profile URL | `--profile-url` → `UCP_AGENT_PROFILE_URL` → selected DIY URL/release URL → selected managed Profile URL | -Naming rule: **`PROFILE_*` is the merchant's document; `AGENT_PROFILE_*` is yours.** No code means both. +## Discovery cache + +Business Profiles are cached according to HTTP `Cache-Control`, with a 60-second minimum; `tools/list` results are cached for 60 seconds. `--refresh` ignores both saved results. `--dry-run` still discovers and validates, but skips the operation's `tools/call`. + +## Error codes + +Branch on the full `code`; CTAs are advisory. `PROFILE_FETCH_FAILED` and `PROFILE_VERSION_MISMATCH` concern the Business Profile, each `AGENT_PROFILE_*` code concerns the selected Profile or its advertised URL, and `PROFILE_NOT_FOUND` concerns a selected local name. Profile authoring and hosting are covered in [SETUP](SETUP.md). | Code | Meaning | Recovery | |---|---|---| | `INVALID_INPUT` | CLI-side parse/validation error (bad JSON, missing positional, malformed URL) | Read `message`; fix the command | -| `SCHEMA_VALIDATION_FAILED` | Payload doesn't match the merchant's advertised schema, or uses unknown plain keys | `ucp --input-schema --business `, correct field names, re-submit. Canonical UCP fields (e.g. `context.currency`) can still need explicit merchant support | +| `SCHEMA_VALIDATION_FAILED` | Operation input or an explicitly selected local Profile file is schema-invalid | Read `message` and `cta`; only operation-input failures use `--input-schema` | | `BUSINESS_NOT_RESOLVED` | Operation needs a merchant; none resolved | Pass `--business `, or `ucp use ` for the session. Catalog ops fall back to the global catalog automatically | | `OPERATION_NOT_OFFERED` | Merchant doesn't expose this operation | `ucp discover --business ` to see what's offered | | `CAPABILITY_NOT_OFFERED` | Merchant doesn't advertise the requested service id — **including when your profile also lacks it** (a typo is not a profile problem) | Check the id against `ucp discover --business ` | | `PROFILE_FETCH_FAILED` | Merchant doesn't speak UCP (or `.well-known/ucp` is unreachable) | Surface to buyer; offer non-UCP fallback (other tools, navigation, alternate merchants with consent) | -| `PROTOCOL_VERSION_INCOMPATIBLE` | Merchant doesn't offer the exact version your agent profile speaks | `message` names both (what the merchant `offers` and what your profile `uses`). If a `cta` names another of your local profiles, re-run with `--profile `. Otherwise upgrade the CLI (`npm i -g @shopify/ucp-cli@latest`) | -| `PROFILE_VERSION_MISMATCH` | The **merchant's** document contradicts itself | Merchant-side bug; nothing to fix client-side. Surface to buyer, treat like `PROFILE_FETCH_FAILED` | +| `PROTOCOL_VERSION_INCOMPATIBLE` | Business and effective Profile share no exact release | Read both sets in `message`; see [SETUP](SETUP.md#selection-and-profile-url-overrides) for intentional selection changes | +| `PROFILE_VERSION_MISMATCH` | A Business Profile contradicts its claimed release | Business-side fault; surface it | | `NO_COMPATIBLE_TRANSPORT` | Right version, no transport both sides can speak | `message` names all three sets (merchant offers / profile declares / ucp-cli supports). Not fixable mid-task | -| `SERVICE_VERSION_INCOMPATIBLE` | Merchant offers the protocol version, but this service's own version line doesn't intersect yours | Not fixable mid-task. Stop, or try another merchant. Operator fix: `references/SETUP.md` | -| `AGENT_PROFILE_*` (any) | **Your side is misconfigured** — the local `profile.json` you declare is off-version, schema-invalid, or missing a service, or (as the merchant reports it) the profile URL you sent could not be fetched. Not the merchant's fault | **Retrying will not help.** Stop and report to the operator: `ucp doctor`, then `references/SETUP.md` | -| `PROFILE_NOT_FOUND` | No agent profile on this machine | `ucp profile init --name agent` — idempotent, safe to run unconditionally | +| `SERVICE_VERSION_INCOMPATIBLE` | Requested service versions do not intersect | Select or repair an intentional Profile, or use another Business | +| `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_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://` | | `INSUFFICIENT_PERMISSIONS` | Authenticated but lacks required scope (HTTP 403) | Same handoff URL priority as `AUTH_REQUIRED` | | `IDEMPOTENCY_CONFLICT` | Idempotency key reused with a different payload (HTTP 409) | Re-issue with a fresh key, or omit and retry | @@ -62,10 +84,9 @@ Naming rule: **`PROFILE_*` is the merchant's document; `AGENT_PROFILE_*` is your | `SERVICE_UNAVAILABLE` | Merchant temporarily unable to handle requests (HTTP 503) | Wait and retry; transient | | `MCP_RPC_ERROR` | JSON-RPC error envelope from merchant (no spec-aligned HTTP status) | Read `message` — it carries the merchant's RPC detail and HTTP status | | `TRANSPORT_HTTP_ERROR` | Non-2xx HTTP without spec-aligned mapping | Read `message` for the status | +| `TRANSPORT_REDIRECT_REFUSED` | A UCP document or endpoint returned a redirect | The declared URL must serve directly; correct its hosting or declaration | | `TRANSPORT_NETWORK_ERROR` | DNS, connection refused, TLS, timeout, abort | Network-level — report and retry | -The `AGENT_PROFILE_*` family and `SERVICE_VERSION_INCOMPATIBLE` are operator fixes (editing a document, and uploading it to the URL that serves it), not runtime ones — per-code detail is in `references/SETUP.md`. - ## `--set` and `--set-string` (overlay flags) Primary payload composition is `--input ''` against the schema returned by `ucp --input-schema --business `. `--set =` is a secondary mechanism that overlays a single value at a [JSON Pointer (RFC 6901)](https://datatracker.ietf.org/doc/html/rfc6901) path on top of `--input` — useful for one-off scalar overrides (e.g. flipping `/context/address_country=US` on top of an existing payload). @@ -87,6 +108,8 @@ Ops that act on an **existing resource** take the id as the first positional arg Operations that create or query (`cart create`, `checkout create`, `catalog search`, `catalog lookup`, `discover`) take no positional argument; the full payload goes in `--input`/`--set`. Cart-to-checkout conversion accepts `cart_id` in the `checkout create` body and requires `line_items`, which can be empty for conversion: `--input '{"cart_id":"","line_items":[]}'`. The merchant uses cart contents when `cart_id` is present. Forgetting the positional on a resource-addressing operation fails dispatch with `INVALID_INPUT` ("requires a positional id"); `--input-schema` works without it (it skips dispatch). +A positional id addresses the resource but does not replace a required body. For completion, derive the body with `ucp checkout complete --input-schema --business `, then pass it with `ucp checkout complete --business --input @complete.json`. A missing required positional fails with `INVALID_INPUT`; `--input-schema` skips dispatch and needs no id. + ## Common flags (every operation) | Flag | Effect | @@ -95,9 +118,12 @@ Operations that create or query (`cart create`, `checkout create`, `catalog sear | `--set =` | Field overlay onto `--input` (repeatable). RFC 6901 JSON Pointer paths. | | `--set-string =` | Same, value treated as string. Use for ZIPs, IDs that look numeric, etc. | | `--business ` | Override session merchant for this call. Bare hostnames (`shop.example.com`) are canonicalized to `https://`. | -| `--input-schema` | Print operation input schema; skip dispatch. Combine with `--business ` to introspect a specific merchant. | -| `--dry-run` | Build + validate request; print exactly what would be sent (including `meta.idempotency-key` and `meta.ucp-agent`). No network. Useful for debugging payloads before issuing them. | -| `--refresh` | Bypass discovery cache (force re-fetch of `.well-known/ucp` and `tools/list`) | +| `--profile ` | Named local Profile override. | +| `--profile-url ` | Advertised Profile URL override; a named DIY Profile still uses its local document. | +| `--header 'Name: Value'` | Repeatable outbound header. | +| `--input-schema` | Print the discovered input schema and skip `tools/call`; discovery still applies. | +| `--dry-run` | Discover, validate, and print the request; skips only the operation's `tools/call`. | +| `--refresh` | Bypass Business Profile and `tools/list` cache reads. | | `--format ` | Output format: `json` (default), `toon`, `yaml`, `md`, `jsonl` | | `--view ` | JMESPath projection. Expression runs over the whole response envelope; output **replaces** the envelope (drop dispatch identity, slim `ucp`, reshape `result`, etc). Inline expression, `@` to load from a file (`~` expanded), or `:` for a package-local view in the current operation capability. Composes with `--format` (project first, render second). `cta` survives the projection. No-op on `--dry-run`, `--input-schema`, and `--mcp` mode. See JMESPath patterns below. | | `--on-escalation ''` | Shell command for checkout `result.status === "requires_escalation"` only (compact JSON payload on stdin). Auth errors use CTA handoff guidance; they do not fire this hook. | diff --git a/skills/ucp/references/SETUP.md b/skills/ucp/references/SETUP.md index 61a568e..fe55c6d 100644 --- a/skills/ucp/references/SETUP.md +++ b/skills/ucp/references/SETUP.md @@ -1,10 +1,10 @@ # UCP CLI — Setup reference -One-time machine wiring — install path, profile init, health check. Read once during initial setup. (Escalation hook configuration lives in the main SKILL.md alongside the escalation flow it gates, since it's part of normal operation, not just one-time setup.) +Install paths, optional Profile configuration, and health checks. (Escalation hook configuration lives in the main SKILL.md alongside the escalation flow it gates, since it's part of normal operation, not just one-time setup.) ## Install -The CLI ships as `@shopify/ucp-cli` on npm. Two practical install paths: +The CLI requires Node.js `22.19.0` or later and ships as `@shopify/ucp-cli` on npm. Two practical install paths: ```sh # Ephemeral (no global install — fetched per invocation): @@ -24,118 +24,112 @@ npm install -g @shopify/ucp-cli The rest of this guide and the main SKILL.md write `ucp ` as shorthand for whichever invocation form is in use. Substitute `npx @shopify/ucp-cli` (etc.) when calling from an environment without a globally-installed binary. -## Profile init +## Profiles -A local agent profile is required for any UCP operation, including the bundled global catalog (`catalog search/lookup/get_product`). Run `ucp profile init --name ` once before issuing any UCP command. The init is idempotent (no-op if the profile already exists, exit 0), so call it unconditionally at the start of a session rather than tracking state. +Commands use the Shopify-managed Profile by default. It appears alongside local Profiles in `list`, and `show` displays the effective selection. ```sh -# Idempotent; safe to run unconditionally. No-op if a profile with this name already exists. -ucp profile init --name - -# Optional: verify install + active profile. Exits 1 if any check is `fail` -# (see Doctor below) — read the `checks` array before treating that as fatal. -ucp doctor +ucp profile list +ucp profile show ``` -The init writes `profile.json` + `meta.json` under `~/.ucp/profiles//` and sets the profile active in `~/.ucp/active.yaml`. +See [REFERENCE](REFERENCE.md#profile-selection) for selection precedence and discovery output. -### The profile decides the protocol version +## DIY authoring and URL ownership -`ucp --version` prints the CLI version and the UCP releases it supports: +`ucp profile init` creates a named DIY Profile. `--version` accepts one installed release; without it, the latest installed release is fixed at creation. The new Profile is selected only with `--activate`. ```sh -ucp --version # ucp 0.7.0 (UCP 2026-04-08, 2026-08-25) +ucp profile init --name pinned-0408 --version 2026-04-08 +ucp discover --business https://shop.example.com --profile pinned-0408 +ucp profile init --name custom --profile-url https://you.example/agent.json --activate ``` -Which release you speak is set by the active profile: +An existing Profile remains unchanged unless `--force` is passed; `--activate` may select that unchanged Profile. Forced init replaces `profile.json` and discards edits. Unless `--profile-url` is given, it retains a readable custom URL or uses the selected release's URL; provide the URL again if the existing configuration is unreadable. + +The authored document is `~/.ucp/profiles//profile.json`; `meta.json` stores its URL and local defaults, and `headers.json` optionally stores persistent request headers. CLI upgrades do not rewrite the authored document. + +The Profile URL must serve the same document as local `profile.json`. A release URL already serves the generated body; custom capabilities require a URL under the author's control: ```sh -ucp profile init --name agent # the latest supported release (default) -ucp profile init --name legacy --version 2026-04-08 # create a profile pinned to an older release -ucp discover shop.example.com --profile legacy # ...and use it +ucp profile init --name custom --profile-url https://you.example/agent.json +# Edit ~/.ucp/profiles/custom/profile.json and publish that file at the URL. +ucp profile use custom +ucp doctor ``` -An unsupported value is rejected and the message lists the supported set. Switch versions by switching profiles — `--profile ` per call, or `ucp profile use `; no reinstall. +`ucp-cli` does not upload Profile documents. Profile URLs are unsigned, so control of the URL controls the advertised Profile. `--catalog ` stores an optional catalog fallback on the DIY Profile. -A profile is two halves with one contract between them: +## Selection and Profile URL overrides -- **`profile.json`** is the document `ucp-cli` declares. Its `ucp.version` is the release you speak; its services and capabilities are what you offer to negotiate. Every request is built from this file. -- **`meta.profile_url`** is where that document lives on the web. It is sent as `meta.ucp-agent.profile` on every call, and the **merchant** fetches it and negotiates against whatever it serves. +Profile names and Profile URLs resolve independently: -**The two must say the same thing**, and keeping them equal is your job — `ucp doctor` is what checks (`protocol` fails while they disagree on `ucp.version` or the URL cannot be read; `profile-drift` warns while anything else differs). `profile init` starts them in agreement: it writes the release's published document to disk — exactly what the default URL serves, and exactly what you upload when the URL is yours. +| Value | Highest to lowest precedence | +|---|---| +| Profile name | per-call `--profile` → `UCP_PROFILE` → `active.yaml.profile` → managed | +| Profile URL | per-call `--profile-url` → `UCP_AGENT_PROFILE_URL` → selected DIY URL/release URL → selected managed Profile URL | -So an edit to `profile.json` takes effect immediately in what `ucp-cli` sends — and becomes real to merchants only once the URL serves it too. On the default URL you cannot change what is served, so an edit there is a disagreement `ucp doctor` will report. Point the profile at a URL you control before editing. +A Profile URL override pins one advertised URL. With a named DIY Profile, commands still use local `profile.json` while advertising the override URL. With the managed Profile or a named managed Profile, a known Shopify release URL selects that release; another URL uses the latest bundled document and warns. A URL override does not suppress a Profile name selected through the other precedence chain. -### Advertising a capability set of your own +`ucp profile use ` and `ucp profile use --managed` update `~/.ucp/active.yaml` but do not outrank environment variables. The `--managed` flag clears a named selection; positional `managed` is an ordinary local Profile name. Business selection is stored separately: ```sh -ucp profile init --name mine --profile-url https://you.example/agent.json -# edit ~/.ucp/profiles/mine/profile.json (add com.acme.*, drop what you don't drive) -# upload it to that URL yourself — scp, S3, whatever you host with -# serve it as Content-Type: application/json, Cache-Control: public, max-age>=60 -ucp doctor # confirms the two agree +ucp use https://shop.example.com +ucp use --clear ``` -Nothing is signed, so **whoever controls that URL controls what this agent claims.** If it stops resolving, merchants that cannot fetch it report `AGENT_PROFILE_UNREACHABLE`. +MCP mode ignores Profile and Business values in `active.yaml` because one server can serve unrelated conversations. Explicit tool arguments and `UCP_PROFILE`, `UCP_AGENT_PROFILE_URL`, and `UCP_BUSINESS` still apply. -`ucp profile init --name --version --force` is the only command that rewrites `profile.json`; upgrading `ucp-cli` does not, so what you declare survives upgrades untouched. +## Persistent headers -To pin a default catalog URL for the profile: +Persistent `~/.ucp/profiles//headers.json` is available only for a named Profile. Per-call `--header 'Name: Value'` works with any selection; creating a DIY Profile only for persistent headers also pins its release. -```sh -ucp profile init --name --catalog https://my-catalog.example.com +Headers merge from low to high priority: CLI `User-Agent`, `headers.json` `default`, `headers.json` `businesses[]`, then repeatable per-call `--header`. Names compare case-insensitively, and an empty value unsets a header for its scope. + +```json +{ + "default": { + "Trace-Id": "my-agent-${HOSTNAME}" + }, + "businesses": { + "https://shop.example.com": { + "Authorization": "Bearer ${EXAMPLE_TOKEN}" + } + } +} ``` -## Profile management +Values support `${ENV_VAR}` interpolation so secrets can remain outside the file. Reserved transport headers are ignored, and sensitive values are redacted from verbose traces. -```sh -ucp profile list # list configured profiles -ucp profile show # dump full profile body (capabilities, payment handlers, etc.) -``` +## Pin before creating a long-lived resource -The active profile is stored in `~/.ucp/active.yaml`. To switch: +The CLI does not remember which Profile created a resource. A resource that must stay on one release needs an intentional DIY pin at creation and the same explicit Profile on later calls. First reset to the managed Profile as described above, then: ```sh -ucp use # set session-default merchant for subsequent calls -ucp use --clear # drop the session merchant +BUSINESS=https://shop.example.com +unset UCP_PROFILE UCP_AGENT_PROFILE_URL +VERSION=$(ucp discover --refresh --business "$BUSINESS" | jq -er '.result.protocol.version') +ucp profile init --name shop-pin --version "$VERSION" +CART_ID=$(ucp cart create --profile shop-pin --business "$BUSINESS" --input @cart-create.json | jq -er '.result.id') +ucp cart get "$CART_ID" --profile shop-pin --business "$BUSINESS" ``` -## Profile errors - -Every code below means **your side is misconfigured**, and none of them are the merchant's fault — the merchant fetches your `meta.profile_url` on every call, so it fails the same way. Retrying does not help; fix the document, then re-run. `ucp doctor` diagnoses all of them. - -These are operator fixes, which is why `references/REFERENCE.md` collapses the whole family into a single "stop and report" row: an agent mid-task cannot re-host a document or upload a new copy of it. - -| Code | Meaning | Fix | -|---|---|---| -| `AGENT_PROFILE_UNREACHABLE` | At request time this means one thing: **the merchant** could not fetch your profile URL and said so (`business_reported`). `ucp doctor` raises the same code for what it finds itself — `network`, `http_status`, `not_json` (a 200 serving an HTML error page — the most common hosting mistake), `redirect` (the URL answers a redirect, which UCP forbids on profiles) | Fix hosting at that URL, or point at a reachable one with `--profile-url`. `ucp doctor` probes it | -| `AGENT_PROFILE_VERSION_UNSUPPORTED` | The profile document declares a UCP version `ucp-cli` does not support | `ucp profile init --name --version ` at a supported version (`ucp --version` prints them), or upgrade the CLI | -| `AGENT_PROFILE_SCHEMA_INVALID` | The profile document failed its release's schema | Fix `profile.json` (and upload it to your profile URL if that URL is yours) | -| `AGENT_PROFILE_VERSION_MISMATCH` | The profile document is internally inconsistent: a `dev.ucp.*` entry at a version other than its own `ucp.version`. Those entries can never negotiate, so this is fatal before the first call | Align the entry versions with the profile's `ucp.version` in `profile.json`, then make the profile URL serve the corrected document (`ucp profile show`, `ucp doctor`) | -| `AGENT_PROFILE_SERVICE_UNDECLARED` | The merchant offers a service your profile does not declare, so there is no platform side to negotiate. `message` lists both `declared:` and `business offers:` | Add the service to `profile.json` and make the profile URL serve the corrected document; if you cannot change that URL, use one you own (`ucp profile show`, `ucp doctor`) | -| `SERVICE_VERSION_INCOMPATIBLE` | The merchant offers the negotiated protocol version, but a requested service's own version line does not intersect what your profile declares (third-party services version independently) | Update the service declaration in `profile.json` to a version the merchant offers, then make the profile URL serve the corrected document; if you cannot change that URL, use one you own. Otherwise, accept unavailability (`ucp profile show`, `ucp doctor`) | +Discovery is enveloped, so the selected release is `.result.protocol.version`. `--refresh` bypasses cached discovery and forces a live compatibility check; every later update, checkout, completion, or order call for the resource uses `--profile shop-pin`. ## Doctor -`ucp doctor` is the **only** thing in the CLI that fetches your profile URL. It runs a battery of local + network checks: runtime version, writable state directories, `active.yaml`, the active profile parsing, proxy configuration, plus the hosted-identity checks: +`ucp doctor` checks the same effective Profile as commerce commands and fetches its advertised URL. Local checks cover the Node runtime, writable state directories, `active.yaml`, Profile parsing, and proxy configuration. -| Check | Answers | +| Hosted check | Result | |---|---| -| `protocol` | can this URL be used as an identity, and does it serve the release you send? Reports the version in force, whether `ucp-cli` supports it, whether it is the latest, and the resolved URL. **Fails** when the URL cannot be fetched or used (transport failure / HTTP status / non-JSON / schema-invalid / a UCP version `ucp-cli` does not support — the merchant fetches the same URL, so this predicts total failure), **and when the `ucp.version` it serves differs from the one in your `profile.json`**, which is what `ucp-cli` sends. That last one is the check that keeps both sides speaking the same release; its message names both versions | -| `profile-drift` | the versions agree — does the rest of the served document match `profile.json`? **Warn**: your requests stay well-formed, but the merchant grants capabilities off the document at the URL while `ucp-cli` plans against your file, so the two disagree about what you can do | -| `profile-redirect` | does the profile URL serve the document itself, without a hop? UCP forbids redirects (3xx) on published profiles. `ucp doctor` is the only part of `ucp-cli` that fetches this URL, and it refuses the hop itself; your commerce requests only advertise the URL, and a conforming merchant dereferencing it is bound by the same rule — so it cannot resolve your identity and those requests cannot negotiate (local commands like `ucp profile list` are unaffected). **Fail**, and the detail names the `Location` plus both fixes: serve the document at the URL you advertise, or advertise the URL that serves it | -| `profile-cache-control` | does the document at the profile URL carry `Cache-Control: public, max-age>=60`, as UCP's hosting rules require? **Warn** only — merchants fetch this URL per request, and `no-store`/missing/short policies just make them refetch it every time | - -Only `fail` gates the verdict (`ok: false`, exit 1). A `warn` never does — read the `checks` array when you want it. +| `protocol` | **Fail** when a URL is unusable or its release differs from the selected Profile. | +| `profile-redirect` | **Fail** when the Profile URL redirects instead of serving directly. | +| `profile-drift` | **Warn** when releases agree but the hosted and selected Profile documents differ. | +| `profile-cache-control` | **Warn** when hosting lacks `Cache-Control: public, max-age>=60`. | -An older-but-supported release (e.g. 2026-04-08 while 2026-08-25 is latest) is `ok`, not a warning: `ucp-cli` supports a set of releases, not a minimum. A deliberately pinned profile — `meta.profile_url` set to a release's published URL, `profile.json` matching — stays green. - -`ucp doctor` **exits 1 when any check is `fail`** (verdict `ok: false`), so it can gate CI; the same JSON is printed either way. Since `protocol` fetches the network, transient network trouble can fail a build — use `--skip-network` for offline or flaky-network jobs. +Managed selection audits every bundled release; a DIY Profile or Profile URL override audits one. Any failure sets `ok: false` and exits `1`; warnings alone exit `0`. `--skip-network` omits exactly the four hosted checks. Error-code meanings are in [REFERENCE](REFERENCE.md#error-codes). ```sh -ucp doctor # full check; exit 1 when ok:false -ucp doctor --skip-network # local-only (CI, offline); omits every hosted-identity check +ucp doctor +ucp doctor --skip-network ``` - -The `--help` output is authoritative for current check coverage. - diff --git a/src/cli-envelope.test.ts b/src/cli-envelope.test.ts index a317389..6cf0063 100644 --- a/src/cli-envelope.test.ts +++ b/src/cli-envelope.test.ts @@ -9,13 +9,15 @@ import { describe, expect, it } from 'vitest' import type { ResolvedSession, ResolveSessionOptions } from './cli/session.js' import { createUcpCli, type ShoppingHelperDep, type UcpCliDependencies } from './cli.js' import * as shoppingHelpers from './services/shopping.js' -import { serveCli } from './test-utils.js' +import { profileFixture, serveCli } from './test-utils.js' const BUSINESS_URL = 'https://shop.example.com' const PROFILE_URL = 'https://agent.example.com/.well-known/ucp' +const PROFILE = profileFixture({ url: PROFILE_URL }) const resolveSession = async (options: ResolveSessionOptions = {}): Promise => ({ - profile: { name: 'agent', profileUrl: PROFILE_URL }, + profile: PROFILE, + profileMeta: {}, ...(options.business !== undefined ? { business: options.business } : {}), }) diff --git a/src/cli-errors.test.ts b/src/cli-errors.test.ts index 7f677bd..2956101 100644 --- a/src/cli-errors.test.ts +++ b/src/cli-errors.test.ts @@ -1,18 +1,19 @@ // CONTRACT: what the CLI actually EMITS on the error path. // // Every other error suite asserts on in-memory `UcpError` objects, which -// cannot see this: `cli.ts`'s error middleware emits `{code, message, -// retryable}`, or `{code, message, cta}` when the error carries one — -// `context` is NEVER serialized. So a remedy encoded in `context` (a -// `context.kind` discriminator, `context.supported`) does not exist for the -// primary audience: an agent reading CLI JSON. +// cannot see this: the emitted error is one FLAT object — `code` and +// `message` always, plus `retryable` and `cta` as independently optional +// fields that can both appear — and `context`/`http_status` are NEVER +// serialized. So a remedy encoded in `context` (a `context.kind` +// discriminator, `context.supported`) does not exist for the primary +// audience: an agent reading CLI JSON. // // The property under test is therefore not "the error has the right fields" // but: IF A CODE'S REMEDY DEPENDS ON A FIELD, THAT FIELD SURVIVES // SERIALIZATION. These run the real dispatcher, the real middleware, and the // real core (mocked transport only) — nothing about the envelope is stubbed. -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,7 +21,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ResolvedSession, ResolveSessionOptions } from './cli/session.js' import { createUcpCli } from './cli.js' +import { + createAdHocProfile, + createDiyProfile, + createManagedProfile, + type ProfileSource, +} from './core/agent.js' import { discover } from './core/discover.js' +import type { ProfileKind } from './core/legacy-profile.js' import { RELEASES, type Version } from './core/releases.js' import { setWarnWriter } from './core/verbose.js' import { serveCli, userProfile } from './test-utils.js' @@ -54,9 +62,8 @@ interface WireError { interface StubOpts { /** - * The active profile's local `profile.json`, which the CLI reads from disk - * for negotiation. Default: the published 08-25 document, representing a - * profile whose local file matches what its URL serves. + * The active Profile's singleton body. Default: the published 08-25 + * document, representing a local body that matches what its URL serves. */ agentProfile?: unknown /** Body for `/.well-known/ucp`. */ @@ -65,11 +72,15 @@ interface StubOpts { leaves?: Record /** JSON-RPC error envelope returned for `tools/list` instead of a result. */ rpcError?: { code: number; message: string; data?: unknown } + /** Body provenance of the active Profile. Defaults to a DIY singleton. */ + profileSource?: ProfileSource + /** Explicit URL-override provenance for a DIY body. Defaults false. */ + urlOverride?: boolean /** - * Other profiles on this machine. Feeds the PROTOCOL_VERSION_INCOMPATIBLE - * switch-profiles hint, which reads each local profile's body version. + * Other Profiles on this machine. DIY candidates use their body version; + * managed aliases offer every installed rendering. */ - localProfiles?: Record + localProfiles?: Record } function jsonResponse(body: unknown): Response { @@ -129,30 +140,43 @@ describe('emitted CLI error JSON', () => { /** Run `ucp discover` against the real core with a stubbed transport. */ async function runDiscover( opts: StubOpts & { capability?: string }, - ): Promise<{ wire: WireError; exitCode: number }> { - // The identity comes off disk, so the fixture is a real profile tree. - const dir = join(home, 'profiles', 'agent') - await mkdir(dir, { recursive: true }) - await writeFile( - join(dir, 'profile.json'), - JSON.stringify(opts.agentProfile ?? publishedAgentProfile()), - ) + ): Promise<{ wire: WireError; exitCode: number; profileListCalls: number }> { const fetch = stubFetch(opts) - const session = async (o: ResolveSessionOptions = {}): Promise => ({ - profile: { name: o.profile ?? 'agent', profileUrl: AGENT_PROFILE_URL }, - ...(o.business !== undefined ? { business: o.business } : {}), - }) + const session = async (o: ResolveSessionOptions = {}): Promise => { + const name = o.profile ?? 'agent' + const profile = + opts.profileSource === 'managed' + ? createManagedProfile(name) + : opts.profileSource === 'url' + ? createAdHocProfile(AGENT_PROFILE_URL, name) + : createDiyProfile({ + name, + url: AGENT_PROFILE_URL, + urlOverride: opts.urlOverride ?? false, + body: opts.agentProfile ?? publishedAgentProfile(), + }) + return { + profile, + profileMeta: {}, + ...(o.business !== undefined ? { business: o.business } : {}), + } + } const localProfiles = opts.localProfiles ?? {} + let profileListCalls = 0 const cli = createUcpCli({ resolveSession: session, profile: { - listProfiles: async () => Object.keys(localProfiles).sort(), + listProfiles: async () => { + profileListCalls += 1 + return Object.keys(localProfiles).sort() + }, readUserProfile: async (name: string) => { const candidate = localProfiles[name] if (candidate === undefined) throw new Error(`no such profile: ${name}`) return userProfile(name, { body: JSON.parse(RELEASES[candidate.version].agentProfileJson), meta: candidate.profileUrl === undefined ? {} : { profile_url: candidate.profileUrl }, + kind: candidate.kind ?? 'diy', }) }, }, @@ -165,7 +189,7 @@ describe('emitted CLI error JSON', () => { }), }) const { output, exitCode } = await serveCli(cli, ['discover', BUSINESS_URL]) - return { wire: JSON.parse(output) as WireError, exitCode } + return { wire: JSON.parse(output) as WireError, exitCode, profileListCalls } } // ── The constraint these tests exist to pin ──────────────────────────── @@ -188,6 +212,82 @@ describe('emitted CLI error JSON', () => { expect(wire.context).toBeUndefined() }) + it('preserves the actual-name Profile repair CTA for local store schema failures', async () => { + const name = 'actual-name' + const dir = join(home, 'profiles', name) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'profile.json'), JSON.stringify({ ucp: { version: '2026-08-25' } })) + await writeFile( + join(dir, 'meta.json'), + JSON.stringify({ profile_url: 'https://owned.example/profile.json' }), + ) + + const { output, exitCode } = await serveCli(createUcpCli(), ['profile', 'show', name]) + const wire = JSON.parse(output) as WireError + + expect(exitCode).toBe(1) + expect(wire.code).toBe('SCHEMA_VALIDATION_FAILED') + expect(wire.cta?.description).toMatch(/rewrite.*local DIY.*document.*identity/i) + expect(wire.cta?.commands?.map((command) => command.command)).toEqual([ + 'ucp profile init --name actual-name --force', + ]) + expect(JSON.stringify(wire.cta)).not.toContain('--input-schema') + expect(JSON.stringify(wire.cta)).not.toContain('') + + // The exact emitted command is executable and repairs the bad document + // without silently abandoning the custom identity URL in valid meta.json. + const repaired = await serveCli(createUcpCli(), ['profile', 'init', '--name', name, '--force']) + expect(repaired.exitCode).toBe(0) + expect(JSON.parse(repaired.output)).toMatchObject({ name, created: true }) + expect(JSON.parse(await readFile(join(dir, 'meta.json'), 'utf-8'))).toMatchObject({ + profile_url: 'https://owned.example/profile.json', + kind: 'diy', + }) + }) + + it('does not promise URL preservation when meta.json itself is invalid', async () => { + const name = 'broken-meta' + const dir = join(home, 'profiles', name) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'profile.json'), RELEASES['2026-08-25'].agentProfileJson) + await writeFile(join(dir, 'meta.json'), '{ not json') + + const { output, exitCode } = await serveCli(createUcpCli(), ['profile', 'show', name]) + const wire = JSON.parse(output) as WireError + + expect(exitCode).toBe(1) + expect(wire.code).toBe('SCHEMA_VALIDATION_FAILED') + expect(wire.cta?.description).toMatch(/cannot preserve.*custom profile_url/i) + expect(wire.cta?.description).toContain('--profile-url') + expect(wire.cta?.description).not.toMatch(/custom profile_url is preserved/i) + expect(wire.cta?.commands?.map((command) => command.command)).toEqual([ + `ucp profile init --name ${name} --force`, + ]) + }) + + it('emits a working actual-name PROFILE_NOT_FOUND CTA on the commerce read path', async () => { + const name = 'missing-commerce-profile' + + const { output, exitCode } = await serveCli(createUcpCli(), [ + 'discover', + BUSINESS_URL, + '--profile', + name, + ]) + const wire = JSON.parse(output) as WireError + + expect(exitCode).toBe(1) + expect(wire.code).toBe('PROFILE_NOT_FOUND') + expect(wire.cta?.commands?.map((command) => command.command)).toEqual([ + `ucp profile init --name ${name} --force`, + ]) + expect(JSON.stringify(wire.cta)).not.toContain('') + + const repaired = await serveCli(createUcpCli(), ['profile', 'init', '--name', name, '--force']) + expect(repaired.exitCode).toBe(0) + expect(JSON.parse(repaired.output)).toMatchObject({ name, created: true }) + }) + // ── PROTOCOL_VERSION_INCOMPATIBLE ────────────────────────────────────── // // Remedy depends on BOTH sets: "upgrade the CLI" if the business is ahead of @@ -214,11 +314,10 @@ describe('emitted CLI error JSON', () => { expect(wire.message).toContain('offers UCP 2026-12-01') // ours expect(wire.message).toContain('ucp-cli supports 2026-04-08, 2026-08-25') - // ...and WHICH identity was presented, by its switchable local name. - // `DiscoverOptions.profileName` carries it from the session, so the label - // is `profile 'agent'` rather than the raw URL — a URL names nothing the - // reader can pass to `--profile`. - expect(wire.message).toContain("profile 'agent' uses 2026-08-25") + // ...and WHICH identity was presented, by its switchable local name. The + // runtime Profile carries that name, so the label is `profile 'agent'` + // rather than a raw URL the reader cannot pass to `--profile`. + expect(wire.message).toContain("profile 'agent' offers 2026-08-25") }) // ── the switch-profiles hint (design §S6) ────────────────────────────── @@ -262,7 +361,13 @@ describe('emitted CLI error JSON', () => { expect(wire.cta?.description).toContain("'agent-0408' speaks 2026-04-08") expect(wire.cta?.description).not.toContain('agent-0825') expect(wire.cta?.description).toContain("'mine' speaks 2026-04-08") + expect(wire.cta?.description).toContain( + 'Shopify managed Profile offers every installed rendering', + ) + expect(wire.cta?.description).toContain('without an explicit --profile') + expect(wire.cta?.description).toContain('UCP_PROFILE unset') expect(wire.cta?.commands?.map((c) => c.command)).toStrictEqual([ + 'ucp profile use --managed', 'ucp discover --profile agent-0408', 'ucp discover --profile mine', ]) @@ -288,11 +393,138 @@ describe('emitted CLI error JSON', () => { }) expect(wire.cta?.commands?.map((c) => c.command)).toStrictEqual([ + 'ucp profile use --managed', 'ucp discover --profile agent-0408', 'ucp discover --profile legacy', ]) }) + it('offers the virtual managed Profile when a DIY singleton misses another installed release', async () => { + const { wire } = await runDiscover({ + business: { + ucp: { + version: '2026-04-08', + services: { + 'dev.ucp.shopping': [ + { version: '2026-04-08', transport: 'mcp', endpoint: MCP_ENDPOINT }, + ], + }, + payment_handlers: {}, + }, + }, + }) + + expect(wire.cta?.commands?.map((c) => c.command)).toEqual(['ucp profile use --managed']) + expect(wire.cta?.description).toContain('newest mutual UCP 2026-04-08') + }) + + it('treats a managed local alias as every installed rendering, not its retained body', async () => { + const { wire } = await runDiscover({ + business: { + ucp: { + version: '2026-04-08', + services: { + 'dev.ucp.shopping': [ + { version: '2026-04-08', transport: 'mcp', endpoint: MCP_ENDPOINT }, + ], + }, + payment_handlers: {}, + }, + }, + localProfiles: { + legacy: { version: '2026-08-25', kind: 'managed' }, + }, + }) + + expect(wire.cta?.description).toContain("'legacy' is managed") + expect(wire.cta?.description).toContain('selects newest mutual UCP 2026-04-08') + expect(wire.cta?.description).not.toContain("'legacy' speaks 2026-08-25") + expect(wire.cta?.commands?.map((c) => c.command)).toContain('ucp discover --profile legacy') + }) + + it('a managed runtime failure does not scan or suggest local aliases', async () => { + const { wire, profileListCalls } = await runDiscover({ + profileSource: 'managed', + business: { + ucp: { + version: '2026-12-01', + services: {}, + payment_handlers: {}, + }, + }, + localProfiles: { + legacy: { version: '2026-08-25', kind: 'managed' }, + }, + }) + + expect(wire.code).toBe('PROTOCOL_VERSION_INCOMPATIBLE') + expect(wire.message).toContain('managed Profile already offers every rendering installed') + expect(wire.message).toContain('no local Profile') + expect(wire.cta).toBeUndefined() + expect(profileListCalls).toBe(0) + }) + + it('a scalar URL override suppresses impossible --profile retries', async () => { + const { wire, profileListCalls } = await runDiscover({ + profileSource: 'url', + business: { + ucp: { + version: '2026-04-08', + services: { + 'dev.ucp.shopping': [ + { version: '2026-04-08', transport: 'mcp', endpoint: MCP_ENDPOINT }, + ], + }, + payment_handlers: {}, + }, + }, + localProfiles: { + 'agent-0408': { version: '2026-04-08' }, + legacy: { version: '2026-08-25', kind: 'managed' }, + }, + }) + + expect(wire.code).toBe('PROTOCOL_VERSION_INCOMPATIBLE') + expect(wire.message).toContain('--profile-url/UCP_AGENT_PROFILE_URL') + expect(wire.message).toContain('outranks stored meta/profile-name switching') + expect(wire.message).toContain('intended exact authored/bundled rendering') + expect(wire.cta).toBeUndefined() + expect(JSON.stringify(wire)).not.toContain('ucp discover --profile') + expect(profileListCalls).toBe(0) + }) + + it('a DIY body under a URL override suppresses every Profile-switch hint', async () => { + const { wire, profileListCalls } = await runDiscover({ + profileSource: 'diy', + urlOverride: true, + business: { + ucp: { + version: '2026-04-08', + services: { + 'dev.ucp.shopping': [ + { version: '2026-04-08', transport: 'mcp', endpoint: MCP_ENDPOINT }, + ], + }, + payment_handlers: {}, + }, + }, + localProfiles: { + 'agent-0408': { version: '2026-04-08' }, + legacy: { version: '2026-08-25', kind: 'managed' }, + }, + }) + + expect(wire.code).toBe('PROTOCOL_VERSION_INCOMPATIBLE') + expect(wire.message).toContain("profile 'agent' offers 2026-08-25") + expect(wire.message).toContain('--profile-url/UCP_AGENT_PROFILE_URL') + expect(wire.message).toContain('outranks stored meta/profile-name switching') + expect(wire.message).toContain('intended exact authored/bundled rendering') + expect(wire.cta).toBeUndefined() + expect(JSON.stringify(wire)).not.toContain('ucp discover --profile') + expect(JSON.stringify(wire)).not.toContain('ucp profile use --managed') + expect(profileListCalls).toBe(0) + }) + it('emits no hint when no local profile speaks an offered version', async () => { const { wire } = await runDiscover({ business: { @@ -336,7 +568,8 @@ describe('emitted CLI error JSON', () => { // ── AGENT_PROFILE_UNREACHABLE ────────────────────────────────────────── // - // `context.reason` is the discriminator; it must be readable on the wire. + // `context.reason` is the in-process discriminator and never serializes, + // so the sub-case has to be readable in `message`/`cta` instead. // 'not_json' is the important one — a 200 serving an HTML error page is a // common hosting failure and is not "unreachable" in any useful sense. @@ -409,6 +642,77 @@ describe('emitted CLI error JSON', () => { expect(wire.code).toBe('AGENT_PROFILE_SERVICE_UNDECLARED') expect(wire.message).toContain('declared: [dev.ucp.shopping]') expect(wire.message).toContain('business offers: [com.other.x]') + expect(wire.message).toContain('local profile') expect(wire.cta?.commands?.map((c) => c.command)).toContain('ucp profile show') }) + + it('AGENT_PROFILE_SERVICE_UNDECLARED keeps DIY edit/publish guidance under a URL override', async () => { + const { wire } = await runDiscover({ + profileSource: 'diy', + urlOverride: true, + capability: 'com.other.x', + business: { + ucp: { + version: '2026-08-25', + services: { + 'com.other.x': [{ version: '2026-08-25', transport: 'mcp', endpoint: MCP_ENDPOINT }], + }, + payment_handlers: {}, + }, + }, + }) + + expect(wire.code).toBe('AGENT_PROFILE_SERVICE_UNDECLARED') + expect(wire.message).toContain('local profile.json') + expect(wire.message).toContain('active --profile-url/UCP_AGENT_PROFILE_URL override') + expect(wire.message).toContain(AGENT_PROFILE_URL) + expect(wire.message).toContain('unset the override') + expect(wire.cta?.commands?.map((c) => c.command)).toContain('ucp profile show') + expect(JSON.stringify(wire)).not.toContain('meta.json') + }) + + it('AGENT_PROFILE_SERVICE_UNDECLARED gives managed-specific DIY guidance', async () => { + const { wire } = await runDiscover({ + profileSource: 'managed', + capability: 'com.other.x', + business: { + ucp: { + version: '2026-08-25', + services: { + 'com.other.x': [{ version: '2026-08-25', transport: 'mcp', endpoint: MCP_ENDPOINT }], + }, + payment_handlers: {}, + }, + }, + }) + + expect(wire.code).toBe('AGENT_PROFILE_SERVICE_UNDECLARED') + expect(wire.message).toContain('selected managed rendering is bundled') + expect(wire.message).toContain('explicit DIY Profile') + expect(wire.cta?.commands?.map((c) => c.command)).toEqual(['ucp profile init --help']) + expect(JSON.stringify(wire)).not.toContain('ucp profile show') + expect(JSON.stringify(wire)).not.toContain('profile.json') + }) + + it('AGENT_PROFILE_SERVICE_UNDECLARED tells a URL override to change or leave the override', async () => { + const { wire } = await runDiscover({ + profileSource: 'url', + capability: 'com.other.x', + business: { + ucp: { + version: '2026-08-25', + services: { + 'com.other.x': [{ version: '2026-08-25', transport: 'mcp', endpoint: MCP_ENDPOINT }], + }, + payment_handlers: {}, + }, + }, + }) + + expect(wire.code).toBe('AGENT_PROFILE_SERVICE_UNDECLARED') + expect(wire.message).toContain('--profile-url/UCP_AGENT_PROFILE_URL') + expect(wire.message).toContain('create a DIY Profile') + expect(JSON.stringify(wire)).not.toContain('meta.profile_url') + expect(JSON.stringify(wire)).not.toContain('profile.json') + }) }) diff --git a/src/cli.test.ts b/src/cli.test.ts index a7c5f54..b6991e3 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -18,9 +18,10 @@ import type { ResolvedSession, ResolveSessionOptions } from './cli/session.js' import { createUcpCli, isSkillsAddInvocation } from './cli.js' import { saveUserProfile, writeActive } from './core/profile-store.js' import { setVerboseWriter } from './core/verbose.js' -import { serveCli, userProfile } from './test-utils.js' +import { profileFixture, serveCli, userProfile } from './test-utils.js' const PROFILE_URL = 'https://agent.example.com/.well-known/ucp' +const RUNTIME_PROFILE = profileFixture({ url: PROFILE_URL }) // Resolver stub used in passing-path tests below: echoes whatever business // the caller passed via `opts.business` (i.e. the resolved chain landed at @@ -28,7 +29,8 @@ const PROFILE_URL = 'https://agent.example.com/.well-known/ucp' // (separate describe block) use a different stub that simulates env / // active.yaml legs. const passthroughSession = async (opts: ResolveSessionOptions = {}): Promise => ({ - profile: { name: 'agent', profileUrl: PROFILE_URL }, + profile: RUNTIME_PROFILE, + profileMeta: {}, ...(opts.business !== undefined ? { business: opts.business } : {}), }) @@ -46,6 +48,7 @@ describe('createUcpCli', () => { protocol: { version: '2026-08-25', source: 'well-known', + agentProfileUrl: PROFILE_URL, businessProfileUrl: `${args[0]}/.well-known/ucp`, }, expectedCapabilities: [], @@ -66,10 +69,19 @@ describe('createUcpCli', () => { result: { business: 'https://shop.example.com', negotiated: {} }, }) expect(calls).toMatchObject([ - ['https://shop.example.com', { force: false, profileUrl: PROFILE_URL }], + ['https://shop.example.com', { force: false, profile: RUNTIME_PROFILE }], ]) }) + it('describes --dry-run as skipping tools/call, not discovery network I/O', async () => { + const { output, exitCode } = await serveCli(createUcpCli(), ['cart', 'create', '--help']) + + expect(exitCode).toBe(0) + expect(output).toMatch(/Skips the operation's tools\/call request/i) + expect(output).toMatch(/cold or forced discovery may still use network/i) + expect(output).not.toMatch(/Skips network I\/O/i) + }) + it('wires catalog search args and options to the search helper', async () => { const calls: unknown[] = [] const cli = createUcpCli({ @@ -107,7 +119,7 @@ describe('createUcpCli', () => { [ 'https://shop.example.com', { catalog: { query: 'boots', pagination: { limit: 2 } } }, - { force: true, profileUrl: PROFILE_URL }, + { force: true, profile: RUNTIME_PROFILE }, ], ]) }) @@ -158,7 +170,7 @@ describe('createUcpCli', () => { [ 'https://shop.example.com', { catalog: { query: 'boots' } }, - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: RUNTIME_PROFILE }, ], ]) }) @@ -187,7 +199,7 @@ describe('createUcpCli', () => { [ 'https://shop.example.com', { checkout: { cart_id: 'cart_1', line_items: [] } }, - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: RUNTIME_PROFILE }, ], ]) }) @@ -222,7 +234,7 @@ describe('createUcpCli', () => { buyer: { email: 'b@example.com' }, }, }, - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: RUNTIME_PROFILE }, ], ]) }) @@ -236,9 +248,11 @@ describe('createUcpCli — business resolution', () => { const stubSession = (sessionBusiness?: string) => async (opts: ResolveSessionOptions = {}): Promise => { - const profile = { name: 'agent', profileUrl: PROFILE_URL } + const profile = RUNTIME_PROFILE const business = opts.business ?? sessionBusiness - return business !== undefined ? { profile, business } : { profile } + return business !== undefined + ? { profile, profileMeta: {}, business } + : { profile, profileMeta: {} } } it('uses --business flag when provided (op command)', async () => { @@ -260,7 +274,7 @@ describe('createUcpCli — business resolution', () => { expect(calls[0]).toMatchObject([ 'https://flag.example.com', { catalog: {} }, - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: RUNTIME_PROFILE }, ]) }) @@ -278,7 +292,7 @@ describe('createUcpCli — business resolution', () => { expect(calls[0]).toMatchObject([ 'https://session.example.com', { catalog: {} }, - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: RUNTIME_PROFILE }, ]) }) @@ -338,7 +352,7 @@ describe('createUcpCli — business resolution', () => { expect(exitCode).toBe(0) expect(calls[0]).toMatchObject([ 'https://positional.example.com', - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: RUNTIME_PROFILE }, ]) }) @@ -355,7 +369,7 @@ describe('createUcpCli — business resolution', () => { expect(exitCode).toBe(0) expect(calls[0]).toMatchObject([ 'https://session.example.com', - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: RUNTIME_PROFILE }, ]) }) @@ -489,17 +503,25 @@ describe('createUcpCli — MCP mode ignores active.yaml', () => { ) }) - it('ignores the active profile too (nothing selects one in MCP mode)', async () => { + it('ignores the active profile and dispatches with managed when no explicit profile exists', async () => { + let selectedProfile: unknown const cli = createUcpCli({ inMcpMode: true, - createCart: async () => { - throw new Error('cart helper must not fire without a profile') + createCart: async (_business, _input, options) => { + selectedProfile = options.profile + return { cart: { id: 'cart_1' } } }, }) - const { output, exitCode } = await serveCli(cli, ['cart', 'create']) - expect(exitCode).toBe(1) - expect(JSON.parse(output).code).toBe('PROFILE_NOT_FOUND') + const { exitCode } = await serveCli(cli, [ + 'cart', + 'create', + '--business', + 'https://flag.example.invalid', + ]) + expect(exitCode).toBe(0) + expect(selectedProfile).toMatchObject({ source: 'managed' }) + expect(selectedProfile).not.toHaveProperty('name') }) it('still honors UCP_PROFILE / UCP_BUSINESS in MCP mode', async () => { @@ -538,6 +560,7 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { // Business origin URL — discovery hits /.well-known/ucp on // the normal `discover()` path; there is no bypass. const CATALOG_URL = 'https://catalog.example.invalid' + const CATALOG_PROFILE = profileFixture({ name: 'with-catalog', url: PROFILE_URL }) // Resolver stub that produces a user profile carrying `defaults.catalog`. // Mirrors what `resolveSession` builds for a real user profile after // `ucp profile init --catalog ` lands. Business stays unresolved so @@ -545,13 +568,10 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { const stubSessionWithCatalogDefault = async ( _opts: ResolveSessionOptions = {}, ): Promise => ({ - profile: { - name: 'with-catalog', - profileUrl: PROFILE_URL, - meta: { - created_at: '2026-05-10T00:00:00Z', - defaults: { catalog: CATALOG_URL }, - }, + profile: CATALOG_PROFILE, + profileMeta: { + created_at: '2026-05-10T00:00:00Z', + defaults: { catalog: CATALOG_URL }, }, }) @@ -579,7 +599,7 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { expect(helperCalls[0]).toMatchObject([ CATALOG_URL, { catalog: {} }, - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: CATALOG_PROFILE }, ]) }) @@ -638,7 +658,7 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { expect(exitCode).toBe(0) expect(discoverCalls[0]).toMatchObject([ CATALOG_URL, - expect.objectContaining({ profileUrl: PROFILE_URL }), + expect.objectContaining({ profile: CATALOG_PROFILE }), ]) const firstCall = discoverCalls[0] as unknown[] expect(firstCall[1] as Record).not.toHaveProperty('directEndpoint') @@ -662,7 +682,7 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { expect(exitCode).toBe(0) expect(discoverCalls[0]).toMatchObject([ CATALOG_URL, - expect.objectContaining({ profileUrl: PROFILE_URL }), + expect.objectContaining({ profile: CATALOG_PROFILE }), ]) const firstCall = discoverCalls[0] as unknown[] const opts = firstCall[1] as Record @@ -675,7 +695,7 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { // fires. This is the init-CTA path (BUSINESS_NOT_RESOLVED with // machine-actionable recovery hint). const cli = createUcpCli({ - resolveSession: async () => ({ profile: { name: 'agent', profileUrl: PROFILE_URL } }), + resolveSession: async () => ({ profile: RUNTIME_PROFILE, profileMeta: {} }), searchCatalog: async () => { throw new Error('helper should not fire without a resolved business') }, @@ -694,13 +714,10 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { // the resolver returns the flag value as `session.business`. resolveSession: async (opts: ResolveSessionOptions = {}) => { const base: ResolvedSession = { - profile: { - name: 'with-catalog', - profileUrl: PROFILE_URL, - meta: { - created_at: '2026-05-10T00:00:00Z', - defaults: { catalog: CATALOG_URL }, - }, + profile: CATALOG_PROFILE, + profileMeta: { + created_at: '2026-05-10T00:00:00Z', + defaults: { catalog: CATALOG_URL }, }, } return opts.business !== undefined ? { ...base, business: opts.business } : base @@ -721,7 +738,7 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { expect(helperCalls[0]).toMatchObject([ 'https://flag.example.com', { catalog: {} }, - { force: false, profileUrl: PROFILE_URL }, + { force: false, profile: CATALOG_PROFILE }, ]) }) @@ -734,7 +751,8 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { const stubSessionNoMeta = async ( _opts: ResolveSessionOptions = {}, ): Promise => ({ - profile: { name: 'agent', profileUrl: PROFILE_URL }, + profile: RUNTIME_PROFILE, + profileMeta: {}, }) // BUSINESS_NOT_RESOLVED CTA is uniform across op families: bind a business @@ -807,6 +825,7 @@ describe('createUcpCli — catalog fallback (meta.defaults.catalog)', () => { // emitted CTA description does — and does not — claim is active. describe('createUcpCli — extension-hint pipeline (negotiated → allowlist → CTA)', () => { const PROFILE_URL_LOCAL = 'https://agent.example.com/.well-known/ucp' + const LOCAL_PROFILE = profileFixture({ url: PROFILE_URL_LOCAL }) // Synthetic DiscoveredBusiness. Two capability sets, deliberately separate: // @@ -841,7 +860,8 @@ describe('createUcpCli — extension-hint pipeline (negotiated → allowlist → it('catalog search: allowlisted extension flows from _onDiscover → CTA hint', async () => { const cli = createUcpCli({ resolveSession: async () => ({ - profile: { name: 'agent', profileUrl: PROFILE_URL_LOCAL }, + profile: LOCAL_PROFILE, + profileMeta: {}, business: 'https://shop.example.com', businessSource: 'flag', }), @@ -864,7 +884,8 @@ describe('createUcpCli — extension-hint pipeline (negotiated → allowlist → // — `expectedCapabilities` is empty here even though `profile` lists them. const cli = createUcpCli({ resolveSession: async () => ({ - profile: { name: 'agent', profileUrl: PROFILE_URL_LOCAL }, + profile: LOCAL_PROFILE, + profileMeta: {}, business: 'https://shop.example.com', businessSource: 'flag', }), @@ -888,7 +909,8 @@ describe('createUcpCli — extension-hint pipeline (negotiated → allowlist → // even when the business advertises it. const cli = createUcpCli({ resolveSession: async () => ({ - profile: { name: 'agent', profileUrl: PROFILE_URL_LOCAL }, + profile: LOCAL_PROFILE, + profileMeta: {}, business: 'https://shop.example.com', businessSource: 'flag', }), @@ -906,7 +928,8 @@ describe('createUcpCli — extension-hint pipeline (negotiated → allowlist → it('mixed advertised: only negotiated entries reach the CTA', async () => { const cli = createUcpCli({ resolveSession: async () => ({ - profile: { name: 'agent', profileUrl: PROFILE_URL_LOCAL }, + profile: LOCAL_PROFILE, + profileMeta: {}, business: 'https://shop.example.com', businessSource: 'flag', }), @@ -1092,7 +1115,7 @@ describe('createUcpCli — --input-schema', () => { expect(exitCode).toBe(0) expect(discoverArgs[0]).toMatchObject([ 'https://shop.example.com', - { capabilities: ['dev.ucp.shopping'], force: true, profileUrl: PROFILE_URL }, + { capabilities: ['dev.ucp.shopping'], force: true, profile: RUNTIME_PROFILE }, ]) }) @@ -1128,7 +1151,7 @@ describe('createUcpCli — --input-schema', () => { it('emits BUSINESS_NOT_RESOLVED with CTA when no business resolves', async () => { const cli = createUcpCli({ // Stub session resolves nothing — no flag, no env, no active.yaml. - resolveSession: async () => ({ profile: { name: 'agent', profileUrl: PROFILE_URL } }), + resolveSession: async () => ({ profile: RUNTIME_PROFILE, profileMeta: {} }), discover: async () => { throw new Error('discover should not be called when business is unresolved') }, @@ -1156,7 +1179,7 @@ describe('createUcpCli — --input-schema', () => { code: 'SCHEMA_VALIDATION_FAILED', message: 'operation input failed schema validation for "search_catalog": : must have required property catalog', - context: { schema: { type: 'object' } }, + context: { kind: 'operation-input', schema: { type: 'object' } }, }) }, }) @@ -1173,6 +1196,39 @@ describe('createUcpCli — --input-schema', () => { expect(parsed.cta?.commands?.[0]?.command).toBe('ucp catalog search --input-schema') }) + it('does not replace a profile-store SCHEMA_VALIDATION_FAILED repair CTA', async () => { + const cli = createUcpCli({ + profile: { + readActive: async () => ({}), + readUserProfile: async () => { + throw new (await import('./lib/errors.js')).UcpError({ + layer: 'client', + code: 'SCHEMA_VALIDATION_FAILED', + message: 'profile "actual-name" profile.json failed schema validation', + context: { kind: 'profile-store', profile: 'actual-name', file: 'profile.json' }, + cta: { + description: 'Rewrite the local DIY Profile document and identity for "actual-name".', + commands: [ + { + command: 'ucp profile init --name actual-name --force', + description: 'rewrite local DIY Profile "actual-name"', + }, + ], + }, + }) + }, + }, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show', 'actual-name']) + expect(exitCode).toBe(1) + const parsed = JSON.parse(output) + expect(parsed.code).toBe('SCHEMA_VALIDATION_FAILED') + expect(parsed.cta?.commands?.[0]?.command).toBe('ucp profile init --name actual-name --force') + expect(JSON.stringify(parsed.cta)).not.toContain('--input-schema') + expect(JSON.stringify(parsed.cta)).not.toContain('') + }) + it('non-SCHEMA UcpError passes through middleware unchanged', async () => { // Sanity: the middleware narrowly targets SCHEMA_VALIDATION_FAILED. A // different UcpError code (here MCP_INVALID_RESPONSE, picked because @@ -1843,7 +1899,7 @@ describe('createUcpCli — --view projection', () => { resolveSession: passthroughSession, searchCatalog: async (_business, input) => ({ dry_run: true, - note: 'Skipped network I/O.', + note: 'Skipped operation tools/call.', arguments: input, }), }) @@ -1917,7 +1973,7 @@ describe('createUcpCli — --view projection', () => { // No active business; opRun errors before helper invocation. The // error envelope has no `result` field, so applyView's passthrough // rule fires — the user's BUSINESS_NOT_RESOLVED message survives. - resolveSession: async () => ({ profile: { name: 'agent', profileUrl: PROFILE_URL } }), + resolveSession: async () => ({ profile: RUNTIME_PROFILE, profileMeta: {} }), searchCatalog: async () => fixtureResult, }) @@ -1958,6 +2014,10 @@ describe('createUcpCli — doctor exit code', () => { it('exits 1 on ok:false and still prints the full checks envelope', async () => { const home = mkdtempSync(join(tmpdir(), 'ucp-doctor-exit-')) + // A NAMED profile that is not on disk. An empty home is healthy — it runs + // on the Shopify-managed Profile — so the local-only failure doctor can + // still produce is an explicitly selected identity that cannot be loaded. + await writeActive({ profile: 'ghost' }, { homeDir: home }) const { output, processExitCode } = await doctorExitCode(['doctor', '--skip-network'], { doctor: { homeDir: home, env: {} }, }) diff --git a/src/cli.ts b/src/cli.ts index 195b3fa..dc668d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { resolveSession } from './cli/session.js' import { syncSkillsWithCleanup } from './cli/skills-sync.js' import { runUse, type UseDeps } from './cli/use.js' import { applyView, resolveView, type ViewState } from './cli/view.js' +import type { Profile, ProfileSource } from './core/agent.js' import { type DiscoveredBusiness, discover } from './core/discover.js' import { buildEscalationPayload, @@ -109,9 +110,7 @@ export type ShoppingHelperDep = ( input: Record, options: { force: boolean - profileUrl: string - /** Local profile name, for messages (see DiscoverOptions.profileName). */ - profileName?: string + profile: Profile dryRun?: boolean /** Resolved outbound HTTP headers; see {@link resolveHeaders}. */ headers?: Record @@ -213,23 +212,32 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { }, }) - // Re-emit SCHEMA_VALIDATION_FAILED via c.error so the recovery cta lands - // on the wire. Incur's outer catch path strips cta from thrown errors; - // c.error (sentinel-based) preserves it. The cta points the agent at - // `--input-schema` so they can fetch the operation input schema and correct - // their payload without spelunking diagnostic context. `c.command` gives - // us the exact subcommand path the user ran (e.g. `cart update`), so the - // suggested command is copy-pasteable verbatim. + // Re-emit operation-input SCHEMA_VALIDATION_FAILED via c.error so the + // recovery cta lands on the wire. Incur's outer catch path strips cta from + // thrown errors; c.error (sentinel-based) preserves it. The explicit + // `context.kind` discriminator prevents local Profile document/schema + // failures (which intentionally keep this stable public code) from being + // misdiagnosed as operation input. For actual operation input, the cta + // points at `--input-schema`; `c.command` supplies the exact subcommand path. // - // Other UcpError codes pass through unchanged — incur emits the standard - // {code, message, retryable} envelope, which is what callers expect today. - // We only intercept here when there's a known structured recovery path. + // Other UcpError codes pass through unchanged — incur emits the flat + // {code, message} envelope, adding `retryable` and/or `cta` when present. + // The two are independent: incur also appends its own maintenance notices + // (skills staleness, update available) to whatever cta reaches the writer, + // so a `retryable` error can carry a cta whose commands are unrelated to + // the failing code. We only intercept here when there's a known structured + // recovery path. cli.use( middleware(async (c, next) => { try { await next() } catch (err) { - if (err instanceof UcpError && err.code === ErrorCodes.SCHEMA_VALIDATION_FAILED) { + if ( + err instanceof UcpError && + err.code === ErrorCodes.SCHEMA_VALIDATION_FAILED && + isPlainRecord(err.context) && + err.context.kind === 'operation-input' + ) { c.error({ code: err.code, message: err.message, @@ -246,23 +254,27 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { }) return } - // PROTOCOL_VERSION_INCOMPATIBLE is thrown in core, which cannot see - // the profile store. Decorate it here with the one remedy that needs - // local state: "another profile of yours speaks a version they do - // offer". Deliberately a CTA — `context` is never serialized, so a - // hint that lived there would not exist for agents reading CLI JSON. + // Core knows runtime provenance but not the local store. Check URL + // precedence first: any explicit --profile-url/UCP_AGENT_PROFILE_URL + // outranks Profile-name switching, including when the body remains a + // locally authored DIY body. Only a non-overridden DIY singleton can + // recover by selecting managed or another local Profile. The remedy + // is a CTA because `context` is never serialized in CLI JSON. if (err instanceof UcpError && err.code === ErrorCodes.PROTOCOL_VERSION_INCOMPATIBLE) { - const cta = buildProfileSwitchCta( - await localProfilesSpeaking( - offeredVersions(err.context), - activeProfileName(err.context), - profileHintDeps, - ), - { command: c.command, displayName: c.displayName }, - ) - if (cta !== undefined) { - c.error({ code: err.code, message: err.message, cta }) - return + const offered = offeredVersions(err.context) + if ( + activeProfileUrlOverride(err.context) === false && + activeProfileSource(err.context) === 'diy' + ) { + const cta = buildProfileSwitchCta( + await localProfilesSpeaking(offered, activeProfileName(err.context), profileHintDeps), + offered, + { command: c.command, displayName: c.displayName }, + ) + if (cta !== undefined) { + c.error({ code: err.code, message: err.message, cta }) + return + } } } if (err instanceof UcpError && err.cta !== undefined) { @@ -312,7 +324,9 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { refresh: z .boolean() .default(false) - .describe('Bypass the local profile cache and re-fetch from the business.'), + .describe( + 'Bypass both cached discovery layers — the Business Profile and tools/list — and re-fetch them from the business.', + ), header: z .array(z.string()) .default([]) @@ -355,21 +369,17 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { // catalog tools instead of a recovery dead-end. State-mutating ops // (cart/checkout) gate on bodyKey at prepareOperation; this site has // no bodyKey because there's no op family — read-only is the gate. - const catalogDefault = session.profile.meta?.defaults?.catalog + const catalogDefault = session.profileMeta?.defaults?.catalog if (catalogDefault !== undefined) businessUrl = catalogDefault } // Bare `discover` is catalog-eligible: the fallback rung above would have // fired had `meta.defaults.catalog` been set, so when it didn't, the // missing-business CTA is the recovery path. if (businessUrl === undefined) return c.error(businessNotResolvedError(inMcpMode)) - const headers = await resolveCallHeaders(c.options, session, businessUrl) + const headers = await resolveCallHeaders(c.options, session.profile, businessUrl) const discoverResult = await discoverImpl(businessUrl, { force: c.options.refresh, - profileUrl: requireProfileUrl(session.profile.profileUrl), - // The session knows the local profile NAME; without passing it, - // PROTOCOL_VERSION_INCOMPATIBLE can only name the URL and cannot - // suggest `--profile `. - profileName: session.profile.name, + profile: session.profile, headers, }) return c.ok(applyView({ result: discoverResult }, viewState)) @@ -417,7 +427,9 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { refresh: z .boolean() .default(false) - .describe('Bypass the local profile cache and re-fetch from the business.'), + .describe( + 'Bypass both cached discovery layers — the Business Profile and tools/list — and re-fetch them from the business.', + ), inputSchema: z .boolean() .default(false) @@ -428,7 +440,7 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { .boolean() .default(false) .describe( - 'Run discovery + schema validation, then print the exact request that would be sent (including meta.idempotency-key and meta.ucp-agent). Skips network I/O. Useful for debugging SCHEMA_VALIDATION_FAILED, capturing payloads for bug reports, and confirming a mutation before issuing it for real.', + "Run discovery + schema validation, then print the exact request that would be sent (including meta.idempotency-key and meta.ucp-agent). Skips the operation's tools/call request; cold or forced discovery may still use network. Useful for debugging SCHEMA_VALIDATION_FAILED, capturing payloads for bug reports, and confirming a mutation before issuing it for real.", ), onEscalation: z .string() @@ -499,11 +511,7 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { if (!prep.ok) return c.error(prep.error) const wrapped = wrapOperationInput(prep.input, bodyKey) const merged = mergeId(wrapped, c.args.id, idPlacement) - const headers = await resolveCallHeaders( - c.options, - { profile: { name: prep.profileName } }, - prep.business, - ) + const headers = await resolveCallHeaders(c.options, prep.profile, prep.business) // Capture the trusted negotiated view via the internal side-channel. // Filled by `callOperation` after `discover()` resolves (BEFORE any // OPERATION_NOT_OFFERED throw), so CTAs on transport-layer failures @@ -513,8 +521,7 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { let discovered: DiscoveredBusiness | undefined const result = await helper(prep.business, merged, { force: prep.force, - profileUrl: prep.profileUrl, - profileName: prep.profileName, + profile: prep.profile, headers, ...(c.options.dryRun ? { dryRun: true } : {}), _onDiscover: (d) => { @@ -828,9 +835,7 @@ type ShoppingHelper = { input: Record, options: { force: boolean - profileUrl: string - /** Local profile name, for messages (see DiscoverOptions.profileName). */ - profileName?: string + profile: Profile dryRun?: boolean /** Resolved outbound HTTP headers; see {@link resolveHeaders}. */ headers?: Record @@ -898,8 +903,7 @@ type PreparedOperation = ok: true input: Record business: string - profileName: string - profileUrl: string + profile: Profile force: boolean } | { ok: false; error: ErrorEnvelopeOpts } @@ -907,7 +911,7 @@ type PreparedOperation = // Prepares the cross-cutting bits every operation command needs: resolve the // active session (with `--business` taking precedence over UCP_BUSINESS and // active.yaml), require a resolved business URL, parse --input/--set/--set-string -// into a single JSON payload, hand back the profileUrl + force flag. Returns +// into a single JSON payload, and hand back the runtime Profile + force flag. Returns // a discriminated result instead of throwing on missing-business so callers // can `return c.error(prep.error)` and get incur's sentinel-path handling // (which carries `cta` to the wire envelope). @@ -932,7 +936,7 @@ async function prepareOperation( // still error because routing a state-mutating op against the catalog // endpoint would silently misroute state-changing operations. if (bodyKey === 'catalog') { - const catalogDefault = session.profile.meta?.defaults?.catalog + const catalogDefault = session.profileMeta?.defaults?.catalog if (catalogDefault !== undefined) { business = catalogDefault usedCatalogDefault = true @@ -943,7 +947,7 @@ async function prepareOperation( } } vlog( - `session: business=${business} (source: ${usedCatalogDefault ? 'meta.defaults.catalog' : (session.businessSource ?? '?')}) profile=${session.profile.name}`, + `session: business=${business} (source: ${usedCatalogDefault ? 'meta.defaults.catalog' : (session.businessSource ?? '?')}) profile=${session.profile.name ?? session.profile.source}`, ) const input = await buildOperationInput({ set: c.options.set, @@ -954,8 +958,7 @@ async function prepareOperation( ok: true, input, business, - profileName: session.profile.name, - profileUrl: requireProfileUrl(session.profile.profileUrl), + profile: session.profile, force: c.options.refresh, } } @@ -966,13 +969,13 @@ async function prepareOperation( // raw string is a safe last-resort origin key against headers.json. async function resolveCallHeaders( options: { header: string[] }, - session: { profile: { name: string } }, + profile: Pick, businessUrl: string, ): Promise { return resolveHeaders({ argFlags: options.header, origin: canonicalizeOrigin(businessUrl) ?? businessUrl, - profile: session.profile.name, + ...(profile.name !== undefined ? { profile: profile.name } : {}), }) } @@ -1018,18 +1021,16 @@ async function inputSchemaOperation( // ops route through `meta.defaults.catalog`; everything else still // errors to avoid silently selecting a business for mutations. if (bodyKey === 'catalog') { - const catalogDefault = session.profile.meta?.defaults?.catalog + const catalogDefault = session.profileMeta?.defaults?.catalog if (catalogDefault !== undefined) businessUrl = catalogDefault } if (businessUrl === undefined) return c.error(businessNotResolvedError(inMcpMode)) } - const profileUrl = requireProfileUrl(session.profile.profileUrl) - const headers = await resolveCallHeaders(c.options, session, businessUrl) + const headers = await resolveCallHeaders(c.options, session.profile, businessUrl) const resolved = await discoverImpl(businessUrl, { capabilities: [helper.capability], - profileUrl, - profileName: session.profile.name, + profile: session.profile, force: c.options.refresh, headers, }) @@ -1112,14 +1113,15 @@ function activeProfileName(context: unknown): string | undefined { return typeof context.profileName === 'string' ? context.profileName : undefined } -function requireProfileUrl(profileUrl: string | undefined): string { - if (profileUrl !== undefined) return profileUrl - throw new UcpError({ - layer: 'client', - code: ErrorCodes.INVALID_INPUT, - message: - 'active profile does not have a profile URL; pass --profile-url or set one on the profile', - }) +function activeProfileUrlOverride(context: unknown): boolean | undefined { + if (!isPlainRecord(context)) return undefined + return typeof context.profileUrlOverride === 'boolean' ? context.profileUrlOverride : undefined +} + +function activeProfileSource(context: unknown): ProfileSource | undefined { + if (!isPlainRecord(context)) return undefined + const source = context.profileSource + return source === 'managed' || source === 'diy' || source === 'url' ? source : undefined } // CLI wire envelope for errors. Root fields are CLI-owned; business data never @@ -1223,11 +1225,14 @@ function canonicalizeBusinessForEcho( // notes worth preserving: // // 1. `c.error()` returns incur's error sentinel (NOT a thrown exception), -// so the only way this reaches the wire envelope cleanly is by being -// returned. Going through `c.error()` (instead of `throw new UcpError`) -// is what makes `cta` survive — incur's default thrown-error catch -// path emits only `code` + `message`. Agents read `error.cta.commands` -// to recover. +// so it only reaches the wire envelope by being `return`ed. The sentinel +// (instead of `throw new UcpError`) is the right shape here because +// BUSINESS_NOT_RESOLVED is envelope-only — it carries no `layer` (see +// ERROR_LAYERS in src/lib/errors.ts). A thrown UcpError keeps its cta +// too, but only because the `cli.use` middleware above re-emits +// `err.cta` through `c.error()`; incur's own default thrown-error catch +// path emits just `code` + `message`. Either way the emitted error is +// flat, so agents read `cta.commands` to recover. // // 2. The CTA block is constructed inside the function (not hoisted to // module scope) on purpose. Top-level await in the bin entrypoint diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index bd4353b..82efc2d 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -1,19 +1,33 @@ // runDoctor() — local install health check. -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +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, vi } from 'vitest' +import { createUcpCli } from '../cli.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' +import { readActive, saveUserProfile, writeActive } from '../core/profile-store.js' import { installProxyDispatcher, resetProxyStateForTests } from '../core/proxy.js' import { LATEST, RELEASES, SUPPORTED_VERSIONS, type Version } from '../core/releases.js' -import { clearProxyEnv } from '../test-utils.js' +import { setWarnWriter } from '../core/verbose.js' +import { clearProxyEnv, serveCli } from '../test-utils.js' import { runDoctor } from './doctor.js' const SELF_HOSTED_URL = 'https://mybot.example.com/profile.json' +afterEach(() => { + setWarnWriter(null) +}) + +/** Every URL a managed Profile publishes a rendering at, in release order. */ +const RELEASE_URLS = SUPPORTED_VERSIONS.map((v) => RELEASES[v].defaultAgentProfileUrl) + +/** A spec-conformant hosting policy, so cache-control never colours a test that isn't about it. */ +const CACHEABLE = 'public, max-age=300' + /** A release's verbatim published agent profile — what the hosted URL serves. */ function publishedProfile(version: Version): unknown { return JSON.parse(RELEASES[version].agentProfileJson) @@ -26,6 +40,45 @@ function jsonResponse(body: unknown): Response { }) } +/** How one URL answers. Absent fields fall back to "serves its own published document, cacheable". */ +interface Route { + body?: unknown + status?: number + headers?: Record + throws?: Error +} + +/** + * A fetch that knows the published renderings: each release-default URL + * answers with that release's own document under a conformant cache policy. + * `routes` replaces the answer for one URL — the shape every managed-defect + * test needs, because the point is always "one rendering is broken, the + * others are not". + */ +function releaseFetch(routes: Record = {}) { + const calls: Array<{ url: string; method: string | undefined }> = [] + const impl = vi.fn(async (input: unknown, init: RequestInit = {}) => { + const url = String(input) + calls.push({ url, method: init.method }) + const route = routes[url] + if (route?.throws !== undefined) throw route.throws + const status = route?.status ?? 200 + if (status >= 300) return new Response(null, { status, headers: route?.headers ?? {} }) + const version = SUPPORTED_VERSIONS.find((v) => RELEASES[v].defaultAgentProfileUrl === url) + const body = + route?.body ?? (version === undefined ? publishedProfile(LATEST) : publishedProfile(version)) + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json', + 'cache-control': CACHEABLE, + ...(route?.headers ?? {}), + }, + }) + }) + return { fetch: impl as unknown as typeof fetch, calls } +} + const SAMPLE_BODY: PlatformProfile = { ucp: { version: '2026-08-25', status: 'success', services: {}, payment_handlers: {} }, keys: [], @@ -36,6 +89,40 @@ const SAMPLE_META = { profile_url: 'https://mybot.example.com/profile.json', } +/** + * A DIY body that PASSES its release schema but violates the snapshot rule: + * a `dev.ucp.*` entry at a version other than the document's own + * `ucp.version` (the historical 0.4.2 … 0.7.0 defect). It is the local + * failure whose authoritative remedy is NON-DESTRUCTIVE — edit the entry — + * which is exactly what a re-derived `profile init --force` remedy destroys. + */ +const OFF_VERSION_BODY: PlatformProfile = { + ucp: { + version: LATEST, + status: 'success', + services: { + 'dev.ucp.shopping': [ + { + version: '2026-01-23', + spec: 'https://ucp.dev/2026-01-23/specification/overview', + transport: 'mcp', + schema: 'https://ucp.dev/2026-01-23/services/shopping/mcp.openrpc.json', + }, + ], + }, + payment_handlers: {}, + }, + keys: [], +} + +/** Marked DIY, so classification cannot rescue the body the operator authored. */ +const DIY_META = { + created_at: '2026-05-05T12:00:00Z', + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy' as const, + profile_url: SELF_HOSTED_URL, +} + function findCheck( result: { checks: { id: string; status: string; detail: string }[] }, id: string, @@ -45,6 +132,11 @@ function findCheck( return check } +/** How many Checks carry this id. One-check-per-id is a contract, not an accident. */ +function countChecks(result: { checks: { id: string }[] }, id: string): number { + return result.checks.filter((c) => c.id === id).length +} + describe('runDoctor — clean install', () => { let homeDir: string @@ -55,15 +147,35 @@ describe('runDoctor — clean install', () => { await rm(homeDir, { recursive: true, force: true }) }) - it('passes writability checks but fails active profile when nothing is configured', async () => { + // A fresh install transacts on the Shopify-managed Profile: no local files + // or initialization are required. `profile init` is a separate opt-in that + // creates a release-pinned DIY Profile; it does not configure managed use. + it('is healthy on a fresh home, on the Shopify-managed Profile', async () => { const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) - expect(result.ok).toBe(false) + expect(result.ok).toBe(true) expect(findCheck(result, 'ucp-home').status).toBe('ok') expect(findCheck(result, 'profiles-dir').status).toBe('ok') expect(findCheck(result, 'cache-dir').status).toBe('ok') expect(findCheck(result, 'active-yaml').status).toBe('ok') - expect(findCheck(result, 'active-profile').status).toBe('fail') - expect(findCheck(result, 'active-profile').detail).toContain('profile init --name agent') + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('ok') + expect(active.detail).toContain('Shopify-managed Profile') + // Which releases this install offers — the thing a managed Profile is. + for (const version of SUPPORTED_VERSIONS) { + expect(active.detail).toContain(`UCP ${version}: ${RELEASES[version].defaultAgentProfileUrl}`) + } + expect(active.detail).toMatch(/no .*init.*required/i) + expect(active.detail).toMatch(/profile init.*release-pinned DIY Profile/i) + expect(active.detail).not.toMatch(/adds a local name|adds .*headers\.json/i) + }) + + it('issues no request at all under --skip-network', async () => { + const { fetch: fetchImpl, calls } = releaseFetch() + const result = await runDoctor({ homeDir, skipNetwork: true, env: {}, fetch: fetchImpl }) + expect(calls).toHaveLength(0) + for (const id of ['protocol', 'profile-drift', 'profile-cache-control', 'profile-redirect']) { + expect(result.checks.find((c) => c.id === id)).toBeUndefined() + } }) }) @@ -77,6 +189,31 @@ describe('runDoctor — active.yaml states', () => { await rm(homeDir, { recursive: true, force: true }) }) + it('keeps doctor healthy after profile use --managed persists an empty session', async () => { + await writeActive({ profile: 'custom' }, { homeDir }) + const switched = await serveCli( + createUcpCli({ + profile: { + env: {}, + readActive: () => readActive({ homeDir }), + writeActive: (session) => writeActive(session, { homeDir }), + }, + }), + ['profile', 'use', '--managed'], + ) + expect(switched.exitCode).toBe(0) + expect(await readActive({ homeDir })).toEqual({}) + + const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) + + expect(result.ok).toBe(true) + expect(findCheck(result, 'active-yaml')).toMatchObject({ + status: 'ok', + detail: expect.stringContaining('no stored selection'), + }) + expect(findCheck(result, 'active-profile').detail).toContain('Shopify-managed Profile') + }) + it('reports active.yaml content when present and parseable', async () => { await saveUserProfile({ name: 'prod', body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) await writeActive({ profile: 'prod', business: 'https://shop.example.com' }, { homeDir }) @@ -86,16 +223,26 @@ describe('runDoctor — active.yaml states', () => { expect(active.detail).toContain('"business":"https://shop.example.com"') }) - it('warns on corrupt active.yaml and fails because no profile is selected', async () => { + // A corrupt active.yaml names nobody, so this is NOT the never-fall-back + // case: readActive degrades to `{}` and the session is the managed default. + // The warn is how the user learns the file was ignored. + it('warns on corrupt active.yaml and stays on the managed Profile', async () => { await writeFile(join(homeDir, 'active.yaml'), '!!! not yaml [[[ broken', 'utf-8') const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) - expect(result.ok).toBe(false) + expect(result.ok).toBe(true) expect(findCheck(result, 'active-yaml').status).toBe('warn') - expect(findCheck(result, 'active-profile').status).toBe('fail') + expect(findCheck(result, 'active-profile').status).toBe('ok') + expect(findCheck(result, 'active-profile').detail).toContain('Shopify-managed Profile') }) }) -describe('runDoctor — user profile branch', () => { +// ─── which Profile resolved ────────────────────────────────────────────── +// +// Doctor reports the Profile `resolveSession` builds — the same one commerce +// runs on — so the kinds it can report are the kinds that exist: managed +// (bundled renderings, named or not), authored DIY (one local document, with +// either a stored or overridden URL), and a URL-only ad-hoc singleton. +describe('runDoctor — resolved Profile', () => { let homeDir: string beforeEach(async () => { @@ -105,23 +252,195 @@ describe('runDoctor — user profile branch', () => { await rm(homeDir, { recursive: true, force: true }) }) - it('passes when the named profile is on disk and parses', async () => { + it('reports a named DIY profile as locally authored, and names its document', async () => { await saveUserProfile({ name: 'prod', body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) await writeActive({ profile: 'prod' }, { homeDir }) const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) expect(result.ok).toBe(true) - expect(findCheck(result, 'active-profile').status).toBe('ok') + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('ok') + expect(active.detail).toContain('locally authored (DIY)') + expect(active.detail).toContain(join(homeDir, 'profiles', 'prod', 'profile.json')) + expect(active.detail).toContain(SELF_HOSTED_URL) }) - it('fails when active.yaml references a profile that does not exist', async () => { + // The one case that must never degrade: an explicitly selected identity + // that cannot be loaded. Falling back to the managed Profile would sell + // under a different identity than the operator asked for. + it('fails on a named profile that is not on disk, and probes nothing', async () => { + await saveUserProfile({ name: 'backup', body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) await writeActive({ profile: 'ghost' }, { homeDir }) + const { fetch: fetchImpl, calls } = releaseFetch() + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + expect(result.ok).toBe(false) + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('fail') + expect(active.detail).toContain('PROFILE_NOT_FOUND') + expect(active.detail).toContain('ghost') + expect(active.detail).toContain('active.yaml') + expect(active.detail).toContain('ucp profile init --name ghost --force') + expect(active.detail).toContain('ucp profile use backup') + expect(active.detail).toContain('ucp profile use --managed') + expect(active.detail).not.toContain('') + expect(active.detail).toMatch(/never silently replaced/) + // No identity resolved, so there is nothing to fetch and no second voice. + expect(calls).toHaveLength(0) + for (const id of ['protocol', 'profile-drift', 'profile-cache-control', 'profile-redirect']) { + expect(result.checks.find((c) => c.id === id)).toBeUndefined() + } + }) + + it('keeps alternative-Profile classification read-only while reporting a failed selection', async () => { + await saveUserProfile( + { + name: 'legacy', + body: publishedProfile(LATEST) as PlatformProfile, + meta: {}, + }, + { homeDir }, + ) + const metaPath = join(homeDir, 'profiles', 'legacy', 'meta.json') + const before = await readFile(metaPath, 'utf-8') + await writeActive({ profile: 'ghost' }, { homeDir }) + + const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) + + expect(findCheck(result, 'active-profile').detail).toContain('ucp profile use legacy') + expect(await readFile(metaPath, 'utf-8')).toBe(before) + expect(JSON.parse(before)).toEqual({}) + }) + + // The store authored this remedy where the failure is understood — it knows + // which file it could not read, and therefore whether a custom profile_url + // survives a re-init. Doctor reports that block verbatim, named profile and + // all; it does not re-derive one from the error's shape. + it('fails on a named profile whose profile.json is corrupt', async () => { + const dir = join(homeDir, 'profiles', 'broken') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'profile.json'), '{ not json', 'utf-8') + await writeFile(join(dir, 'meta.json'), '{}', 'utf-8') + await writeActive({ profile: 'broken' }, { homeDir }) const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) expect(result.ok).toBe(false) - expect(findCheck(result, 'active-profile').status).toBe('fail') - expect(findCheck(result, 'active-profile').detail).toContain('ghost') + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('fail') + expect(active.detail).toContain('broken') + // The exact-name force command, and the file-aware URL promise that only + // a readable meta.json can make. + expect(active.detail).toContain('ucp profile init --name broken --force') + expect(active.detail).toMatch( + /custom profile_url in the readable, valid meta\.json is preserved/i, + ) + expect(active.detail).not.toMatch(/cannot preserve a custom profile_url/i) + expect(active.detail).not.toContain('') + }) + + it('does not promise URL preservation when the selected meta.json is invalid', async () => { + const dir = join(homeDir, 'profiles', 'broken-meta') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'profile.json'), JSON.stringify(SAMPLE_BODY), 'utf-8') + await writeFile(join(dir, 'meta.json'), '{ not json', 'utf-8') + await writeActive({ profile: 'broken-meta' }, { homeDir }) + + const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) + const active = findCheck(result, 'active-profile') + + expect(active.status).toBe('fail') + expect(active.detail).toContain('ucp profile init --name broken-meta --force') + expect(active.detail).toMatch(/meta\.json could not be read and validated/i) + expect(active.detail).toMatch(/cannot preserve a custom profile_url/i) + expect(active.detail).toMatch(/--profile-url with the HTTPS URL you need to retain/i) + expect(active.detail).not.toMatch(/custom profile_url.*is preserved/i) }) - it('UCP_PROFILE env wins over active.yaml when checking which profile to validate', async () => { + // The regression this pass exists for. A schema-valid DIY body with an + // off-version `dev.ucp.*` entry fails with AGENT_PROFILE_VERSION_MISMATCH, + // whose cta says to ALIGN the entries — the local document stays the + // operator's. Reconstructing a remedy from the error's shape replaced that + // with `profile init --force`, which REWRITES the authored document, so the + // absence of `--force` here is the assertion that matters. + it('reports the carried alignment remedy for an off-version dev.ucp.* entry', async () => { + await saveUserProfile({ name: 'authored', body: OFF_VERSION_BODY, meta: DIY_META }, { homeDir }) + await writeActive({ profile: 'authored' }, { homeDir }) + + const result = await runDoctor({ homeDir, skipNetwork: true, env: {} }) + const active = findCheck(result, 'active-profile') + + expect(result.ok).toBe(false) + expect(active.status).toBe('fail') + expect(active.detail).toContain('AGENT_PROFILE_VERSION_MISMATCH') + // The authoritative, non-destructive remedy, plus its inspection command. + expect(active.detail).toMatch(/align every dev\.ucp\.\* entry/i) + expect(active.detail).toContain('ucp profile show') + expect(active.detail).not.toContain('--force') + expect(active.detail).not.toContain('profile init') + // Doctor never sends the reader back into Doctor: the cta's `ucp doctor` + // command is dropped, the useful one is kept. + expect(active.detail).not.toContain('ucp doctor') + // Source + precedence guidance is Doctor's own and still reported. + expect(active.detail).toMatch(/active\.yaml selects local Profile "authored"/) + expect(active.detail).toMatch(/never silently replaced/) + expect(active.detail).toContain('ucp profile use --managed') + }) + + it('keeps UCP_PROFILE precedence guidance around a carried alignment remedy', async () => { + await saveUserProfile({ name: 'authored', body: OFF_VERSION_BODY, meta: DIY_META }, { homeDir }) + + const result = await runDoctor({ + homeDir, + skipNetwork: true, + env: { UCP_PROFILE: 'authored' }, + }) + const active = findCheck(result, 'active-profile') + + expect(active.status).toBe('fail') + expect(active.detail).toMatch(/align every dev\.ucp\.\* entry/i) + expect(active.detail).not.toContain('--force') + expect(active.detail).toContain('unset UCP_PROFILE') + expect(active.detail).toMatch(/profile use.*cannot override UCP_PROFILE/i) + // active.yaml's remedies are not offered against an env selection. + expect(active.detail).not.toContain('ucp profile use --managed') + }) + + it('a failing UCP_PROFILE selection names the env source and cannot be overridden by profile use', async () => { + await writeActive({ profile: 'from-active' }, { homeDir }) + const result = await runDoctor({ + homeDir, + skipNetwork: true, + env: { UCP_PROFILE: 'from-env' }, + }) + + expect(result.ok).toBe(false) + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('fail') + expect(active.detail).toContain('UCP_PROFILE') + expect(active.detail).toContain('from-env') + expect(active.detail).not.toContain('from-active') + expect(active.detail).toContain('ucp profile init --name from-env --force') + expect(active.detail).toContain('unset UCP_PROFILE') + expect(active.detail).toMatch(/profile use.*cannot override UCP_PROFILE/i) + expect(active.detail).not.toContain('') + expect(active.detail).not.toContain('ucp profile use --managed') + }) + + it('an invalid UCP_AGENT_PROFILE_URL reports how to remove or fix that override', async () => { + const result = await runDoctor({ + homeDir, + skipNetwork: true, + env: { UCP_AGENT_PROFILE_URL: 'http://agent.example.com/profile.json' }, + }) + + expect(result.ok).toBe(false) + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('fail') + expect(active.detail).toContain('UCP_AGENT_PROFILE_URL') + expect(active.detail).toContain('http://agent.example.com/profile.json') + expect(active.detail).toContain('unset UCP_AGENT_PROFILE_URL') + expect(active.detail).toMatch(/fix|valid HTTPS/i) + expect(active.detail).not.toContain('') + }) + + it('UCP_PROFILE env wins over active.yaml when deciding which Profile to report', async () => { await saveUserProfile({ name: 'prod', body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) await writeActive({ profile: 'ghost' }, { homeDir }) const result = await runDoctor({ @@ -131,17 +450,291 @@ describe('runDoctor — user profile branch', () => { }) expect(result.ok).toBe(true) expect(findCheck(result, 'active-profile').status).toBe('ok') + expect(findCheck(result, 'active-profile').detail).toContain('prod') + }) + + it('reports UCP_AGENT_PROFILE_URL as a one-rendering ad-hoc identity', async () => { + const warnings: string[] = [] + setWarnWriter((message) => warnings.push(message)) + const result = await runDoctor({ + homeDir, + skipNetwork: true, + env: { UCP_AGENT_PROFILE_URL: SELF_HOSTED_URL }, + }) + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('ok') + expect(active.detail).toContain('UCP_AGENT_PROFILE_URL') + expect(active.detail).toContain(SELF_HOSTED_URL) + expect(active.detail).toContain(`UCP ${LATEST}`) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(`bundled UCP ${LATEST} body`) }) }) -// ─── the hosted-identity fetch ─────────────────────────────────────── +// ─── the managed Profile's renderings ──────────────────────────────────── // -// ONE network request: the GET the business also performs. Never add a second -// probe of the same URL — a `warn`-only HEAD adds no information the GET does -// not have and produces contradictory pairs (a host that 405s HEAD but serves -// GET reports `profile-url: warn` beside `protocol: ok`). Because `protocol` -// is the only voice on this URL, its failure detail has to separate transport -// failure from HTTP status from parse/validate failure: the remedies differ. +// A managed Profile publishes one rendering per installed release and the +// Business selects one of them at negotiation — any of them. So every +// installed rendering URL is audited, and the four hosted ids stay four +// Checks: worst severity wins, and the lines are labelled by release. +describe('runDoctor — managed renderings', () => { + let homeDir: string + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-doctor-managed-')) + }) + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }) + }) + + const FIXTURE_DIR = fileURLToPath( + new URL('../../test/fixtures/legacy-profiles/', import.meta.url), + ) + + /** A profile directory exactly as 0.4.2 … 0.8.0 wrote it: stock body, no marker, no URL. */ + async function seedLegacy(name: string, fixture: string): Promise { + const dir = join(homeDir, 'profiles', name) + await mkdir(dir, { recursive: true, mode: 0o700 }) + await writeFile( + join(dir, 'profile.json'), + await readFile(join(FIXTURE_DIR, fixture), 'utf-8'), + 'utf-8', + ) + await writeFile( + join(dir, 'meta.json'), + `${JSON.stringify({ created_at: '2026-06-01T10:00:00.000Z' }, null, 2)}\n`, + 'utf-8', + ) + return dir + } + + it('probes every rendering URL exactly once and reports one Check per id', async () => { + const { fetch: fetchImpl, calls } = releaseFetch() + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + + expect(calls.map((c) => c.url).sort()).toEqual([...RELEASE_URLS].sort()) + // One GET each — never a second probe of the same URL, and never a HEAD. + expect(calls).toHaveLength(RELEASE_URLS.length) + for (const call of calls) expect(call.method).not.toBe('HEAD') + + for (const id of ['protocol', 'profile-redirect', 'profile-cache-control', 'profile-drift']) { + expect(countChecks(result, id)).toBe(1) + expect(findCheck(result, id).status).toBe('ok') + } + // Version-labelled, so a reader can tell which rendering each line is about. + const protocol = findCheck(result, 'protocol') + for (const version of SUPPORTED_VERSIONS) expect(protocol.detail).toContain(`UCP ${version}:`) + for (const url of RELEASE_URLS) expect(protocol.detail).toContain(url) + expect(result.ok).toBe(true) + }) + + it('starts distinct probes before either response is released and preserves release order', async () => { + const distinctUrls = [...new Set(RELEASE_URLS)] + expect(distinctUrls.length).toBeGreaterThanOrEqual(2) + + const startedUrls: string[] = [] + const responses = new Map>() + const releaseResponse = new Map void>() + for (const url of distinctUrls) { + let release!: (response: Response) => void + responses.set( + url, + new Promise((resolve) => { + release = resolve + }), + ) + releaseResponse.set(url, release) + } + + let markFirstStarted!: () => void + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve + }) + const startedFetch = vi.fn((input: unknown) => { + const url = String(input) + startedUrls.push(url) + markFirstStarted() + const response = responses.get(url) + if (response === undefined) throw new Error(`unexpected rendering URL: ${url}`) + return response + }) + const doctor = runDoctor({ + homeDir, + env: {}, + fetch: startedFetch as unknown as typeof fetch, + }) + + await firstStarted + await new Promise((resolve) => setImmediate(resolve)) + const startedBeforeRelease = [...startedUrls] + + // Settle in reverse to ensure completion timing cannot reorder output. + for (const version of [...SUPPORTED_VERSIONS].reverse()) { + const url = RELEASES[version].defaultAgentProfileUrl + const release = releaseResponse.get(url) + if (release === undefined) throw new Error(`no deferred response for: ${url}`) + release( + new Response(RELEASES[version].agentProfileJson, { + status: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': CACHEABLE, + }, + }), + ) + } + + const result = await doctor + expect(startedBeforeRelease).toEqual(distinctUrls) + expect(startedUrls).toEqual(distinctUrls) + for (const id of ['protocol', 'profile-redirect', 'profile-cache-control', 'profile-drift']) { + expect(findCheck(result, id).detail.split('\n')).toEqual( + SUPPORTED_VERSIONS.map((version) => expect.stringContaining(`UCP ${version}:`)), + ) + } + }) + + it('aggregates one failed protocol check when a single rendering URL 404s', async () => { + const broken = RELEASES['2026-04-08'].defaultAgentProfileUrl + const { fetch: fetchImpl } = releaseFetch({ [broken]: { status: 404 } }) + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + + expect(countChecks(result, 'protocol')).toBe(1) + const protocol = findCheck(result, 'protocol') + expect(protocol.status).toBe('fail') + expect(protocol.detail).toContain('UCP 2026-04-08:') + expect(protocol.detail).toContain('AGENT_PROFILE_UNREACHABLE') + expect(protocol.detail).toContain('http_status: HTTP 404') + // The healthy rendering still reports, in the same Check. + expect(protocol.detail).toContain(`UCP ${LATEST}:`) + expect(protocol.detail).toContain(RELEASES[LATEST].defaultAgentProfileUrl) + expect(result.ok).toBe(false) + }) + + // One fault, one voice. The redirecting rendering is represented by + // `profile-redirect` and nowhere else — no second, vaguer `protocol` fail + // on the same GET. + it('reports a redirecting rendering as profile-redirect only', async () => { + const hopping = RELEASES['2026-04-08'].defaultAgentProfileUrl + const { fetch: fetchImpl } = releaseFetch({ + [hopping]: { status: 301, headers: { location: 'https://cdn.example.com/profile.json' } }, + }) + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + + expect(countChecks(result, 'profile-redirect')).toBe(1) + const redirect = findCheck(result, 'profile-redirect') + expect(redirect.status).toBe('fail') + expect(redirect.detail).toContain('HTTP 301') + expect(redirect.detail).toContain('https://cdn.example.com/profile.json') + expect(redirect.detail).toContain('UCP forbids redirects (3xx) on published profiles') + // A managed rendering is not the reader's to serve, so the remedy is not + // "point meta.profile_url" and never "upload profile.json". + expect(redirect.detail).toContain('upgrade ucp-cli') + expect(redirect.detail).not.toContain('meta.profile_url') + expect(redirect.detail).not.toMatch(/upload/i) + + // `protocol` stays with the rendering it could actually judge. + expect(countChecks(result, 'protocol')).toBe(1) + const protocol = findCheck(result, 'protocol') + expect(protocol.status).toBe('ok') + expect(protocol.detail).not.toContain(hopping) + expect(result.ok).toBe(false) + }) + + it('fails protocol when one rendering URL serves another release', async () => { + const drifted = RELEASES[LATEST].defaultAgentProfileUrl + const { fetch: fetchImpl } = releaseFetch({ + [drifted]: { body: publishedProfile('2026-04-08') }, + }) + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + + expect(countChecks(result, 'protocol')).toBe(1) + const protocol = findCheck(result, 'protocol') + expect(protocol.status).toBe('fail') + expect(protocol.detail).toContain(`UCP ${LATEST}: ${drifted} serves UCP 2026-04-08`) + // Managed remedies: refresh the install / report the hosted change. Never + // "upload profile.json" — the local file (if any) is not what we declare. + expect(protocol.detail).toContain('upgrade ucp-cli') + expect(protocol.detail).not.toMatch(/upload/i) + expect(protocol.detail).not.toContain('profile.json') + // One voice per fault: drift stays quiet for the rendering protocol ruled + // on, and reports the healthy one. + expect(findCheck(result, 'profile-drift').status).toBe('ok') + expect(findCheck(result, 'profile-drift').detail).toContain('UCP 2026-04-08:') + expect(result.ok).toBe(false) + }) + + it('warns profile-drift when one rendering URL serves a modified body', async () => { + const url = RELEASES[LATEST].defaultAgentProfileUrl + const modified = publishedProfile(LATEST) as { ucp: { capabilities: Record } } + delete modified.ucp.capabilities['dev.shopify.catalog.global'] + const { fetch: fetchImpl } = releaseFetch({ [url]: { body: modified } }) + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + + expect(countChecks(result, 'profile-drift')).toBe(1) + const drift = findCheck(result, 'profile-drift') + expect(drift.status).toBe('warn') + expect(drift.detail).toContain(`UCP ${LATEST}: ${url} serves a document that differs`) + expect(drift.detail).toContain('bundled') + expect(drift.detail).not.toMatch(/upload/i) + // The other rendering is still reported as matching, in the same Check. + expect(drift.detail).toContain('UCP 2026-04-08:') + expect(findCheck(result, 'protocol').status).toBe('ok') + // Only `fail` gates the verdict. + expect(result.ok).toBe(true) + }) + + it('warns profile-cache-control when one rendering URL is uncacheable', async () => { + const url = RELEASES['2026-04-08'].defaultAgentProfileUrl + const { fetch: fetchImpl } = releaseFetch({ + [url]: { headers: { 'cache-control': 'no-store' } }, + }) + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + + const cache = findCheck(result, 'profile-cache-control') + expect(countChecks(result, 'profile-cache-control')).toBe(1) + expect(cache.status).toBe('warn') + expect(cache.detail).toContain(`UCP 2026-04-08: ${url} serves \`Cache-Control: no-store\``) + expect(cache.detail).toContain(`UCP ${LATEST}: ${RELEASES[LATEST].defaultAgentProfileUrl}`) + expect(result.ok).toBe(true) + }) + + // The upgraded-legacy population. `profiles//profile.json` is a stock + // 0.7.0 body that ucp-cli no longer declares — the renderings are the + // BUNDLED documents at their published URLs, and that is what doctor must + // 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') + await writeActive({ profile: 'legacy07' }, { homeDir }) + const before = await readFile(join(dir, 'profile.json'), 'utf-8') + const { fetch: fetchImpl, calls } = releaseFetch() + + const result = await runDoctor({ homeDir, env: {}, fetch: fetchImpl }) + + const active = findCheck(result, 'active-profile') + expect(active.status).toBe('ok') + expect(active.detail).toContain('profile "legacy07" is a Shopify-managed Profile') + expect(calls.map((c) => c.url).sort()).toEqual([...RELEASE_URLS].sort()) + for (const id of ['protocol', 'profile-redirect', 'profile-cache-control', 'profile-drift']) { + expect(countChecks(result, id)).toBe(1) + expect(findCheck(result, id).status).toBe('ok') + } + expect(result.ok).toBe(true) + // The stale body stays exactly as found — doctor reads, it does not repair. + expect(await readFile(join(dir, 'profile.json'), 'utf-8')).toBe(before) + }) +}) + +// ─── the hosted-identity fetch, DIY ────────────────────────────────────── +// +// ONE network request per rendering, and a DIY Profile has exactly one. Never +// add a second probe of the same URL — a `warn`-only HEAD adds no information +// the GET does not have and produces contradictory pairs (a host that 405s +// HEAD but serves GET reports `profile-url: warn` beside `protocol: ok`). +// Because `protocol` is the only voice on this URL, its failure detail has to +// separate transport failure from HTTP status from parse/validate failure: +// the remedies differ. describe('runDoctor — hosted-identity fetch', () => { let homeDir: string @@ -172,10 +765,10 @@ describe('runDoctor — hosted-identity fetch', () => { expect(findCheck(result, 'protocol').status).toBe('ok') }) - // The business GETs this URL on every call and hard-fails `-32001 - // profile_unreachable`, so a doctor reporting `ok: true` here would be - // telling the user their install is healthy while every command is - // guaranteed to fail. + // A business dereferences this URL to negotiate with us (and may cache what + // it gets). A doctor reporting `ok: true` on a URL that cannot be read + // would be calling an install healthy while the identity it advertises is + // unreadable to the only party that reads it. it('fails and names the HTTP status when the profile URL 404s', async () => { await saveUserProfile({ name: 'prod', body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) await writeActive({ profile: 'prod' }, { homeDir }) @@ -249,10 +842,9 @@ describe('runDoctor — hosted-identity fetch', () => { expect(result.ok).toBe(false) }) - it('falls back to the latest published default when no profile_url is configured', async () => { - // "No profile_url" is not "no identity": session resolution falls back to - // the latest release's PUBLISHED agent profile, so `protocol` reports that - // URL. + it('falls back to the body release published default when no profile_url is configured', async () => { + // "No profile_url" is not "no identity": a DIY singleton is advertised at + // its own release's PUBLISHED agent profile, so `protocol` reports that URL. await saveUserProfile( { name: 'deferred', body: SAMPLE_BODY, meta: { created_at: SAMPLE_META.created_at } }, { homeDir }, @@ -268,7 +860,7 @@ describe('runDoctor — hosted-identity fetch', () => { expect(findCheck(result, 'protocol').detail).toContain(RELEASES[LATEST].defaultAgentProfileUrl) }) - // Hosting rule 2 beside checkCacheControl's rule 3, off the same single GET. + // Hosting rule 2 beside the cache-control rule 3, off the same single GET. // Doctor is the only place ucp-cli fetches this URL, and it refuses the hop // itself; commerce requests only advertise the URL, and a conforming // business dereferencing it is bound by the same MUST NOT, so they cannot @@ -290,8 +882,10 @@ describe('runDoctor — hosted-identity fetch', () => { fetch: fakeFetch as unknown as typeof fetch, }) // One fault, one voice: exactly one `profile-redirect` check, and no - // second, vaguer `protocol` fail on the same GET. - expect(result.checks.filter((c) => c.id === 'profile-redirect')).toHaveLength(1) + // second, vaguer `protocol` fail on the same GET. With a singleton + // Profile nothing else had anything to say, so `protocol` is absent + // rather than green. + expect(countChecks(result, 'profile-redirect')).toBe(1) expect(result.checks.find((c) => c.id === 'protocol')).toBeUndefined() const check = findCheck(result, 'profile-redirect') expect(check.status).toBe('fail') @@ -312,6 +906,31 @@ describe('runDoctor — hosted-identity fetch', () => { expect(check.detail).not.toContain('every command') }) + it('never suggests stored profile metadata while UCP_AGENT_PROFILE_URL is active', async () => { + await saveUserProfile({ name: 'prod', body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) + await writeActive({ profile: 'prod' }, { homeDir }) + const overrideUrl = 'https://override.example.com/profile.json' + const fakeFetch = vi.fn( + async () => + new Response(null, { + status: 301, + headers: { location: 'https://cdn.example.com/profile.json' }, + }), + ) + + const result = await runDoctor({ + homeDir, + env: { UCP_AGENT_PROFILE_URL: overrideUrl }, + fetch: fakeFetch as unknown as typeof fetch, + }) + const check = findCheck(result, 'profile-redirect') + + expect(check.detail).toContain(overrideUrl) + expect(check.detail).toContain('point UCP_AGENT_PROFILE_URL') + expect(check.detail).not.toContain('meta.profile_url') + expect(check.detail).not.toContain('meta.json') + }) + // Profile URLs are https, so an http target can never be the URL to // advertise — the remedy must not name it. it('does not offer an http redirect target as the profile URL to advertise', async () => { @@ -357,13 +976,14 @@ describe('runDoctor — hosted-identity fetch', () => { }) }) -// ─── protocol + profile drift ──────────────────────────────────────── +// ─── protocol + profile drift, DIY ─────────────────────────────────────── // -// Which UCP version an install speaks is a property of the ACTIVE PROFILE. -// `protocol` is the only place doctor says so, and the only place the local -// document meets the one the URL serves. Severity follows consequence: a -// version disagreement makes every request wrong (`fail`), any other -// difference makes our plan wrong but our requests well-formed (`warn`). +// For an authored Profile, `profile.json` IS the declaration: which UCP +// version it speaks and every capability it claims. `protocol` is the only +// place doctor says so, and the only place the local document meets the one +// the URL serves. Severity follows consequence: a version disagreement makes +// every request wrong (`fail`), any other difference makes our plan wrong but +// our requests well-formed (`warn`). describe('runDoctor — protocol + profile drift', () => { let homeDir: string @@ -374,7 +994,7 @@ describe('runDoctor — protocol + profile drift', () => { await rm(homeDir, { recursive: true, force: true }) }) - /** Serve `body` for the agent-profile GET; 200 for the HEAD probe. */ + /** Serve `body` for the agent-profile GET; 200 for anything else. */ function serving(body: unknown): typeof fetch { return vi.fn(async (_url: unknown, init: RequestInit = {}) => init.method === 'HEAD' ? new Response(null, { status: 200 }) : jsonResponse(body), @@ -382,13 +1002,11 @@ describe('runDoctor — protocol + profile drift', () => { } it('names the active version, the window, whether it is latest, and the URL', async () => { + // A published body at a URL the user typed is an authored (DIY) identity: + // the pairing is a decision, so the local document is what we declare. const body = publishedProfile(LATEST) await saveUserProfile( - { - name: 'prod', - body: body as PlatformProfile, - meta: { profile_url: RELEASES[LATEST].defaultAgentProfileUrl }, - }, + { name: 'prod', body: body as PlatformProfile, meta: { profile_url: SELF_HOSTED_URL } }, { homeDir }, ) await writeActive({ profile: 'prod' }, { homeDir }) @@ -405,54 +1023,10 @@ describe('runDoctor — protocol + profile drift', () => { expect(check.detail).toContain(`ucp-cli supports ${SUPPORTED_VERSIONS.join(', ')}`) expect(check.detail).toContain('this is the latest') expect(check.detail).not.toContain('NOT the latest') - expect(check.detail).toContain(RELEASES[LATEST].defaultAgentProfileUrl) + expect(check.detail).toContain(SELF_HOSTED_URL) expect(result.ok).toBe(true) }) - // Every command declares the local document and makes no request for it. - // Doctor is the exception, and this is the scenario it exists for: a - // published default URL whose content changed under a frozen version is a - // spec-release violation, the CLI would keep declaring the local document - // while the merchant reads the new one, and nothing on the request path can - // see the difference. - it('fails when a release-default URL serves a different version than the local document', async () => { - const body = publishedProfile(LATEST) - await saveUserProfile( - { - name: 'prod', - body: body as PlatformProfile, - meta: { profile_url: RELEASES[LATEST].defaultAgentProfileUrl }, - }, - { homeDir }, - ) - await writeActive({ profile: 'prod' }, { homeDir }) - // The LATEST default URL serving the OTHER release's document. - const requested: string[] = [] - const fetchImpl = vi.fn(async (url: string | URL | Request) => { - requested.push(String(url)) - return jsonResponse(publishedProfile('2026-04-08')) - }) - const result = await runDoctor({ - homeDir, - env: {}, - fetch: fetchImpl as unknown as typeof fetch, - }) - - expect(requested).toContain(RELEASES[LATEST].defaultAgentProfileUrl) - const check = findCheck(result, 'protocol') - expect(check.status).toBe('fail') - // Both values, and which one each side uses. - expect(check.detail).toContain('serves UCP 2026-04-08') - expect(check.detail).toContain(`negotiates as UCP ${LATEST}`) - // The remedy realigns the two documents. Re-pointing meta.profile_url is - // offered too, but never "upgrade ucp-cli": the version ucp-cli sends is - // the local file's, so an upgrade changes nothing here. - expect(check.detail).toContain('--version 2026-04-08 --force') - expect(check.detail).toContain('point meta.profile_url') - expect(check.detail).not.toMatch(/upgrade ucp-cli/i) - 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 @@ -462,7 +1036,7 @@ describe('runDoctor — protocol + profile drift', () => { { name: 'legacy', body: body as PlatformProfile, - meta: { profile_url: RELEASES['2026-04-08'].defaultAgentProfileUrl }, + meta: { profile_url: SELF_HOSTED_URL }, }, { homeDir }, ) @@ -486,41 +1060,9 @@ describe('runDoctor — protocol + profile drift', () => { expect(result.ok).toBe(true) }) - // The legacy-upgrade population: 0.6.x wrote profile.json at 2026-04-08 and - // no meta.profile_url, so the profile URL is the latest published default - // while the document ucp-cli sends says 2026-04-08. The merchant reads - // 2026-08-25 off that URL and validates our 2026-04-08 requests against it: - // every call is wrong, so this is a `fail`, not a note. - it('fails when the local ucp.version differs from what the profile URL serves', async () => { - const legacy = publishedProfile('2026-04-08') - await saveUserProfile( - { - name: 'legacy', - body: legacy as PlatformProfile, - meta: { created_at: '2026-05-05T12:00:00Z' }, - }, - { homeDir }, - ) - await writeActive({ profile: 'legacy' }, { homeDir }) - const result = await runDoctor({ - homeDir, - env: {}, - fetch: serving(publishedProfile(LATEST)), - }) - - const check = findCheck(result, 'protocol') - expect(check.status).toBe('fail') - // Both versions, and which side reads which. - expect(check.detail).toContain('negotiates as UCP 2026-04-08') - expect(check.detail).toContain(`serves UCP ${LATEST}`) - expect(check.detail).toContain(join(homeDir, 'profiles', 'legacy', 'profile.json')) - expect(result.ok).toBe(false) - // One voice per fault: `protocol` has ruled, so drift stays quiet. - expect(result.checks.find((c) => c.id === 'profile-drift')).toBeUndefined() - }) - - // The same fault when the URL is the user's. The remedy differs in what the - // reader can do about it, not in severity. + // The same fault as a managed rendering serving the wrong release, but on a + // URL the reader owns: the remedy differs in what they can do about it, not + // in severity. it('fails a version mismatch on a URL you own and offers uploading', async () => { await saveUserProfile( { @@ -541,12 +1083,17 @@ describe('runDoctor — protocol + profile drift', () => { expect(check.status).toBe('fail') expect(check.detail).toContain(`serves UCP ${LATEST}`) expect(check.detail).toContain('negotiates as UCP 2026-04-08') - expect(check.detail).toContain('profile.json') + expect(check.detail).toContain(join(homeDir, 'profiles', 'mine', 'profile.json')) + expect(check.detail).toContain('point meta.profile_url') // Uploading is something the reader does, never something ucp-cli offers - // to do: it has no command that writes to a URL. + // to do: it has no command that writes to a URL. And never "upgrade + // ucp-cli": the version ucp-cli sends is the local file's. expect(check.detail).toMatch(/upload .*profile\.json to https:/i) expect(check.detail).not.toMatch(/ucp profile publish/) + expect(check.detail).not.toMatch(/upgrade ucp-cli/i) expect(result.ok).toBe(false) + // One voice per fault: `protocol` has ruled, so drift stays quiet. + expect(result.checks.find((c) => c.id === 'profile-drift')).toBeUndefined() }) it('fails when the served document is outside the window', async () => { @@ -611,6 +1158,9 @@ describe('runDoctor — protocol + profile drift', () => { expect(drift.status).toBe('warn') expect(drift.detail).toContain('The business acts on what that URL serves') expect(drift.detail).toMatch(/upload .*profile\.json to https:/i) + expect(drift.detail).toMatch(/edit .*profile\.json to match what the URL serves/i) + expect(drift.detail).toContain('point meta.profile_url') + expect(drift.detail).not.toMatch(/profile init|copy what that URL serves/i) expect(drift.detail).not.toMatch(/ucp profile publish/) // A warn describes optional state and must not gate the verdict. expect(result.ok).toBe(true) @@ -636,7 +1186,7 @@ describe('runDoctor — protocol + profile drift', () => { expect(findCheck(result, 'profile-drift').detail).toContain('matches') }) - it('UCP_AGENT_PROFILE_URL overrides meta.profile_url for the protocol check', async () => { + it('keeps a named DIY body authored under UCP_AGENT_PROFILE_URL for version remedies', async () => { await saveUserProfile( { name: 'prod', @@ -649,22 +1199,121 @@ describe('runDoctor — protocol + profile drift', () => { const result = await runDoctor({ homeDir, env: { UCP_AGENT_PROFILE_URL: SELF_HOSTED_URL }, - fetch: serving(publishedProfile(LATEST)), + fetch: serving(publishedProfile('2026-04-08')), }) - expect(findCheck(result, 'protocol').detail).toContain(SELF_HOSTED_URL) - expect(findCheck(result, 'protocol').detail).not.toContain( - RELEASES[LATEST].defaultAgentProfileUrl, + const bodyPath = join(homeDir, 'profiles', 'prod', 'profile.json') + const active = findCheck(result, 'active-profile') + expect(active.detail).toContain('locally authored (DIY)') + expect(active.detail).toContain('UCP_AGENT_PROFILE_URL overrides') + expect(active.detail).toContain(bodyPath) + expect(active.detail).toContain(SELF_HOSTED_URL) + expect(active.detail).toContain('unset UCP_AGENT_PROFILE_URL') + expect(active.detail).not.toContain('bundled') + expect(active.detail).not.toContain('meta.profile_url') + + const protocol = findCheck(result, 'protocol') + expect(protocol.status).toBe('fail') + expect(protocol.detail).toContain(SELF_HOSTED_URL) + expect(protocol.detail).toContain(bodyPath) + expect(protocol.detail).toMatch(/upload local profile\.json.*override URL/i) + expect(protocol.detail).toContain('unset UCP_AGENT_PROFILE_URL') + expect(protocol.detail).not.toContain('meta.profile_url') + expect(protocol.detail).not.toContain('bundled') + }) + + it('compares a DIY override URL against the local authored body, not the bundle', async () => { + await saveUserProfile( + { + name: 'prod', + body: SAMPLE_BODY, + meta: { profile_url: RELEASES[LATEST].defaultAgentProfileUrl }, + }, + { homeDir }, ) + await writeActive({ profile: 'prod' }, { homeDir }) + + const result = await runDoctor({ + homeDir, + env: { UCP_AGENT_PROFILE_URL: SELF_HOSTED_URL }, + fetch: serving(SAMPLE_BODY), + }) + + const active = findCheck(result, 'active-profile') + expect(active.detail).toContain('locally authored (DIY)') + expect(active.detail).toContain('UCP_AGENT_PROFILE_URL') + const drift = findCheck(result, 'profile-drift') + expect(drift.status).toBe('ok') + expect(drift.detail).toContain(join(homeDir, 'profiles', 'prod', 'profile.json')) + expect(drift.detail).toContain(SELF_HOSTED_URL) + expect(drift.detail).toContain('UCP_AGENT_PROFILE_URL') + expect(drift.detail).not.toContain('bundled') + expect(drift.detail).not.toContain('meta.profile_url') + }) + + it('keeps named DIY + UCP_AGENT_PROFILE_URL drift remedies on the override', async () => { + await saveUserProfile( + { + name: 'prod', + body: SAMPLE_BODY, + meta: { profile_url: RELEASES[LATEST].defaultAgentProfileUrl }, + }, + { homeDir }, + ) + await writeActive({ profile: 'prod' }, { homeDir }) + const served = publishedProfile(LATEST) as { + ucp: { capabilities: Record } + } + delete served.ucp.capabilities['dev.shopify.catalog.global'] + + const result = await runDoctor({ + homeDir, + env: { UCP_AGENT_PROFILE_URL: SELF_HOSTED_URL }, + fetch: serving(served), + }) + + const drift = findCheck(result, 'profile-drift') + expect(drift.status).toBe('warn') + expect(drift.detail).toContain(join(homeDir, 'profiles', 'prod', 'profile.json')) + expect(drift.detail).toMatch(/upload local profile\.json.*override URL/i) + expect(drift.detail).toContain('unset UCP_AGENT_PROFILE_URL') + expect(drift.detail).not.toContain('meta.profile_url') + expect(drift.detail).not.toContain('bundled') + }) + + // A scalar URL demotes even the managed Profile to one rendering, and the + // body at that URL is a BUNDLED document — there is no local file to + // upload, so the remedies name the override instead. + it('audits only the pinned URL when UCP_AGENT_PROFILE_URL overrides the managed Profile', async () => { + const warnings: string[] = [] + setWarnWriter((message) => warnings.push(message)) + const { fetch: fetchImpl, calls } = releaseFetch({ + [SELF_HOSTED_URL]: { body: publishedProfile('2026-04-08') }, + }) + const result = await runDoctor({ + homeDir, + env: { UCP_AGENT_PROFILE_URL: SELF_HOSTED_URL }, + fetch: fetchImpl, + }) + + expect(calls.map((c) => c.url)).toEqual([SELF_HOSTED_URL]) + const check = findCheck(result, 'protocol') + expect(check.status).toBe('fail') + expect(check.detail).toContain(`UCP ${LATEST}: ${SELF_HOSTED_URL} serves UCP 2026-04-08`) + expect(check.detail).toContain('UCP_AGENT_PROFILE_URL') + expect(check.detail).not.toMatch(/upload/i) + expect(result.ok).toBe(false) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(`bundled UCP ${LATEST} body`) }) }) // ─── hosting advisory: Cache-Control ───────────────────────────────────── // -// The merchant fetches this URL to negotiate with us, so its cache policy is -// the merchant's fetch rate. UCP's hosting rules make it normative +// A business dereferences this URL to negotiate with us, so its cache policy +// is that business's refetch rate. UCP's hosting rules make it normative // (`Cache-Control: public, max-age>=60`, never private/no-store/no-cache), -// and a profile served uncacheable turns every call into an origin hit on +// and a profile served uncacheable turns every exchange into an origin hit on // that host. Advisory only — and reported for every URL, so its absence from // the output means "not checked", never "fine". describe('runDoctor — profile URL cache policy', () => { @@ -718,7 +1367,7 @@ describe('runDoctor — profile URL cache policy', () => { const check = findCheck(await doctorWith(SELF_HOSTED_URL, 'no-store'), 'profile-cache-control') expect(check.status).toBe('warn') expect(check.detail).toContain('no-store') - expect(check.detail).toContain('refetches your profile on every request') + expect(check.detail).toContain('cannot reuse a cached copy') }) it('warns below the 60s floor', async () => { @@ -737,8 +1386,8 @@ describe('runDoctor — profile URL cache policy', () => { }) // Reported on a release-default URL too. The headers are somebody else's to - // fix, but merchants still refetch that document per request, and the reader - // can act on it — by moving to a URL they control. + // fix, but merchants still refetch that document, and the reader can act on + // it — by moving to a URL they control. it('reports a release-default URL on the same terms', async () => { const check = findCheck( await doctorWith(RELEASES[LATEST].defaultAgentProfileUrl, 'no-store'), diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 06c3959..fe0f89e 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,10 +1,25 @@ // `ucp doctor` — local install health check. // -// Verifies what can be answered locally, plus one GET of the active profile's -// hosted identity — the same request the business makes. Each check that -// doesn't yet have a feature behind it is deliberately omitted: an -// always-green check for a feature that doesn't ship trains users to ignore -// the output. +// Verifies what can be answered locally, plus one GET per rendering of the +// resolved Profile — the same documents a Business reads when it dereferences +// the identity we advertise. Each check that doesn't yet have a feature +// behind it is deliberately omitted: an always-green check for a feature that +// doesn't ship trains users to ignore the output. +// +// Doctor resolves the SAME Profile commerce runs on, through the same +// {@link resolveSession}: `UCP_PROFILE`, `UCP_AGENT_PROFILE_URL`, +// `active.yaml`, and the managed/DIY classification of a local profile +// directory are decided in exactly one place, so doctor cannot report an +// identity no command would send. Doctor has no per-invocation `--profile` or +// `--profile-url` options; it diagnoses the environment and persisted session. +// +// A managed Profile publishes one rendering per installed release and the +// Business selects one of them at negotiation time — any of them. So the +// network pass audits EVERY installed rendering URL (once each, deduplicated) +// and still reports one Check per id: the worst severity wins and every line +// is labelled with the release it came from. A DIY Profile (including one +// whose URL is explicitly overridden) or URL-only ad-hoc Profile is a +// singleton, so its one URL is the whole audit. // // Returns a structured envelope so machine consumers (CI, agents) can // pattern-match on individual checks. `ok` is the AND of all `fail` checks, @@ -14,20 +29,24 @@ // for state a human should know about but a build should not stop for. // `ok: false` exits nonzero — see the `doctor` command in src/cli.ts. -import { access, constants, mkdir } from 'node:fs/promises' +import { access, constants, mkdir, readFile } from 'node:fs/promises' import { join } from 'node:path' - +import { parse as parseYaml } from 'yaml' import { type AgentProfile, agentProfileRedirect, fetchAgentProfileLive, - resolveAgentProfile, + type LiveAgentProfile, + type Profile, } from '../core/agent.js' import { MIN_CACHE_SECONDS } from '../core/cache.js' import { type RefusedRedirect, usableRedirectTarget } from '../core/http-client.js' import { isSupportedNodeVersion } from '../core/node-version.js' + import { + activeSessionSchema, activeYamlPath, + listProfiles, type ProfileStoreOptions, profileDir, profileStoreHome, @@ -36,8 +55,10 @@ import { readUserProfile, } from '../core/profile-store.js' import { describeProxyState, proxyState } from '../core/proxy.js' -import { LATEST, RELEASES, SUPPORTED_VERSIONS } from '../core/releases.js' -import { isUcpError } from '../lib/errors.js' +import { LATEST, RELEASES, SUPPORTED_VERSIONS, type Version } from '../core/releases.js' +import { ErrorCodes, isUcpError } from '../lib/errors.js' +import type { CtaBlock } from '../lib/types.js' +import { type ResolvedSession, resolveSession } from './session.js' export interface DoctorDeps { homeDir?: string @@ -83,15 +104,16 @@ export async function runDoctor(deps: DoctorDeps = {}): Promise { // 3. active.yaml resolves (degraded-empty allowed; readActive never throws). // The check exists so a corrupt file shows up explicitly rather than silently - // collapsing the session to defaults. - const activeCheck = await checkActive(storeOpts) - checks.push(activeCheck) + // collapsing the session to the managed default. + checks.push(await checkActive(storeOpts)) - // 4. Active local profile parses from disk. Profile init is required; there - // is no synthetic runtime profile for operations. - const active = await readActive(storeOpts) - const profileName = env.UCP_PROFILE ?? active.profile - checks.push(await checkProfile(profileName, storeOpts)) + // 4. The Profile every command runs on. Resolved, not reconstructed: a + // fresh install has no local profile and runs on the Shopify-managed + // Profile, while a NAMED profile that is missing or corrupt is a `fail` + // that never falls back — selling under a different identity than the one + // the operator selected is worse than not selling. + const state = await resolveProfileState(storeOpts, env) + checks.push(state.check) // 5. Outbound network configuration. Reports the proxy decision made at // boot, catching the otherwise-invisible state: proxy env present but @@ -100,21 +122,17 @@ export async function runDoctor(deps: DoctorDeps = {}): Promise { // of the probe's failure. checks.push(checkProxy()) - // 6. Protocol negotiation. Which UCP version this install uses is a - // property of the ACTIVE PROFILE, not the install, and nothing else in - // doctor's output said so. `protocol` answers it — and, since the request - // path never reads the wire, is the only thing that verifies the URL we - // advertise works and serves what we declare. `profile-drift` compares the - // rest of that document against the local one. Last because it is the only - // check whose failure is fully explained by the ones above (proxy, hosting). - // - // There is deliberately no second, softer reachability probe of the same - // URL: a HEAD `warn` next to a GET `fail` produced contradictory verdict - // pairs with no added information (a host that 405s HEAD but serves GET - // reported `warn` + `ok`), so the fetch that decides negotiation is the - // only one that reports. - if (deps.skipNetwork !== true) { - checks.push(...(await checkProtocol(profileName, storeOpts, env, deps.fetch ?? fetch))) + // 6. The hosted renderings. Last because these are the only checks whose + // failure is fully explained by the ones above (proxy, hosting, and which + // Profile resolved at all). + if (deps.skipNetwork !== true && state.hosted !== undefined) { + checks.push( + ...(await checkHostedRenderings( + state.hosted.profile, + state.hosted.audit, + deps.fetch ?? fetch, + )), + ) } const ok = checks.every((c) => c.status !== 'fail') @@ -159,206 +177,614 @@ async function checkWritable(id: string, path: string): Promise { } async function checkActive(opts: ProfileStoreOptions): Promise { - // readActive degrades to {} on parse failure; check the file directly so we - // can distinguish "missing" (fine) from "present but malformed" (warn). + // readActive intentionally degrades every failure to {} for commerce. Read + // the diagnostic copy directly so Doctor can distinguish a missing file and + // an intentionally persisted {} from unreadable, malformed, or wrong-shaped + // state without changing runtime fallback semantics. + const path = activeYamlPath(opts) + let raw: string try { - await access(activeYamlPath(opts), constants.R_OK) - } catch { - return { id: 'active-yaml', status: 'ok', detail: 'no active.yaml' } - } - const active = await readActive(opts) - // readActive returning empty {} when the file exists is the malformed signal. - // It's the same shape as a stale file truncated to 0 bytes; treat as warn so - // the user knows but it doesn't gate `ok`. - if (Object.keys(active).length === 0) { + raw = await readFile(path, 'utf-8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { id: 'active-yaml', status: 'ok', detail: 'no active.yaml' } + } return { id: 'active-yaml', status: 'warn', - detail: `${activeYamlPath(opts)} present but parsed empty (corrupt or truncated?)`, + detail: `${path} could not be read: ${(err as Error).message}`, } } - return { id: 'active-yaml', status: 'ok', detail: JSON.stringify(active) } -} -async function checkProfile(name: string | undefined, opts: ProfileStoreOptions): Promise { - if (name === undefined || name === '') { + let parsed: unknown + try { + parsed = parseYaml(raw) + } catch (err) { return { - id: 'active-profile', - status: 'fail', - detail: 'no local profile selected; run `ucp profile init --name agent`', + id: 'active-yaml', + status: 'warn', + detail: `${path} is malformed YAML: ${(err as Error).message}`, } } - try { - await access(profileDir(name, opts), constants.R_OK) - } catch { + if (parsed === null || parsed === undefined) { + return { id: 'active-yaml', status: 'warn', detail: `${path} is empty or truncated` } + } + const active = activeSessionSchema.safeParse(parsed) + if (!active.success) { return { - id: 'active-profile', - status: 'fail', - detail: `profile "${name}" referenced but not found on disk`, + id: 'active-yaml', + status: 'warn', + detail: `${path} does not contain a valid session object`, } } + if (Object.keys(active.data).length === 0) { + return { id: 'active-yaml', status: 'ok', detail: `${path}: no stored selection` } + } + return { id: 'active-yaml', status: 'ok', detail: JSON.stringify(active.data) } +} + +// ─── which Profile, and which document backs each rendering ────────────── +// +// Three shapes, and every remedy below turns on which one is in play, +// because they differ in what the reader can actually change: +// +// managed the bundled release documents, published by Shopify at their +// own URLs. No local file declares them, so the remedies are +// "upgrade ucp-cli" and "report the hosted change" — never +// "upload your profile.json", which for an upgraded legacy +// profile would mean uploading a stale document ucp-cli does not +// even send. +// authored a DIY profile: `profiles//profile.json` IS the +// declaration. `urlOverride` separately says whether its active +// URL came from UCP_AGENT_PROFILE_URL rather than stored metadata. +// adhoc a scalar `UCP_AGENT_PROFILE_URL` pinning ONE bundled rendering +// when no authored body applies. There is no local file to repair +// — only the URL to fix or the override to drop. +type Audit = + | { readonly mode: 'managed' } + | { + readonly mode: 'authored' + readonly name: string + readonly bodyPath: string + readonly metaPath: string + readonly urlOverride: boolean + } + | { readonly mode: 'adhoc' } + +interface ProfileState { + /** The `active-profile` check, whichever way resolution went. */ + check: Check + /** What the network pass audits. Absent when resolution failed. */ + hosted?: { readonly profile: Profile; readonly audit: Audit } +} + +async function resolveProfileState( + opts: ProfileStoreOptions, + env: Record, +): Promise { + let session: ResolvedSession try { - await readUserProfile(name, opts) - return { id: 'active-profile', status: 'ok', detail: `profile "${name}" parsed cleanly` } + session = await resolveSession({ ...opts, env }) } catch (err) { + const envName = nonEmpty(env.UCP_PROFILE) + const activeName = + envName === undefined ? nonEmpty((await readActive(opts)).profile) : undefined + const selection: FailedProfileSelection | undefined = + envName !== undefined + ? { name: envName, source: 'UCP_PROFILE' } + : activeName !== undefined + ? { name: activeName, source: 'active.yaml' } + : undefined + const alternatives = + selection?.source === 'active.yaml' + ? await readableAlternativeProfiles(selection.name, opts) + : [] + return { + check: { + id: 'active-profile', + status: 'fail', + detail: profileResolutionFailureDetail(err, selection, env, alternatives), + }, + } + } + const audit = auditFor(session, opts) + return { check: checkActiveProfile(session, audit), hosted: { profile: session.profile, audit } } +} + +type FailedProfileSelection = + | { readonly name: string; readonly source: 'UCP_PROFILE' } + | { readonly name: string; readonly source: 'active.yaml' } + +function nonEmpty(value: string | undefined): string | undefined { + return value === undefined || value === '' ? undefined : value +} + +async function readableAlternativeProfiles( + selectedName: string, + opts: ProfileStoreOptions, +): Promise { + const names = await listProfiles(opts) + const readable = await Promise.all( + names + .filter((name) => name !== selectedName) + .map(async (name) => { + try { + await readUserProfile(name, { ...opts, migrate: false }) + return name + } catch { + return undefined + } + }), + ) + return readable.filter((name): name is string => name !== undefined) +} + +// ─── remedies: reported, not reconstructed ─────────────────────────────── +// +// A `UcpError.cta` is recovery POLICY, authored where the failure is +// understood: core/profile-store.ts knows which file it could not read and +// therefore whether a custom `profile_url` can survive a re-init; +// core/agent.ts knows an off-version `dev.ucp.*` entry is repaired by +// EDITING the entry, not by rewriting the document. Doctor is a reporter, so +// it formats the carried block instead of re-deriving one from the error's +// shape. Re-deriving is what silently downgraded the snapshot-rule remedy +// ("align every dev.ucp.* entry") into `profile init --force`, which +// discards the very document the operator authored. +// +// The fallback below therefore stays deliberately small: it covers only a +// failure that carries NO cta, and says nothing a `cta` would have said +// better. +function formattedFailureCta(err: unknown): string | undefined { + if (!isUcpError(err) || err.cta === undefined) return undefined + return nonEmpty(formatCta(err.cta)) +} + +// `ucp doctor` is a legitimate cta command elsewhere — it is how a caller +// compares a local document against the URL it is served from. Inside a +// Doctor check it is the command already running, so it is dropped from the +// rendered remedy; the cta's description and its other commands carry the +// repair. Dropping it unconditionally is what keeps doctor from ever +// offering itself as the only next step. +function isRecursiveDoctorCommand(command: string): boolean { + const trimmed = command.trim() + return trimmed === 'ucp doctor' || trimmed.startsWith('ucp doctor ') +} + +// A cta command is either a bare command string or `{command, description}` +// (incur also allows `args`/`options`, which no CTA in this CLI uses — every +// command string here is already fully spelled out). +function ctaCommandText(entry: CtaBlock['commands'][number]): string | undefined { + const command = typeof entry === 'string' ? entry : entry.command + if (isRecursiveDoctorCommand(command)) return undefined + const description = typeof entry === 'string' ? undefined : entry.description + return description === undefined ? `\`${command}\`` : `\`${command}\` (${description})` +} + +function formatCta(cta: CtaBlock): string { + const commands = cta.commands + .map((entry) => ctaCommandText(entry)) + .filter((text): text is string => text !== undefined) + const description = nonEmpty(cta.description?.trim()) + return [ + description === undefined ? undefined : endsSentence(description), + commands.length === 0 ? undefined : `Run ${commands.join(', or ')}.`, + ] + .filter((part): part is string => part !== undefined) + .join(' ') +} + +function endsSentence(text: string): string { + return /[.!?]$/.test(text) ? text : `${text}.` +} + +/** The carried remedy, or the one thing that is true when none was carried. */ +function repairGuidance(err: unknown, name: string): string { + return ( + formattedFailureCta(err) ?? + `Fix the local files for "${name}", or re-create it with \`ucp profile init --name ${name} --force\` (this rewrites its local DIY document and metadata).` + ) +} + +function profileResolutionFailureDetail( + err: unknown, + selection: FailedProfileSelection | undefined, + env: Record, + alternativeProfiles: readonly string[], +): string { + const failure = describeError(err) + const profileUrlOverride = nonEmpty(env.UCP_AGENT_PROFILE_URL) + + // Within resolveSession, INVALID_INPUT after a readable selected Profile (or + // with no selected name) can only come from parsing the env URL override. + // Profile name/store/body failures retain their distinct stable codes and + // therefore continue into the source-specific branches below. + if ( + profileUrlOverride !== undefined && + isUcpError(err) && + err.code === ErrorCodes.INVALID_INPUT + ) { + return `${failure} — UCP_AGENT_PROFILE_URL is set to ${JSON.stringify(profileUrlOverride)}, and that override prevents Profile resolution. Remove it with \`unset UCP_AGENT_PROFILE_URL\`, or fix it to name a valid HTTPS Profile document URL.` + } + + // An invalid NAME is not a repairable local Profile: no file is wrong, the + // selection is. That remedy belongs to the source that supplied the name, + // which is why it stays here rather than being read off the error. + const invalidName = isUcpError(err) && err.code === ErrorCodes.PROFILE_INVALID_NAME + + if (selection?.source === 'UCP_PROFILE') { + const repair = invalidName + ? 'Fix UCP_PROFILE so it names a valid local Profile.' + : repairGuidance(err, selection.name) + return `${failure} — UCP_PROFILE selects local Profile "${selection.name}"; a named Profile is never silently replaced by the managed one. ${repair} Alternatively, remove the env selection with \`unset UCP_PROFILE\`. \`ucp profile use\` cannot override UCP_PROFILE while that environment variable remains set.` + } + + if (selection?.source === 'active.yaml') { + const repair = invalidName + ? 'The selected name is invalid; edit active.yaml or clear it with `ucp profile use --managed`.' + : repairGuidance(err, selection.name) + const alternative = + alternativeProfiles.length === 0 + ? 'inspect other local choices with `ucp profile list`' + : `select another readable local Profile (${alternativeProfiles.map((name) => `\`ucp profile use ${name}\``).join(' or ')})` + return `${failure} — active.yaml selects local Profile "${selection.name}"; a named Profile is never silently replaced by the managed one. ${repair} Otherwise ${alternative}, or return to the Shopify-managed Profile with \`ucp profile use --managed\`.` + } + + const carried = formattedFailureCta(err) + return `${failure} — the Shopify-managed Profile could not be resolved.${carried === undefined ? '' : ` ${carried}`} No profile initialization is required for managed use; upgrade ucp-cli or report this installation failure.` +} + +// Body provenance and URL-override provenance are assigned independently at +// the acquisition seam. Doctor branches on both directly instead of +// re-deriving either from storage kind, URL equality, name, or body. +function auditFor(session: ResolvedSession, opts: ProfileStoreOptions): Audit { + const { profile } = session + if (profile.source === 'managed') return { mode: 'managed' } + if (profile.source === 'url') return { mode: 'adhoc' } + const name = profile.name + if (name === undefined) return { mode: 'adhoc' } + return { + mode: 'authored', + name, + bodyPath: join(profileDir(name, opts), 'profile.json'), + metaPath: join(profileDir(name, opts), 'meta.json'), + urlOverride: profile.urlOverride, + } +} + +/** + * The Profile's renderings in release order. A managed Profile has one per + * installed release; a DIY or ad-hoc Profile has exactly one. + */ +function installedRenderings(profile: Profile): AgentProfile[] { + return SUPPORTED_VERSIONS.map((version) => profile.renderings[version]).filter( + (agent): agent is AgentProfile => agent !== undefined, + ) +} + +function checkActiveProfile(session: ResolvedSession, audit: Audit): Check { + const { profile } = session + const renderings = installedRenderings(profile) + + if (audit.mode === 'managed') { + // Named or not, a managed Profile sends the bundled documents. The name + // only selects `headers.json` and how messages address this identity. + const who = + profile.name === undefined + ? 'no local profile selected, so ucp-cli runs on the Shopify-managed Profile' + : `profile "${profile.name}" is a Shopify-managed Profile` + const local = + profile.name === undefined + ? 'No profile init is required. `ucp profile init` instead creates a release-pinned DIY Profile.' + : 'Its historical local profile.json, when present, is retained for downgrade compatibility but is not what ucp-cli declares.' + return { + id: 'active-profile', + status: 'ok', + detail: `${who}: bundled renderings ${renderings.map((agent) => `UCP ${agent.version}: ${agent.url}`).join('; ')}. A Business selects one of them at negotiation, so all of them are ucp-cli's identity. ${local}`, + } + } + + // A singleton Profile with no rendering cannot happen (both factories + // populate exactly one key) — reported rather than asserted so a future + // regression surfaces as a check instead of a stack trace. + const sole = renderings[0] + if (sole === undefined) { return { id: 'active-profile', status: 'fail', - detail: `profile "${name}" failed to parse: ${(err as Error).message}`, + detail: 'the resolved Profile has no installed rendering — this is a bug in ucp-cli', } } + if (audit.mode === 'authored') { + const detail = audit.urlOverride + ? `profile "${audit.name}" is locally authored (DIY): UCP_AGENT_PROFILE_URL overrides its stored rendering URL, so ucp-cli advertises the exact UCP ${sole.version} body from ${audit.bodyPath} at ${sole.url}. Doctor audits that local authored body against the override URL. A DIY Profile remains a singleton. Upload local profile.json to the override URL after edits, or unset UCP_AGENT_PROFILE_URL to use the stored URL.` + : `profile "${audit.name}" is locally authored (DIY): the UCP ${sole.version} document at ${audit.bodyPath}, advertised at ${sole.url}. A DIY Profile is a singleton — ucp-cli offers that one rendering and no other.` + return { id: 'active-profile', status: 'ok', detail } + } + return { + id: 'active-profile', + status: 'ok', + detail: `UCP_AGENT_PROFILE_URL pins one rendering${profile.name === undefined ? '' : ` (under local profile "${profile.name}")`}: ucp-cli declares the bundled UCP ${sole.version} document at ${sole.url}. Run \`unset UCP_AGENT_PROFILE_URL\` to use the Profile selected by normal session precedence.`, + } } -// ─── Protocol / profile-drift checks ───────────────────────────────────── -// -// Two checks, one fetch, one boundary between them: +// ─── hosted-rendering checks ───────────────────────────────────────────── // -// `protocol` can this URL be used as an identity, and does it serve -// the release ucp-cli will actually send? +// Four ids, one GET per distinct URL, one boundary between them: // -// `profile-drift` does the rest of that document match the local one? +// `protocol` can this URL be used as an identity, and does it +// serve the release ucp-cli renders there? +// `profile-redirect` is the document served without a hop? +// `profile-cache-control` is it cacheable the way UCP requires? +// `profile-drift` does the rest of the document match ours? // // `protocol` is the one that carries correctness, and its severities follow // consequence, not who owns the URL. The request path never reads the wire -// (core/agent.ts): `profile.json` is what ucp-cli declares, at every URL. The -// business, meanwhile, reads the URL. So if the URL cannot be read, or serves -// a different `ucp.version`, the two sides disagree about who we are and -// nothing else in the CLI will ever notice — both are `fail`, and the message -// names both versions. The business GETs this URL on every call and -// hard-fails `-32001 profile_unreachable` if it 404s, so a red `protocol` is -// a prediction of total failure, not a local inconvenience. +// (core/agent.ts): ucp-cli declares a local document and advertises a URL. +// The Business is the side that dereferences that URL — it may cache the +// result, so a broken URL is not always an immediate hard failure, but a URL +// that cannot be read or serves a different `ucp.version` means the two sides +// disagree about who we are and nothing else in the CLI will ever notice. +// Both are `fail`, and the message names both versions. // // `profile-drift` is what remains once the versions agree: a declaration the -// business will act on (it reads the URL) that differs from the one ucp-cli -// plans against. `warn`, because the requests we send are still well-formed -// and only `fail` gates the verdict. -async function checkProtocol( - name: string | undefined, - opts: ProfileStoreOptions, - env: Record, +// Business will act on that differs from the one ucp-cli plans against. +// `warn`, because the requests we send are still well-formed and only `fail` +// gates the verdict. +// +// Aggregation: one Check per id no matter how many renderings were audited. +// Worst severity wins and each line is labelled `UCP :`, so a +// managed Profile reports "which release is broken" instead of forcing +// consumers to learn new per-release check ids. +const HOSTED_CHECK_IDS = [ + 'protocol', + 'profile-redirect', + 'profile-cache-control', + 'profile-drift', +] as const + +type HostedCheckId = (typeof HOSTED_CHECK_IDS)[number] + +interface Verdict { + status: CheckStatus + detail: string +} + +type Probe = { readonly live: LiveAgentProfile } | { readonly error: unknown } + +async function checkHostedRenderings( + profile: Profile, + audit: Audit, fetchImpl: typeof fetch, ): Promise { - if (name === undefined || name === '') { - return [{ id: 'protocol', status: 'warn', detail: 'no local profile; skipped' }] + const parts: Record = { + protocol: [], + 'profile-redirect': [], + 'profile-cache-control': [], + 'profile-drift': [], } - let local: Awaited> - try { - local = await readUserProfile(name, opts) - } catch { - // Already a `fail` on `active-profile`; no second voice on the same fault. - return [{ id: 'protocol', status: 'warn', detail: 'profile unreadable; skipped' }] - } - // Same precedence resolveSession uses, minus the --profile-url flag (doctor - // reports the persistent configuration, not one invocation's override). - const url = - env.UCP_AGENT_PROFILE_URL ?? local.meta.profile_url ?? RELEASES[LATEST].defaultAgentProfileUrl - // Both paths are named in the remedies below, because the two files answer - // different questions: `profile.json` is what ucp-cli declares, - // `meta.profile_url` is where the business goes to read it. - const metaPath = join(profileDir(name, opts), 'meta.json') - const bodyPath = join(profileDir(name, opts), 'profile.json') - - // What the request path will actually use — the same call `discover` makes, - // no network. Its failures are the ones every command would hit, so they - // are reported before anything is fetched. - let wire: AgentProfile - try { - wire = await resolveAgentProfile({ url, name, ...opts }) - } catch (err) { - return [ - { - id: 'protocol', - status: 'fail', - detail: `${describeError(err)} — ${bodyPath} is the document ucp-cli negotiates from, so every command fails until it is fixed.`, - }, - ] + const record = (id: HostedCheckId, version: Version, verdict: Verdict): void => { + parts[id].push({ status: verdict.status, detail: `UCP ${version}: ${verdict.detail}` }) + } + + // Start one GET per distinct URL before awaiting any of them. The installed + // rendering count bounds this fan-out; retaining release order here keeps + // aggregation deterministic even when the probes settle out of order. + const probesByUrl = new Map>() + const renderings = installedRenderings(profile).map((agent) => { + let probe = probesByUrl.get(agent.url) + if (probe === undefined) { + probe = probeRendering(agent, fetchImpl) + probesByUrl.set(agent.url, probe) + } + return { agent, probe } + }) + + for (const { agent, probe: pendingProbe } of renderings) { + const probe = await pendingProbe + if ('error' in probe) { + // A refused redirect reports as `profile-redirect` and nothing else. It + // is one fault with one remedy, and a `protocol` line beside it would + // be a second, vaguer voice on the same GET. The verdict is identical + // either way: both are `fail`. + const redirect = agentProfileRedirect(probe.error) + if (redirect !== undefined) { + record('profile-redirect', agent.version, redirectVerdict(redirect, agent, audit)) + continue + } + // `fail`, not `warn`: the Business dereferences this URL to negotiate + // with us. If it cannot be read, the Business cannot read it either. + // + // The detail must separate the sub-causes, because they have different + // remedies. `fetchAgentProfileLive` already names them: + // AGENT_PROFILE_UNREACHABLE carries `(network: …)` (DNS/TLS/connect/ + // timeout), `(http_status: HTTP …)`, or `(not_json: …)` in its message, + // and a document that parsed but is not a usable identity arrives under + // a distinct code (AGENT_PROFILE_SCHEMA_INVALID / _VERSION_UNSUPPORTED / + // _VERSION_MISMATCH). So `code: message` is the full discrimination; + // re-deriving it here would be a second copy that can drift. + record('protocol', agent.version, { status: 'fail', detail: describeError(probe.error) }) + continue + } + + const { live } = probe + record('profile-redirect', agent.version, { + status: 'ok', + detail: `${agent.url} serves the document itself, with no redirect`, + }) + record( + 'profile-cache-control', + agent.version, + cacheControlVerdict(live.cacheControl, agent.url), + ) + + // THE check. Both sides read a document; they must agree on its version + // or we are negotiating as somebody the Business does not see. One voice + // per fault: when the versions disagree, drift stays quiet. + if (live.agent.version !== agent.version) { + record('protocol', agent.version, versionMismatchVerdict(agent, live, audit)) + continue + } + record('protocol', agent.version, agreementVerdict(agent, audit)) + record('profile-drift', agent.version, driftVerdict(agent, live, audit)) } - let live: Awaited> + return HOSTED_CHECK_IDS.map((id) => aggregate(id, parts[id])).filter( + (check): check is Check => check !== undefined, + ) +} + +async function probeRendering(agent: AgentProfile, fetchImpl: typeof fetch): Promise { try { - live = await fetchAgentProfileLive({ url, name, fetch: fetchImpl }) - } catch (err) { - // A refused redirect reports as `profile-redirect` and nothing else. It is - // one fault with one remedy, and `protocol` beside it would be a second, - // vaguer voice on the same fetch — the rule applied to `active-profile` - // above. The verdict is identical either way: both are `fail`. - const redirect = agentProfileRedirect(err) - if (redirect !== undefined) return [checkRedirect(redirect, url, metaPath)] - // `fail`, not `warn`: the business GETs this URL to negotiate with us. If - // it cannot be read, the business cannot read it either. - // - // The detail must separate the sub-causes, because they have different - // remedies. `fetchAgentProfileLive` already names them: - // AGENT_PROFILE_UNREACHABLE carries `(network: …)` (DNS/TLS/connect/ - // timeout), `(http_status: HTTP …)`, or `(not_json: …)` in its message, - // and a document that parsed but is not a usable identity arrives under a - // distinct code (AGENT_PROFILE_SCHEMA_INVALID / _VERSION_UNSUPPORTED / - // _VERSION_MISMATCH). So `code: message` is the full discrimination; - // re-deriving it here would be a second copy that can drift. - return [{ id: 'protocol', status: 'fail', detail: describeError(err) }] - } - - // THE check. Both sides read a document; they must agree on its version or - // we are negotiating as somebody the business does not see. - if (live.agent.version !== wire.version) { - return [ - { - id: 'protocol', - status: 'fail', - detail: `${url} serves UCP ${live.agent.version}, but ucp-cli negotiates as UCP ${wire.version} — the version in ${bodyPath}, the document it declares. The business fetches the URL, so it reads ${live.agent.version} while ucp-cli speaks ${wire.version}. Make them agree: \`ucp profile init --name ${name} --version ${live.agent.version} --force\` REWRITES ${bodyPath} as the published UCP ${live.agent.version} document (discarding local edits); or upload ${bodyPath} to ${url} when that URL is yours; or point meta.profile_url (${metaPath}) at a URL serving UCP ${wire.version}.`, - }, - ] + return { + live: await fetchAgentProfileLive({ + url: agent.url, + source: agent.source, + urlOverride: agent.urlOverride, + ...(agent.name !== undefined ? { name: agent.name } : {}), + fetch: fetchImpl, + }), + } + } catch (error) { + return { error } } +} - const latest = wire.version === LATEST - const checks: Check[] = [ - { - id: 'protocol', - status: 'ok', - detail: [ - `profile "${name}" uses UCP ${wire.version}`, - `from ${bodyPath}, sent as ${url},`, - `which serves the same version (checked live);`, - `ucp-cli supports ${SUPPORTED_VERSIONS.join(', ')} —`, - // An older supported release is VALID, not a problem: ucp-cli supports - // a set of releases, not a floor. So this is a note with a command, - // never a warn — a red doctor for a deliberately pinned profile would - // train users to ignore the output. - latest - ? 'this is the latest.' - : `NOT the latest (${LATEST}). Still fully supported; to move, \`ucp profile init --name ${name} --version ${LATEST} --force\` REWRITES ${bodyPath} as the published UCP ${LATEST} document (discarding local edits) and points meta.profile_url (${metaPath}) at ${RELEASES[LATEST].defaultAgentProfileUrl}.`, - ].join(' '), - }, - ] +/** + * Collapse per-rendering verdicts into the single Check that id owns. An id + * nobody had anything to say about is OMITTED rather than reported green: a + * redirecting URL produces no `protocol` line, and inventing an `ok` for a + * document we never read would be the false green this whole module exists + * to avoid. + */ +function aggregate(id: HostedCheckId, verdicts: Verdict[]): Check | undefined { + if (verdicts.length === 0) return undefined + const status: CheckStatus = verdicts.some((v) => v.status === 'fail') + ? 'fail' + : verdicts.some((v) => v.status === 'warn') + ? 'warn' + : 'ok' + return { id, status, detail: verdicts.map((v) => v.detail).join('\n') } +} - checks.push({ - id: 'profile-redirect', - status: 'ok', - detail: `${url} serves the document itself, with no redirect`, - }) - checks.push(checkCacheControl(live.cacheControl, url)) - - // Deep equality on parsed JSON, not bytes: `profile init` re-serializes the - // published document with its own indentation, so a byte compare would flag - // every clean install. Key ORDER differences are likewise not drift — the - // documents are JSON objects, and no UCP reader depends on member order. - // `wire.body` rather than the store's copy: it is the exact object - // negotiation runs against, parsed by the same release schema as the fetched - // one, so neither side of the comparison can pick up a stray default. - if (deepEqual(wire.body as unknown, live.agent.body as unknown)) { - checks.push({ - id: 'profile-drift', +/** `protocol` ok: the URL is readable and serves the release we render there. */ +function agreementVerdict(agent: AgentProfile, audit: Audit): Verdict { + if (audit.mode === 'managed') { + return { status: 'ok', - detail: `local profile.json matches ${url}`, - }) - } else { - checks.push({ - id: 'profile-drift', - status: 'warn', - detail: `${url} serves a document that differs from ${bodyPath} beyond ucp.version (the versions agree). The business acts on what that URL serves; ucp-cli plans against the local file — so a capability you added locally is not one the business will grant, and one it grants is not one ucp-cli will use. Upload ${bodyPath} to ${url} when that URL is yours — ucp-cli has no command that writes to a URL. Otherwise copy what that URL serves into ${bodyPath} (\`ucp profile init --name ${name} --version ${wire.version} --force\` does it for a published release document).`, - }) + detail: `${agent.url} serves the bundled UCP ${agent.version} document ucp-cli offers (checked live)`, + } + } + if (audit.mode === 'adhoc') { + return { + status: 'ok', + detail: `${agent.url} serves UCP ${agent.version}, the bundled document ucp-cli declares there (checked live); UCP_AGENT_PROFILE_URL pins this one rendering`, + } + } + if (audit.urlOverride) { + return { + status: 'ok', + detail: `profile "${audit.name}" uses the locally authored UCP ${agent.version} body at ${audit.bodyPath}; UCP_AGENT_PROFILE_URL advertises it at ${agent.url}, which serves the same version (checked live). Upload local profile.json to the override URL after edits, or unset UCP_AGENT_PROFILE_URL to use the stored URL.`, + } + } + const latest = agent.version === LATEST + return { + status: 'ok', + detail: [ + `profile "${audit.name}" uses UCP ${agent.version}`, + `from ${audit.bodyPath}, sent as ${agent.url},`, + `which serves the same version (checked live);`, + `ucp-cli supports ${SUPPORTED_VERSIONS.join(', ')} —`, + // An older supported release is VALID, not a problem: ucp-cli supports + // a set of releases, not a floor. So this is a note with a command, + // never a warn — a red doctor for a deliberately pinned profile would + // train users to ignore the output. + latest + ? 'this is the latest.' + : `NOT the latest (${LATEST}). Still fully supported; to move, \`ucp profile init --name ${audit.name} --version ${LATEST} --force\` REWRITES ${audit.bodyPath} as the published UCP ${LATEST} document (discarding local edits). A custom meta.profile_url (${audit.metaPath}) is preserved and must serve the rewritten document; an existing Shopify release-default URL rotates to ${RELEASES[LATEST].defaultAgentProfileUrl}.`, + ].join(' '), + } +} + +/** `protocol` fail: the URL is readable but serves another release. */ +function versionMismatchVerdict( + agent: AgentProfile, + live: LiveAgentProfile, + audit: Audit, +): Verdict { + const served = live.agent.version + if (audit.mode === 'managed') { + // No local document declares a managed rendering, so nothing the reader + // can upload fixes this — and the stale profile.json an upgraded legacy + // profile still has on disk is exactly the wrong thing to suggest. + return { + status: 'fail', + detail: `${agent.url} serves UCP ${served}, but that URL is where ucp-cli's bundled UCP ${agent.version} rendering is published — a Business that selects UCP ${agent.version} reads UCP ${served} instead, so that rendering can never negotiate. ucp-cli sends the bundled document, so there is nothing local to change: upgrade ucp-cli (\`npm install -g @shopify/ucp-cli@latest\`) to pick up a build whose snapshot matches, and report the change if that URL is meant to be frozen at UCP ${agent.version}.`, + } + } + if (audit.mode === 'adhoc') { + return { + status: 'fail', + detail: `${agent.url} serves UCP ${served}, but UCP_AGENT_PROFILE_URL pins ucp-cli to the bundled UCP ${agent.version} document — the Business reads UCP ${served} while ucp-cli negotiates as UCP ${agent.version}. Serve the UCP ${agent.version} document at that URL, or unset UCP_AGENT_PROFILE_URL to use the published rendering at ${RELEASES[agent.version].defaultAgentProfileUrl}.`, + } + } + if (audit.urlOverride) { + return { + status: 'fail', + detail: `${agent.url} serves UCP ${served}, but the locally authored profile.json at ${audit.bodyPath} declares UCP ${agent.version}; UCP_AGENT_PROFILE_URL makes that override URL active, so the Business and ucp-cli see different versions. Upload local profile.json to the override URL ${agent.url} (from ${audit.bodyPath}), or unset UCP_AGENT_PROFILE_URL to use the stored rendering URL.`, + } + } + return { + status: 'fail', + detail: `${agent.url} serves UCP ${served}, but ucp-cli negotiates as UCP ${agent.version} — the version in ${audit.bodyPath}, the document it declares. The Business reads the URL, so it sees UCP ${served} while ucp-cli speaks UCP ${agent.version}. Make them agree: \`ucp profile init --name ${audit.name} --version ${served} --force\` REWRITES ${audit.bodyPath} as the published UCP ${served} document (discarding local edits); or upload ${audit.bodyPath} to ${agent.url} when that URL is yours; or point meta.profile_url (${audit.metaPath}) at a URL serving UCP ${agent.version}.`, } - return checks +} + +// Deep equality on parsed JSON, not bytes: `profile init` re-serializes the +// published document with its own indentation, so a byte compare would flag +// every clean install. Key ORDER differences are likewise not drift — the +// documents are JSON objects, and no UCP reader depends on member order. +// `agent.body` rather than a re-read of the store: it is the exact object +// negotiation runs against (bundled for managed/ad-hoc, the local file for +// DIY), parsed by the same release schema as the fetched one, so neither +// side of the comparison can pick up a stray default. +function driftVerdict(agent: AgentProfile, live: LiveAgentProfile, audit: Audit): Verdict { + const matches = deepEqual(agent.body as unknown, live.agent.body as unknown) + if (audit.mode === 'managed') { + return matches + ? { status: 'ok', detail: `${agent.url} matches the bundled UCP ${agent.version} document` } + : { + status: 'warn', + detail: `${agent.url} serves a document that differs from the bundled UCP ${agent.version} rendering ucp-cli declares (the versions agree). A Business acts on what that URL serves while ucp-cli plans against the bundled copy, so a capability one side has is not one the other will use. No local file declares this document — upgrade ucp-cli to pick up a refreshed snapshot, and report the drift if that URL is meant to be frozen at UCP ${agent.version}.`, + } + } + if (audit.mode === 'adhoc') { + return matches + ? { + status: 'ok', + detail: `${agent.url} matches the bundled UCP ${agent.version} document ucp-cli declares there`, + } + : { + status: 'warn', + detail: `${agent.url} serves a document that differs from the bundled UCP ${agent.version} document ucp-cli declares there (the versions agree). Serve the bundled document published at ${RELEASES[agent.version].defaultAgentProfileUrl} at the URL you pinned, or unset UCP_AGENT_PROFILE_URL to use that published rendering directly.`, + } + } + if (audit.urlOverride) { + return matches + ? { + status: 'ok', + detail: `local profile.json at ${audit.bodyPath} matches the active UCP_AGENT_PROFILE_URL override ${agent.url}`, + } + : { + status: 'warn', + detail: `${agent.url} serves a document that differs from the locally authored profile.json at ${audit.bodyPath} beyond ucp.version (the versions agree). The Business acts on the override URL while ucp-cli plans against the editable local body. Upload local profile.json to the override URL ${agent.url} (from ${audit.bodyPath}), or unset UCP_AGENT_PROFILE_URL to use the stored rendering URL.`, + } + } + return matches + ? { status: 'ok', detail: `local profile.json matches ${agent.url}` } + : { + status: 'warn', + detail: `${agent.url} serves a document that differs from ${audit.bodyPath} beyond ucp.version (the versions agree). The business acts on what that URL serves; ucp-cli plans against the local file — so a capability you added locally is not one the business will grant, and one it grants is not one ucp-cli will use. Choose which document is authoritative: upload ${audit.bodyPath} to ${agent.url} when that URL is yours; edit ${audit.bodyPath} to match what the URL serves; or point meta.profile_url (${audit.metaPath}) at a URL that serves the local document.`, + } } /** `code: message` for a UcpError, plain message otherwise. */ @@ -366,16 +792,16 @@ function describeError(err: unknown): string { return isUcpError(err) ? `${err.code}: ${err.message}` : (err as Error).message } -// Hosting rule 2 on the profile URL — the pair to checkCacheControl's rule 3 -// below, reported from the same single GET. +// Hosting rule 2 on the profile URL — the pair to cacheControlVerdict's rule +// 3 below, reported from the same single GET. // // UCP overview §"Profile Requirements / Hosting": profile endpoints MUST NOT // use redirects (3xx), repeated in the fetching rules for every URL an // exchange dereferences. // -// Who hits it, precisely: doctor is the only part of ucp-cli that fetches this -// URL (fetchAgentProfileLive), and it refused the hop itself via `ucpFetch`. -// Commerce requests do not fetch it — they advertise it in +// Who hits it, precisely: doctor is the only part of ucp-cli that fetches +// this URL (fetchAgentProfileLive), and it refused the hop itself via +// `ucpFetch`. Commerce requests do not fetch it — they advertise it in // `meta.ucp-agent.profile`, and a conforming business dereferencing it is // independently bound by the same MUST NOT, so it cannot resolve the agent // identity and the request cannot negotiate. Local commands (`profile list`, @@ -384,22 +810,37 @@ function describeError(err: unknown): string { // `fail`, unlike the cache-control advisory: a redirecting profile URL is // unreadable to the business the URL exists for, and only `fail` gates // doctor's verdict. -function checkRedirect(redirect: RefusedRedirect, url: string, metaPath: string): Check { +function redirectVerdict(redirect: RefusedRedirect, agent: AgentProfile, audit: Audit): Verdict { const spec = 'UCP forbids redirects (3xx) on published profiles' const target = redirect.location === null ? 'no `Location` header' : `\`Location: ${redirect.location}\`` - const destination = usableRedirectTarget(url, redirect.location) - const remedy = - destination !== null - ? `Serve the document at ${url} itself, or point meta.profile_url (${metaPath}) at ${destination} once that URL serves the document.` - : redirect.location === null - ? `Serve the document at ${url} itself, or point meta.profile_url (${metaPath}) at a URL that does.` - : `Serve the document at ${url} itself, over https. Profile URLs are https, so the target named here cannot go in meta.profile_url (${metaPath}) either.` return { - id: 'profile-redirect', status: 'fail', - detail: `${url} answers HTTP ${redirect.status} with ${target} — ${spec}. Doctor is the only part of ucp-cli that fetches this URL, and it refused the hop rather than following it; commerce requests only advertise the URL, and a conforming business dereferencing it is bound by the same rule, so it cannot resolve your identity and those requests cannot negotiate. Local profile commands (\`ucp profile list\`, \`ucp profile show\`) are unaffected. ${remedy}`, + detail: `${agent.url} answers HTTP ${redirect.status} with ${target} — ${spec}. Doctor is the only part of ucp-cli that fetches this URL, and it refused the hop rather than following it; commerce requests only advertise the URL, and a conforming business dereferencing it is bound by the same rule, so it cannot resolve your identity and those requests cannot negotiate. Local profile commands (\`ucp profile list\`, \`ucp profile show\`) are unaffected. ${redirectRemedy(redirect, agent, audit)}`, + } +} + +function redirectRemedy(redirect: RefusedRedirect, agent: AgentProfile, audit: Audit): string { + const destination = usableRedirectTarget(agent.url, redirect.location) + if (audit.mode === 'managed') { + // That URL is Shopify's published rendering, so "serve it yourself" is + // not a remedy the reader has. Naming the hop still matters: it is what + // gets reported. + return `That URL publishes ucp-cli's bundled UCP ${agent.version} rendering, so it is not yours to serve: upgrade ucp-cli in case a newer build renders that release elsewhere, and report the redirect.` + } + // Profile URLs are https, so an http `Location` can never be the URL to + // advertise — the remedy must not offer it. + const advertise = + audit.mode === 'authored' && !audit.urlOverride + ? (url: string) => `point meta.profile_url (${audit.metaPath}) at ${url}` + : (url: string) => `point UCP_AGENT_PROFILE_URL at ${url}` + if (destination !== null) { + return `Serve the document at ${agent.url} itself, or ${advertise(destination)} once that URL serves the document.` + } + if (redirect.location === null) { + return `Serve the document at ${agent.url} itself, or ${advertise('a URL that does')}.` } + return `Serve the document at ${agent.url} itself, over https. Profile URLs are https, so the target named here cannot be advertised either.` } // Hosting advisory on the profile URL. @@ -407,34 +848,31 @@ function checkRedirect(redirect: RefusedRedirect, url: string, metaPath: string) // UCP overview §"Profile Requirements / Hosting": published artifacts MUST // carry `Cache-Control: public` with `max-age` of at least 60 seconds and // MUST NOT be served `private`/`no-store`/`no-cache`. That rule exists -// because merchants fetch this URL per request; a document served -// uncacheable turns every one of your requests into an extra origin hit on -// that host, and is the first thing to look at when a merchant rate-limits +// because a Business dereferences this URL to negotiate and caches what it +// gets; a document served uncacheable turns that into an origin hit per +// exchange, and is the first thing to look at when a merchant rate-limits // discovery. // // `warn`, not `fail`: it degrades the merchant's fetch pattern, not this // install's ability to transact, and doctor's `ok` gates CI. Reported for // every URL — the header is a fact about the identity this profile presents, // and reporting it only sometimes would make its absence ambiguous. -function checkCacheControl(cacheControl: string | null, url: string): Check { - const id = 'profile-cache-control' +function cacheControlVerdict(cacheControl: string | null, url: string): Verdict { const spec = 'UCP requires `Cache-Control: public, max-age>=60` on published profiles' if (cacheControl === null) { - return { id, status: 'warn', detail: `${url} serves no Cache-Control header — ${spec}.` } + return { status: 'warn', detail: `${url} serves no Cache-Control header — ${spec}.` } } const cc = cacheControl.toLowerCase() const forbidden = ['no-store', 'no-cache', 'private'].filter((d) => cc.includes(d)) if (forbidden.length > 0) { return { - id, status: 'warn', - detail: `${url} serves \`Cache-Control: ${cacheControl}\` — ${forbidden.join(', ')} forbids shared caching, so every merchant refetches your profile on every request. ${spec}.`, + detail: `${url} serves \`Cache-Control: ${cacheControl}\` — ${forbidden.join(', ')} forbids shared caching, so a merchant cannot reuse a cached copy and refetches your profile every time it needs your identity. ${spec}.`, } } const maxAge = /max-age\s*=\s*(\d+)/.exec(cc) if (maxAge === null) { return { - id, status: 'warn', detail: `${url} serves \`Cache-Control: ${cacheControl}\` with no max-age — ${spec}.`, } @@ -442,19 +880,17 @@ function checkCacheControl(cacheControl: string | null, url: string): Check { const seconds = Number(maxAge[1]) if (seconds < MIN_CACHE_SECONDS) { return { - id, status: 'warn', detail: `${url} serves max-age=${seconds}, below the ${MIN_CACHE_SECONDS}s floor — ${spec}.`, } } if (!cc.includes('public')) { return { - id, status: 'warn', detail: `${url} serves \`Cache-Control: ${cacheControl}\` — max-age is fine but the directive is not marked \`public\`. ${spec}.`, } } - return { id, status: 'ok', detail: `${url} serves \`Cache-Control: ${cacheControl}\`` } + return { status: 'ok', detail: `${url} serves \`Cache-Control: ${cacheControl}\`` } } // Structural equality for parsed JSON. `JSON.stringify` comparison would make diff --git a/src/cli/profile-hint.test.ts b/src/cli/profile-hint.test.ts index ab80c4d..f8ac7c6 100644 --- a/src/cli/profile-hint.test.ts +++ b/src/cli/profile-hint.test.ts @@ -1,12 +1,13 @@ // The switch-profiles hint for PROTOCOL_VERSION_INCOMPATIBLE. // -// The rule under test is where a local profile's version comes from: -// `profile.json`, exactly like request-time negotiation. The end-to-end wire -// assertions live in `src/cli-errors.test.ts`; this suite pins the derivation -// itself so the profile URL cannot override the local declaration. +// The rule under test is where local candidates get their eligibility: a DIY +// Profile contributes its one profile.json release; a managed alias contributes +// every installed rendering regardless of its retained body. End-to-end wire +// assertions live in `src/cli-errors.test.ts`. import { describe, expect, it } from 'vitest' +import type { ProfileKind } from '../core/legacy-profile.js' import type { UserProfile } from '../core/profile-store.js' import { LATEST, RELEASES, type Version } from '../core/releases.js' import { userProfile } from '../test-utils.js' @@ -18,6 +19,7 @@ const URL_0825 = RELEASES['2026-08-25'].defaultAgentProfileUrl interface StoredProfile { version: Version profileUrl?: string + kind?: ProfileKind } function profileBody(version: Version): UserProfile['body'] { @@ -33,13 +35,14 @@ function store(profiles: Record) { return userProfile(name, { body: profileBody(profile.version), meta: profile.profileUrl === undefined ? {} : { profile_url: profile.profileUrl }, + kind: profile.kind ?? 'diy', }) }, } } describe('localProfilesSpeaking', () => { - it('reads the version from profile.json even when the URL implies another release', async () => { + it('reads a DIY version from profile.json even when the URL implies another release', async () => { const matches = await localProfilesSpeaking( ['2026-04-08'], 'agent', @@ -48,7 +51,7 @@ describe('localProfilesSpeaking', () => { 'agent-0408': { version: '2026-04-08', profileUrl: URL_0825 }, }), ) - expect(matches).toStrictEqual([{ name: 'agent-0408', version: '2026-04-08' }]) + expect(matches).toStrictEqual([{ name: 'agent-0408', kind: 'diy', version: '2026-04-08' }]) }) it('excludes the active profile — it is the one that just failed', async () => { @@ -81,7 +84,7 @@ describe('localProfilesSpeaking', () => { mine: { version: '2026-04-08', profileUrl: 'https://you.example/agent.json' }, }), ) - expect(matches).toStrictEqual([{ name: 'mine', version: '2026-04-08' }]) + expect(matches).toStrictEqual([{ name: 'mine', kind: 'diy', version: '2026-04-08' }]) }) it('includes a matching profile with no profile_url', async () => { @@ -93,7 +96,7 @@ describe('localProfilesSpeaking', () => { deferred: { version: '2026-04-08' }, }), ) - expect(matches).toStrictEqual([{ name: 'deferred', version: '2026-04-08' }]) + expect(matches).toStrictEqual([{ name: 'deferred', kind: 'diy', version: '2026-04-08' }]) }) it('is best-effort: an unreadable profile is skipped, not fatal', async () => { @@ -106,10 +109,52 @@ describe('localProfilesSpeaking', () => { return userProfile(name, { body: profileBody('2026-04-08'), meta: { profile_url: URL_0408 }, + kind: 'diy', }) }, }) - expect(matches).toStrictEqual([{ name: 'agent-0408', version: '2026-04-08' }]) + expect(matches).toStrictEqual([{ name: 'agent-0408', kind: 'diy', version: '2026-04-08' }]) + }) + + it('treats a managed alias as every installed rendering, not its retained body version', async () => { + const matches = await localProfilesSpeaking( + ['2026-04-08'], + 'agent', + store({ + agent: { version: '2026-08-25' }, + legacy: { version: '2026-08-25', kind: 'managed' }, + }), + ) + + expect(matches).toStrictEqual([{ name: 'legacy', kind: 'managed', version: '2026-04-08' }]) + }) + + it('a managed alias selects the newest mutual installed rendering', async () => { + const matches = await localProfilesSpeaking( + ['2026-04-08', '2026-08-25'], + 'agent', + store({ legacy: { version: '2026-04-08', kind: 'managed' } }), + ) + + expect(matches).toStrictEqual([{ name: 'legacy', kind: 'managed', version: '2026-08-25' }]) + }) + + it('classifies alternative Profiles through the read-only migration seam', async () => { + const options: unknown[] = [] + const matches = await localProfilesSpeaking(['2026-04-08'], 'active', { + listProfiles: async () => ['legacy'], + readUserProfile: async (name, opts) => { + options.push(opts) + return userProfile(name, { + body: profileBody('2026-04-08'), + meta: {}, + kind: 'managed', + }) + }, + }) + + expect(matches).toEqual([{ name: 'legacy', kind: 'managed', version: '2026-04-08' }]) + expect(options).toEqual([{ migrate: false }]) }) it('survives a profile store that cannot be listed at all', async () => { @@ -124,16 +169,22 @@ describe('localProfilesSpeaking', () => { }) describe('buildProfileSwitchCta', () => { - it('is undefined with no matches — an empty CTA is worse than none', () => { - expect(buildProfileSwitchCta([], { command: 'discover', displayName: 'ucp' })).toBeUndefined() + it('is undefined when neither managed nor a local Profile supports the offer', () => { + expect( + buildProfileSwitchCta([], ['2026-12-01'], { + command: 'discover', + displayName: 'ucp', + }), + ).toBeUndefined() }) it('names every match, because which one to use depends on what else it declares', () => { const cta = buildProfileSwitchCta( [ - { name: 'agent-0408', version: '2026-04-08' }, - { name: 'legacy', version: '2026-04-08' }, + { name: 'agent-0408', kind: 'diy', version: '2026-04-08' }, + { name: 'legacy', kind: 'diy', version: '2026-04-08' }, ], + ['2026-04-08'], { command: 'catalog search', displayName: 'ucp' }, ) expect(cta?.description).toContain("'agent-0408' speaks 2026-04-08") @@ -141,17 +192,39 @@ describe('buildProfileSwitchCta', () => { // `Cta` is incur's generic command type (a string or a {command,...} // object); the hint always emits the object form. expect(cta?.commands.map((c) => (typeof c === 'string' ? c : c.command))).toStrictEqual([ + 'ucp profile use --managed', 'ucp catalog search --profile agent-0408', 'ucp catalog search --profile legacy', ]) }) - it('explains that switching profiles is the version switch, with no reinstall', () => { - const cta = buildProfileSwitchCta([{ name: 'other', version: LATEST }], { + it('offers managed and explains how explicit Profile selection must be removed', () => { + const cta = buildProfileSwitchCta([{ name: 'other', kind: 'diy', version: LATEST }], [LATEST], { command: 'discover', displayName: 'ucp', }) - expect(cta?.description).toMatch(/ACTIVE profile/) + expect(cta?.description).toContain('managed Profile offers every installed rendering') + expect(cta?.description).toContain('newest mutual UCP') + expect(cta?.description).toContain('without an explicit --profile') + expect(cta?.description).toContain('UCP_PROFILE unset') + expect(cta?.description).toContain( + 'Both override active.yaml; leaving either pointed at the DIY Profile would keep it active', + ) + expect(cta?.description).not.toContain('either selection would keep the DIY Profile active') expect(cta?.description).toMatch(/No reinstall/) }) + + it('describes a matching managed alias by newest mutual selection', () => { + const cta = buildProfileSwitchCta( + [{ name: 'legacy', kind: 'managed', version: '2026-04-08' }], + ['2026-04-08'], + { command: 'discover', displayName: 'ucp' }, + ) + + expect(cta?.description).toContain("'legacy' is managed") + expect(cta?.description).toContain('selects newest mutual UCP 2026-04-08') + expect(cta?.commands.map((c) => (typeof c === 'string' ? c : c.command))).toContain( + 'ucp discover --profile legacy', + ) + }) }) diff --git a/src/cli/profile-hint.ts b/src/cli/profile-hint.ts index 4880923..b626daa 100644 --- a/src/cli/profile-hint.ts +++ b/src/cli/profile-hint.ts @@ -1,19 +1,20 @@ // The `PROTOCOL_VERSION_INCOMPATIBLE` recovery hint. // -// When the business does not offer the version the ACTIVE profile speaks, the -// remedy is either "upgrade the CLI" (the business is outside our window) or -// "switch profiles" (some other local profile speaks a version it offers). -// Only the CLI layer can tell those apart: `core/profile.ts` knows the two -// version sets but not what profiles exist on this machine, so it throws with -// `context.offered` and this module supplies the switchable names. +// This hint applies only when the active body source is a singleton DIY +// Profile AND its URL is not explicitly overridden. The virtual managed +// Profile is then an alternative whenever the +// Business offers an installed release, and matching local Profiles can be +// retried directly by name. A managed local alias is eligible for every +// installed rendering; a DIY candidate is eligible only for its body version. // -// A local profile's version comes from `profile.json`, the same document the -// request path uses for negotiation. The profile URL is deliberately irrelevant -// here: `ucp doctor` separately reports any disagreement between the file and -// what that URL serves. +// Managed runtime failures never scan local aliases: managed already offered +// every installed rendering. Any explicit URL override never suggests a +// profile-name switch either, even with a DIY body: +// --profile-url/UCP_AGENT_PROFILE_URL outranks that switch. +import type { ProfileKind } from '../core/legacy-profile.js' import type { listProfiles, readUserProfile } from '../core/profile-store.js' -import { isSupportedVersion, type Version } from '../core/releases.js' +import { isSupportedVersion, SUPPORTED_VERSIONS, type Version } from '../core/releases.js' import type { CtaBlock } from '../lib/types.js' export interface ProfileHintDeps { @@ -23,13 +24,20 @@ export interface ProfileHintDeps { export interface ProfileVersionCandidate { name: string + kind: ProfileKind + /** DIY body version, or the newest installed Business-offered version for managed. */ version: Version } +function newestInstalledOffered(offered: readonly string[]): Version | undefined { + return SUPPORTED_VERSIONS.filter((version) => offered.includes(version)).at(-1) +} + /** - * Local profiles (excluding `activeName`) whose `profile.json` declares a - * version in `offered`. Best-effort: an unreadable profile is skipped, never - * fatal — this decorates an error that has already happened. + * Local profiles (excluding `activeName`) that can negotiate an `offered` + * release. A managed alias carries every installed rendering; a DIY Profile + * carries only its body version. Best-effort: an unreadable Profile is skipped, + * never fatal — this decorates an error that has already happened. */ export async function localProfilesSpeaking( offered: readonly string[], @@ -45,39 +53,77 @@ export async function localProfilesSpeaking( const matches: ProfileVersionCandidate[] = [] for (const name of names) { if (name === activeName) continue - let version: string + let kind: ProfileKind + let version: string | undefined try { - version = (await deps.readUserProfile(name)).body.ucp.version + const profile = await deps.readUserProfile(name, { migrate: false }) + kind = profile.kind + version = + profile.kind === 'managed' ? newestInstalledOffered(offered) : profile.body.ucp.version } catch { continue } - if (!isSupportedVersion(version) || !offered.includes(version)) continue - matches.push({ name, version }) + if (version === undefined || !isSupportedVersion(version) || !offered.includes(version)) + continue + matches.push({ name, kind, version }) } return matches.sort((a, b) => a.name.localeCompare(b.name)) } /** - * Build the switch-profiles CTA for a `PROTOCOL_VERSION_INCOMPATIBLE`. - * `undefined` when no local profile qualifies — in that case the error's own - * message ("… offers … / ucp-cli supports …") already carries the only - * available remedy, and an empty CTA would be worse than none. + * Build the recovery CTA for a non-URL-overridden singleton DIY + * `PROTOCOL_VERSION_INCOMPATIBLE`. The caller gates URL precedence before + * invoking this helper. `undefined` when neither the virtual + * managed Profile nor a local Profile can negotiate a Business-offered + * installed release. * - * ALL matches are named, not just the first: which one to use depends on what - * else that profile declares (services and capabilities), and picking for the - * user hides the choice. + * ALL local matches are named, not just the first: which one to use depends on + * what else that Profile declares (services and capabilities), and picking for + * the user hides the choice. */ export function buildProfileSwitchCta( matches: readonly ProfileVersionCandidate[], + offered: readonly string[], context: { command: string; displayName: string }, ): CtaBlock | undefined { - if (matches.length === 0) return undefined - const summary = matches.map((m) => `'${m.name}' speaks ${m.version}`).join(', ') + const managedVersion = newestInstalledOffered(offered) + if (managedVersion === undefined && matches.length === 0) return undefined + + const summaries = matches.map((match) => + match.kind === 'managed' + ? `'${match.name}' is managed and selects newest mutual UCP ${match.version}` + : `'${match.name}' speaks ${match.version}`, + ) + const description = [ + 'The active DIY Profile is a singleton.', + ...(managedVersion === undefined + ? [] + : [ + `The Shopify managed Profile offers every installed rendering and will select newest mutual UCP ${managedVersion}. Run \`ucp profile use --managed\`, then retry without an explicit --profile and with UCP_PROFILE unset. Both override active.yaml; leaving either pointed at the DIY Profile would keep it active.`, + ]), + ...(summaries.length === 0 + ? [] + : [`Other matching local Profiles: ${summaries.join(', ')}. No reinstall.`]), + ].join(' ') + return { - description: `The business offers a version one of your other local profiles speaks (${summary}) — the protocol version comes from the ACTIVE profile's profile.json, so switching profiles switches version. No reinstall.`, - commands: matches.map((m) => ({ - command: `${context.displayName} ${context.command} --profile ${m.name}`.trim(), - description: `retry as UCP ${m.version}`, - })), + description, + commands: [ + ...(managedVersion === undefined + ? [] + : [ + { + command: 'ucp profile use --managed', + description: `select the managed Profile; it will negotiate UCP ${managedVersion}`, + }, + ]), + ...matches.map((match) => ({ + command: `${context.displayName} ${context.command} --profile ${match.name}`.trim(), + description: + match.kind === 'managed' + ? `retry with managed Profile '${match.name}', selecting UCP ${match.version}` + : `retry as UCP ${match.version}`, + })), + ], } } diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index f06c426..7dfc43a 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -4,11 +4,25 @@ // profile URLs, and default catalog inheritance. There is no upload verb — // ucp-cli never writes to a profile URL, so hosting is entirely the user's. -import { describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' import { createUcpCli } from '../cli.js' -import type { ProfileMeta } from '../core/profile-store.js' -import { LATEST, RELEASES, SUPPORTED_VERSIONS } from '../core/releases.js' +import { PROFILE_FORMAT_VERSION } from '../core/legacy-profile.js' +import type { PlatformProfile } from '../core/profile.js' +import { + listProfiles as listStoredProfiles, + type ProfileMeta, + readActive as readStoredActive, + readUserProfile as readStoredUserProfile, + profileExists as storedProfileExists, + writeActive as writeStoredActive, +} from '../core/profile-store.js' +import { LATEST, RELEASES, SUPPORTED_VERSIONS, type Version } from '../core/releases.js' +import { setWarnWriter } from '../core/verbose.js' import { captureSaves, captureWrites, @@ -22,60 +36,554 @@ const META: ProfileMeta = { created_at: '2026-05-01T00:00:00.000Z', defaults: { catalog: 'https://catalog.shopify.com/api/ucp/mcp' }, profile_url: 'https://example.com/.well-known/ucp', + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', +} + +const MANAGED_RENDERINGS = SUPPORTED_VERSIONS.map((version) => ({ + version, + profile_url: RELEASES[version].defaultAgentProfileUrl, +})) + +function profileBody(version: Version = LATEST): PlatformProfile { + return JSON.parse(RELEASES[version].agentProfileJson) as PlatformProfile +} + +function internallyInconsistentDiyBody(): PlatformProfile { + const body = profileBody() + body.ucp.services = { + 'dev.ucp.shopping': [ + { version: LATEST, transport: 'mcp' }, + { version: '2026-04-08', transport: 'mcp' }, + ], + } + return body } function makeCli(overrides: Partial = {}) { return createUcpCli({ profile: { ...defaultProfileDeps(), ...overrides } }) } +function makeStoredCli(homeDir: string) { + return makeCli({ + listProfiles: () => listStoredProfiles({ homeDir }), + profileExists: (name) => storedProfileExists(name, { homeDir }), + readUserProfile: (name, options = {}) => readStoredUserProfile(name, { ...options, homeDir }), + readActive: () => readStoredActive({ homeDir }), + writeActive: (active) => writeStoredActive(active, { homeDir }), + }) +} + +const temporaryHomes = new Set() + +async function seedManagedAlias(bodyBytes?: string) { + const homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-profile-managed-test-')) + temporaryHomes.add(homeDir) + const name = 'legacy' + const dir = join(homeDir, 'profiles', name) + const bodyPath = join(dir, 'profile.json') + const meta = { + created_at: '2026-06-01T10:00:00.000Z', + format_version: PROFILE_FORMAT_VERSION, + kind: 'managed' as const, + } + await mkdir(dir, { recursive: true, mode: 0o700 }) + await writeFile(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf-8') + if (bodyBytes !== undefined) await writeFile(bodyPath, bodyBytes, 'utf-8') + return { homeDir, name, bodyPath, meta } +} + +afterEach(async () => { + setWarnWriter(null) + await Promise.all([...temporaryHomes].map((homeDir) => rm(homeDir, { recursive: true }))) + temporaryHomes.clear() +}) + describe('ucp profile list', () => { - it('returns names with the active marker resolved', async () => { + it('shows the virtual managed Profile as active on a fresh install', async () => { + const { output, exitCode } = await serveCli(makeCli(), ['profile', 'list']) + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toEqual({ + profiles: [ + { + kind: 'managed', + label: 'Shopify managed', + active: true, + renderings: MANAGED_RENDERINGS, + }, + ], + }) + }) + + it('marks non-empty UCP_PROFILE instead of active.yaml', async () => { const cli = makeCli({ - listProfiles: async () => ['alpha', 'beta'], - readActive: async () => ({ profile: 'beta' }), + env: { UCP_PROFILE: 'from-env' }, + listProfiles: async () => ['from-active', 'from-env'], + readActive: async () => ({ profile: 'from-active' }), + readUserProfile: async (name) => userProfile(name, { meta: META, kind: 'diy' }), }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'list']) + expect(exitCode).toBe(0) + const profiles = JSON.parse(output).profiles as Array<{ + name?: string + kind: string + active: boolean + }> + expect( + profiles.find((profile) => profile.kind === 'managed' && profile.name === undefined), + ).toMatchObject({ active: false }) + expect(profiles.find((profile) => profile.name === 'from-active')).toMatchObject({ + active: false, + }) + expect(profiles.find((profile) => profile.name === 'from-env')).toMatchObject({ active: true }) + }) + + it('shows every managed rendering and a singleton pinned DIY rendering', async () => { + const cli = makeCli({ + listProfiles: async () => ['legacy', 'pinned'], + readActive: async () => ({ profile: 'legacy' }), + readUserProfile: async (name) => { + if (name === 'legacy') { + return userProfile(name, { + body: profileBody('2026-04-08'), + meta: { ...META, kind: 'managed' }, + kind: 'managed', + }) + } + return userProfile(name, { + body: profileBody('2026-04-08'), + meta: { + ...META, + profile_url: 'https://agent.example.com/pinned.json', + kind: 'diy', + }, + kind: 'diy', + }) + }, + }) + const { output, exitCode } = await serveCli(cli, ['profile', 'list']) expect(exitCode).toBe(0) expect(JSON.parse(output)).toEqual({ - active: 'beta', profiles: [ - { name: 'alpha', active: false }, - { name: 'beta', active: true }, + { + kind: 'managed', + label: 'Shopify managed', + active: false, + renderings: MANAGED_RENDERINGS, + }, + { + name: 'legacy', + kind: 'managed', + active: true, + renderings: MANAGED_RENDERINGS, + }, + { + name: 'pinned', + kind: 'diy', + active: false, + renderings: [ + { + version: '2026-04-08', + profile_url: 'https://agent.example.com/pinned.json', + }, + ], + }, ], }) }) + + it('shows and warns about the effective unknown URL on the active managed-alias row only', async () => { + const warnings: string[] = [] + setWarnWriter((message) => warnings.push(message)) + const overrideUrl = 'https://override.example/profile.json' + const cli = makeCli({ + env: { UCP_AGENT_PROFILE_URL: overrideUrl }, + listProfiles: async () => ['legacy', 'other'], + readActive: async () => ({ profile: 'legacy' }), + readUserProfile: async (name) => + name === 'legacy' + ? userProfile(name, { + body: profileBody('2026-04-08'), + meta: { ...META, kind: 'managed' }, + kind: 'managed', + }) + : userProfile(name, { + body: profileBody('2026-04-08'), + meta: META, + kind: 'diy', + }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'list']) + expect(exitCode).toBe(0) + const profiles = JSON.parse(output).profiles as Array> + expect(profiles.find((profile) => profile.name === 'legacy')).toMatchObject({ + kind: 'managed', + active: true, + renderings: [{ version: LATEST, profile_url: overrideUrl }], + overridden_by: ['UCP_AGENT_PROFILE_URL'], + }) + expect(profiles.find((profile) => profile.name === 'other')).toMatchObject({ + active: false, + renderings: [{ version: '2026-04-08', profile_url: META.profile_url }], + }) + expect(profiles.find((profile) => profile.name === 'other')).not.toHaveProperty('overridden_by') + expect(profiles[0]).toMatchObject({ active: false, renderings: MANAGED_RENDERINGS }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(`bundled UCP ${LATEST} body`) + }) + + it('migrates only the effective active local Profile while scanning inactive rows read-only', async () => { + const reads: Array<{ name: string; migrate: boolean | undefined }> = [] + const cli = makeCli({ + env: { UCP_PROFILE: 'active' }, + listProfiles: async () => ['active', 'inactive'], + readUserProfile: async (name, options = {}) => { + reads.push({ name, migrate: options.migrate }) + return userProfile(name, { meta: META, kind: 'diy' }) + }, + }) + + const { exitCode } = await serveCli(cli, ['profile', 'list']) + + expect(exitCode).toBe(0) + expect(reads).toEqual([ + { name: 'active', migrate: undefined }, + { name: 'inactive', migrate: false }, + ]) + }) + + it('marks an active DIY Profile invalid when runtime materialization rejects its body', async () => { + const cli = makeCli({ + env: { UCP_PROFILE: 'broken' }, + listProfiles: async () => ['broken'], + readUserProfile: async (name) => + userProfile(name, { body: internallyInconsistentDiyBody(), meta: META, kind: 'diy' }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'list']) + + expect(exitCode).toBe(0) + expect(JSON.parse(output).profiles).toContainEqual({ + name: 'broken', + kind: 'invalid', + active: true, + }) + }) + + it('keeps unreadable local Profiles visible as invalid rows', async () => { + const cli = makeCli({ + listProfiles: async () => ['broken', 'good'], + readUserProfile: async (name) => { + if (name === 'broken') throw new Error('bad profile.json') + return userProfile(name, { meta: META, kind: 'diy' }) + }, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'list']) + expect(exitCode).toBe(0) + expect(JSON.parse(output).profiles).toContainEqual({ + name: 'broken', + kind: 'invalid', + active: false, + }) + }) +}) + +describe('ucp profile commands — marked managed aliases', () => { + it.each([ + { state: 'missing', bodyBytes: undefined }, + { state: 'corrupt', bodyBytes: '\n' }, + ])('lists, shows, and uses an alias with $state profile.json', async ({ bodyBytes }) => { + const { homeDir, name, bodyPath, meta } = await seedManagedAlias(bodyBytes) + + const listed = await serveCli(makeStoredCli(homeDir), ['profile', 'list']) + expect(listed.exitCode).toBe(0) + expect(JSON.parse(listed.output).profiles).toContainEqual({ + name, + kind: 'managed', + active: false, + renderings: MANAGED_RENDERINGS, + }) + + const shown = await serveCli(makeStoredCli(homeDir), ['profile', 'show', name]) + expect(shown.exitCode).toBe(0) + expect(JSON.parse(shown.output)).toEqual({ + name, + kind: 'managed', + active: false, + renderings: MANAGED_RENDERINGS, + meta, + }) + + const used = await serveCli(makeStoredCli(homeDir), ['profile', 'use', name]) + expect(used.exitCode).toBe(0) + expect(JSON.parse(used.output)).toEqual({ profile: name, previous: null }) + expect(await readStoredActive({ homeDir })).toEqual({ profile: name }) + + if (bodyBytes === undefined) { + await expect(readFile(bodyPath, 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' }) + } else { + expect(await readFile(bodyPath, 'utf-8')).toBe(bodyBytes) + } + }) }) describe('ucp profile show', () => { - it('errors with CTA when no active profile is set', async () => { - const cli = makeCli() - const { output, exitCode } = await serveCli(cli, ['profile', 'show']) - expect(exitCode).toBe(1) - expect(output).toMatch(/PROFILE_NOT_FOUND/) - expect(output).toMatch(/profile init --name agent/) + it('returns the virtual managed descriptor when no local Profile is active', async () => { + const { output, exitCode } = await serveCli(makeCli(), ['profile', 'show']) + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toEqual({ + kind: 'managed', + label: 'Shopify managed', + active: true, + renderings: MANAGED_RENDERINGS, + }) }) - it('returns the named profile', async () => { + it.each([ + { + label: 'known release-default', + url: RELEASES['2026-04-08'].defaultAgentProfileUrl, + version: '2026-04-08' as const, + warns: false, + }, + { + label: 'unknown URL', + url: 'https://override.example/profile.json', + version: LATEST, + warns: true, + }, + ])( + 'pins effective managed show to one bundled rendering for a $label', + async ({ url, version, warns }) => { + const warnings: string[] = [] + setWarnWriter((message) => warnings.push(message)) + const cli = makeCli({ env: { UCP_AGENT_PROFILE_URL: url } }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show']) + + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toEqual({ + kind: 'managed', + label: 'Shopify managed', + active: true, + renderings: [{ version, profile_url: url }], + overridden_by: ['UCP_AGENT_PROFILE_URL'], + }) + expect(warnings).toHaveLength(warns ? 1 : 0) + if (warns) expect(warnings[0]).toContain(`bundled UCP ${LATEST} body`) + }, + ) + + it('returns a named DIY Profile with its one pinned rendering, body, and meta', async () => { + const body = profileBody('2026-04-08') const cli = makeCli({ - readUserProfile: async (name) => userProfile(name, { meta: META }), + readUserProfile: async (name) => userProfile(name, { body, meta: META, kind: 'diy' }), }) const { output, exitCode } = await serveCli(cli, ['profile', 'show', 'alpha']) expect(exitCode).toBe(0) - expect(JSON.parse(output)).toMatchObject({ name: 'alpha', meta: META }) + expect(JSON.parse(output)).toEqual({ + name: 'alpha', + kind: 'diy', + active: false, + renderings: [ + { + version: '2026-04-08', + profile_url: META.profile_url, + }, + ], + body, + meta: META, + }) }) - it('falls back to the active profile when name is omitted', async () => { + it('shows a named managed Profile with all renderings and meta, not its stale body', async () => { + const legacyMeta: ProfileMeta = { + ...META, + profile_url: RELEASES['2026-04-08'].defaultAgentProfileUrl, + kind: 'managed', + legacy_note: 'preserved', + } + const cli = makeCli({ + readActive: async () => ({ profile: 'legacy' }), + readUserProfile: async (name) => + userProfile(name, { + body: profileBody('2026-04-08'), + meta: legacyMeta, + kind: 'managed', + }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show']) + const shown = JSON.parse(output) + expect(exitCode).toBe(0) + expect(shown).toEqual({ + name: 'legacy', + kind: 'managed', + active: true, + renderings: MANAGED_RENDERINGS, + meta: legacyMeta, + }) + expect(shown).not.toHaveProperty('body') + }) + + it('applies an effective URL override to a named DIY rendering without replacing its body or meta', async () => { + const body = profileBody('2026-04-08') + const meta = Object.freeze({ ...META }) + const overrideUrl = 'https://override.example/profile.json' + const cli = makeCli({ + env: { UCP_AGENT_PROFILE_URL: overrideUrl }, + readActive: async () => ({ profile: 'alpha' }), + readUserProfile: async (name) => userProfile(name, { body, meta, kind: 'diy' }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show']) + const shown = JSON.parse(output) + + expect(exitCode).toBe(0) + expect(shown).toMatchObject({ + name: 'alpha', + kind: 'diy', + active: true, + renderings: [{ version: '2026-04-08', profile_url: overrideUrl }], + overridden_by: ['UCP_AGENT_PROFILE_URL'], + body, + meta: META, + }) + expect(meta.profile_url).toBe(META.profile_url) + }) + + it('falls back to the active named Profile when name is omitted', async () => { const reads: string[] = [] const cli = makeCli({ readActive: async () => ({ profile: 'live' }), readUserProfile: async (name) => { reads.push(name) - return userProfile(name, { meta: META }) + return userProfile(name, { meta: META, kind: 'diy' }) }, }) const { output } = await serveCli(cli, ['profile', 'show']) expect(reads).toEqual(['live']) - expect(JSON.parse(output)).toMatchObject({ name: 'live' }) + expect(JSON.parse(output)).toMatchObject({ name: 'live', kind: 'diy', active: true }) + }) + + it('uses non-empty UCP_PROFILE before active.yaml when no name is passed', async () => { + const reads: string[] = [] + const cli = makeCli({ + env: { UCP_PROFILE: 'from-env' }, + readActive: async () => ({ profile: 'from-active' }), + readUserProfile: async (name) => { + reads.push(name) + return userProfile(name, { meta: META, kind: 'diy' }) + }, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show']) + expect(exitCode).toBe(0) + expect(reads).toEqual(['from-env']) + expect(JSON.parse(output)).toMatchObject({ name: 'from-env', active: true }) + }) + + it('applies the effective URL override when the active DIY name is explicit', async () => { + const reads: string[] = [] + const overrideUrl = 'https://override.example/profile.json' + const cli = makeCli({ + env: { + UCP_PROFILE: 'requested', + UCP_AGENT_PROFILE_URL: overrideUrl, + }, + readActive: async () => ({ profile: 'from-active' }), + readUserProfile: async (name) => { + reads.push(name) + return userProfile(name, { meta: META, kind: 'diy' }) + }, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show', 'requested']) + expect(exitCode).toBe(0) + expect(reads).toEqual(['requested']) + expect(JSON.parse(output)).toMatchObject({ + name: 'requested', + active: true, + renderings: [{ profile_url: overrideUrl }], + overridden_by: ['UCP_AGENT_PROFILE_URL'], + }) + }) + + it('applies the effective URL override when the active managed-alias name is explicit', async () => { + const overrideUrl = RELEASES['2026-04-08'].defaultAgentProfileUrl + const managedMeta: ProfileMeta = { ...META, kind: 'managed' } + const cli = makeCli({ + env: { + UCP_PROFILE: 'legacy', + UCP_AGENT_PROFILE_URL: overrideUrl, + }, + readUserProfile: async (name) => userProfile(name, { meta: managedMeta, kind: 'managed' }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show', 'legacy']) + + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toEqual({ + name: 'legacy', + kind: 'managed', + active: true, + renderings: [{ version: '2026-04-08', profile_url: overrideUrl }], + overridden_by: ['UCP_AGENT_PROFILE_URL'], + meta: managedMeta, + }) + }) + + it('canonicalizes a stored DIY rendering URL without changing inspected metadata', async () => { + const rawProfileUrl = 'https://EXAMPLE.com:443/.well-known/ucp' + const meta: ProfileMeta = { ...META, profile_url: rawProfileUrl } + const cli = makeCli({ + readUserProfile: async (name) => userProfile(name, { meta, kind: 'diy' }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show', 'other']) + + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toMatchObject({ + renderings: [{ profile_url: 'https://example.com/.well-known/ucp' }], + meta: { profile_url: rawProfileUrl }, + }) + }) + + it('keeps explicit foreign-name inspection on the stored DIY descriptor', async () => { + const cli = makeCli({ + env: { + UCP_PROFILE: 'active', + UCP_AGENT_PROFILE_URL: 'https://override.example/profile.json', + }, + readUserProfile: async (name) => userProfile(name, { meta: META, kind: 'diy' }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show', 'other']) + const shown = JSON.parse(output) + + expect(exitCode).toBe(0) + expect(shown).toMatchObject({ + name: 'other', + active: false, + renderings: [{ profile_url: META.profile_url }], + }) + expect(shown).not.toHaveProperty('overridden_by') + }) + + it('keeps explicit DIY inspection available when runtime snapshot validation would fail', async () => { + const body = internallyInconsistentDiyBody() + const cli = makeCli({ + readUserProfile: async (name) => userProfile(name, { body, meta: META, kind: 'diy' }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'show', 'broken']) + + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toMatchObject({ name: 'broken', kind: 'diy', body }) }) }) @@ -89,10 +597,22 @@ describe('ucp profile init', () => { const { output, exitCode } = await serveCli(cli, ['profile', 'init']) expect(exitCode).toBe(1) expect(output).toMatch(/PROFILE_INIT_REQUIRES_NAME/) + expect(output).toMatch(/normal users do not need to initialize/i) + expect(output).toMatch(/custom or release-pinned DIY Profile/i) expect(output).toMatch(/profile init --name agent/) expect(output).toMatch(/--profile-url https:\/\/example\.com\/\.well-known\/ucp/) }) + it('describes init as an opt-in DIY command', async () => { + const { output, exitCode } = await serveCli(makeCli(), ['profile', 'init', '--help']) + expect(exitCode).toBe(0) + expect(output).toMatch(/DIY Profile pinned to one UCP release/i) + expect(output).toMatch(/without this flag.*Shopify managed/i) + expect(output).toMatch(/custom.*URL.*preserv/i) + expect(output).toMatch(/release-default URL.*rotate/i) + expect(output).not.toMatch(/first profile is activated automatically/i) + }) + it('prompts in TTY mode when name is omitted', async () => { const { saves, saveUserProfile } = captureSaves() const cli = makeCli({ @@ -107,12 +627,16 @@ describe('ucp profile init', () => { expect(exitCode).toBe(0) expect(saves[0]).toMatchObject({ name: 'prompted', - meta: { profile_url: 'https://p.example/.well-known/ucp' }, + meta: { + profile_url: 'https://p.example/.well-known/ucp', + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', + }, }) - expect(JSON.parse(output)).toMatchObject({ name: 'prompted', activated: true }) + expect(JSON.parse(output)).toMatchObject({ name: 'prompted', activated: false }) }) - it('creates a DIY profile when --profile-url is provided', async () => { + it('creates and marks a DIY Profile when --profile-url is provided', async () => { const { saves, saveUserProfile } = captureSaves() const { writes, writeActive } = captureWrites() const cli = makeCli({ saveUserProfile, writeActive }) @@ -130,13 +654,77 @@ describe('ucp profile init', () => { name: 'fresh', meta: { profile_url: 'https://example.com/.well-known/ucp', + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', }, overwrite: false, }) - expect(writes).toEqual([{ profile: 'fresh' }]) + expect(writes).toEqual([]) + expect(JSON.parse(output)).toMatchObject({ name: 'fresh', activated: false }) + }) + + it('does not auto-activate the first local Profile', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + listProfiles: async () => [], + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'init', '--name', 'first']) + expect(exitCode).toBe(0) + expect(writes).toEqual([]) + expect(JSON.parse(output)).toMatchObject({ name: 'first', activated: false }) + }) + + it('activates only when --activate is explicit and preserves other session state', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + readActive: async () => ({ business: 'https://shop.example.com' }), + writeActive, + }) + const { output, exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'fresh', + '--activate', + ]) + + expect(exitCode).toBe(0) + expect(writes).toEqual([{ business: 'https://shop.example.com', profile: 'fresh' }]) expect(JSON.parse(output)).toMatchObject({ name: 'fresh', activated: true }) }) + it('does not blame an equal UCP_PROFILE while independently warning on the URL override', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + env: { + UCP_PROFILE: 'fresh', + UCP_AGENT_PROFILE_URL: 'https://override.example/profile.json', + }, + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'fresh', + '--activate', + ]) + + expect(exitCode).toBe(0) + expect(writes).toEqual([{ profile: 'fresh' }]) + expect(JSON.parse(output)).toMatchObject({ + name: 'fresh', + created: true, + activated: true, + overridden_by: ['UCP_AGENT_PROFILE_URL'], + message: expect.stringMatching(/stored identity.*UCP_AGENT_PROFILE_URL/i), + }) + expect(output).not.toContain('UCP_PROFILE') + }) + it('persists catalog only when --catalog is explicit', async () => { const { saves, saveUserProfile } = captureSaves() const cli = makeCli({ saveUserProfile }) @@ -172,8 +760,12 @@ describe('ucp profile init', () => { expect(exitCode).toBe(0) // `profile_url` is always written so the remote identity is explicit on // disk where `doctor` and the user can inspect it. - expect(saves[0]?.meta.profile_url).toBe(RELEASES[LATEST].defaultAgentProfileUrl) - expect(JSON.parse(output)).toMatchObject({ name: 'fresh' }) + expect(saves[0]?.meta).toMatchObject({ + profile_url: RELEASES[LATEST].defaultAgentProfileUrl, + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', + }) + expect(JSON.parse(output)).toMatchObject({ name: 'fresh', activated: false }) }) // ── --version ───────────────────────────────────────────────── @@ -280,14 +872,28 @@ describe('ucp profile init', () => { expect(exitCode).toBe(0) expect(Object.keys(saves[0]?.meta ?? {}).sort()).toEqual([ 'created_at', + 'format_version', + 'kind', 'profile_url', 'updated_at', ]) }) - it('returns no-op output when the profile already exists', async () => { + it('returns a total no-op when the profile already exists and --activate is absent', async () => { const { saves, saveUserProfile } = captureSaves() - const cli = makeCli({ profileExists: async () => true, saveUserProfile }) + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async () => { + throw new Error('existing Profile must not be read') + }, + readActive: async () => { + throw new Error('active.yaml must not be read') + }, + writeActive: async () => { + throw new Error('active.yaml must not be written') + }, + saveUserProfile, + }) const { output, exitCode } = await serveCli(cli, ['profile', 'init', '--name', 'fresh']) expect(exitCode).toBe(0) expect(saves).toEqual([]) @@ -299,6 +905,124 @@ describe('ucp profile init', () => { }) }) + it('validates and activates an existing Profile with --activate without re-creating it', async () => { + const events: string[] = [] + const { saves, saveUserProfile } = captureSaves() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async (name) => { + events.push(`read:${name}`) + return userProfile(name, { meta: META, kind: 'diy' }) + }, + readActive: async () => ({ + profile: 'old', + business: 'https://shop.example.com', + future_state: 'preserved', + }), + writeActive: async (session) => { + events.push(`write:${JSON.stringify(session)}`) + }, + saveUserProfile, + }) + + const { output, exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'fresh', + '--activate', + ]) + expect(exitCode).toBe(0) + expect(saves).toEqual([]) + expect(events).toEqual([ + 'read:fresh', + 'write:{"profile":"fresh","business":"https://shop.example.com","future_state":"preserved"}', + ]) + expect(JSON.parse(output)).toEqual({ + name: 'fresh', + created: false, + activated: true, + message: 'profile already exists; activated without re-creating it', + }) + }) + + it('returns the profile-use override warning when existing init --activate is ineffective', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + env: { + UCP_PROFILE: 'from-env', + UCP_AGENT_PROFILE_URL: 'https://override.example/profile.json', + }, + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { meta: META, kind: 'diy' }), + readActive: async () => ({ profile: 'old' }), + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'fresh', + '--activate', + ]) + + expect(exitCode).toBe(0) + expect(writes).toEqual([{ profile: 'fresh' }]) + expect(JSON.parse(output)).toEqual({ + name: 'fresh', + created: false, + activated: true, + overridden_by: ['UCP_PROFILE', 'UCP_AGENT_PROFILE_URL'], + message: + 'active.yaml now selects local Profile "fresh", but UCP_PROFILE selects the effective name and UCP_AGENT_PROFILE_URL overrides its stored identity rendering; unset both environment variables to use this selection and its stored descriptor', + }) + }) + + it('does not activate an unchanged DIY Profile that fails runtime snapshot validation', async () => { + const { writes, writeActive } = captureWrites() + const body = internallyInconsistentDiyBody() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { body, meta: META, kind: 'diy' }), + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'broken', + '--activate', + ]) + + expect(exitCode).toBe(1) + expect(output).toContain('AGENT_PROFILE_VERSION_MISMATCH') + expect(writes).toEqual([]) + }) + + it('does not activate an existing Profile that fails validation', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async () => { + throw new Error('invalid existing Profile') + }, + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'broken', + '--activate', + ]) + expect(exitCode).toBe(1) + expect(output).toMatch(/invalid existing Profile/) + expect(writes).toEqual([]) + }) + it('rejects HTTP profile URLs', async () => { const cli = makeCli({ saveUserProfile: async () => { @@ -317,37 +1041,339 @@ describe('ucp profile init', () => { expect(output).toMatch(/https|profile-url/i) }) - it('honors --force by overwriting and preserving prior created_at', async () => { - const PRIOR_CREATED = '2026-01-15T08:00:00.000Z' + it('preserves prior metadata and a custom profile_url on --force when --profile-url is omitted', async () => { + const priorMeta: ProfileMeta = { + created_at: '2026-01-15T08:00:00.000Z', + updated_at: '2026-01-16T08:00:00.000Z', + profile_url: 'https://old.example.com/profile.json', + defaults: { + catalog: 'https://old-catalog.example.com', + cart: 'https://cart.example.com', + }, + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', + profile_id: 'legacy-id', + } const { saves, saveUserProfile } = captureSaves() const cli = makeCli({ profileExists: async () => true, readUserProfile: async (name) => - userProfile(name, { meta: { ...META, created_at: PRIOR_CREATED } }), + userProfile(name, { + body: profileBody('2026-04-08'), + meta: priorMeta, + kind: 'diy', + }), saveUserProfile, }) - await serveCli(cli, ['profile', 'init', '--name', 'fresh', '--force']) - expect(saves[0]).toMatchObject({ - meta: { created_at: PRIOR_CREATED }, - overwrite: true, + const { exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'fresh', + '--version', + '2026-04-08', + '--catalog', + 'https://new-catalog.example.com', + '--force', + ]) + + expect(exitCode).toBe(0) + expect(saves[0]?.overwrite).toBe(true) + expect(saves[0]?.meta).toEqual({ + ...priorMeta, + updated_at: expect.any(String), + profile_url: priorMeta.profile_url, + defaults: { + catalog: 'https://new-catalog.example.com', + cart: 'https://cart.example.com', + }, + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', + }) + expect(saves[0]?.meta.updated_at).not.toBe(priorMeta.updated_at) + }) + + it('salvages a valid custom URL and unknown metadata when profile.json needs force repair', async () => { + const priorMeta: ProfileMeta = { + ...META, + profile_url: 'https://owned.example/profile.json', + future_metadata: { keep: true }, + } + const { saves, saveUserProfile } = captureSaves() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async () => { + throw new Error('profile.json failed schema validation') + }, + readProfileMeta: async () => priorMeta, + saveUserProfile, + }) + + const { exitCode } = await serveCli(cli, ['profile', 'init', '--name', 'broken', '--force']) + expect(exitCode).toBe(0) + expect(saves[0]?.meta.profile_url).toBe('https://owned.example/profile.json') + expect(saves[0]?.meta.future_metadata).toEqual({ keep: true }) + }) + + it('rotates a canonically equivalent Shopify release-default URL on --force', async () => { + const releaseDefault = new URL(RELEASES['2026-04-08'].defaultAgentProfileUrl) + const priorMeta: ProfileMeta = { + ...META, + profile_url: `https://${releaseDefault.hostname.toUpperCase()}:443${releaseDefault.pathname}`, + } + const { saves, saveUserProfile } = captureSaves() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { meta: priorMeta, kind: 'diy' }), + saveUserProfile, + }) + + const { exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'fresh', + '--version', + LATEST, + '--force', + ]) + expect(exitCode).toBe(0) + expect(saves[0]?.meta.profile_url).toBe(RELEASES[LATEST].defaultAgentProfileUrl) + }) + + it('lets explicit --profile-url replace an existing custom URL on --force', async () => { + const { saves, saveUserProfile } = captureSaves() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { meta: META, kind: 'diy' }), + saveUserProfile, }) + + const { exitCode } = await serveCli(cli, [ + 'profile', + 'init', + '--name', + 'fresh', + '--profile-url', + 'https://replacement.example/profile.json', + '--force', + ]) + expect(exitCode).toBe(0) + expect(saves[0]?.meta.profile_url).toBe('https://replacement.example/profile.json') + }) + + it('preserves the complete defaults block on --force when --catalog is omitted', async () => { + const priorMeta: ProfileMeta = { + ...META, + defaults: { + catalog: 'https://existing-catalog.example.com', + checkout: 'https://checkout.example.com', + }, + future_metadata: { keep: true }, + } + const { saves, saveUserProfile } = captureSaves() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { meta: priorMeta, kind: 'diy' }), + saveUserProfile, + }) + + const { exitCode } = await serveCli(cli, ['profile', 'init', '--name', 'fresh', '--force']) + expect(exitCode).toBe(0) + expect(saves[0]?.meta.defaults).toEqual(priorMeta.defaults) + expect(saves[0]?.meta.future_metadata).toEqual({ keep: true }) }) }) describe('ucp profile use', () => { - it('writes active.yaml when the profile exists', async () => { - const { writes, writeActive } = captureWrites() + it('migrates and validates a named Profile before writing active.yaml', async () => { + const events: string[] = [] const cli = makeCli({ profileExists: async () => true, + readUserProfile: async (name) => { + events.push(`read:${name}`) + return userProfile(name, { meta: META, kind: 'diy' }) + }, readActive: async () => ({ profile: 'old' }), - writeActive, + writeActive: async (session) => { + events.push(`write:${session.profile}`) + }, }) const { output, exitCode } = await serveCli(cli, ['profile', 'use', 'newp']) expect(exitCode).toBe(0) - expect(writes).toEqual([{ profile: 'newp' }]) + expect(events).toEqual(['read:newp', 'write:newp']) expect(JSON.parse(output)).toEqual({ profile: 'newp', previous: 'old' }) }) + it('uses --managed by removing only active.yaml.profile', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + readActive: async () => ({ + profile: 'custom', + business: 'https://shop.example.com', + future_state: 'preserved', + }), + profileExists: async () => { + throw new Error('should not inspect a local profile') + }, + readUserProfile: async () => { + throw new Error('should not read a local profile') + }, + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'use', '--managed']) + expect(exitCode).toBe(0) + expect(writes).toEqual([{ business: 'https://shop.example.com', future_state: 'preserved' }]) + expect(JSON.parse(output)).toEqual({ profile: null, previous: 'custom' }) + }) + + it('warns that env overrides keep --managed from becoming effective', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + env: { + UCP_PROFILE: 'from-env', + UCP_AGENT_PROFILE_URL: 'https://override.example/profile.json', + }, + readActive: async () => ({ profile: 'custom' }), + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'use', '--managed']) + expect(exitCode).toBe(0) + expect(writes).toEqual([{}]) + expect(JSON.parse(output)).toEqual({ + profile: null, + previous: 'custom', + overridden_by: ['UCP_PROFILE', 'UCP_AGENT_PROFILE_URL'], + message: + 'active.yaml now selects the Shopify managed Profile, but it is not effective while UCP_PROFILE and UCP_AGENT_PROFILE_URL are set; unset those environment variables to use it', + }) + }) + + it('does not claim UCP_PROFILE blocks profile use when it already selects that name', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + env: { UCP_PROFILE: 'chosen' }, + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { meta: META, kind: 'diy' }), + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'use', 'chosen']) + + expect(exitCode).toBe(0) + expect(writes).toEqual([{ profile: 'chosen' }]) + expect(JSON.parse(output)).toEqual({ profile: 'chosen', previous: null }) + expect(output).not.toContain('overridden_by') + }) + + it('warns when UCP_PROFILE overrides profile use ', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + env: { UCP_PROFILE: 'from-env' }, + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { meta: META, kind: 'diy' }), + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'use', 'chosen']) + expect(exitCode).toBe(0) + expect(writes).toEqual([{ profile: 'chosen' }]) + expect(JSON.parse(output)).toEqual({ + profile: 'chosen', + previous: null, + overridden_by: ['UCP_PROFILE'], + message: + 'active.yaml now selects local Profile "chosen", but UCP_PROFILE takes precedence; unset it to use this selection.', + }) + }) + + it('warns when UCP_AGENT_PROFILE_URL overrides a selected Profile rendering', async () => { + const cli = makeCli({ + env: { UCP_AGENT_PROFILE_URL: 'https://override.example/profile.json' }, + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { meta: META, kind: 'diy' }), + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'use', 'chosen']) + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toMatchObject({ + profile: 'chosen', + overridden_by: ['UCP_AGENT_PROFILE_URL'], + message: expect.stringMatching(/stored identity.*UCP_AGENT_PROFILE_URL/i), + }) + }) + + it('rejects a name together with --managed', async () => { + const { output, exitCode } = await serveCli(makeCli(), [ + 'profile', + 'use', + 'custom', + '--managed', + ]) + expect(exitCode).toBe(1) + expect(output).toMatch(/INVALID_INPUT/) + expect(output).toMatch(/either.*name.*--managed|not both/i) + }) + + it('requires either a name or --managed', async () => { + const { output, exitCode } = await serveCli(makeCli(), ['profile', 'use']) + expect(exitCode).toBe(1) + expect(output).toMatch(/name or --managed/i) + }) + + it.each(['managed', 'default'])('treats %s as an ordinary local Profile name', async (name) => { + const reads: string[] = [] + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async (readName) => { + reads.push(readName) + return userProfile(readName, { meta: META, kind: 'diy' }) + }, + writeActive, + }) + + const { output, exitCode } = await serveCli(cli, ['profile', 'use', name]) + expect(exitCode).toBe(0) + expect(reads).toEqual([name]) + expect(writes).toEqual([{ profile: name }]) + expect(JSON.parse(output)).toEqual({ profile: name, previous: null }) + }) + + it('does not activate a DIY Profile that fails runtime snapshot validation', async () => { + const { writes, writeActive } = captureWrites() + const body = internallyInconsistentDiyBody() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async (name) => userProfile(name, { body, meta: META, kind: 'diy' }), + writeActive, + }) + + const { exitCode, output } = await serveCli(cli, ['profile', 'use', 'broken']) + + expect(exitCode).toBe(1) + expect(output).toContain('AGENT_PROFILE_VERSION_MISMATCH') + expect(writes).toEqual([]) + }) + + it('does not activate a named Profile that fails validation', async () => { + const { writes, writeActive } = captureWrites() + const cli = makeCli({ + profileExists: async () => true, + readUserProfile: async () => { + throw new Error('invalid profile') + }, + writeActive, + }) + + const { exitCode, output } = await serveCli(cli, ['profile', 'use', 'broken']) + expect(exitCode).toBe(1) + expect(output).toMatch(/invalid profile/i) + expect(writes).toEqual([]) + }) + it('errors when the profile does not exist', async () => { const cli = makeCli({ profileExists: async () => false, diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 9591af2..ba210cb 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -1,37 +1,54 @@ // `ucp profile *` command tree. // // Local profile work only: generate/inspect/select profiles. There is no -// upload verb, because ucp-cli never writes to a profile URL. `meta.profile_url` -// is the identity that goes on the wire; if you own that URL, you put -// profile.json there yourself by whatever means you host with, and `ucp doctor` -// is what checks the two agree. +// upload verb, because ucp-cli never writes to a profile URL. For a DIY +// Profile, `meta.profile_url` is the identity that goes on the wire; if you own +// that URL, you put profile.json there yourself by whatever means you host +// with, and `ucp doctor` is what checks the two agree. Managed renderings come +// from the installed release registry instead. import { stdin as promptInput, stderr as promptOutput } from 'node:process' import { createInterface } from 'node:readline/promises' import { Cli, z } from 'incur' +import { createAdHocProfile, type Profile } from '../core/agent.js' +import { PROFILE_FORMAT_VERSION } from '../core/legacy-profile.js' import { DEFAULT_CATALOG_URL } from '../core/profile.js' import { + type ActiveSession, listProfiles, type ProfileMeta, profileDir, profileExists, readActive, + readProfileMeta, readUserProfile, saveUserProfile, + type UserProfile, writeActive, } from '../core/profile-store.js' -import { LATEST, release, SUPPORTED_VERSIONS } from '../core/releases.js' +import { + LATEST, + RELEASES, + release, + releaseByDefaultAgentProfileUrl, + SUPPORTED_VERSIONS, + type Version, +} from '../core/releases.js' import { acceptsHttpsUrl, parseHttpsUrl } from '../core/url.js' import { ErrorCodes, UcpError } from '../lib/errors.js' +import { materializeUserProfile } from './session.js' export interface ProfileCliDependencies { listProfiles?: typeof listProfiles readUserProfile?: typeof readUserProfile + readProfileMeta?: typeof readProfileMeta saveUserProfile?: typeof saveUserProfile profileExists?: typeof profileExists readActive?: typeof readActive writeActive?: typeof writeActive + /** Override environment lookup for deterministic tests. Defaults to process.env. */ + env?: Record /** Test override for prompt eligibility. Default derives from TTY + c.agent. */ canPrompt?: boolean /** Test/user-interface injection. Default uses readline on stderr. */ @@ -73,6 +90,186 @@ function requireHttpsString(value: string, label: string): string { return parseHttpsUrl(value, label).toString() } +function isSet(value: string | undefined): value is string { + return value !== undefined && value !== '' +} + +function selectedProfileName( + env: Record, + active: ActiveSession, +): string | undefined { + if (isSet(env.UCP_PROFILE)) return env.UCP_PROFILE + return isSet(active.profile) ? active.profile : undefined +} + +function initProfileUrl( + explicit: string | undefined, + priorMeta: ProfileMeta | undefined, + selectedReleaseDefault: string, +): string { + if (explicit !== undefined) return requireHttpsString(explicit, 'profile URL') + const prior = priorMeta?.profile_url + if (prior !== undefined && releaseByDefaultAgentProfileUrl(prior) === undefined) return prior + return selectedReleaseDefault +} + +const PROFILE_OVERRIDE_KEYS = ['UCP_PROFILE', 'UCP_AGENT_PROFILE_URL'] as const + +type ProfileOverrideKey = (typeof PROFILE_OVERRIDE_KEYS)[number] + +type ProfileUseTarget = { kind: 'managed' } | { kind: 'local'; name: string } + +function profileOverrides( + env: Record, + target: ProfileUseTarget, +): ProfileOverrideKey[] { + return PROFILE_OVERRIDE_KEYS.filter((key) => { + const value = env[key] + if (!isSet(value)) return false + // UCP_PROFILE still has higher precedence, but when it selects the same + // name it does not make the newly persisted selection ineffective. + if (key === 'UCP_PROFILE' && target.kind === 'local') return value !== target.name + return true + }) +} + +function joinOverrideKeys(keys: readonly ProfileOverrideKey[]): string { + return keys.length === 2 ? `${keys[0]} and ${keys[1]}` : (keys[0] ?? '') +} + +function useResultWithOverrideWarning( + result: T, + target: ProfileUseTarget, + env: Record, +) { + const overriddenBy = profileOverrides(env, target) + if (overriddenBy.length === 0) return result + + const setKeys = joinOverrideKeys(overriddenBy) + let message: string + if (target.kind === 'managed') { + message = `active.yaml now selects the Shopify managed Profile, but it is not effective while ${setKeys} ${overriddenBy.length === 1 ? 'is' : 'are'} set; unset ${overriddenBy.length === 1 ? 'that environment variable' : 'those environment variables'} to use it` + } else if ( + overriddenBy.includes('UCP_PROFILE') && + overriddenBy.includes('UCP_AGENT_PROFILE_URL') + ) { + message = `active.yaml now selects local Profile "${target.name}", but UCP_PROFILE selects the effective name and UCP_AGENT_PROFILE_URL overrides its stored identity rendering; unset both environment variables to use this selection and its stored descriptor` + } else if (overriddenBy.includes('UCP_PROFILE')) { + message = `active.yaml now selects local Profile "${target.name}", but UCP_PROFILE takes precedence; unset it to use this selection.` + } else { + message = `active.yaml now selects local Profile "${target.name}", but its stored identity rendering is overridden by UCP_AGENT_PROFILE_URL; unset that environment variable to use the stored descriptor` + } + return { ...result, overridden_by: overriddenBy, message } +} + +const MANAGED_PROFILE_LABEL = 'Shopify managed' + +interface ProfileRenderingDescriptor { + version: Version + profile_url: string +} + +const PROFILE_URL_OVERRIDE_PROVENANCE = ['UCP_AGENT_PROFILE_URL'] as const + +function activeProfileUrlOverride(env: Record): string | undefined { + return isSet(env.UCP_AGENT_PROFILE_URL) + ? requireHttpsString(env.UCP_AGENT_PROFILE_URL, 'profile URL') + : undefined +} + +function managedRenderings(): ProfileRenderingDescriptor[] { + return SUPPORTED_VERSIONS.map((version) => ({ + version, + profile_url: requireHttpsString(RELEASES[version].defaultAgentProfileUrl, 'profile URL'), + })) +} + +function managedOverrideRendering(profileUrl: string): ProfileRenderingDescriptor { + // Use the same live factory as session resolution. Besides keeping the + // known-URL/LATEST choice in one place, this emits the required warning when + // an unknown scalar URL makes ucp-cli plan against the bundled LATEST body. + const runtime = createAdHocProfile(profileUrl) + const rendering = Object.values(runtime.renderings)[0] + if (rendering === undefined) throw new Error('ad-hoc Profile has no rendering') + return { version: rendering.version, profile_url: rendering.url } +} + +function runtimeRenderings(profile: Profile): ProfileRenderingDescriptor[] { + return SUPPORTED_VERSIONS.flatMap((version) => { + const rendering = profile.renderings[version] + return rendering === undefined ? [] : [{ version, profile_url: rendering.url }] + }) +} + +// Stored inspection deliberately has its own projection: `profile show ` +// must expose an invalid DIY body so the user can repair it, not materialize it. +function renderingsFor( + profile: UserProfile, + profileUrlOverride?: string, +): ProfileRenderingDescriptor[] { + if (profile.kind === 'managed') { + if (profileUrlOverride === undefined) return managedRenderings() + return [managedOverrideRendering(profileUrlOverride)] + } + + const rel = release(profile.body.ucp.version) + if (rel === undefined) { + throw new Error( + `profile "${profile.name}" declares unsupported UCP ${profile.body.ucp.version}`, + ) + } + return [ + { + version: rel.version, + profile_url: requireHttpsString( + profileUrlOverride ?? profile.meta.profile_url ?? rel.defaultAgentProfileUrl, + 'profile URL', + ), + }, + ] +} + +function virtualManagedDescriptor(active: boolean, profileUrlOverride?: string) { + return { + kind: 'managed' as const, + label: MANAGED_PROFILE_LABEL, + active, + renderings: + profileUrlOverride === undefined + ? managedRenderings() + : [managedOverrideRendering(profileUrlOverride)], + ...(profileUrlOverride !== undefined ? { overridden_by: PROFILE_URL_OVERRIDE_PROVENANCE } : {}), + } +} + +function localProfileSummary( + profile: UserProfile, + active: boolean, + profileUrlOverride?: string, + runtimeProfile?: Profile, +) { + return { + name: profile.name, + kind: profile.kind, + active, + renderings: + runtimeProfile === undefined + ? renderingsFor(profile, profileUrlOverride) + : runtimeRenderings(runtimeProfile), + ...(profileUrlOverride !== undefined ? { overridden_by: PROFILE_URL_OVERRIDE_PROVENANCE } : {}), + } +} + +function localProfileDescriptor( + profile: UserProfile, + active: boolean, + profileUrlOverride?: string, +) { + const summary = localProfileSummary(profile, active, profileUrlOverride) + if (profile.kind === 'managed') return { ...summary, meta: profile.meta } + return { ...summary, body: profile.body, meta: profile.meta } +} + // Belt-and-braces for `--version`: the option schema rejects unsupported // values, so this is the structured form of a condition that should be // unreachable. Kept because the alternative is a cast, and because a future @@ -90,7 +287,7 @@ function unsupportedVersionError(version: string): { description: `Pick a supported release, or upgrade ucp-cli if you need a newer one.`, commands: SUPPORTED_VERSIONS.map((v) => ({ command: `ucp profile init --name ${DEFAULT_PROFILE_NAME} --version ${v}`, - description: `create a UCP ${v} profile`, + description: `create a DIY Profile pinned to UCP ${v}`, })), }, } @@ -106,18 +303,19 @@ function profileInitRequiresNameError(): { } { return { code: ErrorCodes.PROFILE_INIT_REQUIRES_NAME, - message: 'profile init needs a profile name in non-interactive mode', + message: + 'profile init creates a custom or release-pinned DIY Profile and needs --name in non-interactive mode; normal users do not need to initialize a Profile', cta: { description: - "Pass a profile name. Omit --profile-url to use the release's published profile URL; pass --profile-url when the URL is yours.", + "Shopify's managed Profile is used by default. Run profile init only for a custom or release-pinned DIY Profile; omit --profile-url to pin the selected release at its published URL.", commands: [ { command: `ucp profile init --name ${DEFAULT_PROFILE_NAME}`, - description: "create a local profile on the platform's published profile URL", + description: "create a DIY Profile pinned to the release's published profile URL", }, { command: `ucp profile init --name ${DEFAULT_PROFILE_NAME} --profile-url https://example.com/.well-known/ucp`, - description: 'create a local profile for an HTTPS URL you own', + description: 'create a DIY Profile for an HTTPS URL you own', }, ], }, @@ -125,19 +323,18 @@ function profileInitRequiresNameError(): { } async function promptForInit(defaults: InitPromptDefaults): Promise { - promptOutput.write('No UCP profile found.\n\n') promptOutput.write( - 'A UCP profile is a public JSON document businesses fetch to identify this agent and discover supported capabilities.\n\n', + 'Create a DIY Profile pinned to one UCP release. Normal use relies on the Shopify managed Profile and does not require initialization.\n\n', ) promptOutput.write( - 'To use an existing profile, place profile.json and meta.json under ~/.ucp/profiles//, then run `ucp profile use `.\n\n', + 'Use a DIY Profile when you need a custom capability document or want to stay pinned to a specific release.\n\n', ) const rl = createInterface({ input: promptInput, output: promptOutput }) try { - const rawName = await rl.question(`Profile name [${defaults.name}]: `) + const rawName = await rl.question(`DIY Profile name [${defaults.name}]: `) const rawProfileUrl = await rl.question( - "Profile URL (optional HTTPS; leave blank to use the platform's published profile): ", + "Profile URL (optional HTTPS; leave blank to pin the release's published profile): ", ) const trimmedProfileUrl = rawProfileUrl.trim() return { @@ -152,80 +349,98 @@ async function promptForInit(defaults: InitPromptDefaults): Promise { + try { + const isActive = name === selected + const stored = isActive ? await read(name) : await read(name, { migrate: false }) + const effectiveUrlOverride = isActive ? profileUrlOverride : undefined + const runtime = isActive + ? materializeUserProfile(stored, effectiveUrlOverride) + : undefined + return localProfileSummary(stored, isActive, effectiveUrlOverride, runtime) + } catch { + return { name, kind: 'invalid' as const, active: name === selected } + } + }), + ) return { - active: active.profile ?? null, - profiles: profiles.map((name) => ({ - name, - active: name === active.profile, - })), + profiles: [ + virtualManagedDescriptor( + selected === undefined, + selected === undefined ? profileUrlOverride : undefined, + ), + ...localProfiles, + ], } }, }) .command('show', { - description: 'Display a profile (defaults to active)', + description: 'Display a Profile (defaults to the active Profile)', mcp: false, - args: z.object({ name: z.string().optional() }), + args: z.object({ name: z.string().optional().describe('Local Profile name.') }), options: z.object({}), async run(c) { - const name = c.args.name ?? (await readAct()).profile - if (name === undefined) { - return c.error({ - code: ErrorCodes.PROFILE_NOT_FOUND, - message: 'no local profile selected', - cta: { - description: 'Create a local profile first, then run show again.', - commands: [ - { - command: `ucp profile init --name ${DEFAULT_PROFILE_NAME}`, - description: 'create and activate a local profile', - }, - ], - }, - }) - } - return read(name) + const active = await readAct() + const selected = selectedProfileName(env, active) + const name = c.args.name ?? selected + // The selected Profile is shown with its effective URL override, even + // when named explicitly. A foreign positional name remains a stored + // inspection so it can be repaired without active env precedence. + const profileUrlOverride = name === selected ? activeProfileUrlOverride(env) : undefined + if (name === undefined) return virtualManagedDescriptor(true, profileUrlOverride) + return localProfileDescriptor(await read(name), name === selected, profileUrlOverride) }, }) .command('init', { - description: 'Create a local profile', + description: 'Create a DIY Profile pinned to one UCP release', mcp: false, args: z.object({}), options: z.object({ name: z.string().optional().describe('Profile name (filesystem-safe identifier).'), profileUrl: optionalHttpsOption('--profile-url').describe( - 'Public HTTPS URL where businesses read profile.json. Use a URL you own to advertise a custom capability set; whoever controls the URL controls what this agent claims (there is no signing). Omit to use the published URL for --version.', + 'Public HTTPS URL where businesses read profile.json. Use a URL you own to advertise a custom capability set; whoever controls the URL controls what this agent claims (there is no signing). With --force, omitting this preserves a custom URL from readable metadata, while missing/unreadable metadata or a Shopify release-default URL uses the selected --version default.', ), version: supportedVersionOption() .default(LATEST) .describe( - `UCP release template to write (${SUPPORTED_VERSIONS.join(', ')}; default ${LATEST}). The written profile.json declares the release this profile uses. Omit --profile-url to use that release's published profile URL.`, + `UCP release to pin (${SUPPORTED_VERSIONS.join(', ')}; default ${LATEST}). Writes that release's profile.json snapshot. On --force without --profile-url, a custom URL from readable metadata is preserved and a Shopify release-default URL rotates to this release's default.`, ), activate: z .boolean() .default(false) .describe( - 'Mark the new profile as active in active.yaml. The first profile is activated automatically.', + 'Activate the target DIY Profile, including an existing Profile left unchanged without --force. Without this flag, the current selection stays active (Shopify managed on a fresh install).', + ), + force: z + .boolean() + .default(false) + .describe( + 'Re-create an existing Profile in place, preserving readable metadata and any custom profile_url it contains; a Shopify release-default URL rotates to --version. If meta.json is unreadable, pass --profile-url to retain a custom URL.', ), - force: z.boolean().default(false).describe('Re-create an existing profile in place.'), catalog: optionalHttpsOption('--catalog').describe( `Catalog business URL recorded as meta.defaults.catalog (discovery hits /.well-known/ucp). If omitted, session resolution falls through to UCP_DEFAULT_CATALOG, then the baked-in default '${DEFAULT_CATALOG_URL}'.`, ), @@ -245,11 +460,28 @@ export function buildProfileCli(deps: ProfileCliDependencies = {}) { if (name === undefined) return c.error(profileInitRequiresNameError()) - if (!c.options.force && (await exists(name))) { - // Idempotent no-op so agents can safely run `profile init` at the - // start of a session without flooding output or mutating state. - // Return shape mirrors the create case (`name` + `activated`) so a - // caller doesn't need to branch on `created` to read either. + const alreadyExists = await exists(name) + if (!c.options.force && alreadyExists) { + // Without --force, init never re-creates the Profile. Explicit + // activation still validates it (which may stamp a legacy kind + // marker) before writing active.yaml; without --activate this is a + // total no-op. + if (c.options.activate) { + const target = await read(name) + materializeUserProfile(target) + const prev = await readAct() + await writeAct({ ...prev, profile: name }) + return useResultWithOverrideWarning( + { + name, + created: false, + activated: true, + message: 'profile already exists; activated without re-creating it', + }, + { kind: 'local', name }, + env, + ) + } return { name, created: false, @@ -258,14 +490,19 @@ export function buildProfileCli(deps: ProfileCliDependencies = {}) { } } - let priorCreatedAt: string | undefined - if (c.options.force && (await exists(name))) { + let priorMeta: ProfileMeta | undefined + if (c.options.force && alreadyExists) { try { - const prior = await read(name) - priorCreatedAt = prior.meta.created_at + priorMeta = (await read(name)).meta } catch { - // Corrupt prior meta — fall back to a fresh timestamp rather - // than refuse the re-init, which is exactly what --force is for. + // A broken profile.json must not cost an otherwise valid custom + // URL/defaults block. Salvage meta.json independently; if metadata + // is also unreadable, --force intentionally rebuilds it fresh. + try { + priorMeta = await readMeta(name) + } catch { + // Repairing both files is exactly what --force is for. + } } } @@ -275,7 +512,6 @@ export function buildProfileCli(deps: ProfileCliDependencies = {}) { const rel = release(c.options.version) if (rel === undefined) return c.error(unsupportedVersionError(c.options.version)) - const profilesBefore = await list() const now = new Date().toISOString() // profile.json starts as the verbatim document the release's default // URL serves. Cloned, not shared: the user is expected to edit this @@ -284,23 +520,30 @@ export function buildProfileCli(deps: ProfileCliDependencies = {}) { // from this file; the profile URL must serve the same document for the // business to see the same declaration. const body = structuredClone(rel.agentProfileTemplate) + const defaults = + c.options.catalog === undefined + ? priorMeta?.defaults + : { ...(priorMeta?.defaults ?? {}), catalog: c.options.catalog } const meta: ProfileMeta = { - created_at: priorCreatedAt ?? now, + ...(priorMeta ?? {}), + created_at: priorMeta?.created_at ?? now, updated_at: now, - ...(c.options.catalog !== undefined ? { defaults: { catalog: c.options.catalog } } : {}), + ...(defaults !== undefined ? { defaults } : {}), // The CLI reads `ucp.version` from profile.json; the business reads - // it from this URL. Init writes a matching pair, and `ucp doctor` - // reports any later disagreement. - profile_url: - profileUrl !== undefined - ? requireHttpsString(profileUrl, 'profile URL') - : rel.defaultAgentProfileUrl, + // it from this URL. Preserve an owned/custom URL across repair, but + // rotate a Shopify release URL with the body so version and URL do + // not silently diverge. An explicit --profile-url always wins. + profile_url: initProfileUrl(profileUrl, priorMeta, rel.defaultAgentProfileUrl), + // A current template body is also the managed rendering body. The + // explicit marker is what makes init's pinned DIY intent durable + // instead of eligible for legacy auto-classification on next read. + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', } const profile = await save({ name, body, meta, overwrite: c.options.force }) - const shouldActivate = c.options.activate || profilesBefore.length === 0 - if (shouldActivate) { + if (c.options.activate) { const prev = await readAct() await writeAct({ ...prev, profile: name }) } @@ -309,10 +552,10 @@ export function buildProfileCli(deps: ProfileCliDependencies = {}) { // context noise on every init. Callers that want the full body // can run `ucp profile show ` or read profile.json directly // at the path returned here. - return { + const result = { name: profile.name, created: true, - activated: shouldActivate, + activated: c.options.activate, path: profileDir(profile.name), // `version` echoes the `ucp.version` written to profile.json; // `profile_url` names the copy businesses read. @@ -321,24 +564,67 @@ export function buildProfileCli(deps: ProfileCliDependencies = {}) { ? { profile_url: profile.meta.profile_url } : {}), } + return c.options.activate + ? useResultWithOverrideWarning(result, { kind: 'local', name }, env) + : result }, }) .command('use', { - description: 'Switch the active profile', + description: 'Switch to a local Profile, or return to Shopify managed with --managed', mcp: false, - args: z.object({ name: z.string() }), - options: z.object({}), + args: z.object({ name: z.string().optional().describe('Local Profile name.') }), + options: z.object({ + managed: z + .boolean() + .default(false) + .describe('Use the virtual Shopify managed Profile instead of a local Profile.'), + }), async run(c) { - if (!(await exists(c.args.name))) { + const name = c.args.name + if (c.options.managed && name !== undefined) { + return c.error({ + code: ErrorCodes.INVALID_INPUT, + message: 'profile use accepts either a local Profile name or --managed, not both', + }) + } + + if (c.options.managed) { + const prev = await readAct() + const { profile: previous, ...remaining } = prev + await writeAct(remaining) + return useResultWithOverrideWarning( + { profile: null, previous: previous ?? null }, + { kind: 'managed' }, + env, + ) + } + + if (name === undefined) { + return c.error({ + code: ErrorCodes.INVALID_INPUT, + message: 'profile use needs a local Profile name or --managed', + }) + } + if (!(await exists(name))) { throw new UcpError({ layer: 'client', code: ErrorCodes.PROFILE_NOT_FOUND, - message: `profile "${c.args.name}" does not exist`, + message: `profile "${name}" does not exist`, }) } + + // Reading validates storage and performs the one-time legacy kind + // migration; materialization then applies the complete runtime + // validator before active.yaml can point at the target. + const target = await read(name) + materializeUserProfile(target) const prev = await readAct() - await writeAct({ ...prev, profile: c.args.name }) - return { profile: c.args.name, previous: prev.profile ?? null } + await writeAct({ ...prev, profile: name }) + return useResultWithOverrideWarning( + { profile: name, previous: prev.profile ?? null }, + { kind: 'local', name }, + env, + ) }, }) } diff --git a/src/cli/session.test.ts b/src/cli/session.test.ts index cd5d105..1d5683f 100644 --- a/src/cli/session.test.ts +++ b/src/cli/session.test.ts @@ -1,12 +1,17 @@ // resolveSession tests. -import { mkdtemp, rm } from 'node:fs/promises' +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' import { LATEST, RELEASES } from '../core/releases.js' +import { setWarnWriter } from '../core/verbose.js' import { resolveSession } from './session.js' const SAMPLE_BODY: PlatformProfile = { @@ -40,7 +45,17 @@ async function seedUserProfile(homeDir: string, name = 'prod'): Promise { await saveUserProfile({ name, body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) } -describe('resolveSession — profile required branch', () => { +function onlyProfileUrl(session: Awaited>): string { + const renderings = Object.values(session.profile.renderings) + expect(renderings).toHaveLength(1) + return (renderings[0] as (typeof renderings)[number]).url +} + +afterEach(() => { + setWarnWriter(null) +}) + +describe('resolveSession — implicit managed Profile', () => { let homeDir: string beforeEach(async () => { homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-session-test-')) @@ -49,16 +64,48 @@ describe('resolveSession — profile required branch', () => { await rm(homeDir, { recursive: true, force: true }) }) - it('throws PROFILE_NOT_FOUND with CTA when no profile is selected', async () => { - await expect(resolveSession({ homeDir, env: {} })).rejects.toMatchObject({ - code: 'PROFILE_NOT_FOUND', - layer: 'client', - cta: expect.objectContaining({ - commands: expect.arrayContaining([ - expect.objectContaining({ command: 'ucp profile init --name agent' }), - ]), - }), + it('resolves a fresh home to managed renderings plus the catalog default', async () => { + const session = await resolveSession({ homeDir, env: {} }) + + expect(session.profile.source).toBe('managed') + expect(session.profile).not.toHaveProperty('kind') + expect(session.profile.urlOverride).toBe(false) + expect(session.profile.name).toBeUndefined() + expect(Object.keys(session.profile.renderings).sort()).toEqual(['2026-04-08', '2026-08-25']) + expect(session.profileMeta?.defaults?.catalog).toBe('https://catalog.shopify.com') + }) + + it('turns a known --profile-url with no name into that release singleton without warning', async () => { + const warnings: string[] = [] + setWarnWriter((message) => warnings.push(message)) + const url = RELEASES['2026-04-08'].defaultAgentProfileUrl + const session = await resolveSession({ homeDir, env: {}, profileUrl: url }) + + expect(session.profile.source).toBe('url') + expect(session.profile.urlOverride).toBe(true) + expect(session.profile.name).toBeUndefined() + expect(Object.keys(session.profile.renderings)).toEqual(['2026-04-08']) + expect(onlyProfileUrl(session)).toBe(url) + expect(warnings).toEqual([]) + }) + + it('warns that unknown UCP_AGENT_PROFILE_URL uses the latest bundled body', async () => { + const warnings: string[] = [] + setWarnWriter((message) => warnings.push(message)) + const url = 'https://agent.example.com/custom.json' + const session = await resolveSession({ + homeDir, + env: { UCP_AGENT_PROFILE_URL: url }, }) + + expect(session.profile.source).toBe('url') + expect(session.profile.urlOverride).toBe(true) + expect(Object.keys(session.profile.renderings)).toEqual([LATEST]) + expect(onlyProfileUrl(session)).toBe(url) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(`bundled UCP ${LATEST} body`) + expect(warnings[0]).toMatch(/planning and negotiation/i) + expect(warnings[0]).toContain('ucp doctor') }) it("treats active.yaml profile 'default' as a normal local profile name", async () => { @@ -81,8 +128,13 @@ describe('resolveSession — user profile branch', () => { it('loads profile from disk when --profile names a user profile', async () => { await seedUserProfile(homeDir, 'prod') const session = await resolveSession({ homeDir, env: {}, profile: 'prod' }) - expect(session.profile.name).toBe('prod') - expect(session.profile.profileUrl).toBe('https://mybot.example.com/.well-known/ucp') + expect(session.profile).toMatchObject({ + source: 'diy', + urlOverride: false, + name: 'prod', + }) + expect(Object.keys(session.profile.renderings)).toEqual(['2026-08-25']) + expect(onlyProfileUrl(session)).toBe('https://mybot.example.com/.well-known/ucp') }) it('throws PROFILE_NOT_FOUND when the named profile is not on disk', async () => { @@ -92,59 +144,102 @@ describe('resolveSession — user profile branch', () => { }) }) - it("falls back to the latest release's published agent profile when meta.profile_url is absent", async () => { - // The fallback must be a reachable document because the business reads - // it. The CLI reads the named profile's local profile.json instead. + it("falls back to the body release's published URL when meta.profile_url is absent", async () => { + const body = JSON.parse(RELEASES['2026-04-08'].agentProfileJson) as PlatformProfile await saveUserProfile( { - name: 'managed-local', - body: SAMPLE_BODY, - meta: { ...SAMPLE_META, profile_url: undefined }, + name: 'pinned-0408', + body, + // Marked DIY on purpose. These bytes ARE the 04-08 release template, + // so an unmarked copy is a generated body and upgrades to managed + // (see profile-store.test.ts); the URL fallback under test is the DIY + // one, and the marker is how a user keeps a template body pinned. + meta: { ...SAMPLE_META, profile_url: undefined, format_version: 2, kind: 'diy' }, }, { homeDir }, ) - const session = await resolveSession({ homeDir, env: {}, profile: 'managed-local' }) - expect(session.profile.name).toBe('managed-local') - expect(session.profile.profileUrl).toBe(RELEASES[LATEST].defaultAgentProfileUrl) + const session = await resolveSession({ homeDir, env: {}, profile: 'pinned-0408' }) + + expect(Object.keys(session.profile.renderings)).toEqual(['2026-04-08']) + expect(onlyProfileUrl(session)).toBe(RELEASES['2026-04-08'].defaultAgentProfileUrl) }) - it('--profile-url override fills in for a local profile without profile_url', async () => { - await saveUserProfile( - { - name: 'deferred', - body: SAMPLE_BODY, - meta: { ...SAMPLE_META, profile_url: undefined }, - }, - { homeDir }, - ) + it('--profile-url wins over env and named profile metadata', async () => { + await seedUserProfile(homeDir, 'hosted') const session = await resolveSession({ homeDir, - env: {}, - profile: 'deferred', - profileUrl: 'https://mybot.example.com/profile.json', + env: { UCP_AGENT_PROFILE_URL: 'https://env.example.com/profile.json' }, + profile: 'hosted', + profileUrl: 'https://flag.example.com/profile.json', }) - expect(session.profile.profileUrl).toBe('https://mybot.example.com/profile.json') + expect(session.profile).toMatchObject({ source: 'diy', urlOverride: true }) + expect(onlyProfileUrl(session)).toBe('https://flag.example.com/profile.json') }) - it('UCP_AGENT_PROFILE_URL fills in for a local profile without profile_url', async () => { - await saveUserProfile( - { - name: 'deferred', - body: SAMPLE_BODY, - meta: { ...SAMPLE_META, profile_url: undefined }, - }, - { homeDir }, - ) + it('marks an explicit URL override even when it equals stored metadata', async () => { + await seedUserProfile(homeDir, 'same-url') const session = await resolveSession({ homeDir, - env: { UCP_AGENT_PROFILE_URL: 'https://env.example.com/profile.json' }, - profile: 'deferred', + env: { UCP_AGENT_PROFILE_URL: SAMPLE_META.profile_url }, + profile: 'same-url', + }) + + expect(session.profile).toMatchObject({ source: 'diy', urlOverride: true }) + expect(session.profile.renderings['2026-08-25']).toMatchObject({ + source: 'diy', + urlOverride: true, + url: SAMPLE_META.profile_url, + }) + }) + + it('preserves an exact custom DIY body without a bundled-body substitution warning', async () => { + const warnings: string[] = [] + setWarnWriter((message) => warnings.push(message)) + const customBody = structuredClone(SAMPLE_BODY) + customBody.ucp.services = { + 'com.example.override_service': [ + { version: '2025-11-01', transport: 'mcp', config: { marker: 'unique-service' } }, + ], + } + customBody.ucp.capabilities = { + 'com.example.override_capability': [ + { version: '2025-11-01', config: { marker: 'unique-capability' } }, + ], + } + await saveUserProfile({ name: 'hosted', body: customBody, meta: SAMPLE_META }, { homeDir }) + + const overrideUrl = 'https://env.example.com/profile.json' + const session = await resolveSession({ + homeDir, + env: { UCP_AGENT_PROFILE_URL: overrideUrl }, + profile: 'hosted', + }) + const selected = session.profile.renderings['2026-08-25'] + + // Assert authored content before provenance labels so a bundled-body + // substitution mutant is killed by the behavior this test exists to pin. + expect(selected?.body).toStrictEqual(customBody) + expect(selected?.services['com.example.override_service']).toStrictEqual( + customBody.ucp.services['com.example.override_service'], + ) + expect(selected?.capabilities).toContain('com.example.override_capability') + expect(session.profile).toMatchObject({ + source: 'diy', + urlOverride: true, + name: 'hosted', + }) + expect(Object.keys(session.profile.renderings)).toEqual(['2026-08-25']) + expect(selected).toMatchObject({ + source: 'diy', + urlOverride: true, + url: overrideUrl, + version: customBody.ucp.version, }) - expect(session.profile.profileUrl).toBe('https://env.example.com/profile.json') + expect(warnings).toEqual([]) }) it('passes user profile meta through to ResolvedSession', async () => { - // The catalog-op handler needs `profile.meta.defaults.catalog` after a + // The catalog-op handler needs `profileMeta.defaults.catalog` after a // resolveSession() that returned no business; surfacing meta is the seam // that lets the handler decide whether to fire the fallback rung without // re-reading the profile from disk. @@ -160,15 +255,15 @@ describe('resolveSession — user profile branch', () => { { homeDir }, ) const session = await resolveSession({ homeDir, env: {}, profile: 'with-defaults' }) - expect(session.profile.meta?.defaults?.catalog).toBe('https://custom-catalog.example.com') - expect(session.profile.meta?.created_at).toBe(SAMPLE_META.created_at) + expect(session.profileMeta?.defaults?.catalog).toBe('https://custom-catalog.example.com') + expect(session.profileMeta?.created_at).toBe(SAMPLE_META.created_at) }) it('falls back to the baked-in default catalog when profile + env are unset', async () => { await seedUserProfile(homeDir, 'no-defaults') const session = await resolveSession({ homeDir, env: {}, profile: 'no-defaults' }) - expect(session.profile.meta).toBeDefined() - expect(session.profile.meta?.defaults?.catalog).toBe('https://catalog.shopify.com') + expect(session.profileMeta).toBeDefined() + expect(session.profileMeta?.defaults?.catalog).toBe('https://catalog.shopify.com') }) it('UCP_DEFAULT_CATALOG overrides the baked-in default but loses to the profile value', async () => { @@ -178,7 +273,7 @@ describe('resolveSession — user profile branch', () => { env: { UCP_DEFAULT_CATALOG: 'https://staging-catalog.example.com' }, profile: 'env-override', }) - expect(envOnly.profile.meta?.defaults?.catalog).toBe('https://staging-catalog.example.com') + expect(envOnly.profileMeta?.defaults?.catalog).toBe('https://staging-catalog.example.com') await saveUserProfile( { @@ -193,11 +288,161 @@ describe('resolveSession — user profile branch', () => { env: { UCP_DEFAULT_CATALOG: 'https://staging-catalog.example.com' }, profile: 'profile-wins', }) - expect(profileWins.profile.meta?.defaults?.catalog).toBe('https://from-profile.example.com') + expect(profileWins.profileMeta?.defaults?.catalog).toBe('https://from-profile.example.com') }) }) -describe('resolveSession — precedence (flag > env > active.yaml > required profile)', () => { +// 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. +describe('resolveSession — upgraded legacy profile', () => { + const FIXTURE_DIR = fileURLToPath( + new URL('../../test/fixtures/legacy-profiles/', import.meta.url), + ) + /** meta.json as 0.4.2 … 0.8.0 wrote it: no profile_url, no marker. */ + const LEGACY_META = { + created_at: '2026-06-01T10:00:00.000Z', + updated_at: '2026-06-01T10:00:00.000Z', + protocol_versions: { min: '2026-01-23', max: '2026-04-08' }, + } + + let homeDir: string + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-session-test-')) + }) + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }) + }) + + async function seedLegacy(name: string, fixture: string, meta = LEGACY_META): Promise { + const dir = join(homeDir, 'profiles', name) + await mkdir(dir, { recursive: true, mode: 0o700 }) + await writeFile( + join(dir, 'profile.json'), + await readFile(join(FIXTURE_DIR, fixture), 'utf-8'), + 'utf-8', + ) + await writeFile(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf-8') + return dir + } + + 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 before = await readFile(join(dir, 'profile.json'), 'utf-8') + + const session = await resolveSession({ homeDir, env: {}, profile: 'legacy07' }) + + expect(session.profile.source).toBe('managed') + expect(session.profile.urlOverride).toBe(false) + // The name is what selects headers.json and addresses this identity. + expect(session.profile.name).toBe('legacy07') + expect(Object.keys(session.profile.renderings).sort()).toEqual(['2026-04-08', '2026-08-25']) + for (const version of ['2026-04-08', '2026-08-25'] as const) { + const installed = session.profile.renderings[version] + expect(installed?.url).toBe(RELEASES[version].defaultAgentProfileUrl) + expect(installed?.name).toBe('legacy07') + } + // Local metadata and defaults survive the upgrade. + expect(session.profileMeta.created_at).toBe(LEGACY_META.created_at) + expect(session.profileMeta.defaults?.catalog).toBe('https://catalog.shopify.com') + // The old body is retained on disk for a downgrade; it just is not what + // we negotiate from any more. + 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') + + const session = await resolveSession({ homeDir, env: {}, profile: 'legacy08' }) + + expect(session.profile.source).toBe('managed') + expect(Object.keys(session.profile.renderings).sort()).toEqual(['2026-04-08', '2026-08-25']) + }) + + it.each([ + { state: 'missing', bodyBytes: undefined }, + { state: 'corrupt', bodyBytes: '\n' }, + ])('resolves a marked managed alias with $state profile.json', async ({ bodyBytes }) => { + const name = 'marked-managed' + const dir = join(homeDir, 'profiles', name) + const bodyPath = join(dir, 'profile.json') + await mkdir(dir, { recursive: true, mode: 0o700 }) + await writeFile( + join(dir, 'meta.json'), + `${JSON.stringify({ + ...LEGACY_META, + format_version: PROFILE_FORMAT_VERSION, + kind: 'managed', + })}\n`, + 'utf-8', + ) + if (bodyBytes !== undefined) await writeFile(bodyPath, bodyBytes, 'utf-8') + + const session = await resolveSession({ homeDir, env: {}, profile: name }) + + expect(session.profile).toMatchObject({ source: 'managed', name }) + expect(Object.keys(session.profile.renderings).sort()).toEqual(['2026-04-08', '2026-08-25']) + if (bodyBytes !== undefined) expect(await readFile(bodyPath, 'utf-8')).toBe(bodyBytes) + }) + + 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', { + ...LEGACY_META, + profile_url: 'https://mybot.example.com/.well-known/ucp', + } as typeof LEGACY_META) + + const session = await resolveSession({ homeDir, env: {}, profile: 'hosted' }) + + expect(session.profile.source).toBe('diy') + expect(Object.keys(session.profile.renderings)).toEqual(['2026-08-25']) + expect(onlyProfileUrl(session)).toBe('https://mybot.example.com/.well-known/ucp') + }) + + 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') + + const session = await resolveSession({ + homeDir, + env: {}, + profile: 'legacy07', + profileUrl: 'https://agent.example.com/custom.json', + }) + + // A scalar URL is one ad-hoc bundled rendering, never a managed spread — + // but the local name (and so headers.json) still applies. + expect(session.profile).toMatchObject({ source: 'url', urlOverride: true }) + expect(Object.keys(session.profile.renderings)).toEqual([LATEST]) + expect(onlyProfileUrl(session)).toBe('https://agent.example.com/custom.json') + expect(session.profile.name).toBe('legacy07') + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(`bundled UCP ${LATEST} body`) + }) +}) + +describe('resolveSession — precedence (flag > env > active.yaml > managed)', () => { let homeDir: string beforeEach(async () => { homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-session-test-')) @@ -306,14 +551,13 @@ describe('resolveSession — business precedence', () => { expect(fromFlag.businessSource).toBe('flag') }) - it('MCP mode ignores the active profile as well, so none is selected', async () => { - // The profile chain has the same file leg. Dropping it is what makes an - // argument-less tool call fail closed rather than run as whoever the - // machine's owner last selected. + it('MCP mode ignores the active profile and defaults to managed', async () => { await writeActive({ profile: 'prod', business: 'https://active.example.com' }, { homeDir }) - await expect(resolveSession({ homeDir, env: {}, inMcpMode: true })).rejects.toMatchObject({ - code: 'PROFILE_NOT_FOUND', - }) + const session = await resolveSession({ homeDir, env: {}, inMcpMode: true }) + + expect(session.profile.source).toBe('managed') + expect(session.profile.name).toBeUndefined() + expect(session.business).toBeUndefined() }) it('treats empty-string flag/env as unset (falls through precedence)', async () => { diff --git a/src/cli/session.ts b/src/cli/session.ts index dc81137..90901da 100644 --- a/src/cli/session.ts +++ b/src/cli/session.ts @@ -1,60 +1,62 @@ -// Resolve the active profile and target business for dispatch. +// Resolve the runtime Profile and target Business for dispatch. // // Precedence is first-defined-wins: -// profile: option → UCP_PROFILE → active.yaml.profile → required local profile -// profile URL: option → UCP_AGENT_PROFILE_URL → profile metadata → default -// business: option → UCP_BUSINESS → active.yaml.business +// profile name: option → UCP_PROFILE → active.yaml.profile → managed +// profile URL: option → UCP_AGENT_PROFILE_URL → named profile metadata → +// named body release default +// business: option → UCP_BUSINESS → active.yaml.business // -// The resolver returns what it can prove from local state. When a profile -// carries no `meta.profile_url`, the fallback is the latest release's PUBLISHED -// agent profile (`release(LATEST).defaultAgentProfileUrl`) — a real, reachable -// document for the business to read. The CLI still negotiates from the named -// profile's local `profile.json`; `ucp doctor` reports any disagreement. +// A named local profile starts from the store's persisted classification; +// this resolver turns that classification into the runtime rendering set. An +// authored (DIY) profile is a singleton pinned to its body release. A profile whose +// body is one ucp-cli generated — including every unmarked profile written by +// 0.4.2 … 0.8.0 — is managed, and gets the same multi-rendering Profile a +// fresh install gets, still carrying its local name for headers.json and +// messages. core/legacy-profile.ts owns that decision; nothing here inspects +// a body. // -// `ActiveProfile` stays a POINTER: `{name, profileUrl, meta}`. The identity -// document is not resolved here — `discover` does that from the URL plus the -// profile NAME, and core/agent.ts answers it from that profile's own -// `profile.json`, with no request. +// With no name, the default is a managed Profile containing one independently +// hosted rendering per installed release. An explicit URL override always +// pins one rendering. For a named DIY Profile it replaces only the rendering +// URL: the exact locally authored body remains the declaration. For managed +// or nameless resolution there is no authored body to retain, so the override +// produces one ad-hoc bundled rendering (known published URLs select their +// release; unknown URLs use latest). A scalar URL is never spread across +// managed renderings. // -// v0.1/v0.2 local profile work: signing material is intentionally not threaded -// through. The profile body may advertise public keys later, but request -// signing remains a separate phase. +// MCP mode does not read active.yaml. Explicit flags/env still apply; with no +// explicit Profile it gets the same managed default as a fresh CLI session. +import { + createAdHocProfile, + createDiyProfile, + createManagedProfile, + type Profile, +} from '../core/agent.js' import { DEFAULT_CATALOG_URL } from '../core/profile.js' import { type ActiveSession, type ProfileMeta, readActive, readUserProfile, + type UserProfile, } from '../core/profile-store.js' -import { LATEST, RELEASES } from '../core/releases.js' -import { ErrorCodes, UcpError } from '../lib/errors.js' - -export interface ActiveProfile { - /** User-supplied local profile name. */ - name: string - /** - * Where this profile is hosted. `resolveSession` precedence: - * option → `UCP_AGENT_PROFILE_URL` → `meta.profile_url` → - * `release(LATEST).defaultAgentProfileUrl` (the published default identity). - */ - profileUrl?: string - /** - * Per-machine meta from the profile's `meta.json`. Catalog resolution reads - * `meta.defaults.catalog` here, not `package.json`. - */ - meta?: ProfileMeta -} /** * Where the resolved business URL came from. Used by --verbose to print a - * one-liner at boot so agents can confirm precedence ate the right value - * (e.g., a stale UCP_BUSINESS shadowing a newer `ucp use`). + * one-liner at boot so agents can confirm precedence ate the right value. */ export type BusinessSource = 'flag' | 'env' | 'active.yaml' export interface ResolvedSession { - profile: ActiveProfile + profile: Profile + /** + * Local-only metadata/defaults for this session. Required: every + * resolution path produces one — a named profile's `meta.json`, or the + * synthesized defaults a nameless session runs on — and catalog fallback + * reads `defaults.catalog` off it unconditionally. + */ + profileMeta: ProfileMeta /** Resolved business URL. Empty string is treated as unset. */ business?: string /** Where `business` came from. Undefined when `business` is undefined. */ @@ -64,7 +66,7 @@ export interface ResolvedSession { export interface ResolveSessionOptions { /** `--profile ` flag override. */ profile?: string - /** `--profile-url ` flag override. Tops the precedence chain. */ + /** `--profile-url ` flag override. Tops the URL precedence chain. */ profileUrl?: string /** `--business ` flag override. */ business?: string @@ -73,89 +75,95 @@ export interface ResolveSessionOptions { /** Override env-var lookup for tests. Defaults to `process.env`. */ env?: Record /** - * Set by `ucp --mcp`. Drops the `active.yaml` legs of both precedence - * chains; flags and `UCP_PROFILE` / `UCP_BUSINESS` are unaffected. + * Set by `ucp --mcp`. Drops both `active.yaml` precedence legs; explicit + * flags and environment variables are unaffected. */ inMcpMode?: boolean } /** - * Resolve active profile and active business target. + * Turn one classified stored Profile into the runtime rendering set used for + * planning and negotiation. Pure storage-to-runtime boundary: callers that + * already hold a UserProfile reuse the exact validation session resolution + * applies, including loadAgentProfile's cross-version snapshot invariant. */ +export function materializeUserProfile(user: UserProfile, profileUrlOverride?: string): Profile { + if (user.kind === 'managed') { + return profileUrlOverride === undefined + ? createManagedProfile(user.name) + : createAdHocProfile(profileUrlOverride, user.name) + } + + const selectedUrl = profileUrlOverride ?? user.meta.profile_url + return createDiyProfile({ + name: user.name, + body: user.body, + urlOverride: profileUrlOverride !== undefined, + // createDiyProfile derives the body release's published URL when no + // explicit or stored URL exists. + ...(selectedUrl !== undefined ? { url: selectedUrl } : {}), + }) +} + +/** Resolve the Profile and active Business target. */ export async function resolveSession(opts: ResolveSessionOptions = {}): Promise { const env = opts.env ?? process.env const storeOpts = opts.homeDir !== undefined ? { homeDir: opts.homeDir } : {} - // MCP mode does not read active.yaml at all — not read-then-discard. One - // stdio server serves many unrelated agent conversations, so process-global - // routing state a human wrote with `ucp use` would leak into all of them; - // an omitted business must fail closed instead of inheriting a target. The - // file is the only thing dropped: explicit options and UCP_PROFILE / - // UCP_BUSINESS resolve exactly as they do on the CLI path. const active: ActiveSession = opts.inMcpMode === true ? {} : await readActive(storeOpts) - const profileName = opts.profile ?? env.UCP_PROFILE ?? active.profile - // Walk precedence explicitly so we can pin the source label to the layer - // that actually contributed the value. Empty string is "unset" (matches - // historical behavior — env vars often default to '' under shells). + const profileName = firstSet(opts.profile, env.UCP_PROFILE, active.profile) + const profileUrlOverride = firstSet(opts.profileUrl, env.UCP_AGENT_PROFILE_URL) + + let profile: Profile + let profileMeta: ProfileMeta + if (profileName !== undefined) { + // An explicit or active name must exist, and a missing or corrupt one + // throws out of here. Never fall back to managed when a caller asked for + // a specific local identity: silently selling under a different identity + // than the operator named is worse than not selling. + const user = await readUserProfile(profileName, storeOpts) + profileMeta = withDefaultCatalog(user.meta, env.UCP_DEFAULT_CATALOG) + profile = materializeUserProfile(user, profileUrlOverride) + } else { + profileMeta = withDefaultCatalog({}, env.UCP_DEFAULT_CATALOG) + profile = + profileUrlOverride === undefined + ? createManagedProfile() + : createAdHocProfile(profileUrlOverride) + } + let businessSource: BusinessSource | undefined - let rawBusiness: string | undefined - if (opts.business !== undefined && opts.business !== '') { - rawBusiness = opts.business + let business: string | undefined + if (isSet(opts.business)) { + business = opts.business businessSource = 'flag' - } else if (env.UCP_BUSINESS !== undefined && env.UCP_BUSINESS !== '') { - rawBusiness = env.UCP_BUSINESS + } else if (isSet(env.UCP_BUSINESS)) { + business = env.UCP_BUSINESS businessSource = 'env' - } else if (active.business !== undefined && active.business !== '') { - rawBusiness = active.business + } else if (isSet(active.business)) { + business = active.business businessSource = 'active.yaml' } - const business = rawBusiness - if (profileName === undefined || profileName === '') { - throw new UcpError({ - layer: 'client', - code: ErrorCodes.PROFILE_NOT_FOUND, - message: 'no local profile selected', - cta: { - description: 'Create a local profile before running UCP operations.', - commands: [ - { - command: 'ucp profile init --name agent', - description: 'create and activate a local profile', - }, - ], - }, - }) + if (business !== undefined && businessSource !== undefined) { + return { profile, profileMeta, business, businessSource } } + return { profile, profileMeta } +} - const user = await readUserProfile(profileName, storeOpts) - const meta = withDefaultCatalog(user.meta, env.UCP_DEFAULT_CATALOG) - // A profile with no `meta.profile_url` advertises the latest release's - // published default. The CLI still reads the named profile's profile.json; - // doctor reports a mismatch if that file does not match the fallback URL. - // A capability set of your own needs a URL you control because businesses - // read the URL, and keeping it equal to profile.json is the user's obligation. - const profileUrl = - opts.profileUrl ?? - env.UCP_AGENT_PROFILE_URL ?? - meta.profile_url ?? - RELEASES[LATEST].defaultAgentProfileUrl - const profile: ActiveProfile = { name: profileName, profileUrl, meta } +function isSet(value: string | undefined): value is string { + return value !== undefined && value !== '' +} - if (business !== undefined && businessSource !== undefined) { - return { profile, business, businessSource } - } - return { profile } +function firstSet(...values: Array): string | undefined { + return values.find(isSet) } -// Resolution order for `meta.defaults.catalog`: -// profile meta > UCP_DEFAULT_CATALOG env > baked-in DEFAULT_CATALOG_URL -// Profile wins because the user said so explicitly; env lets ops point a -// machine at a staging catalog without rewriting the local profile; baked-in -// is the floor so the catalog fallback rung is never accidentally empty. +// Resolution order for catalog defaults: +// local profile meta > UCP_DEFAULT_CATALOG > baked-in DEFAULT_CATALOG_URL. function withDefaultCatalog(meta: ProfileMeta, envOverride: string | undefined): ProfileMeta { if (meta.defaults?.catalog !== undefined) return meta - const fromEnv = envOverride !== undefined && envOverride !== '' ? envOverride : undefined + const fromEnv = isSet(envOverride) ? envOverride : undefined const catalog = fromEnv ?? DEFAULT_CATALOG_URL return { ...meta, defaults: { ...(meta.defaults ?? {}), catalog } } } diff --git a/src/core/agent.test.ts b/src/core/agent.test.ts index 826ea99..44275ec 100644 --- a/src/core/agent.test.ts +++ b/src/core/agent.test.ts @@ -5,20 +5,19 @@ // `PROFILE_*` codes (profile.ts) and no code may mean both; the code→layer // invariant is asserted separately in `lib/error-layers.test.ts`. -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ErrorCodes, UcpError } from '../lib/errors.js' +import { rendering } from '../test-utils.js' import { agentProfileRedirect, + createAdHocProfile, + createDiyProfile, + createManagedProfile, fetchAgentProfileLive, loadAgentProfile, - resolveAgentProfile, } from './agent.js' -import { LATEST, RELEASES } from './releases.js' +import { LATEST, RELEASES, SUPPORTED_VERSIONS } from './releases.js' import { setWarnWriter } from './verbose.js' const SELF_HOSTED = 'https://agent.example.invalid/agent.json' @@ -45,7 +44,14 @@ afterEach(() => { describe('loadAgentProfile — failure codes are all AGENT_PROFILE_*', () => { it('AGENT_PROFILE_SCHEMA_INVALID when the body carries no ucp.version', () => { - expect(() => loadAgentProfile({ body: { nope: true }, url: SELF_HOSTED })).toThrowError( + expect(() => + loadAgentProfile({ + body: { nope: true }, + url: SELF_HOSTED, + source: 'diy', + urlOverride: false, + }), + ).toThrowError( expect.objectContaining({ code: 'AGENT_PROFILE_SCHEMA_INVALID', layer: 'client', @@ -56,7 +62,15 @@ describe('loadAgentProfile — failure codes are all AGENT_PROFILE_*', () => { it('AGENT_PROFILE_SCHEMA_INVALID when the release schema rejects the body', () => { const body = publishedBody() body.ucp.services = 'reshaped' - expect(() => loadAgentProfile({ body, url: SELF_HOSTED, name: 'agent' })).toThrowError( + expect(() => + loadAgentProfile({ + body, + url: SELF_HOSTED, + source: 'diy', + urlOverride: false, + name: 'agent', + }), + ).toThrowError( expect.objectContaining({ code: 'AGENT_PROFILE_SCHEMA_INVALID', layer: 'client', @@ -68,7 +82,9 @@ describe('loadAgentProfile — failure codes are all AGENT_PROFILE_*', () => { it('AGENT_PROFILE_VERSION_UNSUPPORTED names both the profile version and our window', () => { const body = publishedBody() body.ucp.version = '2026-12-01' - expect(() => loadAgentProfile({ body, url: SELF_HOSTED })).toThrowError( + expect(() => + loadAgentProfile({ body, url: SELF_HOSTED, source: 'diy', urlOverride: false }), + ).toThrowError( expect.objectContaining({ code: 'AGENT_PROFILE_VERSION_UNSUPPORTED', layer: 'client', @@ -82,11 +98,9 @@ describe('loadAgentProfile — failure codes are all AGENT_PROFILE_*', () => { // ─── Severity split: whose document is it? ───────────────────────────────── // -// A `dev.ucp.*` entry off the profile's own `ucp.version` is the same defect -// wherever the URL points, and the remedy is the same too: the bytes ucp-cli -// declares come from `profile.json`, which the reader can edit. Proceeding -// would send a declaration whose off-version entries silently fail to -// negotiate. +// These direct-loader fixtures model DIY documents. A `dev.ucp.*` entry off +// the profile's own `ucp.version` would silently fail to negotiate, so loading +// it is fatal regardless of where its DIY author publishes it. describe('loadAgentProfile — AGENT_PROFILE_VERSION_MISMATCH', () => { function mixedVersionBody(): ReturnType { @@ -103,7 +117,13 @@ describe('loadAgentProfile — AGENT_PROFILE_VERSION_MISMATCH', () => { it('is fatal, naming the entry that is off and both versions', () => { captureWarnings() expect(() => - loadAgentProfile({ body: mixedVersionBody(), url: SELF_HOSTED, name: 'mine' }), + loadAgentProfile({ + body: mixedVersionBody(), + url: SELF_HOSTED, + source: 'diy', + urlOverride: false, + name: 'mine', + }), ).toThrowError( expect.objectContaining({ code: 'AGENT_PROFILE_VERSION_MISMATCH', @@ -121,7 +141,12 @@ describe('loadAgentProfile — AGENT_PROFILE_VERSION_MISMATCH', () => { it('the fatal case carries no `kind` — that discriminator belongs to the merchant code', () => { let caught: { context: Record } | undefined try { - loadAgentProfile({ body: mixedVersionBody(), url: SELF_HOSTED }) + loadAgentProfile({ + body: mixedVersionBody(), + url: SELF_HOSTED, + source: 'diy', + urlOverride: false, + }) } catch (err) { caught = err as { context: Record } } @@ -133,7 +158,13 @@ describe('loadAgentProfile — AGENT_PROFILE_VERSION_MISMATCH', () => { it('is fatal on a release-default URL too', () => { captureWarnings() expect(() => - loadAgentProfile({ body: mixedVersionBody(), url: DEFAULT_0825, name: 'agent' }), + loadAgentProfile({ + body: mixedVersionBody(), + url: DEFAULT_0825, + source: 'diy', + urlOverride: false, + name: 'agent', + }), ).toThrowError( expect.objectContaining({ code: 'AGENT_PROFILE_VERSION_MISMATCH' }) as unknown as Error, ) @@ -150,6 +181,8 @@ describe('loadAgentProfile — AGENT_PROFILE_VERSION_MISMATCH', () => { loadAgentProfile({ body: JSON.parse(rel.agentProfileJson), url: rel.defaultAgentProfileUrl, + source: 'managed', + urlOverride: false, }), ).not.toThrow() } @@ -164,134 +197,130 @@ describe('loadAgentProfile — AGENT_PROFILE_VERSION_MISMATCH', () => { // tests have to prove is that the SOURCE is right — and that no fetch // implementation is even accepted, let alone called. -describe('resolveAgentProfile — no profile name falls back to a published template', () => { - it('picks the template for the release the URL belongs to', async () => { - for (const rel of Object.values(RELEASES)) { - const agent = await resolveAgentProfile({ url: rel.defaultAgentProfileUrl }) - - expect(agent.version).toBe(rel.version) - expect(agent.url).toBe(rel.defaultAgentProfileUrl) - // Identical to what a GET of that URL would have produced: the template - // is the verbatim published body (byte-identity is enforced by - // `pnpm gen:schemas` + the CI drift gate). - expect(agent).toStrictEqual( - loadAgentProfile({ - body: JSON.parse(rel.agentProfileJson), - url: rel.defaultAgentProfileUrl, - }), - ) +describe('createManagedProfile', () => { + it('matches every installed release with exact key/body/url/release invariants', () => { + const managed = createManagedProfile() + const another = createManagedProfile() + + expect(managed.source).toBe('managed') + expect(managed).not.toHaveProperty('kind') + expect(managed.urlOverride).toBe(false) + expect(managed.name).toBeUndefined() + expect(Object.keys(managed.renderings)).toEqual(SUPPORTED_VERSIONS) + for (const version of SUPPORTED_VERSIONS) { + const installed = rendering(managed, version) + expect(installed.version).toBe(version) + expect(installed.source).toBe('managed') + expect(installed.urlOverride).toBe(false) + expect(installed.name).toBeUndefined() + expect(installed.body.ucp.version).toBe(version) + expect(installed.url).toBe(RELEASES[version].defaultAgentProfileUrl) + expect(installed.release).toBe(RELEASES[version]) + expect(installed.body).not.toBe(RELEASES[version].agentProfileTemplate) + expect(installed.body).not.toBe(rendering(another, version).body) } }) - it('defaults to the latest release when no URL is configured', async () => { - const agent = await resolveAgentProfile() - expect(agent.url).toBe(RELEASES[LATEST].defaultAgentProfileUrl) - expect(agent.version).toBe(LATEST) - }) - - it('does not hand out the shared release template', async () => { - const agent = await resolveAgentProfile({ url: DEFAULT_0825 }) - expect(agent.body).not.toBe(RELEASES['2026-08-25'].agentProfileTemplate) + // The name is the local profile directory an upgraded legacy profile came + // from: it selects headers.json and addresses this identity in messages. + // It must reach every rendering (that is what error text reads) without + // changing any URL — managed renderings are always the published documents. + it('carries an optional local profile name onto the Profile and every rendering', () => { + const named = createManagedProfile('legacy') + + expect(named.source).toBe('managed') + expect(named.urlOverride).toBe(false) + expect(named.name).toBe('legacy') + expect(Object.keys(named.renderings)).toEqual(SUPPORTED_VERSIONS) + for (const version of SUPPORTED_VERSIONS) { + expect(rendering(named, version).source).toBe('managed') + expect(rendering(named, version).urlOverride).toBe(false) + expect(rendering(named, version).name).toBe('legacy') + expect(rendering(named, version).url).toBe(RELEASES[version].defaultAgentProfileUrl) + } }) }) -describe('resolveAgentProfile — a named profile is answered by its own profile.json', () => { - let home: string +describe('runtime Profile provenance', () => { + it('tracks body source and explicit URL override independently', () => { + const body = publishedBody() + const diy = createDiyProfile({ name: 'mine', body, url: SELF_HOSTED }) + const overriddenDiy = createDiyProfile({ + name: 'mine', + body, + url: SELF_HOSTED, + urlOverride: true, + }) + const adhoc = createAdHocProfile(DEFAULT_0825, 'mine') - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'ucp-agent-home-')) + expect(diy).toMatchObject({ + source: 'diy', + urlOverride: false, + name: 'mine', + }) + expect(rendering(diy, LATEST)).toMatchObject({ source: 'diy', urlOverride: false }) + expect(overriddenDiy).toMatchObject({ + source: 'diy', + urlOverride: true, + name: 'mine', + }) + expect(rendering(overriddenDiy, LATEST)).toMatchObject({ + source: 'diy', + urlOverride: true, + body: rendering(diy, LATEST).body, + }) + expect(adhoc).toMatchObject({ + source: 'url', + urlOverride: true, + name: 'mine', + }) + expect(rendering(adhoc, LATEST)).toMatchObject({ source: 'url', urlOverride: true }) }) - afterEach(async () => { - await rm(home, { recursive: true, force: true }) - }) + it('warns on every unknown URL-only or managed-alias override and uses the bundled LATEST body', () => { + const warnings = captureWarnings() - async function writeProfile(name: string, body: unknown): Promise { - const dir = join(home, 'profiles', name) - await mkdir(dir, { recursive: true }) - const path = join(dir, 'profile.json') - await writeFile(path, `${typeof body === 'string' ? body : JSON.stringify(body, null, 2)}\n`) - return path - } + const nameless = createAdHocProfile(SELF_HOSTED) + const named = createAdHocProfile(SELF_HOSTED, 'legacy') - // THE contract, in one test: the URL is a Shopify-published release default - // and the local file still wins. Anything else makes an edit to the one file - // the reader owns a no-op that nothing reports. - it('reads profile.json even when the URL is a release default', async () => { - const body = publishedBody() - body.ucp.services = { - 'dev.ucp.shopping': [{ version: '2026-08-25', transport: 'mcp' }], - 'com.acme.svc': [{ version: '2025-01-01', transport: 'mcp' }], + expect(warnings).toHaveLength(2) + for (const warning of warnings) { + expect(warning).toContain(SELF_HOSTED) + expect(warning).toContain(`bundled UCP ${LATEST} body`) + expect(warning).toMatch(/planning and negotiation/i) + expect(warning).toContain('ucp doctor') } - await writeProfile('mine', body) - - const agent = await resolveAgentProfile({ url: DEFAULT_0825, name: 'mine', homeDir: home }) - - expect(agent.url).toBe(DEFAULT_0825) - expect(Object.keys(agent.services).sort()).toEqual(['com.acme.svc', 'dev.ucp.shopping']) + expect(rendering(nameless, LATEST).body).toMatchObject( + JSON.parse(RELEASES[LATEST].agentProfileJson), + ) + expect(rendering(named, LATEST)).toMatchObject({ name: 'legacy', url: SELF_HOSTED }) }) - // The premise the whole design rests on: `profile init` writes the same - // document the URL serves, so the disk document and a fetched body are the - // same shape and one validator consumes either. If this ever stops holding, - // the request path is reading something a merchant will never see. - it('produces exactly what the same bytes would produce over the wire', async () => { - const served = JSON.parse(RELEASES['2026-08-25'].agentProfileJson) as unknown - await writeProfile('mine', served) - - const agent = await resolveAgentProfile({ url: SELF_HOSTED, name: 'mine', homeDir: home }) - - expect(agent).toStrictEqual(loadAgentProfile({ body: served, url: SELF_HOSTED, name: 'mine' })) - expect(agent.version).toBe('2026-08-25') - expect(Object.keys(agent.services)).toEqual(['dev.ucp.shopping']) - }) + it('does not warn when a scalar override is a known release-default URL', () => { + const warnings = captureWarnings() - it('reads the user edits, not a published template', async () => { - const body = publishedBody() - body.ucp.services = { - 'dev.ucp.shopping': [{ version: '2026-08-25', transport: 'mcp' }], - 'com.acme.svc': [{ version: '2025-01-01', transport: 'mcp' }], + for (const rel of Object.values(RELEASES)) { + const profile = createAdHocProfile(rel.defaultAgentProfileUrl) + expect(Object.keys(profile.renderings)).toEqual([rel.version]) + expect(rendering(profile, rel.version).url).toBe(rel.defaultAgentProfileUrl) } - await writeProfile('mine', body) - - const agent = await resolveAgentProfile({ url: SELF_HOSTED, name: 'mine', homeDir: home }) - - expect(Object.keys(agent.services).sort()).toEqual(['com.acme.svc', 'dev.ucp.shopping']) + expect(warnings).toEqual([]) }) - it('is AGENT_PROFILE_SCHEMA_INVALID when the local document is not a UCP profile', async () => { - await writeProfile('mine', { nope: true }) - await expect( - resolveAgentProfile({ url: SELF_HOSTED, name: 'mine', homeDir: home }), - ).rejects.toMatchObject({ code: 'AGENT_PROFILE_SCHEMA_INVALID', layer: 'client' }) - }) - - it('is AGENT_PROFILE_SCHEMA_INVALID when profile.json is not valid JSON', async () => { - await writeProfile('mine', '{ not json') - await expect( - resolveAgentProfile({ url: SELF_HOSTED, name: 'mine', homeDir: home }), - ).rejects.toMatchObject({ code: 'AGENT_PROFILE_SCHEMA_INVALID', layer: 'client' }) - }) + it('does not emit the bundled-body substitution warning for a named DIY URL override', () => { + const warnings = captureWarnings() + const body = publishedBody() - it('names the file when there is no local document to read', async () => { - await expect( - resolveAgentProfile({ url: SELF_HOSTED, name: 'ghost', homeDir: home }), - ).rejects.toMatchObject({ - code: 'PROFILE_NOT_FOUND', - message: expect.stringContaining('profile.json') as unknown as string, + const profile = createDiyProfile({ + name: 'mine', + body, + url: SELF_HOSTED, + urlOverride: true, }) - }) - - // The rung no CLI path reaches: `discover({profileUrl})` with no profile - // name and no injected agent. The URL on the wire is still right, so this - // warns and negotiates generically rather than failing. - it('warns and falls back to the latest published template when no profile is named', async () => { - const warnings = captureWarnings() - const agent = await resolveAgentProfile({ url: SELF_HOSTED }) - expect(agent.url).toBe(SELF_HOSTED) - expect(agent.version).toBe(LATEST) - expect(warnings.join('')).toContain('no local profile was named') + expect(warnings).toEqual([]) + expect(rendering(profile, LATEST).body).toMatchObject(body) + expect(rendering(profile, LATEST).url).toBe(SELF_HOSTED) }) }) @@ -322,6 +351,7 @@ describe('fetchAgentProfileLive — AGENT_PROFILE_UNREACHABLE carries a reason', const live = await fetchAgentProfileLive({ url: DEFAULT_0825, fetch }) expect(live.agent.version).toBe('2026-04-08') + expect(live.agent.source).toBe('url') expect(live.cacheControl).toBe('public, max-age=300') }) diff --git a/src/core/agent.ts b/src/core/agent.ts index 9b1d41d..4104e72 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,47 +1,35 @@ -// Agent identity: which document ucp-cli negotiates AS, resolved with zero -// network. +// Agent identity, resolved locally with zero network. // -// ONE contract, and it does not depend on who owns the URL. Every request -// carries `meta.ucp-agent.profile` — a URL, never a body — the business GETs -// it, and negotiates against whatever it serves. Locally, -// `~/.ucp/profiles//profile.json` is the claim about what that URL -// serves, and it is the document ucp-cli negotiates from. The two must agree; -// `ucp doctor` is what compares them, and is the only fetcher -// ({@link fetchAgentProfileLive}). +// `AgentProfile` is one exact, validated wire rendering: one URL, one body, +// and one release. `Profile` is the runtime selection input. A managed +// Profile carries one fresh bundled rendering for every installed release; +// a local DIY Profile and a URL-only ad-hoc Profile each carry one rendering. +// Body provenance (`source`) and URL selection (`urlOverride`) are independent: +// an explicit URL override replaces a DIY rendering's URL without replacing +// its locally authored body. Discovery reads the Business envelope first and +// selects the newest exact version present on both sides. // -// Reading disk rather than the wire at request time is deliberate. The -// merchant may hold the document in cache, so a pre-flight GET would let a -// blip on the URL hard-block a request that would have succeeded. What the -// round trip would buy — a sharper `expectedCapabilities` — is ADVISORY (the -// authority is `ucp.capabilities` in the response), and a URL that has -// drifted from the local file is an authoring bug with a check of its own. +// Every MCP request carries the selected rendering's URL at +// `meta.ucp-agent.profile`. The Business reads that URL and negotiates against +// what it serves. For a named DIY Profile, the local `profile.json` is our +// claim about those bytes; `ucp doctor` is the only live fetcher and checks +// that the local and hosted documents agree. // -// `RELEASES[v].agentProfileTemplate` is the published document for release v, -// shipped verbatim. It is what `profile init` writes to disk, and the -// identity of last resort for a caller that supplies a URL and no profile -// name — never something that overrides a `profile.json` that exists. -// -// Version model: a platform profile declares exactly ONE `ucp.version` and -// the business validates that exact version (no ranges, no date-order -// compatibility inference). The profile therefore selects the release; the -// engine only constrains what it can physically execute (ENGINE_TRANSPORTS). -// Pattern throughout: the profile declares, the engine constrains, the -// intersection is effective. - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' +// Version compatibility is exact equality only. No ranges or date-order +// inference are used. The Profile declares renderings, the engine constrains +// transports, and the Business offer selects the rendering. import { z } from 'incur' import { ErrorCodes, isUcpError, UcpError } from '../lib/errors.js' import type { Transport } from '../lib/types.js' import { formatZodIssues } from '../lib/zod-format.js' import { refusedRedirect, ucpFetch } from './http-client.js' -import { type ProfileStoreOptions, profileDir } from './profile-store.js' import { LATEST, type PlatformProfile, RELEASES, release, + releaseByDefaultAgentProfileUrl, type SpecRelease, SUPPORTED_VERSIONS, type Version, @@ -68,12 +56,26 @@ export interface AgentServiceEntry { } /** - * The fetched, validated agent identity — the platform side of every - * negotiation. This is an argument passed alongside the business profile, - * NOT stored on the session (`ActiveProfile` stays a pointer). + * Where a runtime rendering's body came from: `managed` is the bundled + * multi-rendering set, `diy` is one locally authored body, and `url` is one + * bundled body used for URL-only ad-hoc resolution. This does not say how its + * URL was selected; {@link Profile.urlOverride} records that independent fact. + */ +export type ProfileSource = 'managed' | 'diy' | 'url' + +/** + * One validated agent identity rendering — the platform side of one exact + * negotiation. Runtime {@link Profile}s store these by release. */ export interface AgentProfile { - /** Local profile name, for messages/diagnostics. Absent when only a URL is known. */ + /** Body provenance, not URL provenance. */ + readonly source: ProfileSource + /** + * Whether `url` came from explicit `--profile-url` / `UCP_AGENT_PROFILE_URL`. + * Supplied by the resolution path; never inferred from URL equality. + */ + readonly urlOverride: boolean + /** Local Profile name for messages/diagnostics; absent when no directory was selected. */ name?: string /** The identity URL sent on every request; the business reads what it serves. */ url: string @@ -81,7 +83,7 @@ export interface AgentProfile { version: Version /** Registry entry for `version`: schemas, reverse-domain grammar, defaults. */ release: SpecRelease - /** Fetched, parsed body. */ + /** Validated, parsed wire body. */ body: PlatformProfile /** * Declared service entries keyed by capability id. Deliberately NOT @@ -93,20 +95,37 @@ export interface AgentProfile { capabilities: readonly string[] } +/** + * Canonical runtime agent profile. + * + * The map is readonly, `Partial`, and keyed only by installed + * {@link Version}s. Managed Profiles populate every key; DIY Profiles + * intentionally populate exactly one. `Partial` is the honest type and the + * type-checked form of the same rule: enumerate own keys, then index — + * `Record` alone would name the closed key domain while quietly promising + * every Profile source fills it. + */ +export interface Profile { + /** Body provenance shared by this Profile's rendering set. */ + readonly source: ProfileSource + /** + * Whether rendering URL selection came from explicit + * `--profile-url` / `UCP_AGENT_PROFILE_URL`, independent of `source`. + */ + readonly urlOverride: boolean + /** Local Profile name; absent when no profile directory was selected. */ + readonly name?: string + readonly renderings: Readonly>> +} + /** `dev.ucp.*` names the protocol's own services/capabilities — the ones the snapshot rule binds to `ucp.version`. */ export function isDevUcpKey(key: string): boolean { return key === 'dev.ucp' || key.startsWith('dev.ucp.') } -/** The release whose published default profile lives at `url`, if any. */ -function releaseByDefaultProfileUrl(url: string): SpecRelease | undefined { - return Object.values(RELEASES).find((rel) => rel.defaultAgentProfileUrl === url) -} - /** - * How an agent identity is named in agent-facing messages. One format for - * every layer (Step 1 version selection, Step 2 negotiation) so operators see - * the same string wherever the profile is implicated. + * How one selected AgentProfile rendering is named in Step 2 negotiation + * messages. Step 1 labels the whole runtime Profile and includes its source. */ export function agentLabel(agent: Pick): string { return agent.name === undefined ? `agent profile ${agent.url}` : `profile '${agent.name}'` @@ -124,17 +143,22 @@ export interface LoadAgentProfileInput { body: unknown /** URL this document is (or will be) served from: identity + messages. */ url: string + /** Body provenance supplied by the acquisition path, never inferred from the document. */ + source: ProfileSource + /** Explicit URL-override provenance, never inferred from URL equality. */ + urlOverride: boolean /** Local profile name for messages. */ name?: string } /** - * Validate an agent-profile body into an {@link AgentProfile}. Pure — no I/O, - * and deliberately indifferent to where the bytes came from: the bundled - * snapshot, the local `profile.json`, and a doctor GET are the same JSON - * shape, so one validator serves all three ({@link resolveAgentProfile} and - * {@link fetchAgentProfileLive} compose acquisition on top; tests inject - * fixture bodies here directly). + * Validate an agent-profile body into an {@link AgentProfile}. Pure — no I/O. + * Bundled snapshots, local `profile.json`, and Doctor GETs share one JSON + * validator, while the acquisition path must supply `source` and + * `urlOverride` explicitly so downstream remedies never reverse-engineer + * either fact from those bytes or their URL. The runtime factories and + * {@link fetchAgentProfileLive} all pass acquired bodies through this same + * validator; tests inject fixture bodies here directly. * * Failure modes (all `layer: 'client'` — our own document, the agent acts). * Every code here is `AGENT_PROFILE_*`: the business's document has its own @@ -198,11 +222,11 @@ export function loadAgentProfile(input: LoadAgentProfileInput): AgentProfile { // Third-party entries (com.acme.*) carry their own version lines and are // exempt — that independence is the point of the reverse-DNS registry. // - // Fatal, unconditionally: this is the document ucp-cli sends as its - // identity, it comes from a file the reader can edit, and an off-version - // `dev.ucp.*` entry silently drops that capability at negotiation time. The - // published templates ucp-cli ships all satisfy the rule (asserted in - // agent.test.ts), so the no-profile-name fallback cannot trip it. + // Fatal, unconditionally: this is the document ucp-cli models as its + // identity, and an off-version `dev.ucp.*` entry silently drops that + // capability at negotiation time. The published templates ucp-cli ships all + // satisfy the rule (asserted in agent.test.ts), so managed and scalar-URL + // factories cannot trip it; DIY authors can repair their own document. for (const [registry, entries] of [ ['services', services], ['capabilities', capabilityEntries], @@ -255,6 +279,8 @@ export function loadAgentProfile(input: LoadAgentProfileInput): AgentProfile { } return { + source: input.source, + urlOverride: input.urlOverride, ...(input.name !== undefined ? { name: input.name } : {}), url: input.url, version: rel.version, @@ -265,134 +291,137 @@ export function loadAgentProfile(input: LoadAgentProfileInput): AgentProfile { } } -function normalizeEntries(registry: unknown): Record { - if (typeof registry !== 'object' || registry === null) return {} - const out: Record = {} - for (const [key, value] of Object.entries(registry as Record)) { - if (!Array.isArray(value)) continue - out[key] = value.filter( - (e): e is AgentServiceEntry => - typeof e === 'object' && - e !== null && - typeof (e as { version?: unknown }).version === 'string', - ) +/** + * Build the managed Profile from every release installed in this binary. + * + * `name` is the local profile directory this Profile was resolved from, if + * any — an upgraded legacy profile is still `profiles//`, and that name + * is what selects `headers.json` and what messages call this identity. It + * changes no rendering: managed renderings are always the bundled templates + * at their own published URLs. + */ +export function createManagedProfile(name?: string): Profile { + const entries = Object.values(RELEASES).map((rel) => { + // Parse the generated JSON for every factory call: no returned body can + // mutate the release template or another managed Profile instance. + const agent = loadAgentProfile({ + body: JSON.parse(rel.agentProfileJson) as unknown, + url: parseHttpsUrl(rel.defaultAgentProfileUrl, 'agent profile URL').toString(), + source: 'managed', + urlOverride: false, + ...(name !== undefined ? { name } : {}), + }) + return [agent.version, agent] as const + }) + return { + source: 'managed', + urlOverride: false, + ...(name !== undefined ? { name } : {}), + renderings: Object.freeze(Object.fromEntries(entries)), } - return out } -export interface ResolveAgentProfileOptions { - /** - * The URL that goes on the wire in `meta.ucp-agent.profile`. Defaults to - * the latest release's published default — the identity a local profile - * with no `meta.profile_url` presents. - */ +export interface CreateDiyProfileInput { + /** The one locally authored wire body. */ + body: unknown + /** Hosted identity URL. Defaults to the body release's published URL. */ url?: string - /** - * Local profile name. Selects the `profile.json` that IS our declaration, - * and names the profile in messages (`profile 'x'` instead of a raw URL). - */ - name?: string - /** Override the profile store home. `ucp doctor` and tests; the CLI uses `$UCP_HOME`. */ - homeDir?: string + /** True only when the URL came from `--profile-url` / `UCP_AGENT_PROFILE_URL`. */ + urlOverride?: boolean + /** Local profile name. */ + name: string } -/** - * Resolve the identity every negotiation runs against. **No network, ever.** - * - * A named profile is answered by its own `profile.json`, at every URL. That - * file is the one thing the reader controls, so an edit to it either takes - * effect or gets reported by `ucp doctor` — it is never silently discarded. - * - * Without a name there is no local document to read — a `discover({profileUrl})` - * call that passes neither `profileName` nor `agent`, which no CLI path does - * (every command resolves a named profile first). The published template then - * stands in: for the release that URL belongs to, or {@link LATEST} when the - * URL is not one ucp-cli publishes a template for. - */ -export async function resolveAgentProfile( - options: ResolveAgentProfileOptions = {}, -): Promise { - const { name } = options +/** 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 bodyRelease = envelope.success ? release(envelope.data.ucp.version) : undefined const url = parseHttpsUrl( - options.url ?? RELEASES[LATEST].defaultAgentProfileUrl, + input.url ?? bodyRelease?.defaultAgentProfileUrl ?? RELEASES[LATEST].defaultAgentProfileUrl, 'agent profile URL', ).toString() + const agent = loadAgentProfile({ + body: input.body, + url, + source: 'diy', + urlOverride: input.urlOverride ?? false, + name: input.name, + }) + return singletonProfile(agent, input.name) +} - if (name !== undefined) { - const store: ProfileStoreOptions = - options.homeDir === undefined ? {} : { homeDir: options.homeDir } - const path = join(profileDir(name, store), 'profile.json') - return validated(await readLocalProfileBody(name, path), url, name, path) - } - - const rel = releaseByDefaultProfileUrl(url) - if (rel === undefined) { - // Nothing local says what this URL serves. Warn rather than fail: the URL - // on the wire is still right and the merchant negotiates against what it - // serves; only our own prediction is generic. +/** + * Build the URL-pinned ad-hoc Profile used when an explicit profile URL is + * the identity. Known published URLs select their own release; any other URL + * gets one latest-release rendering. The URL remains exactly the caller's. + * + * `name` is optional and purely local: it names the profile directory whose + * `headers.json` applies and how messages address this identity. A scalar URL + * still pins one rendering — a name never spreads it across releases. + */ +export function createAdHocProfile(url: string, name?: string): Profile { + const normalizedUrl = parseHttpsUrl(url, 'agent profile URL').toString() + const publishedRelease = releaseByDefaultAgentProfileUrl(normalizedUrl) + const rel = publishedRelease ?? RELEASES[LATEST] + if (publishedRelease === undefined) { uwarn( - `no local profile was named for ${url}, so ucp-cli declares the published UCP ${LATEST} document. Pass the profile name (or a resolved agent profile) to declare the document that URL serves.`, + `${normalizedUrl} is not a known release-default Profile URL; ucp-cli is using the bundled UCP ${LATEST} body for planning and negotiation at that URL. Run \`ucp doctor\` to check what the URL actually serves.`, ) } - // structuredClone for the same reason `profile init` clones it: the template - // is one shared object per release and must not become reachable from a - // returned profile. - const template = (rel ?? RELEASES[LATEST]).agentProfileTemplate - return validated(structuredClone(template), url, undefined, 'published release template') + const agent = loadAgentProfile({ + body: JSON.parse(rel.agentProfileJson) as unknown, + url: normalizedUrl, + source: 'url', + urlOverride: true, + ...(name !== undefined ? { name } : {}), + }) + return singletonProfile(agent, name) } -/** - * The local `profile.json`, raw. Deliberately NOT `readUserProfile`: - * {@link loadAgentProfile} must see the same bytes the URL serves (zod would - * fill defaults first), and our own document belongs under - * `AGENT_PROFILE_SCHEMA_INVALID`, not the generic `SCHEMA_VALIDATION_FAILED` - * the store reports for authoring reads. - */ -async function readLocalProfileBody(name: string, path: string): Promise { - let raw: string - try { - raw = await readFile(path, 'utf-8') - } catch (err) { - throw new UcpError({ - layer: 'client', - code: ErrorCodes.PROFILE_NOT_FOUND, - message: `profile "${name}" has no readable profile.json at ${path}; that file is the document ucp-cli declares on every request`, - cause: err as Error, - cta: { - description: - 'Re-create the local document, then make sure the profile URL serves the same thing.', - commands: [ - { - command: `ucp profile init --name ${name} --force`, - description: 'rewrite profile.json from the published release document', - }, - ], - }, - }) +function singletonProfile(agent: AgentProfile, name?: string): Profile { + return { + source: agent.source, + urlOverride: agent.urlOverride, + ...(name !== undefined ? { name } : {}), + // A singleton Profile is pinned: never synthesize adjacent release + // renderings from one scalar URL or body. + renderings: Object.freeze({ [agent.version]: agent }), } - try { - return JSON.parse(raw) as unknown - } catch (err) { - throw new UcpError({ - layer: 'client', - code: ErrorCodes.AGENT_PROFILE_SCHEMA_INVALID, - message: `profile "${name}": ${path} is not valid JSON`, - context: { path }, - cause: err as Error, - }) +} + +function normalizeEntries(registry: unknown): Record { + if (typeof registry !== 'object' || registry === null) return {} + const out: Record = {} + for (const [key, value] of Object.entries(registry as Record)) { + if (!Array.isArray(value)) continue + out[key] = value.filter( + (e): e is AgentServiceEntry => + typeof e === 'object' && + e !== null && + typeof (e as { version?: unknown }).version === 'string', + ) } + return out } -/** Shared tail of every path: validate the body, trace where it came from. */ +/** Validate and trace a live acquisition through the shared profile-body loader. */ function validated( body: unknown, url: string, name: string | undefined, - source: string, + acquisition: string, + source: ProfileSource, + urlOverride: boolean, ): AgentProfile { - const agent = loadAgentProfile({ body, url, ...(name !== undefined ? { name } : {}) }) + const agent = loadAgentProfile({ + body, + url, + source, + urlOverride, + ...(name !== undefined ? { name } : {}), + }) vlog( - `agent-profile: ${agent.name ?? url} uses UCP ${agent.version} (${source}); services [${Object.keys(agent.services).sort().join(', ')}]`, + `agent-profile: ${agent.name ?? url} uses UCP ${agent.version} (${acquisition}); services [${Object.keys(agent.services).sort().join(', ')}]`, ) return agent } @@ -408,6 +437,10 @@ export interface FetchAgentProfileOptions { fetch?: typeof fetch /** AbortSignal composed with the 30 s timeout. */ signal?: AbortSignal + /** Body provenance to retain on the observed rendering. Defaults to URL-only. */ + source?: ProfileSource + /** Explicit URL-override provenance to retain. Defaults false. */ + urlOverride?: boolean } /** A live read of the hosted document, plus the hosting facts doctor judges. */ @@ -458,9 +491,10 @@ export function agentProfileRedirect(err: unknown) { /** * GET the hosted agent profile and validate what it serves. **`ucp doctor` - * only** — the request path resolves its identity locally - * ({@link resolveAgentProfile}), so this is the one place that learns whether - * the URL we advertise actually works. Everything doctor's `protocol` check + * only** — the request path materializes its identity locally through + * {@link createManagedProfile}, {@link createDiyProfile}, or + * {@link createAdHocProfile}, so this is the one place that learns whether the + * URL we advertise actually works. Everything doctor's `protocol` check * needs comes from here: reachability, HTTP status, JSON-ness, schema * validity, the declared version, and the `Cache-Control` the merchant's * fetch of the same URL will see. @@ -544,7 +578,14 @@ export async function fetchAgentProfileLive( } return { - agent: validated(body, url, options.name, 'live GET'), + agent: validated( + body, + url, + options.name, + 'live GET', + options.source ?? 'url', + options.urlOverride ?? false, + ), cacheControl: response.headers.get('cache-control'), } } diff --git a/src/core/cache.test.ts b/src/core/cache.test.ts index 8d64854..da617d3 100644 --- a/src/core/cache.test.ts +++ b/src/core/cache.test.ts @@ -1,10 +1,11 @@ // Generic fetch + on-disk cache primitive tests. // -// Covers URL-origin cache naming, Cache-Control TTL parsing, home-directory -// resolution, and the fetch/cache failure modes. Higher-level tests cover -// artifact-specific schemas and error codes. +// Covers canonical full-URL cache naming, legacy origin-key reads, +// Cache-Control TTL parsing, home-directory resolution, and fetch/cache +// failure modes. Higher-level tests cover artifact-specific schemas and error +// codes. -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import { z } from 'incur' @@ -20,6 +21,7 @@ import { originToFilename, parseMaxAge, ucpHomeDir, + urlToFilename, } from './cache.js' import { installProxyDispatcher, resetProxyStateForTests } from './proxy.js' @@ -62,6 +64,22 @@ describe('originToFilename', () => { }) }) +describe('urlToFilename', () => { + it('uses the canonical full URL and produces a SHA-256 filename', () => { + const canonical = 'https://example.com/profiles/a.json?release=2026-04-08' + const equivalent = 'https://EXAMPLE.com:443/ignored/../profiles/a.json?release=2026-04-08' + + expect(urlToFilename(equivalent)).toBe(urlToFilename(canonical)) + expect(urlToFilename(canonical)).toMatch(/^[a-f0-9]{64}$/) + expect(urlToFilename(canonical)).not.toBe( + urlToFilename('https://example.com/profiles/b.json?release=2026-04-08'), + ) + expect(urlToFilename(canonical)).not.toBe( + urlToFilename('https://example.com/profiles/a.json?release=2026-08-25'), + ) + }) +}) + describe('parseMaxAge (UCP 60s floor)', () => { it.each([ ['public, max-age=600', 600], @@ -155,6 +173,17 @@ const codes = { schemaInvalid: 'TEST_SCHEMA_INVALID', } +async function writeFreshCache(path: string, url: string, body: T): Promise { + const now = Date.now() + const entry: CacheEntry = { + url, + fetched_at: now, + expires_at: now + 300_000, + body, + } + await writeFile(path, JSON.stringify(entry), 'utf-8') +} + describe('fetchCached', () => { let cacheDir: string @@ -167,10 +196,11 @@ describe('fetchCached', () => { }) it('fetches, returns parsed body, and writes the cache envelope', async () => { + const url = 'https://example.com/x' const mock = makeMockFetch([ { body: { hello: 'world' }, headers: { 'cache-control': 'max-age=300' } }, ]) - const body = await fetchCached('https://example.com/x', { + const body = await fetchCached(url, { cacheDir, errorCodes: codes, fetch: mock.fn, @@ -178,8 +208,9 @@ describe('fetchCached', () => { expect(body).toStrictEqual({ hello: 'world' }) const cached = JSON.parse( - await readFile(join(cacheDir, 'example.com.json'), 'utf-8'), + await readFile(join(cacheDir, `${urlToFilename(url)}.json`), 'utf-8'), ) as CacheEntry + expect(cached.url).toBe(url) expect(cached.body).toStrictEqual({ hello: 'world' }) expect(cached.expires_at - cached.fetched_at).toBe(300_000) }) @@ -193,6 +224,148 @@ describe('fetchCached', () => { expect(mock.calls).toHaveLength(1) }) + it('keeps same-origin URLs with distinct paths in separate cache entries', async () => { + const firstUrl = 'https://cdn.example.com/profiles/business-a.json?release=2026-04-08' + const secondUrl = 'https://cdn.example.com/profiles/business-b.json?release=2026-04-08' + const mock = makeMockFetch([ + { body: { business: 'A' }, headers: { 'cache-control': 'max-age=300' } }, + { body: { business: 'B' }, headers: { 'cache-control': 'max-age=300' } }, + ]) + + const first = await fetchCached(firstUrl, { cacheDir, errorCodes: codes, fetch: mock.fn }) + const second = await fetchCached(secondUrl, { cacheDir, errorCodes: codes, fetch: mock.fn }) + + expect(first).toEqual({ business: 'A' }) + expect(second).toEqual({ business: 'B' }) + expect(mock.calls.map((call) => call.url)).toEqual([firstUrl, secondUrl]) + expect((await readdir(cacheDir)).sort()).toEqual( + [`${urlToFilename(firstUrl)}.json`, `${urlToFilename(secondUrl)}.json`].sort(), + ) + }) + + it('treats a primary entry whose envelope names another URL as a miss', async () => { + const requestedUrl = 'https://cdn.example.com/profiles/business-b.json' + await writeFreshCache( + join(cacheDir, `${urlToFilename(requestedUrl)}.json`), + 'https://cdn.example.com/profiles/business-a.json', + { business: 'A' }, + ) + const mock = makeMockFetch([ + { body: { business: 'B' }, headers: { 'cache-control': 'max-age=300' } }, + ]) + + const result = await fetchCached(requestedUrl, { + cacheDir, + errorCodes: codes, + fetch: mock.fn, + }) + + expect(result).toEqual({ business: 'B' }) + expect(mock.calls).toHaveLength(1) + }) + + it('accepts a canonically equivalent URL in the stored envelope', async () => { + const requestedUrl = 'https://example.com/profiles/business-a.json?release=2026-04-08' + const storedUrl = + 'https://EXAMPLE.com:443/ignored/../profiles/business-a.json?release=2026-04-08' + await writeFreshCache(join(cacheDir, `${urlToFilename(requestedUrl)}.json`), storedUrl, { + source: 'cache', + }) + const mock = makeMockFetch([]) + + const result = await fetchCached(requestedUrl, { + cacheDir, + errorCodes: codes, + fetch: mock.fn, + }) + + expect(result).toEqual({ source: 'cache' }) + expect(mock.calls).toHaveLength(0) + }) + + it('reads a matching fresh legacy origin-key entry without rewriting it', async () => { + const requestedUrl = 'https://example.com/profiles/business-a.json' + const legacyPath = join(cacheDir, `${originToFilename(requestedUrl)}.json`) + await writeFreshCache( + legacyPath, + 'https://EXAMPLE.com:443/ignored/../profiles/business-a.json', + { source: 'legacy' }, + ) + const mock = makeMockFetch([]) + + const result = await fetchCached(requestedUrl, { + cacheDir, + errorCodes: codes, + fetch: mock.fn, + }) + + expect(result).toEqual({ source: 'legacy' }) + expect(mock.calls).toHaveLength(0) + expect(await readdir(cacheDir)).toEqual([`${originToFilename(requestedUrl)}.json`]) + }) + + it('does not roll an expired primary back to an older fresh legacy entry', async () => { + const requestedUrl = 'https://example.com/profiles/business-a.json' + const now = Date.now() + const primary: CacheEntry<{ source: string }> = { + url: requestedUrl, + fetched_at: now - 60_000, + expires_at: now - 1, + body: { source: 'expired-primary' }, + } + const legacy: CacheEntry<{ source: string }> = { + url: requestedUrl, + fetched_at: now - 120_000, + expires_at: now + 60_000, + body: { source: 'legacy' }, + } + await Promise.all([ + writeFile( + join(cacheDir, `${urlToFilename(requestedUrl)}.json`), + JSON.stringify(primary), + 'utf-8', + ), + writeFile( + join(cacheDir, `${originToFilename(requestedUrl)}.json`), + JSON.stringify(legacy), + 'utf-8', + ), + ]) + const mock = makeMockFetch([ + { body: { source: 'network' }, headers: { 'cache-control': 'max-age=300' } }, + ]) + + const result = await fetchCached(requestedUrl, { + cacheDir, + errorCodes: codes, + fetch: mock.fn, + }) + + expect(mock.calls).toHaveLength(1) + expect(result).toEqual({ source: 'network' }) + }) + + it('never reuses a mismatching legacy origin-key entry', async () => { + const requestedUrl = 'https://cdn.example.com/profiles/business-b.json' + const legacyPath = join(cacheDir, `${originToFilename(requestedUrl)}.json`) + await writeFreshCache(legacyPath, 'https://cdn.example.com/profiles/business-a.json', { + business: 'A', + }) + const mock = makeMockFetch([ + { body: { business: 'B' }, headers: { 'cache-control': 'max-age=300' } }, + ]) + + const result = await fetchCached(requestedUrl, { + cacheDir, + errorCodes: codes, + fetch: mock.fn, + }) + + expect(result).toEqual({ business: 'B' }) + expect(mock.calls).toHaveLength(1) + expect(await readdir(cacheDir)).toContain(`${urlToFilename(requestedUrl)}.json`) + }) + it('force:true bypasses cache even when fresh', async () => { const mock = makeMockFetch([ { body: { v: 1 }, headers: { 'cache-control': 'max-age=300' } }, @@ -213,11 +386,7 @@ describe('fetchCached', () => { const mock = makeMockFetch([{ body: {}, headers: { 'cache-control': 'no-store' } }]) await fetchCached('https://example.com/x', { cacheDir, errorCodes: codes, fetch: mock.fn }) - const exists = await readFile(join(cacheDir, 'example.com.json'), 'utf-8').then( - () => true, - () => false, - ) - expect(exists).toBe(false) + expect(await readdir(cacheDir)).toEqual([]) }) it('validates body against the supplied zod schema', async () => { @@ -370,6 +539,26 @@ describe('cacheCompute', () => { expect(calls).toBe(1) }) + it('treats a fresh entry whose opaque envelope key mismatches as a miss', async () => { + await writeFreshCache(join(cacheDir, 'k.json'), 'another-key', { v: 1 }) + let calls = 0 + + const result = await cacheCompute({ + cacheDir, + cacheKey: 'k', + ttlSeconds: 300, + compute: async () => { + calls++ + return { v: 2 } + }, + }) + + expect(result).toEqual({ v: 2 }) + expect(calls).toBe(1) + const rewritten = JSON.parse(await readFile(join(cacheDir, 'k.json'), 'utf-8')) as CacheEntry + expect(rewritten.url).toBe('k') + }) + it('force:true bypasses cache even when fresh', async () => { let calls = 0 const compute = async () => ({ v: ++calls }) diff --git a/src/core/cache.ts b/src/core/cache.ts index 8d8c7f2..a9bb7cf 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -2,14 +2,14 @@ // // Used by modules that fetch UCP artifacts, such as business profiles and // tools/list responses. This module owns cache-entry envelope shape, -// URL-origin cache naming, Cache-Control TTL parsing, and the shared fetch +// canonical URL cache naming, Cache-Control TTL parsing, and the shared fetch // timeout/error-mapping behavior. // // Callers own three things this primitive should not know: which cache // subdirectory to use, which schema (if any) validates the body, and which // UCP error codes should be surfaced for that artifact type. Callers also // validate protocol-specific URL rules before passing externally supplied URLs -// here; the cache layer only canonicalizes already-accepted origins. +// here; the cache layer only canonicalizes already-accepted URLs. import { createHash } from 'node:crypto' import { mkdir, readFile, writeFile } from 'node:fs/promises' @@ -62,6 +62,27 @@ export function originToFilename(input: string | URL): string { return safe } +function canonicalUrl(input: string | URL): string { + return (typeof input === 'string' ? new URL(input) : input).toString() +} + +/** + * Derive a filesystem-safe, collision-resistant key from a canonical full URL. + * Path and query are part of the identity; SHA-256 keeps arbitrary URL bytes + * (including credentials or query values) out of filenames. + */ +export function urlToFilename(input: string | URL): string { + return createHash('sha256').update(canonicalUrl(input)).digest('hex') +} + +function isSameCanonicalUrl(storedUrl: string, requestedUrl: string): boolean { + try { + return canonicalUrl(storedUrl) === requestedUrl + } catch { + return false + } +} + /** * Parse `max-age` from a `Cache-Control` header, applying the UCP * 60-second floor. Returns `null` for `no-store` (must not cache). @@ -145,8 +166,9 @@ export interface FetchCachedOptions { /** * Fetch a URL with an on-disk cache. Reads cache first when fresh, fetches - * + writes on miss/expiry. Filename is derived from `URL.origin`; cache - * envelope is the {@link cacheEntrySchema} shape. + * + writes on miss/expiry. The primary filename is a hash of the canonical + * full URL; a matching pre-change origin-key entry is read as a legacy fallback. + * Cache envelope is the {@link cacheEntrySchema} shape. * * Throws `UcpError(layer, code, ...)` on every failure mode using the * caller-supplied codes, except a redirect refused by `ucpFetch` @@ -157,16 +179,32 @@ export async function fetchCached( options: FetchCachedOptions, ): Promise { const layer: ErrorLayer = options.errorLayer ?? 'transport' - const cachePath = join(options.cacheDir, `${originToFilename(url)}.json`) + const requestedUrl = canonicalUrl(url) + const cachePath = join(options.cacheDir, `${urlToFilename(requestedUrl)}.json`) + const legacyCachePath = join(options.cacheDir, `${originToFilename(requestedUrl)}.json`) + const matchesRequestedUrl = (storedUrl: string) => isSameCanonicalUrl(storedUrl, requestedUrl) if (options.force !== true) { - const cached = await readCachedBody(cachePath, options.schema) + const cached = await readCachedBody(cachePath, options.schema, matchesRequestedUrl) if (cached !== null && cached.expires_at > Date.now()) { vlog( `cache: HIT ${cachePath} (expires in ${Math.max(0, Math.round((cached.expires_at - Date.now()) / 1000))}s)`, ) return cached.body } + + // Pre-full-URL releases wrote one file per origin. Keep a fresh entry hot + // only when no usable primary exists and its embedded URL proves it belongs + // to this exact request. An expired primary must fetch rather than roll back. + if (cached === null) { + const legacy = await readCachedBody(legacyCachePath, options.schema, matchesRequestedUrl) + if (legacy !== null && legacy.expires_at > Date.now()) { + vlog( + `cache: HIT ${legacyCachePath} (legacy; expires in ${Math.max(0, Math.round((legacy.expires_at - Date.now()) / 1000))}s)`, + ) + return legacy.body + } + } } vlog(`cache: MISS ${cachePath}${options.force === true ? ' (force)' : ''} → fetch ${url}`) @@ -256,7 +294,7 @@ export async function fetchCached( if (maxAge !== null) { const now = Date.now() await writeCachedBody(cachePath, { - url, + url: requestedUrl, fetched_at: now, expires_at: now + maxAge * 1000, body, @@ -301,7 +339,11 @@ export async function cacheCompute(options: CacheComputeOptions): Promise< const cachePath = join(options.cacheDir, `${options.cacheKey}.json`) if (options.force !== true) { - const cached = await readCachedBody(cachePath, options.schema) + const cached = await readCachedBody( + cachePath, + options.schema, + (storedKey) => storedKey === options.cacheKey, + ) if (cached !== null && cached.expires_at > Date.now()) { vlog( `cache: HIT ${cachePath} (expires in ${Math.max(0, Math.round((cached.expires_at - Date.now()) / 1000))}s)`, @@ -347,6 +389,7 @@ export async function cacheCompute(options: CacheComputeOptions): Promise< async function readCachedBody( path: string, schema: z.ZodType | undefined, + matchesIdentity: (storedIdentity: string) => boolean, ): Promise | null> { let raw: string try { @@ -361,7 +404,7 @@ async function readCachedBody( return null } const envelope = cacheEntrySchema.safeParse(parsed) - if (!envelope.success) return null + if (!envelope.success || !matchesIdentity(envelope.data.url)) return null if (schema !== undefined) { const body = schema.safeParse(envelope.data.body) if (!body.success) return null diff --git a/src/core/discover.test.ts b/src/core/discover.test.ts index 43be38a..63b74f8 100644 --- a/src/core/discover.test.ts +++ b/src/core/discover.test.ts @@ -5,20 +5,21 @@ // verbatim business profile plus negotiated dispatch view out, and the // two-layer cache behavior for profile + tools/list. // -// Tests either inject a validated `AgentProfile` or exercise the explicit -// no-name template fallback. That profile is the platform side of negotiation -// and selects one exact protocol version; there is no range in this file. +// Tests inject singleton DIY Profiles or exercise the implicit managed +// Profile. Discovery reads the Business offer before selecting one exact +// rendering; there is no range or date-order inference in this file. // // Scenario names (S3′, S4, S5, S6, S8) refer to the negotiation design doc's // consumer-experience section. -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentProfileFixture } from '../test-utils.js' +import { profileFixture, rendering } from '../test-utils.js' +import { urlToFilename } from './cache.js' import { discover } from './discover.js' import { RELEASES } from './releases.js' import { setVerboseWriter } from './verbose.js' @@ -27,9 +28,9 @@ const BUSINESS_URL = 'https://shop.example.invalid' const MCP_ENDPOINT = 'https://shop.example.invalid/ucp/mcp' /** The default identity: Shopify's published agent profile for the latest release. */ -const AGENT = agentProfileFixture({ version: '2026-08-25' }) +const PROFILE = profileFixture({ version: '2026-08-25' }) /** Same binary, other profile. S3′ / S6 use this one. */ -const AGENT_0408 = agentProfileFixture({ version: '2026-04-08', name: 'agent-0408' }) +const PROFILE_0408 = profileFixture({ version: '2026-04-08', name: 'agent-0408' }) const SAMPLE_PROFILE = { ucp: { @@ -88,12 +89,18 @@ interface MockFetchOpts { function mockFetch(opts: MockFetchOpts = {}): { fetch: typeof fetch - calls: { url: string; method: string }[] + calls: { url: string; method: string; body?: unknown }[] } { - const calls: { url: string; method: string }[] = [] + const calls: { url: string; method: string; body?: unknown }[] = [] const fetch = vi.fn(async (url: string | URL | Request, init: RequestInit = {}) => { const u = String(url) - calls.push({ url: u, method: init.method ?? 'GET' }) + const requestBody = + typeof init.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + calls.push({ + url: u, + method: init.method ?? 'GET', + ...(requestBody !== undefined ? { body: requestBody } : {}), + }) if (u.endsWith('/.well-known/ucp')) { return new Response(JSON.stringify(opts.profile ?? SAMPLE_PROFILE), { status: 200, @@ -112,11 +119,10 @@ function mockFetch(opts: MockFetchOpts = {}): { headers: { 'content-type': 'application/json', 'cache-control': 'max-age=300' }, }) } - const requestBody = - typeof init.body === 'string' ? (JSON.parse(init.body) as { id?: unknown }) : undefined const requestId = - typeof requestBody?.id === 'string' || typeof requestBody?.id === 'number' - ? requestBody.id + typeof (requestBody as { id?: unknown } | undefined)?.id === 'string' || + typeof (requestBody as { id?: unknown } | undefined)?.id === 'number' + ? ((requestBody as { id: string | number }).id as string | number) : 1 const toolsList = opts.toolsList === undefined @@ -143,7 +149,7 @@ describe('discover — composition', () => { it('returns verbatim profile + dispatch view keyed by capability and tool name', async () => { const { fetch } = mockFetch() - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect(result.business).toBe('https://shop.example.invalid') expect(result.profile.ucp.version).toBe('2026-08-25') @@ -163,13 +169,14 @@ describe('discover — composition', () => { it('reports which document supplied the rendering and at what version', async () => { const { fetch } = mockFetch() - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) // No `businessVersion`: `source` already carries that bit, and a second // date invites date-order compatibility inference. expect(result.protocol).toEqual({ version: '2026-08-25', source: 'well-known', + agentProfileUrl: rendering(PROFILE, '2026-08-25').url, businessProfileUrl: `${BUSINESS_URL}/.well-known/ucp`, }) }) @@ -188,7 +195,7 @@ describe('discover — composition', () => { }, } const { fetch } = mockFetch({ profile }) - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect([...result.expectedCapabilities].sort()).toEqual([ 'dev.shopify.catalog', @@ -206,7 +213,7 @@ describe('discover — composition', () => { // against a business that advertises it. The server negotiates it (it // fetches the same document); `expectedCapabilities` is the client-side // prediction the CTA layer reads, so it must contain it. - const agent = agentProfileFixture({ + const runtimeProfile = profileFixture({ version: '2026-08-25', url: 'https://you.example/agent.json', name: 'mine', @@ -226,7 +233,7 @@ describe('discover — composition', () => { }, } const { fetch } = mockFetch({ profile }) - const result = await discover(BUSINESS_URL, { cacheDir, agent, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: runtimeProfile, fetch }) expect([...result.expectedCapabilities].sort()).toEqual([ 'com.acme.loyalty', @@ -236,7 +243,7 @@ describe('discover — composition', () => { it('issues exactly one profile fetch + one tools/list per capability', async () => { const { fetch, calls } = mockFetch() - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) const profileCalls = calls.filter((c) => c.url.endsWith('/.well-known/ucp')) const rpcCalls = calls.filter((c) => c.method === 'POST') @@ -245,6 +252,23 @@ describe('discover — composition', () => { expect(rpcCalls[0]?.url).toBe(MCP_ENDPOINT) }) + it('sends the selected AgentProfile URL in tools/list metadata', async () => { + const selectedUrl = 'https://agent.example.invalid/selected.json' + const { fetch, calls } = mockFetch() + const result = await discover(BUSINESS_URL, { + cacheDir, + profile: profileFixture({ version: '2026-08-25', url: selectedUrl }), + fetch, + }) + + const rpc = calls.find((call) => call.method === 'POST') + expect(result.protocol.agentProfileUrl).toBe(selectedUrl) + expect(rpc?.body).toMatchObject({ + method: 'tools/list', + params: { arguments: { meta: { 'ucp-agent': { profile: selectedUrl } } } }, + }) + }) + it('respects an explicit capability filter', async () => { const profile = { ucp: { @@ -265,7 +289,7 @@ describe('discover — composition', () => { const { fetch, calls } = mockFetch({ profile }) const result = await discover(BUSINESS_URL, { cacheDir, - agent: AGENT, + profile: PROFILE, capabilities: ['dev.ucp.shopping'], fetch, }) @@ -302,7 +326,7 @@ describe('discover — composition', () => { }, } const { fetch, calls } = mockFetch({ profile }) - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect(Object.keys(result.negotiated)).toEqual(['dev.ucp.shopping']) expect(Object.keys(result.profile.ucp.services ?? {}).sort()).toEqual([ @@ -322,7 +346,7 @@ describe('discover — composition', () => { lines.push(msg) }) const { fetch } = mockFetch() - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect(lines.join('')).not.toContain('not negotiating') setVerboseWriter(null) }) @@ -347,16 +371,14 @@ describe('discover — composition', () => { await expect( discover(BUSINESS_URL, { cacheDir, - agent: AGENT, + profile: PROFILE, capabilities: ['com.other.x'], fetch, }), ).rejects.toMatchObject({ code: 'AGENT_PROFILE_SERVICE_UNDECLARED', layer: 'client' }) }) - it('uses the published template fallback when the caller supplies no profile', async () => { - // This exercises the no-name fallback directly; production CLI paths - // resolve a named profile and read its profile.json instead. + it('uses managed when the caller supplies no Profile', async () => { const { fetch } = mockFetch() const result = await discover(BUSINESS_URL, { cacheDir, fetch }) @@ -364,61 +386,36 @@ describe('discover — composition', () => { }) }) -// ─── Step 0 costs no round trip ────────────────────────────────────────── -// -// A named profile is resolved from its local profile.json at every URL. The -// identity therefore adds no pre-flight fetch or availability dependency; -// only `ucp doctor` reads the profile URL. +// ─── Local Profile resolution costs no round trip ──────────────────────── // -// These assert on CALL COUNTS, not on outcomes: every other test here would -// still pass if a pre-flight GET were introduced. The full call list proves a -// negotiation makes exactly the requests the BUSINESS conversation needs. +// Session resolution materializes a named DIY Profile before discovery. The +// identity therefore adds no pre-flight fetch; only Business GETs and MCP +// POSTs appear here. describe('discover — the agent identity never costs a request', () => { const SELF_HOSTED = 'https://agent.example.invalid/agent.json' let cacheDir: string - let home: string beforeEach(async () => { cacheDir = await mkdtemp(join(tmpdir(), 'ucp-cli-discover-test-')) - home = await mkdtemp(join(tmpdir(), 'ucp-cli-discover-home-')) - vi.stubEnv('UCP_HOME', home) }) afterEach(async () => { - vi.unstubAllEnvs() await rm(cacheDir, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) }) - /** A local profile serving `version`'s published document — what `profile init` writes. */ - async function localProfile(name: string, version: '2026-04-08' | '2026-08-25'): Promise { - const dir = join(home, 'profiles', name) - await mkdir(dir, { recursive: true }) - await writeFile(join(dir, 'profile.json'), RELEASES[version].agentProfileJson) - } - - it('resolves a release-default URL from profile.json — business traffic only', async () => { - await localProfile('agent', '2026-08-25') - for (const profileUrl of [undefined, RELEASES['2026-08-25'].defaultAgentProfileUrl]) { - const { fetch, calls } = mockFetch() - const result = await discover(BUSINESS_URL, { - cacheDir, - fetch, - force: true, - profileName: 'agent', - ...(profileUrl === undefined ? {} : { profileUrl }), - }) + it('uses a release-default DIY rendering with Business traffic only', async () => { + const { fetch, calls } = mockFetch() + const result = await discover(BUSINESS_URL, { + cacheDir, + fetch, + profile: profileFixture({ version: '2026-08-25' }), + }) - expect(result.protocol.version).toBe('2026-08-25') - expect(calls.map((c) => c.url)).toEqual([`${BUSINESS_URL}/.well-known/ucp`, MCP_ENDPOINT]) - } + expect(result.protocol.version).toBe('2026-08-25') + expect(calls.map((c) => c.url)).toEqual([`${BUSINESS_URL}/.well-known/ucp`, MCP_ENDPOINT]) }) - it('resolves a URL of your own from the local profile.json — still business traffic only', async () => { - // profile.json is the 04-08 document, so if the identity came from - // anywhere else (a fetch, or the latest published template) this - // negotiates 08-25. - await localProfile('mine', '2026-04-08') + it('uses a self-hosted singleton rendering with Business traffic only', async () => { const leafUrl = `${BUSINESS_URL}/.well-known/ucp/2026-04-08` const { fetch, calls } = mockFetch({ profile: { ucp: { ...SAMPLE_PROFILE.ucp, supported_versions: { '2026-04-08': leafUrl } } }, @@ -428,11 +425,11 @@ describe('discover — the agent identity never costs a request', () => { const result = await discover(BUSINESS_URL, { cacheDir, fetch, - profileUrl: SELF_HOSTED, - profileName: 'mine', + profile: profileFixture({ version: '2026-04-08', name: 'mine', url: SELF_HOSTED }), }) expect(result.protocol.version).toBe('2026-04-08') + expect(result.protocol.agentProfileUrl).toBe(SELF_HOSTED) expect(calls.map((c) => c.url)).toEqual([ `${BUSINESS_URL}/.well-known/ucp`, leafUrl, @@ -452,12 +449,15 @@ describe('discover — caching', () => { await rm(cacheDir, { recursive: true, force: true }) }) - it('writes tools/list to /toolslist//.json', async () => { + it('writes tools/list under a negotiation-scoped hash', async () => { const { fetch } = mockFetch() - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) - const cacheFile = join(cacheDir, 'toolslist', 'shop.example.invalid', 'dev.ucp.shopping.json') - const cached = JSON.parse(await readFile(cacheFile, 'utf-8')) as { + const toolsDir = join(cacheDir, 'toolslist', 'shop.example.invalid') + const entries = await readdir(toolsDir) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatch(/^[a-f0-9]{64}\.json$/) + const cached = JSON.parse(await readFile(join(toolsDir, entries[0] as string), 'utf-8')) as { body: { tools: { name: string }[] } } expect(cached.body.tools.map((t) => t.name).sort()).toEqual(['get_product', 'search_catalog']) @@ -465,21 +465,116 @@ describe('discover — caching', () => { it('second discover call hits caches — no network', async () => { const { fetch, calls } = mockFetch() - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) // 1 profile + 1 tools/list, total 2 — second call is fully cached. expect(calls).toHaveLength(2) }) it('force:true re-issues both profile and tools/list', async () => { const { fetch, calls } = mockFetch() - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch, force: true }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch, force: true }) const profileCalls = calls.filter((c) => c.url.endsWith('/.well-known/ucp')) const rpcCalls = calls.filter((c) => c.method === 'POST') expect(profileCalls).toHaveLength(2) expect(rpcCalls).toHaveLength(2) }) + + const toolsResult = (name: string) => ({ + jsonrpc: '2.0', + id: 1, + result: { tools: [{ name, inputSchema: { type: 'object' } }] }, + }) + + it('does not reuse tools/list across selected protocol versions', async () => { + const sharedUrl = 'https://agent.example.invalid/shared.json' + const first = mockFetch({ + profile: SAMPLE_PROFILE_0408, + profileCacheControl: 'no-store', + toolsList: toolsResult('from_0408'), + }) + await discover(BUSINESS_URL, { + cacheDir, + profile: profileFixture({ version: '2026-04-08', url: sharedUrl }), + fetch: first.fetch, + }) + + const second = mockFetch({ + profile: SAMPLE_PROFILE, + profileCacheControl: 'no-store', + toolsList: toolsResult('from_0825'), + }) + const result = await discover(BUSINESS_URL, { + cacheDir, + profile: profileFixture({ version: '2026-08-25', url: sharedUrl }), + fetch: second.fetch, + }) + + expect(Object.keys(result.negotiated['dev.ucp.shopping']?.tools ?? {})).toEqual(['from_0825']) + expect(second.calls.filter((call) => call.method === 'POST')).toHaveLength(1) + }) + + it('does not reuse tools/list across selected AgentProfile URLs', async () => { + const first = mockFetch({ + profileCacheControl: 'no-store', + toolsList: toolsResult('from_first_profile'), + }) + await discover(BUSINESS_URL, { + cacheDir, + profile: profileFixture({ url: 'https://agent.example.invalid/first.json' }), + fetch: first.fetch, + }) + + const second = mockFetch({ + profileCacheControl: 'no-store', + toolsList: toolsResult('from_second_profile'), + }) + const result = await discover(BUSINESS_URL, { + cacheDir, + profile: profileFixture({ url: 'https://agent.example.invalid/second.json' }), + fetch: second.fetch, + }) + + expect(Object.keys(result.negotiated['dev.ucp.shopping']?.tools ?? {})).toEqual([ + 'from_second_profile', + ]) + expect(second.calls.filter((call) => call.method === 'POST')).toHaveLength(1) + }) + + it('does not reuse tools/list across full endpoints', async () => { + const selectedProfile = profileFixture({ url: 'https://agent.example.invalid/shared.json' }) + const profileAt = (endpoint: string) => ({ + ucp: { + ...SAMPLE_PROFILE.ucp, + services: { + 'dev.ucp.shopping': [{ version: '2026-08-25', transport: 'mcp', endpoint }], + }, + }, + }) + const first = mockFetch({ + profile: profileAt(`${MCP_ENDPOINT}?tenant=first`), + profileCacheControl: 'no-store', + toolsList: toolsResult('from_first_endpoint'), + }) + await discover(BUSINESS_URL, { cacheDir, profile: selectedProfile, fetch: first.fetch }) + + const second = mockFetch({ + profile: profileAt(`${MCP_ENDPOINT}?tenant=second`), + profileCacheControl: 'no-store', + toolsList: toolsResult('from_second_endpoint'), + }) + const result = await discover(BUSINESS_URL, { + cacheDir, + profile: selectedProfile, + fetch: second.fetch, + }) + + expect(Object.keys(result.negotiated['dev.ucp.shopping']?.tools ?? {})).toEqual([ + 'from_second_endpoint', + ]) + expect(second.calls.filter((call) => call.method === 'POST')).toHaveLength(1) + }) }) describe('discover — error propagation', () => { @@ -504,7 +599,7 @@ describe('discover — error propagation', () => { await expect( discover(BUSINESS_URL, { cacheDir, - agent: AGENT, + profile: PROFILE, capabilities: ['dev.ucp.shopping.checkout'], fetch, }), @@ -515,7 +610,7 @@ describe('discover — error propagation', () => { }) it('CAPABILITY_NOT_OFFERED when the profile declares a service the business omits', async () => { - const agent = agentProfileFixture({ + const runtimeProfile = profileFixture({ version: '2026-08-25', services: { 'dev.ucp.shopping': [{ version: '2026-08-25', transport: 'mcp' }], @@ -524,7 +619,12 @@ describe('discover — error propagation', () => { }) const { fetch } = mockFetch() await expect( - discover(BUSINESS_URL, { cacheDir, agent, capabilities: ['com.acme.svc'], fetch }), + discover(BUSINESS_URL, { + cacheDir, + profile: runtimeProfile, + capabilities: ['com.acme.svc'], + fetch, + }), ).rejects.toThrowError( expect.objectContaining({ code: 'CAPABILITY_NOT_OFFERED' }) as unknown as Error, ) @@ -541,7 +641,9 @@ describe('discover — error propagation', () => { }, } const { fetch } = mockFetch({ profile }) - await expect(discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch })).rejects.toThrowError( + await expect( + discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }), + ).rejects.toThrowError( expect.objectContaining({ code: 'SERVICE_ENDPOINT_MISSING', layer: 'transport', @@ -553,7 +655,9 @@ describe('discover — error propagation', () => { const { fetch } = mockFetch({ toolsList: { jsonrpc: '2.0', id: 1, result: { tools: 'oops' } }, }) - await expect(discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch })).rejects.toThrow(/tools/) + await expect(discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch })).rejects.toThrow( + /tools/, + ) }) it('surfaces tools/list transport failures with their original error code', async () => { @@ -568,7 +672,9 @@ describe('discover — error propagation', () => { return new Response('boom', { status: 503 }) }) as unknown as typeof globalThis.fetch - await expect(discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch })).rejects.toThrowError( + await expect( + discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }), + ).rejects.toThrowError( expect.objectContaining({ code: 'SERVICE_UNAVAILABLE', http_status: 503, @@ -579,7 +685,7 @@ describe('discover — error propagation', () => { it('rejects non-https business URLs', async () => { const { fetch } = mockFetch() await expect( - discover('http://shop.example.invalid', { cacheDir, agent: AGENT, fetch }), + discover('http://shop.example.invalid', { cacheDir, profile: PROFILE, fetch }), ).rejects.toThrowError( expect.objectContaining({ code: 'INVALID_INPUT', layer: 'client' }) as unknown as Error, ) @@ -587,7 +693,7 @@ describe('discover — error propagation', () => { it('sanitizes cache key for capabilities that are not filesystem-safe', async () => { const unsafeCapability = '../bad' - const agent = agentProfileFixture({ + const runtimeProfile = profileFixture({ version: '2026-08-25', services: { [unsafeCapability]: [{ version: '2026-08-25', transport: 'mcp' }] }, }) @@ -603,7 +709,7 @@ describe('discover — error propagation', () => { const { fetch } = mockFetch({ profile }) await discover(BUSINESS_URL, { cacheDir, - agent, + profile: runtimeProfile, fetch, capabilities: [unsafeCapability], }) @@ -659,16 +765,98 @@ describe('discover — supported_versions selection', () => { }, } + it.each([ + { + name: '04-only', + root: SAMPLE_PROFILE_0408, + leaves: {}, + version: '2026-04-08' as const, + source: 'well-known' as const, + }, + { + name: '08-only', + root: SAMPLE_PROFILE, + leaves: {}, + version: '2026-08-25' as const, + source: 'well-known' as const, + }, + { + name: 'both chooses newest', + root: { + ucp: { + ...SAMPLE_PROFILE_0408.ucp, + supported_versions: { '2026-08-25': LEAF_0825_URL }, + }, + }, + leaves: { '2026-08-25': SAMPLE_PROFILE }, + version: '2026-08-25' as const, + source: 'supported_versions' as const, + }, + { + name: 'future root with known leaves chooses newest leaf', + root: TOP_FUTURE, + leaves: { '2026-08-25': SAMPLE_PROFILE, '2026-04-08': SAMPLE_PROFILE_0408 }, + version: '2026-08-25' as const, + source: 'supported_versions' as const, + }, + ])('managed discovery: $name', async ({ root, leaves, version, source }) => { + const { fetch } = mockFetch({ profile: root, versionedProfiles: leaves }) + const result = await discover(BUSINESS_URL, { cacheDir, fetch }) + + expect(result.protocol).toMatchObject({ + version, + source, + agentProfileUrl: RELEASES[version].defaultAgentProfileUrl, + }) + }) + + it('managed discovery rejects a future-only Business before any MCP POST', async () => { + const { supported_versions: _omitted, ...futureOnly } = TOP_FUTURE.ucp + const { fetch, calls } = mockFetch({ profile: { ucp: futureOnly } }) + + await expect(discover(BUSINESS_URL, { cacheDir, fetch })).rejects.toMatchObject({ + code: 'PROTOCOL_VERSION_INCOMPATIBLE', + message: expect.stringContaining( + "managed Profile already offers every rendering installed in this ucp-cli build, so no local Profile using this build's installed versions can recover", + ), + context: { + offered: ['2026-12-01'], + profileVersions: ['2026-04-08', '2026-08-25'], + profileSource: 'managed', + }, + }) + expect(calls.filter((call) => call.method === 'POST')).toHaveLength(0) + }) + + it('does not fall back to an older root when the selected newest leaf fails', async () => { + const root = { + ucp: { + ...SAMPLE_PROFILE_0408.ucp, + supported_versions: { '2026-08-25': LEAF_0825_URL }, + }, + } + const { fetch, calls } = mockFetch({ profile: root, versionedProfiles: {} }) + + await expect(discover(BUSINESS_URL, { cacheDir, fetch })).rejects.toMatchObject({ + code: 'PROFILE_FETCH_FAILED', + }) + expect(calls.map((call) => [call.method, call.url])).toEqual([ + ['GET', `${BUSINESS_URL}/.well-known/ucp`], + ['GET', LEAF_0825_URL], + ]) + }) + it('S3′: the 04-08 profile negotiates at 04-08 through the leaf — same binary', async () => { const { fetch, calls } = mockFetch({ profile: TOP_0825, versionedProfiles: { '2026-04-08': SAMPLE_PROFILE_0408 }, }) - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT_0408, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE_0408, fetch }) expect(result.protocol).toEqual({ version: '2026-04-08', source: 'supported_versions', + agentProfileUrl: rendering(PROFILE_0408, '2026-04-08').url, businessProfileUrl: LEAF_0408_URL, }) expect(result.profile.ucp.version).toBe('2026-04-08') @@ -686,7 +874,7 @@ describe('discover — supported_versions selection', () => { profile: TOP_0825, versionedProfiles: { '2026-04-08': SAMPLE_PROFILE_0408 }, }) - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect(result.protocol.source).toBe('well-known') expect(result.protocol.version).toBe('2026-08-25') @@ -702,11 +890,12 @@ describe('discover — supported_versions selection', () => { profile: TOP_FUTURE, versionedProfiles: { '2026-08-25': SAMPLE_PROFILE }, }) - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect(result.protocol).toEqual({ version: '2026-08-25', source: 'supported_versions', + agentProfileUrl: rendering(PROFILE, '2026-08-25').url, businessProfileUrl: LEAF_0825_URL, }) expect(result.negotiated['dev.ucp.shopping']?.endpoint).toBe(MCP_ENDPOINT) @@ -717,37 +906,66 @@ describe('discover — supported_versions selection', () => { profile: TOP_FUTURE, versionedProfiles: { '2026-08-25': SAMPLE_PROFILE }, }) - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) const firstPass = calls.length + const topUrl = `${BUSINESS_URL}/.well-known/ucp` const top = JSON.parse( - await readFile(join(cacheDir, 'businesses', 'shop.example.invalid.json'), 'utf8'), + await readFile(join(cacheDir, 'businesses', `${urlToFilename(topUrl)}.json`), 'utf8'), ) as { body: { ucp: { version: string } } } const leaf = JSON.parse( await readFile( - join(cacheDir, 'businesses', '2026-08-25', 'shop.example.invalid.json'), + join(cacheDir, 'businesses', '2026-08-25', `${urlToFilename(LEAF_0825_URL)}.json`), 'utf8', ), ) as { body: { ucp: { version: string } } } expect(top.body.ucp.version).toBe('2026-12-01') expect(leaf.body.ucp.version).toBe('2026-08-25') - await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect(calls.length).toBe(firstPass) }) it('S5: business at a single unknown release → PROTOCOL_VERSION_INCOMPATIBLE naming both sets', async () => { const { supported_versions: _omit, ...ucp } = TOP_FUTURE.ucp const { fetch } = mockFetch({ profile: { ucp } }) - await expect(discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch })).rejects.toMatchObject({ + await expect( + discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }), + ).rejects.toMatchObject({ code: 'PROTOCOL_VERSION_INCOMPATIBLE', layer: 'transport', message: - "https://shop.example.invalid offers UCP 2026-12-01; profile 'agent' uses 2026-08-25. ucp-cli supports 2026-04-08, 2026-08-25", + "https://shop.example.invalid offers UCP 2026-12-01; profile 'agent' offers 2026-08-25. ucp-cli supports 2026-04-08, 2026-08-25. A DIY Profile is pinned to this one rendering.", context: { businessVersion: '2026-12-01', offered: ['2026-12-01'], supported: ['2026-04-08', '2026-08-25'], + profileSource: 'diy', + profileUrlOverride: false, + agentVersion: '2026-08-25', + }, + }) + }) + + it('reports explicit URL precedence independently from a DIY body source', async () => { + const { fetch } = mockFetch({ profile: SAMPLE_PROFILE_0408 }) + const overridden = profileFixture({ + version: '2026-08-25', + url: 'https://agent.example.invalid/override.json', + urlOverride: true, + }) + + await expect( + discover(BUSINESS_URL, { cacheDir, profile: overridden, fetch }), + ).rejects.toMatchObject({ + code: 'PROTOCOL_VERSION_INCOMPATIBLE', + message: expect.stringContaining( + 'Remove --profile-url/UCP_AGENT_PROFILE_URL or make that URL serve the intended exact authored/bundled rendering', + ), + context: { + offered: ['2026-04-08'], + profileSource: 'diy', + profileUrlOverride: true, agentVersion: '2026-08-25', }, }) @@ -757,12 +975,18 @@ describe('discover — supported_versions selection', () => { // The recovery is switching profiles, not upgrading the CLI: AGENT_0408 // would negotiate with this same business. const { fetch } = mockFetch({ profile: SAMPLE_PROFILE_0408 }) - await expect(discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch })).rejects.toMatchObject({ + await expect( + discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }), + ).rejects.toMatchObject({ code: 'PROTOCOL_VERSION_INCOMPATIBLE', - context: { offered: ['2026-04-08'], agentVersion: '2026-08-25' }, + context: { + offered: ['2026-04-08'], + profileSource: 'diy', + agentVersion: '2026-08-25', + }, }) const { fetch: fetch2 } = mockFetch({ profile: SAMPLE_PROFILE_0408 }) - const ok = await discover(BUSINESS_URL, { cacheDir, agent: AGENT_0408, fetch: fetch2 }) + const ok = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE_0408, fetch: fetch2 }) expect(ok.protocol).toMatchObject({ version: '2026-04-08', source: 'well-known' }) }) @@ -773,7 +997,7 @@ describe('discover — supported_versions selection', () => { versionedProfiles: { '2026-04-08': SAMPLE_PROFILE }, }) await expect( - discover(BUSINESS_URL, { cacheDir, agent: AGENT_0408, fetch }), + discover(BUSINESS_URL, { cacheDir, profile: PROFILE_0408, fetch }), ).rejects.toMatchObject({ code: 'PROFILE_VERSION_MISMATCH', layer: 'transport', @@ -801,7 +1025,7 @@ describe('discover — supported_versions selection', () => { profile: TOP_0825, versionedProfiles: { '2026-04-08': nested }, }) - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT_0408, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE_0408, fetch }) expect(result.protocol.version).toBe('2026-04-08') expect(lines.join('')).toContain('version-specific documents are leaves') @@ -814,14 +1038,16 @@ describe('discover — supported_versions selection', () => { // job it exists for. const profile = { ucp: { ...TOP_FUTURE.ucp, services: 'reshaped' } } const { fetch } = mockFetch({ profile, versionedProfiles: { '2026-08-25': SAMPLE_PROFILE } }) - const result = await discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch }) + const result = await discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }) expect(result.negotiated['dev.ucp.shopping']?.version).toBe('2026-08-25') }) it('still validates a selected top-level document against the full profile schema', async () => { const profile = { ...SAMPLE_PROFILE, ucp: { ...SAMPLE_PROFILE.ucp, services: 'reshaped' } } const { fetch } = mockFetch({ profile }) - await expect(discover(BUSINESS_URL, { cacheDir, agent: AGENT, fetch })).rejects.toMatchObject({ + await expect( + discover(BUSINESS_URL, { cacheDir, profile: PROFILE, fetch }), + ).rejects.toMatchObject({ code: 'PROFILE_SCHEMA_INVALID', layer: 'transport', }) @@ -835,14 +1061,14 @@ describe('discover — supported_versions selection', () => { }, }) await expect( - discover(BUSINESS_URL, { cacheDir, agent: AGENT_0408, fetch }), + discover(BUSINESS_URL, { cacheDir, profile: PROFILE_0408, fetch }), ).rejects.toMatchObject({ code: 'PROFILE_SCHEMA_INVALID', layer: 'transport' }) }) it('surfaces a failed leaf fetch as PROFILE_FETCH_FAILED', async () => { const { fetch } = mockFetch({ profile: TOP_0825, versionedProfiles: {} }) await expect( - discover(BUSINESS_URL, { cacheDir, agent: AGENT_0408, fetch }), + discover(BUSINESS_URL, { cacheDir, profile: PROFILE_0408, fetch }), ).rejects.toMatchObject({ code: 'PROFILE_FETCH_FAILED' }) }) @@ -855,7 +1081,7 @@ describe('discover — supported_versions selection', () => { } const { fetch } = mockFetch({ profile }) await expect( - discover(BUSINESS_URL, { cacheDir, agent: AGENT_0408, fetch }), + discover(BUSINESS_URL, { cacheDir, profile: PROFILE_0408, fetch }), ).rejects.toMatchObject({ code: 'PROFILE_SCHEMA_INVALID', layer: 'transport' }) }) }) diff --git a/src/core/discover.ts b/src/core/discover.ts index e2e8043..eefdb23 100644 --- a/src/core/discover.ts +++ b/src/core/discover.ts @@ -11,9 +11,9 @@ // a narrower capability list and call discovery once per group. // // Cache layout: -// /cache/businesses/.json -// /cache/businesses//.json (supported_versions) -// /cache/toolslist//.json +// /cache/businesses/.json +// /cache/businesses//.json (supported_versions) +// /cache/toolslist//.json import { createHash } from 'node:crypto' import { join } from 'node:path' @@ -22,14 +22,15 @@ import { z } from 'incur' import { ErrorCodes, UcpError } from '../lib/errors.js' import { omitUndefined } from '../lib/omit-undefined.js' -import type { Transport } from '../lib/types.js' +import type { CtaBlock, Transport } from '../lib/types.js' import { type AgentProfile, type AgentServiceEntry, agentLabel, + createManagedProfile, ENGINE_TRANSPORTS, isDevUcpKey, - resolveAgentProfile, + type Profile, } from './agent.js' import { cacheCompute, originToFilename, ucpHomeDir } from './cache.js' import { mcpRpc } from './mcp-client.js' @@ -64,9 +65,9 @@ export interface NegotiatedCapability { * Which document supplied the negotiated business rendering and at what * version. * - * `version` is always the agent profile's exact version (the profile IS the - * pick) and is deliberately unqualified: it mirrors `ucp.version` on the - * wire. + * `version` is the exact version shared by the selected AgentProfile and + * Business rendering. It is deliberately unqualified: it mirrors + * `ucp.version` on the wire. * * There is no `businessVersion`. It encoded the same bit `source` already * carries — the biconditional (`businessVersion !== version` ⇔ `source === @@ -80,11 +81,11 @@ export interface NegotiatedCapability { export interface NegotiatedProtocol { version: Version source: 'well-known' | 'supported_versions' + /** URL of the exact AgentProfile rendering selected for this exchange. */ + agentProfileUrl: string /** - * URL of the BUSINESS document `profile` was parsed from. Named in full - * because everywhere else in this codebase (`DiscoverOptions.profileUrl`, - * `--profile-url`, `AgentProfile.url`) `profileUrl` means the AGENT's URL, - * and that collision would ship inside JSON agents script against. + * URL of the BUSINESS document `profile` was parsed from. Named in full to + * distinguish it from `agentProfileUrl` and the CLI's `--profile-url`. */ businessProfileUrl: string } @@ -115,13 +116,10 @@ export interface DiscoveredBusiness { export interface DiscoverOptions { /** - * The resolved, validated agent identity (NOT the local `ActiveProfile` - * pointer). When omitted, `discover` resolves it from `profileUrl` + - * `profileName` via `resolveAgentProfile`: a named profile comes from its - * local `profile.json`; without a name, a published release template is the - * fallback. Neither path uses the network. + * Runtime Profile eligible for negotiation. When omitted, discovery uses a + * fresh managed Profile containing every installed release rendering. */ - agent?: AgentProfile + profile?: Profile /** * Capabilities to resolve. When omitted, the capabilities negotiated are * `keys(agent.services) ∩ keys(business.ucp.services)` — what both sides @@ -137,17 +135,6 @@ export interface DiscoverOptions { force?: boolean /** AbortSignal forwarded to fetch + JSON-RPC. */ signal?: AbortSignal - /** Platform profile URL advertised to the business during MCP discovery. */ - profileUrl?: string - /** - * LOCAL name of the profile `profileUrl` belongs to. Two jobs: it selects - * the `profile.json` used for negotiation, and it labels messages — a - * version mismatch reads `profile 'agent-0408' speaks 2026-04-08` and the - * remedy is `--profile agent-0408`, where the raw URL (`agentLabel`) names - * no switchable thing. Ignored when `agent` is injected — that object - * already carries both. - */ - profileName?: string /** * Outbound headers (auth, tenancy, etc) attached to every HTTP call made * during discovery: the `/.well-known/ucp` GET and any `tools/list` POSTs. @@ -181,23 +168,14 @@ export async function discover( const profileCacheDir = join(cacheRoot, 'businesses') const toolsListCacheRoot = join(cacheRoot, 'toolslist', originToFilename(normalizedBusiness)) - // Step 0 — agent identity, resolved locally. A named profile always comes - // from its `profile.json`; only the no-name fallback uses a published - // release template. Neither costs a request. Callers that already hold a - // resolved AgentProfile inject it. - const agent = - options.agent ?? - (await resolveAgentProfile( - omitUndefined({ url: options.profileUrl, name: options.profileName }), - )) - // Advertise the URL paired with the local declaration. The business reads - // that URL; `ucp doctor` checks that its document agrees with profile.json. - const advertisedProfileUrl = options.profileUrl ?? agent.url - - // Step 1 — business profile selection at the agent's exact version. + // Step 0 — local eligibility only. No rendering is selected until after + // the Business envelope has been fetched in Step 1. + const runtimeProfile = options.profile ?? createManagedProfile() + + // Step 1 — newest exact rendering in Profile ∩ Business offer. const resolved = await fetchCompatibleBusinessProfile(normalizedBusiness.origin, { cacheDir: profileCacheDir, - agent, + profile: runtimeProfile, ...omitUndefined({ fetch: options.fetch, signal: options.signal, @@ -205,7 +183,7 @@ export async function discover( headers: options.headers, }), }) - const { profile } = resolved + const { agentProfile: agent, profile } = resolved if (resolved.source === 'supported_versions') { vlog( `discover: /.well-known/ucp is UCP ${resolved.businessVersion}; using supported_versions[${resolved.version}] → ${resolved.profileUrl}`, @@ -262,8 +240,8 @@ export async function discover( capability, negotiation: { ...negotiation, endpoint }, cacheDir: toolsListCacheRoot, - cacheKey: capabilityToCacheKey(capability), - profileUrl: advertisedProfileUrl, + cacheKey: toolsListCacheKey(capability, resolved.version, agent.url, endpoint), + profileUrl: agent.url, ...omitUndefined({ force: options.force, fetch: options.fetch, @@ -284,7 +262,7 @@ export async function discover( const expectedCapabilities = businessCapabilities === undefined ? [] - : agent.capabilities.filter((c) => businessCapabilities[c] !== undefined) + : agent.capabilities.filter((c) => Object.hasOwn(businessCapabilities, c)) return { business: normalizedBusiness.origin, @@ -292,6 +270,7 @@ export async function discover( protocol: { version: resolved.version, source: resolved.source, + agentProfileUrl: agent.url, businessProfileUrl: resolved.profileUrl, }, expectedCapabilities, @@ -346,9 +325,18 @@ async function hydrateCapability(opts: HydrateOptions): Promise e.version)).join(', ') + const remedy = incompatibleServiceRemedy(agent) throw new UcpError({ layer: 'transport', code: ErrorCodes.SERVICE_VERSION_INCOMPATIBLE, - message: `${capability}: ${who} declares [${declaredList}], business offers [${offeredList}]. Update the service declaration in profile.json to a version the business offers, then make ${agent.url} serve the corrected document; if you cannot change that URL, use one you own. Otherwise, accept unavailability.`, + message: `${capability}: ${who} declares [${declaredList}], business offers [${offeredList}]. ${remedy.message}`, context: { capability, declaredVersions: uniqueSorted([...declaredVersions]), offeredVersions: uniqueSorted(bizEntries.map((e) => e.version)), profileUrl: agent.url, + profileSource: agent.source, + profileUrlOverride: agent.urlOverride, ...(agent.name !== undefined ? { profile: agent.name } : {}), }, + ...(remedy.cta !== undefined ? { cta: remedy.cta } : {}), }) } diff --git a/src/core/legacy-profile.test.ts b/src/core/legacy-profile.test.ts new file mode 100644 index 0000000..4cd6b1d --- /dev/null +++ b/src/core/legacy-profile.test.ts @@ -0,0 +1,471 @@ +// One-time legacy profile upgrade: the classifier, and the storage boundary +// that applies it. +// +// The fixtures under test/fixtures/legacy-profiles/ are the actual documents +// old releases wrote — see PROVENANCE.md for how each was extracted from git. +// They are frozen: a test that needs a variation copies and edits one here, so +// "what shipped" and "what a user did to it" never share a file. + +import { mkdir, mkdtemp, readFile, rm, stat, 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 { + canonicalJson, + classifyStoredProfile, + GENERATED_BODY_FINGERPRINTS, + generatedBodyVersion, + HISTORICAL_GENERATED_BODY_PROVENANCE, + PROFILE_FORMAT_VERSION, + profileBodyFingerprint, +} from './legacy-profile.js' +import { type ProfileMeta, profileDir, readUserProfile } from './profile-store.js' +import { RELEASES } from './releases.js' +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' + +async function fixtureBytes(name: string): Promise { + return readFile(join(FIXTURE_DIR, name), 'utf-8') +} + +async function fixtureBody(name: string): Promise> { + return JSON.parse(await fixtureBytes(name)) as Record +} + +/** + * `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 + * written only from an explicit `--profile-url`; the upload seam that could + * otherwise have filled it always returned `{}`). + */ +const LEGACY_META: ProfileMeta = { + created_at: '2026-06-01T10:00:00.000Z', + updated_at: '2026-06-01T10:00:00.000Z', + protocol_versions: { min: '2026-01-23', max: '2026-04-08' }, +} + +/** Same document, every object's keys emitted in the opposite order. */ +function reverseKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(reverseKeys) + if (typeof value !== 'object' || value === null) return value + const record = value as Record + return Object.fromEntries( + Object.keys(record) + .reverse() + .map((key) => [key, reverseKeys(record[key])]), + ) +} + +// ─── the fingerprint table itself ───────────────────────────────────────── + +describe('generated-body fingerprints', () => { + it('matches the frozen fixtures byte-for-byte through canonical JSON', async () => { + for (const entry of HISTORICAL_GENERATED_BODY_PROVENANCE) { + expect(GENERATED_BODY_FINGERPRINTS.get(entry.sha256)).toBe(entry.version) + } + // 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'], + ] + for (const [file, sha256] of expected) { + expect(profileBodyFingerprint(await fixtureBody(file))).toBe(sha256) + } + }) + + it('recognizes every current release template', () => { + for (const rel of Object.values(RELEASES)) { + expect(generatedBodyVersion(JSON.parse(rel.agentProfileJson))).toBe(rel.version) + } + }) + + 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') + }) + + it('is blind to formatting and key order, and sensitive to values', async () => { + const body = await fixtureBody(STOCK_B) + expect(profileBodyFingerprint(reverseKeys(body))).toBe(profileBodyFingerprint(body)) + + // Array order is data, not formatting. + expect(canonicalJson([1, 2])).not.toBe(canonicalJson([2, 1])) + expect(canonicalJson({ a: 1, b: 2 })).toBe(canonicalJson({ b: 2, a: 1 })) + }) +}) + +// ─── 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]) { + expect(classifyStoredProfile(await fixtureBody(file), LEGACY_META)).toEqual({ + kind: 'managed', + needsMarker: true, + }) + } + }) + + 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), { + profile_url: RELEASES['2026-04-08'].defaultAgentProfileUrl, + }).kind, + ).toBe('managed') + expect( + classifyStoredProfile(await fixtureBody(STOCK_B), { + 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), { + profile_url: RELEASES['2026-08-25'].defaultAgentProfileUrl.replace( + 'shopify.dev', + 'SHOPIFY.dev', + ), + }).kind, + ).toBe('managed') + }) + + it('treats any other URL beside a stock body as DIY', async () => { + const body = await fixtureBody(STOCK_A) + for (const profile_url of [ + 'https://mybot.example.com/.well-known/ucp', + // A different release's default URL is still a decision: a managed + // Profile would never advertise the 08-25 document for an 04-08 body. + RELEASES['2026-08-25'].defaultAgentProfileUrl, + ]) { + expect(classifyStoredProfile(body, { ...LEGACY_META, profile_url }).kind).toBe('diy') + } + }) + + it('treats one semantic edit as DIY', async () => { + const body = await fixtureBody(STOCK_A) + const ucp = body.ucp as Record + const capabilities = { ...(ucp.capabilities as Record) } + delete capabilities['dev.shopify.catalog.global'] + const edited = { ...body, ucp: { ...ucp, capabilities } } + + expect(classifyStoredProfile(edited, LEGACY_META).kind).toBe('diy') + }) + + it('honors an explicit marker over any fingerprint, in both directions', async () => { + const stock = await fixtureBody(STOCK_B) + expect(classifyStoredProfile(stock, { kind: 'diy', format_version: 2 })).toEqual({ + kind: 'diy', + needsMarker: false, + }) + expect( + classifyStoredProfile({ hand: 'written' }, { kind: 'managed', format_version: 2 }), + ).toEqual({ kind: 'managed', needsMarker: false }) + }) + + 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({ + kind: 'diy', + needsMarker: true, + }) + }) + + it('accepts a marker from a newer format_version without downgrading it', async () => { + expect( + classifyStoredProfile(await fixtureBody(STOCK_B), { + kind: 'diy', + format_version: PROFILE_FORMAT_VERSION + 1, + }), + ).toEqual({ kind: 'diy', needsMarker: false }) + }) +}) + +// ─── the storage boundary ───────────────────────────────────────────────── + +describe('readUserProfile — one-time legacy upgrade', () => { + let homeDir: string + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-legacy-test-')) + }) + + afterEach(async () => { + setWarnWriter(null) + await rm(homeDir, { recursive: true, force: true }) + }) + + /** Write a profile directory exactly as an old release left it: no marker. */ + async function seedLegacy( + name: string, + bodyBytes: string, + meta: Record = LEGACY_META, + ): Promise { + const dir = profileDir(name, { homeDir }) + await mkdir(dir, { recursive: true, mode: 0o700 }) + await writeFile(join(dir, 'profile.json'), bodyBytes, 'utf-8') + await writeFile(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf-8') + return dir + } + + it('upgrades an untouched v0.7 profile to managed and leaves profile.json alone', async () => { + const bytes = await fixtureBytes(STOCK_A) + const dir = await seedLegacy('legacy07', bytes) + + const read = await readUserProfile('legacy07', { homeDir }) + + expect(read.kind).toBe('managed') + expect(read).not.toHaveProperty('body') + expect(await readFile(join(dir, 'profile.json'), 'utf-8')).toBe(bytes) + const meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf-8')) as ProfileMeta + expect(meta).toEqual({ + ...LEGACY_META, + format_version: PROFILE_FORMAT_VERSION, + kind: 'managed', + }) + // Everything the user (or an old release) owned survives, unmoved. + expect(meta.created_at).toBe(LEGACY_META.created_at) + expect(meta.updated_at).toBe(LEGACY_META.updated_at) + expect(read.meta).toEqual(meta) + }) + + it('upgrades an untouched v0.8 profile to managed', async () => { + const dir = await seedLegacy('legacy08', await fixtureBytes(STOCK_B)) + + const read = await readUserProfile('legacy08', { homeDir }) + + expect(read.kind).toBe('managed') + expect((JSON.parse(await readFile(join(dir, 'meta.json'), 'utf-8')) as ProfileMeta).kind).toBe( + 'managed', + ) + }) + + it('reads a current managed marker without requiring profile.json', async () => { + const dir = profileDir('bodyless', { homeDir }) + const meta = { + ...LEGACY_META, + format_version: PROFILE_FORMAT_VERSION, + kind: 'managed' as const, + } + await mkdir(dir, { recursive: true, mode: 0o700 }) + await writeFile(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf-8') + let writes = 0 + + const read = await readUserProfile('bodyless', { + homeDir, + writeMeta: async () => { + writes += 1 + throw new Error('a current marker must not be re-stamped') + }, + }) + + expect(read).toEqual({ name: 'bodyless', meta, kind: 'managed' }) + expect(read).not.toHaveProperty('body') + expect(writes).toBe(0) + await expect(stat(join(dir, 'profile.json'))).rejects.toThrow() + }) + + it('finishes an old managed marker without parsing or changing corrupt downgrade bytes', async () => { + const corruptBody = '{ historical profile bytes are now corrupt\n' + const dir = await seedLegacy('old-marker', corruptBody, { + ...LEGACY_META, + kind: 'managed', + }) + + const read = await readUserProfile('old-marker', { homeDir }) + + expect(read.kind).toBe('managed') + expect(read).not.toHaveProperty('body') + expect(read.meta).toMatchObject({ + ...LEGACY_META, + format_version: PROFILE_FORMAT_VERSION, + kind: 'managed', + }) + expect(await readFile(join(dir, 'profile.json'), 'utf-8')).toBe(corruptBody) + }) + + it.each([ + { 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 bodyPath = join(dir, 'profile.json') + if (damage === 'missing') await rm(bodyPath) + else await writeFile(bodyPath, '', 'utf-8') + + await expect(readUserProfile('unmarked-damage', { homeDir })).rejects.toMatchObject({ + code, + context: { kind: 'profile-store', profile: 'unmarked-damage', file: 'profile.json' }, + }) + }) + + it('upgrades a current generated body + default URL that carries no marker', async () => { + await seedLegacy('fresh', `${RELEASES['2026-08-25'].agentProfileJson}`, { + created_at: '2026-09-01T00:00:00.000Z', + profile_url: RELEASES['2026-08-25'].defaultAgentProfileUrl, + }) + + expect((await readUserProfile('fresh', { homeDir })).kind).toBe('managed') + }) + + it('marks one semantic edit DIY and preserves the edited body and user meta', async () => { + const body = await fixtureBody(STOCK_A) + const ucp = body.ucp as Record + const services = ucp.services as Record + const edited = { + ...body, + ucp: { + ...ucp, + services: { + ...services, + 'com.acme.loyalty': [{ version: '2026-04-08', transport: 'mcp' }], + }, + }, + } + const bytes = `${JSON.stringify(edited, null, 2)}\n` + const dir = await seedLegacy('edited', bytes, { + ...LEGACY_META, + defaults: { catalog: 'https://catalog.acme.example' }, + }) + + const read = await readUserProfile('edited', { homeDir }) + + expect(read.kind).toBe('diy') + expect(await readFile(join(dir, 'profile.json'), 'utf-8')).toBe(bytes) + expect(read.meta).toMatchObject({ + created_at: LEGACY_META.created_at, + protocol_versions: LEGACY_META.protocol_versions, + defaults: { catalog: 'https://catalog.acme.example' }, + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', + }) + if (read.kind !== 'diy') throw new Error('expected edited legacy Profile to remain DIY') + expect( + ((read.body.ucp.services ?? {}) as Record)['com.acme.loyalty'], + ).toBeDefined() + }) + + it('marks a stock body under a custom URL DIY', async () => { + await seedLegacy('hosted', await fixtureBytes(STOCK_B), { + ...LEGACY_META, + profile_url: 'https://mybot.example.com/.well-known/ucp', + }) + + const read = await readUserProfile('hosted', { homeDir }) + + expect(read.kind).toBe('diy') + expect(read.meta.profile_url).toBe('https://mybot.example.com/.well-known/ucp') + }) + + it('leaves an explicitly marked DIY stock body DIY, and writes nothing', async () => { + const dir = await seedLegacy('pinned', await fixtureBytes(STOCK_B), { + ...LEGACY_META, + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', + }) + const before = await stat(join(dir, 'meta.json')) + + const read = await readUserProfile('pinned', { + homeDir, + writeMeta: async () => { + throw new Error('a marked profile must not be re-stamped') + }, + }) + + expect(read.kind).toBe('diy') + expect((await stat(join(dir, 'meta.json'))).mtimeMs).toBe(before.mtimeMs) + }) + + it('never touches headers.json', async () => { + const dir = await seedLegacy('withheaders', await fixtureBytes(STOCK_A)) + // 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}' + await writeFile(join(dir, 'headers.json'), headers, 'utf-8') + + await readUserProfile('withheaders', { homeDir }) + + expect(await readFile(join(dir, 'headers.json'), 'utf-8')).toBe(headers) + }) + + it('is idempotent: the second read neither writes nor changes anything', async () => { + const dir = await seedLegacy('twice', await fixtureBytes(STOCK_A)) + let writes = 0 + const writeMeta = async (path: string, content: string) => { + writes += 1 + await writeFile(path, content, 'utf-8') + } + + const first = await readUserProfile('twice', { homeDir, writeMeta }) + const afterFirst = await readFile(join(dir, 'meta.json'), 'utf-8') + const second = await readUserProfile('twice', { homeDir, writeMeta }) + + expect(writes).toBe(1) + expect(second.kind).toBe(first.kind) + expect(second.meta).toEqual(first.meta) + expect(await readFile(join(dir, 'meta.json'), 'utf-8')).toBe(afterFirst) + }) + + 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 untouched = await readFile(join(dir, 'meta.json'), 'utf-8') + let writes = 0 + + const read = await readUserProfile('scanned', { + homeDir, + migrate: false, + writeMeta: async () => { + writes += 1 + throw new Error('EROFS: scan must never try this write') + }, + }) + + expect(read.kind).toBe('managed') + expect(read.meta.kind).toBeUndefined() + expect(read.meta.format_version).toBeUndefined() + expect(writes).toBe(0) + expect(warnings).toEqual([]) + expect(await readFile(join(dir, 'meta.json'), 'utf-8')).toBe(untouched) + }) + + 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 untouched = await readFile(join(dir, 'meta.json'), 'utf-8') + + const read = await readUserProfile('readonly', { + homeDir, + writeMeta: async () => { + throw new Error('EROFS: read-only file system') + }, + }) + + // The classification still holds for this process: commerce does not wait + // on a bookkeeping write. + expect(read.kind).toBe('managed') + expect(read.meta.kind).toBe('managed') + expect(read.meta.format_version).toBe(PROFILE_FORMAT_VERSION) + expect(warnings.join('')).toContain('EROFS') + expect(warnings.join('')).toContain('readonly') + // Nothing was half-written, and the next process retries. + expect(await readFile(join(dir, 'meta.json'), 'utf-8')).toBe(untouched) + expect((await readUserProfile('readonly', { homeDir })).kind).toBe('managed') + }) +}) diff --git a/src/core/legacy-profile.ts b/src/core/legacy-profile.ts new file mode 100644 index 0000000..bb7fb81 --- /dev/null +++ b/src/core/legacy-profile.ts @@ -0,0 +1,232 @@ +// One-time upgrade of local profiles written before the managed/DIY split. +// +// ── 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. +// +// 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. +// +// ── Why a fingerprint, and why canonical JSON ───────────────────────────── +// +// The only evidence on disk is the body itself, so classification is exact +// equality against the finite set of bodies ucp-cli has ever generated. +// Fingerprints are sha256 over CANONICAL JSON (recursively key-sorted, +// separator-free) rather than raw bytes: re-indenting or key-reordering a +// document with `jq` is not a semantic edit and must not cost a user their +// upgrade, while any added/removed/changed value must. Array order is +// preserved — in a profile it is data, not formatting. +// +// ── Provenance (verified from git, not from prose) ──────────────────────── +// +// 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 +// and the full derivation live in test/fixtures/legacy-profiles/. +// +// Those releases never wrote `meta.profile_url` on their own: `profile init` +// set it only from an explicit `--profile-url`, and the upload seam that +// could otherwise have filled it (`noopUploadProfile`) always returned `{}`. +// A stock 0.4.2 … 0.8.0 profile therefore has NO profile_url, and any URL +// found next to a stock body was typed by a human — which is why a URL that +// is not this binary's published default for that body means DIY. +// +// ── Scope ───────────────────────────────────────────────────────────────── +// +// 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. + +import { createHash } from 'node:crypto' + +import { RELEASES, releaseByDefaultAgentProfileUrl, type Version } from './releases.js' + +/** + * How a local profile's body is treated at runtime. + * + * - `managed`: the body is one ucp-cli generated, so the Profile is rebuilt + * from the bundled templates and offers every installed release. + * - `diy`: somebody authored this identity. Exactly one rendering, pinned to + * the body's own `ucp.version`, at the body's own URL. + */ +export type ProfileKind = 'managed' | 'diy' + +/** + * `meta.format_version` written by the upgrade. Bump only when the on-disk + * meaning of the profile directory changes; the marker is what makes the + * upgrade a one-time event instead of a fingerprint on every read. + */ +export const PROFILE_FORMAT_VERSION = 2 + +/** + * The fields of `meta.json` classification reads. Structural on purpose: + * this module must not import the store it is called from. + */ +export interface StoredProfileMarker { + readonly format_version?: number | undefined + readonly kind?: ProfileKind | undefined + readonly profile_url?: string | undefined +} + +export interface ProfileClassification { + /** The kind this profile is read as from now on. */ + readonly kind: ProfileKind + /** + * True when `meta.json` still has to be stamped. False for an already + * marked profile — the marker is the promise that no body is fingerprinted + * twice, and re-stamping an unchanged file is a write nobody asked for. + */ + readonly needsMarker: boolean +} + +/** One body ucp-cli has generated, identified by canonical-JSON digest. */ +interface GeneratedBody { + /** `ucp.version` the body declares — selects the URL that keeps it managed. */ + readonly version: Version + /** sha256 of {@link canonicalJson} over the parsed body. */ + readonly sha256: string + /** Exactly where these bytes come from. */ + readonly provenance: string +} + +/** + * Bodies shipped by releases that no longer exist in the tree. Frozen + * literals, never recomputed: the whole point is to recognize documents + * written by binaries this source can no longer build. + * + * Regenerate/verify with test/fixtures/legacy-profiles/PROVENANCE.md. + */ +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.', + }, + { + version: '2026-08-25', + sha256: '3a75f9cf8e416ecbc716c303b6356dc1c9f6dce702f419654460eda0bf692ff5', + provenance: 'STOCK-B — 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.', + }, +]) + +/** + * Canonical JSON: recursively key-sorted, no insignificant whitespace. + * Deterministic for any `JSON.parse` output, which is the only input shape + * this module ever sees. + */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (typeof value === 'object' && value !== null) { + const record = value as Record + const fields = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + return `{${fields.join(',')}}` + } + // `undefined` cannot appear in parsed JSON; JSON.stringify would return it + // as `undefined`, so normalize to null rather than emit a broken digest. + return JSON.stringify(value) ?? 'null' +} + +/** sha256 of {@link canonicalJson} — the identity used for classification. */ +export function profileBodyFingerprint(body: unknown): string { + return createHash('sha256').update(canonicalJson(body)).digest('hex') +} + +/** + * Every body ucp-cli is known to have generated, as digest → declared + * release. Historical entries are frozen literals; the current release + * templates are hashed at load so a codegen refresh cannot silently turn a + * freshly-initialized profile into "user-authored". + */ +export const GENERATED_BODY_FINGERPRINTS: ReadonlyMap = new Map([ + ...HISTORICAL_GENERATED_BODIES.map((entry) => [entry.sha256, entry.version] as const), + ...Object.values(RELEASES).map( + (rel) => [profileBodyFingerprint(JSON.parse(rel.agentProfileJson)), rel.version] as const, + ), +]) + +/** Provenance strings for the frozen historical entries; for tests and docs. */ +export const HISTORICAL_GENERATED_BODY_PROVENANCE: readonly GeneratedBody[] = + HISTORICAL_GENERATED_BODIES + +/** + * The release a body was generated for, or `undefined` when these bytes are + * not one ucp-cli ever wrote (i.e. the user authored or edited them). + */ +export function generatedBodyVersion(body: unknown): Version | undefined { + return GENERATED_BODY_FINGERPRINTS.get(profileBodyFingerprint(body)) +} + +/** + * Whether `profileUrl` still lets a generated body count as managed. + * + * Absent is the stock case (0.4.2 … 0.8.0 never wrote one). The only URL that + * survives is THIS binary's published default for the release the body + * declares — the document a managed rendering would advertise anyway. A + * different release's default URL is as much of a decision as example.com, + * so it is DIY too: the pairing says the user wants that exact URL, and a + * managed Profile would not send it. + */ +function keepsManagedUrl(version: Version, profileUrl: string | undefined): boolean { + if (profileUrl === undefined) return true + return releaseByDefaultAgentProfileUrl(profileUrl)?.version === version +} + +/** + * Resolve a stored profile's kind, and say whether `meta.json` still needs + * the marker. + * + * Order matters: an explicit `kind` wins over any fingerprint. A profile that + * says it is DIY is never re-upgraded because its body happens to match a + * template, and a profile that says it is managed is not demoted by a body + * this build no longer recognizes. + */ +export function classifyStoredProfile( + body: unknown, + meta: StoredProfileMarker, +): ProfileClassification { + const marked = meta.kind + if (marked !== undefined) { + const complete = + meta.format_version !== undefined && meta.format_version >= PROFILE_FORMAT_VERSION + return { kind: marked, needsMarker: !complete } + } + const version = generatedBodyVersion(body) + if (version === undefined) return { kind: 'diy', needsMarker: true } + return { kind: keepsManagedUrl(version, meta.profile_url) ? 'managed' : 'diy', needsMarker: true } +} + +/** + * The marked form of `meta`. Appends the two canonical fields and touches + * nothing else — `created_at`/`updated_at`, `protocol_versions`, `defaults`, + * and any unknown key a future (or older) build wrote all survive, in place. + * `updated_at` deliberately does NOT move: the user changed nothing. + */ +export function markProfileMeta( + meta: T, + kind: ProfileKind, +): T & { format_version: number; kind: ProfileKind } { + return { ...meta, format_version: PROFILE_FORMAT_VERSION, kind } +} diff --git a/src/core/negotiate.test.ts b/src/core/negotiate.test.ts index 36112a4..eb34db6 100644 --- a/src/core/negotiate.test.ts +++ b/src/core/negotiate.test.ts @@ -9,8 +9,8 @@ // consumer-experience section. import { afterEach, describe, expect, it } from 'vitest' - import { agentProfileFixture } from '../test-utils.js' +import type { ProfileSource } from './agent.js' import { negotiateService } from './discover.js' import { businessProfileSchema as businessSchema20260408 } from './generated/2026-04-08/business_profile.zod.js' import { businessProfileSchema as businessSchema20260825 } from './generated/2026-08-25/business_profile.zod.js' @@ -131,9 +131,16 @@ describe('negotiateService — third-party services (S7)', () => { const ACME = 'com.acme.svc' const PROFILE_URL = 'https://agent.example.invalid/agent.json' - function agentWithAcme(versions: string[], transport = 'mcp') { + function agentWithAcme( + versions: string[], + transport = 'mcp', + source: ProfileSource = 'diy', + urlOverride = false, + ) { return agentProfileFixture({ version: '2026-08-25', + source, + urlOverride, url: PROFILE_URL, services: { 'dev.ucp.shopping': [{ version: '2026-08-25', transport: 'mcp' }], @@ -167,6 +174,69 @@ describe('negotiateService — third-party services (S7)', () => { ) }) + it('a managed mismatch directs the caller to an explicit DIY Profile', () => { + const agent = agentWithAcme(['2025-11-01'], 'mcp', 'managed') + const profile = business('2026-08-25', { + [ACME]: [{ version: '2025-06-01', transport: 'mcp' }], + }) + + expect(() => negotiateService({ profile, capability: ACME, agent })).toThrowError( + expect.objectContaining({ + code: 'SERVICE_VERSION_INCOMPATIBLE', + message: expect.stringMatching(/managed rendering.*bundled.*explicit DIY Profile/i), + cta: expect.objectContaining({ + commands: [expect.objectContaining({ command: 'ucp profile init --help' })], + }), + }) as unknown as Error, + ) + let emitted: { message?: string; cta?: unknown } | undefined + try { + negotiateService({ profile, capability: ACME, agent }) + } catch (error) { + emitted = error as { message?: string; cta?: unknown } + } + expect(emitted?.message).not.toContain('profile.json') + expect(JSON.stringify(emitted?.cta)).not.toContain('ucp profile show') + }) + + it('a scalar URL mismatch says to change or remove the override', () => { + const agent = agentWithAcme(['2025-11-01'], 'mcp', 'url', true) + const profile = business('2026-08-25', { + [ACME]: [{ version: '2025-06-01', transport: 'mcp' }], + }) + + expect(() => negotiateService({ profile, capability: ACME, agent })).toThrowError( + expect.objectContaining({ + code: 'SERVICE_VERSION_INCOMPATIBLE', + message: expect.stringContaining('--profile-url/UCP_AGENT_PROFILE_URL'), + cta: expect.objectContaining({ + commands: [expect.objectContaining({ command: 'ucp profile init --help' })], + }), + }) as unknown as Error, + ) + }) + + it('a DIY mismatch under a URL override retains editable-body guidance', () => { + const agent = agentWithAcme(['2025-11-01'], 'mcp', 'diy', true) + const profile = business('2026-08-25', { + [ACME]: [{ version: '2025-06-01', transport: 'mcp' }], + }) + + let emitted: { message?: string; context?: unknown; cta?: unknown } | undefined + try { + negotiateService({ profile, capability: ACME, agent }) + } catch (error) { + emitted = error as typeof emitted + } + + expect(emitted?.message).toContain('local profile.json') + expect(emitted?.message).toContain('active --profile-url/UCP_AGENT_PROFILE_URL override') + expect(emitted?.message).toContain(PROFILE_URL) + expect(emitted?.message).toContain('unset the override') + expect(emitted?.context).toMatchObject({ profileSource: 'diy', profileUrlOverride: true }) + expect(JSON.stringify(emitted)).not.toContain('meta.json') + }) + it('a third-party mismatch is NOT reported as a merchant defect', () => { // `PROFILE_VERSION_MISMATCH kind:'service-entries'` is dev.ucp.* only: // a com.acme.* entry at another version is a legitimate independent @@ -212,7 +282,7 @@ describe('negotiateService — third-party services (S7)', () => { expect(result.entry.endpoint).toBe('https://b.example.invalid/mcp') }) - it('AGENT_PROFILE_SERVICE_UNDECLARED when the profile does not declare a service the business offers', () => { + it('AGENT_PROFILE_SERVICE_UNDECLARED keeps edit/publish guidance for DIY', () => { const agent = agentProfileFixture({ version: '2026-08-25' }) const profile = business('2026-08-25', { [ACME]: [{ version: '2025-11-01', transport: 'mcp' }], @@ -223,6 +293,27 @@ describe('negotiateService — third-party services (S7)', () => { code: 'AGENT_PROFILE_SERVICE_UNDECLARED', // Agent acts: it is our own document that is missing the declaration. layer: 'client', + message: expect.stringMatching(/local profile.*Profile URL serve/i), + cta: expect.objectContaining({ + commands: [expect.objectContaining({ command: 'ucp profile show' })], + }), + }) as unknown as Error, + ) + }) + + it('AGENT_PROFILE_SERVICE_UNDECLARED never presents managed as editable', () => { + const agent = agentProfileFixture({ version: '2026-08-25', source: 'managed' }) + const profile = business('2026-08-25', { + [ACME]: [{ version: '2025-11-01', transport: 'mcp' }], + }) + + expect(() => negotiateService({ profile, capability: ACME, agent })).toThrowError( + expect.objectContaining({ + code: 'AGENT_PROFILE_SERVICE_UNDECLARED', + message: expect.stringMatching(/managed rendering.*bundled.*explicit DIY Profile/i), + cta: expect.objectContaining({ + commands: [expect.objectContaining({ command: 'ucp profile init --help' })], + }), }) as unknown as Error, ) }) diff --git a/src/core/operation.test.ts b/src/core/operation.test.ts index 603826b..4693b7d 100644 --- a/src/core/operation.test.ts +++ b/src/core/operation.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentProfileFixture } from '../test-utils.js' +import { profileFixture } from '../test-utils.js' import { callOperation, isDryRunPreview, @@ -25,9 +25,9 @@ const PROFILE_URL = 'https://agent.example.com/.well-known/ucp' // The platform side of negotiation: Shopify's published 2026-08-25 agent // profile, fetched-and-validated. It declares `dev.ucp.shopping` over mcp at // that exact version, which is what the PROFILE fixture below offers. -const AGENT = agentProfileFixture({ version: '2026-08-25' }) +const RUNTIME_PROFILE = profileFixture({ version: '2026-08-25', url: PROFILE_URL }) -const PROFILE = { +const BUSINESS_PROFILE = { ucp: { version: '2026-08-25', services: { @@ -107,7 +107,7 @@ describe('callOperation', () => { ? (JSON.parse(init.body) as Record) : undefined if (body !== undefined) bodies.push(body) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) const id = body?.id if (body?.method === 'tools/list') { @@ -128,7 +128,7 @@ describe('callOperation', () => { toolName: 'search_catalog', input: { catalog: { query: 'boots', pagination: { limit: 2 } } }, }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch }, ), ).resolves.toEqual({ products: [] }) @@ -151,7 +151,7 @@ describe('callOperation', () => { typeof init.body === 'string' ? (JSON.parse(init.body) as Record) : undefined - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) return jsonResponse({ jsonrpc: '2.0', id: body?.id, @@ -170,7 +170,7 @@ describe('callOperation', () => { catalog: { query: 'boots' }, }, }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch }, ), ).rejects.toMatchObject({ code: 'INVALID_INPUT', @@ -187,7 +187,7 @@ describe('callOperation', () => { ? (JSON.parse(init.body) as Record) : undefined if (body !== undefined) bodies.push(body) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) const id = body?.id if (body?.method === 'tools/list') { return jsonResponse({ @@ -206,7 +206,7 @@ describe('callOperation', () => { toolName: 'search_catalog', input: { meta: { trace_id: 'abc-123' }, catalog: { query: 'boots' } }, }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch }, ) const args = bodies.find((body) => body.method === 'tools/call')?.params as { @@ -230,7 +230,7 @@ describe('callOperation', () => { ? (JSON.parse(init.body) as Record) : undefined if (body !== undefined) bodies.push(body) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) const id = body?.id if (body?.method === 'tools/list') { return jsonResponse({ @@ -252,7 +252,7 @@ describe('callOperation', () => { catalog: { query: 'boots' }, }, }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch }, ) const args = bodies.find((body) => body.method === 'tools/call')?.params as { @@ -270,7 +270,7 @@ describe('callOperation', () => { ? (JSON.parse(init.body) as Record) : undefined if (body !== undefined) bodies.push(body) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) return jsonResponse({ jsonrpc: '2.0', id: body?.id, @@ -286,13 +286,18 @@ describe('callOperation', () => { await callOperation( BUSINESS_URL, { capability: 'dev.ucp.shopping', toolName: 'search_catalog', input: { query: 'boots' } }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch }, ).catch((err) => { captured = err }) - const err = captured as { code: string; layer: string; context: { schema: unknown } } + const err = captured as { + code: string + layer: string + context: { kind: string; schema: unknown } + } expect(err.code).toBe('SCHEMA_VALIDATION_FAILED') expect(err.layer).toBe('client') + expect(err.context.kind).toBe('operation-input') // The exact schema the upstream advertised — same object the dispatcher // ran ajv against — so the caller doesn't have to re-fetch tools/list. expect(err.context.schema).toEqual(SEARCH_SCHEMA) @@ -308,7 +313,7 @@ describe('callOperation', () => { ? (JSON.parse(init.body) as Record) : undefined if (body !== undefined) bodies.push(body) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) const id = body?.id if (body?.method === 'tools/list') { return jsonResponse({ @@ -332,7 +337,7 @@ describe('callOperation', () => { }, }, }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch }, ) const params = bodies.find((body) => body.method === 'tools/call')?.params as { @@ -341,7 +346,7 @@ describe('callOperation', () => { expect(params.arguments.catalog.context['com.example.fulfillment_hint']).toBe('dock') }) - it('dry-run: returns preview after validation, skips tools/call', async () => { + it('dry-run: resolves managed and injects its selected URL without a caller URL', async () => { const bodies: Record[] = [] const fetch = vi.fn(async (url: string | URL | Request, init: RequestInit = {}) => { const u = String(url) @@ -350,7 +355,7 @@ describe('callOperation', () => { ? (JSON.parse(init.body) as Record) : undefined if (body !== undefined) bodies.push(body) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) return jsonResponse({ jsonrpc: '2.0', id: body?.id, @@ -365,7 +370,7 @@ describe('callOperation', () => { toolName: 'search_catalog', input: { catalog: { query: 'boots' } }, }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL, dryRun: true }, + { cacheDir, fetch, dryRun: true }, ) expect(isDryRunPreview(result)).toBe(true) if (!isDryRunPreview(result)) throw new Error('unreachable') @@ -385,14 +390,22 @@ describe('callOperation', () => { // pin its presence so the agent-facing explanation can't silently drop. expect(typeof result.note).toBe('string') expect(result.note).toMatch(/dry-run|--dry-run|wire/i) + expect(result.note).toMatch(/operation's tools\/call request was not issued/i) + expect(result.note).toMatch(/discovery may still have used network/i) + expect(result.note).not.toMatch(/no network call issued/i) expect(result.arguments).toMatchObject({ catalog: { query: 'boots' }, - meta: { 'ucp-agent': { profile: PROFILE_URL } }, + meta: { + 'ucp-agent': { profile: RELEASES['2026-08-25'].defaultAgentProfileUrl }, + }, }) expect(typeof (result.arguments.meta as Record)['idempotency-key']).toBe( 'string', ) - // The whole point: no tools/call hit the wire. + // Cold discovery did use the network; only the operation mutation is + // skipped. This distinction is part of the user-facing dry-run contract. + expect(fetch).toHaveBeenCalled() + expect(bodies.some((body) => body.method === 'tools/list')).toBe(true) expect(bodies.some((body) => body.method === 'tools/call')).toBe(false) }) @@ -403,7 +416,7 @@ describe('callOperation', () => { typeof init.body === 'string' ? (JSON.parse(init.body) as Record) : undefined - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) return jsonResponse({ jsonrpc: '2.0', id: body?.id, @@ -420,7 +433,7 @@ describe('callOperation', () => { toolName: 'search_catalog', input: { query: 'boots' }, }, - { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL, dryRun: true }, + { cacheDir, profile: RUNTIME_PROFILE, fetch, dryRun: true }, ), ).rejects.toMatchObject({ code: 'SCHEMA_VALIDATION_FAILED' }) }) @@ -438,7 +451,7 @@ describe('callOperation', () => { typeof init.body === 'string' ? (JSON.parse(init.body) as Record) : undefined - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) if (body?.method === 'tools/list') { return jsonResponse({ jsonrpc: '2.0', @@ -459,9 +472,8 @@ describe('callOperation', () => { }, { cacheDir, - agent: AGENT, + profile: RUNTIME_PROFILE, fetch, - profileUrl: PROFILE_URL, _onDiscover: (d) => { captured.push({ business: d.business, negotiatedKeys: Object.keys(d.negotiated) }) }, @@ -477,7 +489,7 @@ describe('callOperation', () => { typeof init.body === 'string' ? (JSON.parse(init.body) as Record) : undefined - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) // tools/list omits the requested tool — discover succeeds, but the // operation lookup will throw OPERATION_NOT_OFFERED. Callback must // still have fired by then. @@ -498,9 +510,8 @@ describe('callOperation', () => { }, { cacheDir, - agent: AGENT, + profile: RUNTIME_PROFILE, fetch, - profileUrl: PROFILE_URL, _onDiscover: () => { fired = true }, @@ -556,7 +567,7 @@ describe('validateOperationInput — dialect resilience and soft signals', () => ? (JSON.parse(init.body) as Record) : undefined if (body?.method) calls.push(body.method as string) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) if (body?.method === 'tools/list') { return jsonResponse({ jsonrpc: '2.0', @@ -589,7 +600,7 @@ describe('validateOperationInput — dialect resilience and soft signals', () => toolName: 'search_catalog', input: { catalog: { query: 'boots' } }, }, - { cacheDir, agent: AGENT, fetch: buildFetch(SEARCH_SCHEMA), profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch: buildFetch(SEARCH_SCHEMA) }, ) expect(result).toBeDefined() // Happy path emits no validator-related verbose traces (the discover @@ -610,9 +621,8 @@ describe('validateOperationInput — dialect resilience and soft signals', () => }, { cacheDir, - agent: AGENT, + profile: RUNTIME_PROFILE, fetch: buildFetch(UNCOMPILABLE_SCHEMA, calls), - profileUrl: PROFILE_URL, }, ) // Request reached the server; no local throw. @@ -630,9 +640,8 @@ describe('validateOperationInput — dialect resilience and soft signals', () => }, { cacheDir, - agent: AGENT, + profile: RUNTIME_PROFILE, fetch: buildFetch(UNCOMPILABLE_SCHEMA), - profileUrl: PROFILE_URL, }, ) const skipTrace = verboseLines.find((l) => l.includes('validate: skipped')) @@ -657,9 +666,8 @@ describe('validateOperationInput — dialect resilience and soft signals', () => }, { cacheDir, - agent: AGENT, + profile: RUNTIME_PROFILE, fetch: buildFetch(UNCOMPILABLE_SCHEMA), - profileUrl: PROFILE_URL, }, ).catch((err) => { captured = err @@ -695,9 +703,8 @@ describe('validateOperationInput — dialect resilience and soft signals', () => }, { cacheDir, - agent: AGENT, + profile: RUNTIME_PROFILE, fetch: buildFetch(SEARCH_SCHEMA, calls), - profileUrl: PROFILE_URL, }, ) expect(calls).toContain('tools/call') @@ -717,7 +724,7 @@ describe('validateOperationInput — dialect resilience and soft signals', () => }, }, }, - { cacheDir, agent: AGENT, fetch: buildFetch(SEARCH_SCHEMA), profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch: buildFetch(SEARCH_SCHEMA) }, ) const flagTrace = verboseLines.find((l) => l.includes('not listed in published schema')) expect(flagTrace).toBeDefined() @@ -741,7 +748,7 @@ describe('validateOperationInput — dialect resilience and soft signals', () => }, }, }, - { cacheDir, agent: AGENT, fetch: buildFetch(SEARCH_SCHEMA), profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch: buildFetch(SEARCH_SCHEMA) }, ).catch((err) => { captured = err }) @@ -764,7 +771,7 @@ describe('validateOperationInput — dialect resilience and soft signals', () => BUSINESS_URL, // Missing the required `catalog` field. { capability: 'dev.ucp.shopping', toolName: 'search_catalog', input: {} }, - { cacheDir, agent: AGENT, fetch: buildFetch(SEARCH_SCHEMA), profileUrl: PROFILE_URL }, + { cacheDir, profile: RUNTIME_PROFILE, fetch: buildFetch(SEARCH_SCHEMA) }, ).catch((err) => { captured = err }) @@ -960,9 +967,8 @@ describe('callOperation — extension keys are judged at the NEGOTIATED release' }, { cacheDir, - agent: agentProfileFixture({ version }), + profile: profileFixture({ version, url: PROFILE_URL }), fetch: fetchAt(version), - profileUrl: PROFILE_URL, }, ) } diff --git a/src/core/operation.ts b/src/core/operation.ts index 0cb77a3..c07c846 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -26,7 +26,7 @@ import { vlog } from './verbose.js' export type CallOperationCallerOptions = Pick< DiscoverOptions, - 'agent' | 'cacheDir' | 'fetch' | 'force' | 'headers' | 'profileUrl' | 'profileName' | 'signal' + 'cacheDir' | 'fetch' | 'force' | 'headers' | 'profile' | 'signal' > & { /** * `--dry-run`: run the full pre-flight (discover → meta inject → schema @@ -35,7 +35,8 @@ export type CallOperationCallerOptions = Pick< * including the `meta.idempotency-key` UUID and `meta.ucp-agent` envelope. * Validation still fires — a payload that would fail SCHEMA_VALIDATION_FAILED * with a real call also fails here. That's deliberate: dry-run should match - * a real call's behavior up to (but not including) network I/O. + * a real call through discovery and validation, stopping only before the + * operation's `tools/call`. Cold or forced discovery can still use network. */ dryRun?: boolean /** @@ -49,36 +50,20 @@ export type CallOperationCallerOptions = Pick< _onDiscover?: (discovered: DiscoveredBusiness) => void } -// Caller-facing options keep `profileUrl` optional so help/diagnostics paths -// can omit it; dispatch requires it. forwardCallOptions narrows to the -// dispatcher contract and rejects undefined optional keys explicitly so we -// don't transmit `key: undefined` (which exactOptionalPropertyTypes treats as -// distinct from absence and which the dispatcher rejects). -export function forwardCallOptions( - options: CallOperationCallerOptions, - opName: string, -): CallOperationOptions { - if (options.profileUrl === undefined) { - throw new UcpError({ - layer: 'client', - code: ErrorCodes.INVALID_INPUT, - message: `${opName} operation requires a profile URL`, - }) - } - return { - profileUrl: options.profileUrl, - ...omitUndefined({ - agent: options.agent, - cacheDir: options.cacheDir, - fetch: options.fetch, - force: options.force, - headers: options.headers, - profileName: options.profileName, - signal: options.signal, - dryRun: options.dryRun, - _onDiscover: options._onDiscover, - }), - } +// Keep the forwarding seam explicit so undefined optional keys are omitted +// (exactOptionalPropertyTypes treats `key: undefined` as distinct from +// absence). Profile selection, including its URL, belongs to discovery. +export function forwardCallOptions(options: CallOperationCallerOptions): CallOperationOptions { + return omitUndefined({ + cacheDir: options.cacheDir, + fetch: options.fetch, + force: options.force, + headers: options.headers, + profile: options.profile, + signal: options.signal, + dryRun: options.dryRun, + _onDiscover: options._onDiscover, + }) } export interface CallOperationInput { @@ -92,9 +77,8 @@ export interface CallOperationInput { // once and exposes typed per-tool functions. Keeping the helpers as named // exports — not a Map — preserves IDE auto-import and tree-shaking. // -// `opName` is purely diagnostic: it's the short label that appears in the -// "missing profile URL" error message and aligns with the body sub-domain -// the helper operates on (e.g. 'cart', 'checkout', 'catalog'). +// `opName` aligns with the body sub-domain the helper operates on (e.g. +// 'cart', 'checkout', 'catalog') and is used by CLI view resolution. // // The returned function carries `capability`, `toolName`, and `opName` as own // properties. `--input-schema` reads capability/toolName to look up the @@ -119,22 +103,14 @@ export function serviceOp(capability: string, toolName: string, opName: string): input: Record, options: CallOperationCallerOptions = {}, ): Promise => - callOperation( - businessUrl, - { capability, toolName, input }, - forwardCallOptions(options, opName), - ), + callOperation(businessUrl, { capability, toolName, input }, forwardCallOptions(options)), { capability, toolName, opName }, ) return helper } export interface CallOperationOptions - extends Pick< - DiscoverOptions, - 'agent' | 'cacheDir' | 'fetch' | 'force' | 'headers' | 'profileName' | 'signal' - > { - profileUrl: string + extends Pick { dryRun?: boolean /** See {@link CallOperationCallerOptions._onDiscover}. */ _onDiscover?: (discovered: DiscoveredBusiness) => void @@ -184,18 +160,16 @@ export function isDryRunPreview(value: unknown): value is DryRunPreview { export async function callOperation( businessUrl: string, input: CallOperationInput, - options: CallOperationOptions, + options: CallOperationOptions = {}, ): Promise { const resolved = await discover(businessUrl, { capabilities: [input.capability], - profileUrl: options.profileUrl, ...omitUndefined({ - agent: options.agent, cacheDir: options.cacheDir, fetch: options.fetch, force: options.force, headers: options.headers, - profileName: options.profileName, + profile: options.profile, signal: options.signal, }), }) @@ -220,7 +194,7 @@ export async function callOperation( }) } - const args = withProfileMetadata(input.input, options.profileUrl) + const args = withProfileMetadata(input.input, resolved.protocol.agentProfileUrl) validateOperationInput({ business: resolved.business, capability: input.capability, @@ -241,7 +215,7 @@ export async function callOperation( // surface for what is fundamentally a debug detour. const preview: DryRunPreview = { dry_run: true, - note: 'No network call issued. `arguments` is exactly what would hit the wire, including the auto-injected meta.idempotency-key and meta.ucp-agent. Re-run without --dry-run to dispatch. Envelope root carries `business`/`endpoint`/`transport` — the canonical dispatch target.', + note: "The operation's tools/call request was not issued. Discovery may still have used network on a cold or forced cache. `arguments` is exactly what the operation would send, including the auto-injected meta.idempotency-key and meta.ucp-agent. Re-run without --dry-run to dispatch. Envelope root carries `business`/`endpoint`/`transport` — the canonical dispatch target.", capability: input.capability, tool: { name: tool.name }, arguments: args, @@ -419,6 +393,7 @@ function validateOperationInput(opts: { // reaches the wire once incur supports passthrough of `error.context` // (today's incur strips it from the thrown-error catch path). context: { + kind: 'operation-input', business: opts.business, capability: opts.capability, tool: opts.toolName, @@ -452,6 +427,7 @@ function flagUnknownPlainFields(opts: { code: ErrorCodes.SCHEMA_VALIDATION_FAILED, message: `operation input contains unknown field${unknown.length === 1 ? '' : 's'} for "${opts.toolName}": ${formatUnknownFields(unknown)}. The business's advertised input schema does not list this field, and per client policy only listed fields plus reverse-DNS extension keys are sent (some canonical UCP fields are still spec-valid but require explicit business support). Run \` --input-schema\` to see what this business actually accepts.`, context: { + kind: 'operation-input', business: opts.business, capability: opts.capability, tool: opts.toolName, diff --git a/src/core/profile-store.test.ts b/src/core/profile-store.test.ts index eea1430..eff8ae6 100644 --- a/src/core/profile-store.test.ts +++ b/src/core/profile-store.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { PROFILE_FORMAT_VERSION } from './legacy-profile.js' import type { PlatformProfile } from './profile.js' import { activeYamlPath, @@ -96,6 +97,8 @@ describe('profile CRUD', () => { expect(await profileExists('prod', { homeDir })).toBe(true) const read = await readUserProfile('prod', { homeDir }) + expect(read.kind).toBe('diy') + if (read.kind !== 'diy') throw new Error('expected saved custom Profile to remain DIY') expect(read.body.ucp.version).toBe('2026-08-25') expect(read.meta.profile_url).toBe('https://mybot.example.com/.well-known/ucp') }) @@ -121,10 +124,15 @@ describe('profile CRUD', () => { expect(read.meta.profile_url).toBe('https://newhost.example.com/.well-known/ucp') }) - it('readUserProfile on missing profile throws PROFILE_NOT_FOUND with layer=client', async () => { + it('readUserProfile on a missing profile carries an actual-name --force repair CTA', async () => { await expect(readUserProfile('ghost', { homeDir })).rejects.toMatchObject({ code: 'PROFILE_NOT_FOUND', layer: 'client', + context: { kind: 'profile-store', profile: 'ghost', file: 'meta.json' }, + cta: { + description: expect.stringMatching(/cannot preserve.*custom profile_url.*--profile-url/i), + commands: [{ command: 'ucp profile init --name ghost --force' }], + }, }) }) @@ -146,6 +154,84 @@ describe('profile CRUD', () => { await writeFile(join(profileDir('prod', { homeDir }), 'profile.json'), '', 'utf-8') await expect(readUserProfile('prod', { homeDir })).rejects.toMatchObject({ code: 'SCHEMA_VALIDATION_FAILED', + context: { kind: 'profile-store', profile: 'prod', file: 'profile.json' }, + cta: { + description: expect.stringMatching( + /rewrite.*local DIY.*document.*custom profile_url.*readable.*meta\.json.*preserved/i, + ), + commands: [ + { + command: 'ucp profile init --name prod --force', + description: expect.stringMatching(/rewrite.*prod/i), + }, + ], + }, + }) + }) + + it.each([ + { damage: 'missing', expectedCode: 'PROFILE_NOT_FOUND' }, + { damage: 'corrupt', expectedCode: 'SCHEMA_VALIDATION_FAILED' }, + ])( + 'a marked DIY Profile still rejects a $damage profile.json', + async ({ damage, expectedCode }) => { + await saveUserProfile( + { + name: 'marked-diy', + body: SAMPLE_BODY, + meta: { + ...SAMPLE_META, + format_version: PROFILE_FORMAT_VERSION, + kind: 'diy', + }, + }, + { homeDir }, + ) + const bodyPath = join(profileDir('marked-diy', { homeDir }), 'profile.json') + if (damage === 'missing') await rm(bodyPath) + else await writeFile(bodyPath, '', 'utf-8') + + await expect(readUserProfile('marked-diy', { homeDir })).rejects.toMatchObject({ + code: expectedCode, + context: { kind: 'profile-store', profile: 'marked-diy', file: 'profile.json' }, + }) + }, + ) + + it('wraps profile.json schema failures with local-store provenance and repair CTA', async () => { + await saveUserProfile( + { name: 'actual-name', body: SAMPLE_BODY, meta: SAMPLE_META }, + { homeDir }, + ) + await writeFile( + join(profileDir('actual-name', { homeDir }), 'profile.json'), + JSON.stringify({ ucp: { version: '2026-08-25' } }), + 'utf-8', + ) + + await expect(readUserProfile('actual-name', { homeDir })).rejects.toMatchObject({ + code: 'SCHEMA_VALIDATION_FAILED', + context: { kind: 'profile-store', profile: 'actual-name', file: 'profile.json' }, + cta: { + commands: [{ command: 'ucp profile init --name actual-name --force' }], + }, + }) + }) + + it('preserves AGENT_PROFILE_VERSION_UNSUPPORTED while adding store provenance', async () => { + await saveUserProfile({ name: 'old-agent', body: SAMPLE_BODY, meta: SAMPLE_META }, { homeDir }) + await writeFile( + join(profileDir('old-agent', { homeDir }), 'profile.json'), + JSON.stringify({ ...SAMPLE_BODY, ucp: { ...SAMPLE_BODY.ucp, version: '2025-01-01' } }), + 'utf-8', + ) + + await expect(readUserProfile('old-agent', { homeDir })).rejects.toMatchObject({ + code: 'AGENT_PROFILE_VERSION_UNSUPPORTED', + context: { kind: 'profile-store', profile: 'old-agent', file: 'profile.json' }, + cta: { + commands: [{ command: 'ucp profile init --name old-agent --force' }], + }, }) }) @@ -159,6 +245,11 @@ describe('profile CRUD', () => { ) await expect(readUserProfile('prod', { homeDir })).rejects.toMatchObject({ code: 'SCHEMA_VALIDATION_FAILED', + context: { kind: 'profile-store', profile: 'prod', file: 'meta.json' }, + cta: { + description: expect.stringMatching(/cannot preserve.*custom profile_url.*--profile-url/i), + commands: [{ command: 'ucp profile init --name prod --force' }], + }, }) }) diff --git a/src/core/profile-store.ts b/src/core/profile-store.ts index ffbd663..06738d0 100644 --- a/src/core/profile-store.ts +++ b/src/core/profile-store.ts @@ -7,12 +7,13 @@ // profile.json — agent profile body (the artifact the user hosts) // meta.json — { profile_url?, defaults?, created_at? } // -// Role split, load-bearing (see src/core/agent.ts). `profile.json` is the -// declaration ucp-cli negotiates from for every named profile, and -// `meta.profile_url` is the URL every request advertises for businesses to -// read. The two documents must agree; `ucp doctor` compares them. Nothing here -// fetches or uploads: the request path never reads the wire, and no ucp-cli -// command writes to a profile URL. +// Role split, load-bearing (see src/cli/session.ts and src/core/agent.ts). +// For a named DIY Profile, `profile.json` is the declaration ucp-cli plans +// from and `meta.profile_url` is its stored rendering URL. A named managed +// alias keeps those files only as migration/storage state; session resolution +// rebuilds its runtime renderings from bundled releases. Nothing here fetches +// or uploads: `ucp doctor` is the only live reader of a Profile URL, and no +// ucp-cli command writes to one. // // Plus the session-state pair: // @@ -42,9 +43,17 @@ import { z } from 'incur' import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import { ErrorCodes, UcpError } from '../lib/errors.js' +import type { CtaBlock } from '../lib/types.js' import { formatZodIssues } from '../lib/zod-format.js' +import { + classifyStoredProfile, + markProfileMeta, + PROFILE_FORMAT_VERSION, + type ProfileKind, +} from './legacy-profile.js' import { type PlatformProfile, parsePlatformProfile } from './profile.js' import { acceptsHttpsUrl } from './url.js' +import { uwarn } from './verbose.js' // ─── Schemas (zod) ──────────────────────────────────────────────────────── // @@ -66,8 +75,7 @@ export const profileMetaSchema = z updated_at: z.string().optional(), // Optional for forward/backward compatibility: `profile init` writes it // so the remote identity is explicit, but an older profile may omit it, - // in which case session resolution falls back to the latest release - // default. + // in which case session resolution uses the profile body's release URL. profile_url: httpsUrlSchema.optional(), // `defaults.catalog` is the business URL catalog ops fall back to when // no business is resolved. Discovery hits `/.well-known/ucp` @@ -76,6 +84,19 @@ export const profileMetaSchema = z // old clients. HTTPS-only so a broken meta.json fails at the profile // boundary, not mid-dispatch. defaults: z.object({ catalog: httpsUrlSchema.optional() }).loose().optional(), + // ── Canonical format marker ───────────────────────────────────────── + // Written once, by the legacy upgrade in `readUserProfile`. `kind` is + // the stored classification that session resolution turns into a runtime + // Profile (see core/legacy-profile.ts); `format_version` is what makes the + // upgrade a one-time event — a marked profile is never fingerprinted + // again. Both stay optional: an + // unmigrated profile is legal input, that is the entire point. + // + // `format_version` is a plain integer, not a literal: a profile written + // by a NEWER build must not fail this schema on an older client. Reading + // it as "at least PROFILE_FORMAT_VERSION" keeps forward-compat honest. + format_version: z.number().int().optional(), + kind: z.enum(['managed', 'diy']).optional(), }) .loose() @@ -89,12 +110,32 @@ export const activeSessionSchema = z export type ProfileMeta = z.infer export type ActiveSession = z.infer -export interface UserProfile { +interface UserProfileBase { name: string - body: PlatformProfile meta: ProfileMeta } +export interface ManagedUserProfile extends UserProfileBase { + /** + * A managed Profile never requires a body. Reads omit even a historical + * body used for classification; a save may still echo the body it wrote. + */ + body?: PlatformProfile + kind: 'managed' +} + +export interface DiyUserProfile extends UserProfileBase { + /** The locally authored declaration a DIY runtime Profile is built from. */ + body: PlatformProfile + kind: 'diy' +} + +/** + * A stored Profile after classification. Callers branch on `kind`, never on + * body shape — the fingerprint vocabulary stops at core/legacy-profile.ts. + */ +export type UserProfile = ManagedUserProfile | DiyUserProfile + export interface ProfileStoreOptions { /** Override the UCP home directory ($UCP_HOME or ~/.ucp). For tests. */ homeDir?: string @@ -102,6 +143,83 @@ export interface ProfileStoreOptions { const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ +type StoredProfileFile = 'profile.json' | 'meta.json' + +function profileRepairCta(name: string, file: StoredProfileFile): CtaBlock { + const metadataGuidance = + file === 'meta.json' + ? 'Because meta.json could not be read and validated, ucp-cli cannot preserve a custom profile_url from it. Add --profile-url with the HTTPS URL you need to retain.' + : 'Any custom profile_url in the readable, valid meta.json is preserved unless --profile-url is passed.' + return { + description: `Re-initializing Profile "${name}" rewrites its local DIY document from the selected release and updates its identity metadata, replacing local profile.json edits. ${metadataGuidance}`, + commands: [ + { + command: `ucp profile init --name ${name} --force`, + description: `rewrite local DIY Profile "${name}" document and metadata`, + }, + ], + } +} + +function parseStoredJson(raw: string, name: string, file: StoredProfileFile): unknown { + try { + return JSON.parse(raw) + } catch (err) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.SCHEMA_VALIDATION_FAILED, + message: `profile "${name}" ${file} is not valid JSON`, + cause: err as Error, + context: { kind: 'profile-store', profile: name, file }, + cta: profileRepairCta(name, file), + }) + } +} + +function validateStoredMeta(input: unknown, name: string): ProfileMeta { + const meta = profileMetaSchema.safeParse(input) + if (meta.success) return meta.data + throw new UcpError({ + layer: 'client', + code: ErrorCodes.SCHEMA_VALIDATION_FAILED, + message: `profile "${name}" meta.json failed schema validation: ${formatZodIssues(meta.error.issues)}`, + context: { + kind: 'profile-store', + profile: name, + file: 'meta.json', + issues: meta.error.issues, + }, + cta: profileRepairCta(name, 'meta.json'), + }) +} + +function storedProfileBodyError(name: string, err: UcpError): UcpError { + const details = { + message: err.shortMessage, + ...(err.hint !== undefined ? { hint: err.hint } : {}), + retryable: err.retryable, + context: { + kind: 'profile-store', + profile: name, + file: 'profile.json', + ...(err.context !== undefined ? { validation: err.context } : {}), + }, + cta: profileRepairCta(name, 'profile.json'), + } + if (err.code === ErrorCodes.AGENT_PROFILE_VERSION_UNSUPPORTED) { + return new UcpError({ + layer: 'client', + code: ErrorCodes.AGENT_PROFILE_VERSION_UNSUPPORTED, + ...details, + }) + } + return new UcpError({ + layer: 'client', + code: ErrorCodes.SCHEMA_VALIDATION_FAILED, + ...details, + }) +} + // ─── Path helpers ───────────────────────────────────────────────────────── export function profileStoreHome(opts: ProfileStoreOptions = {}): string { @@ -130,6 +248,26 @@ export function validateProfileName(name: string): void { } } +async function readStoredProfileFile( + name: string, + file: StoredProfileFile, + opts: ProfileStoreOptions, +): Promise { + const path = join(profileDir(name, opts), file) + try { + return await readFile(path, 'utf-8') + } catch (err) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.PROFILE_NOT_FOUND, + message: `profile "${name}" ${file} could not be read at ${path}`, + cause: err as Error, + context: { kind: 'profile-store', profile: name, file }, + cta: profileRepairCta(name, file), + }) + } +} + // ─── Profile CRUD ───────────────────────────────────────────────────────── export async function listProfiles(opts: ProfileStoreOptions = {}): Promise { @@ -167,53 +305,114 @@ export async function profileExists( } } -export async function readUserProfile( +/** Read and validate only a local Profile's metadata (used by --force repair). */ +export async function readProfileMeta( name: string, opts: ProfileStoreOptions = {}, -): Promise { +): Promise { validateProfileName(name) - const dir = profileDir(name, opts) - let bodyRaw: string - let metaRaw: string + const raw = await readStoredProfileFile(name, 'meta.json', opts) + return validateStoredMeta(parseStoredJson(raw, name, 'meta.json'), name) +} + +export interface ReadUserProfileOptions extends ProfileStoreOptions { + /** + * Test seam for the one-time marker write. Production uses the same atomic + * rename `active.yaml` gets; a test injects a rejecting implementation to + * prove a read-only (or full) `~/.ucp` still resolves a session. + */ + writeMeta?: (path: string, content: string) => Promise + /** + * Set false for exploratory scans. The Profile is still classified, but an + * unmarked legacy meta.json is neither stamped nor reported as a failed + * migration write. Active session reads leave this enabled (the default). + */ + migrate?: boolean +} + +async function stampProfileMeta( + name: string, + meta: ProfileMeta, + kind: ProfileKind, + needsMarker: boolean, + opts: ReadUserProfileOptions, +): Promise { + if (!needsMarker || opts.migrate === false) return meta + + const marked = markProfileMeta(meta, kind) + const metaPath = join(profileDir(name, opts), 'meta.json') + const write = opts.writeMeta ?? writeFileAtomic try { - ;[bodyRaw, metaRaw] = await Promise.all([ - readFile(join(dir, 'profile.json'), 'utf-8'), - readFile(join(dir, 'meta.json'), 'utf-8'), - ]) + await write(metaPath, `${JSON.stringify(marked, null, 2)}\n`) } catch (err) { - throw new UcpError({ - layer: 'client', - code: ErrorCodes.PROFILE_NOT_FOUND, - message: `profile "${name}" not found at ${dir}`, - cause: err as Error, - }) + uwarn( + `profile "${name}": could not record format_version ${PROFILE_FORMAT_VERSION} / kind "${kind}" in ${metaPath} (${(err as Error).message}); continuing with the resolved profile and retrying on the next run`, + ) } - let bodyParsed: unknown - let metaParsed: unknown + return marked +} + +/** + * Read a local profile, resolving its {@link ProfileKind}. + * + * Unmarked profiles are classified once — see core/legacy-profile.ts for the + * rules — and active reads stamp the decision into `meta.json` so the next + * read is a plain lookup. Exploratory scans can set `migrate: false` to keep + * classification read-only. Only `meta.json` is ever written: `profile.json` + * and `headers.json` are the user's bytes and are left exactly as found, which + * also keeps a downgrade to an older ucp-cli working. + * + * The marker write is best-effort. Commerce does not depend on it: a failed + * stamp warns on stderr and returns the same in-memory classification, and + * the next process tries again. The classification itself is deterministic, + * so "retry later" cannot mean "decide differently". + */ +export async function readUserProfile( + name: string, + opts: ReadUserProfileOptions = {}, +): Promise { + // Metadata first is deliberate. Besides making repair guidance accurate, + // it lets an explicit managed marker terminate the read before profile.json: + // that file is retained only for downgrade compatibility and may be absent + // or damaged without changing the managed runtime identity. + const meta = await readProfileMeta(name, opts) + if (meta.kind === 'managed') { + const { needsMarker } = classifyStoredProfile(undefined, meta) + return { + name, + meta: await stampProfileMeta(name, meta, 'managed', needsMarker, opts), + kind: 'managed', + } + } + + // DIY markers and unmarked legacy entries both require a real, valid body. + // The latter must also fingerprint the raw parsed JSON before it can acquire + // a durable classification. + const bodyRaw = await readStoredProfileFile(name, 'profile.json', opts) + const bodyParsed = parseStoredJson(bodyRaw, name, 'profile.json') + let body: PlatformProfile try { - bodyParsed = JSON.parse(bodyRaw) - metaParsed = JSON.parse(metaRaw) + body = parsePlatformProfile(bodyParsed, `profile "${name}"`) } catch (err) { - throw new UcpError({ - layer: 'client', - code: ErrorCodes.SCHEMA_VALIDATION_FAILED, - message: `profile "${name}": profile.json or meta.json is not valid JSON`, - cause: err as Error, - }) - } - // parsePlatformProfile throws UcpError(client, SCHEMA_VALIDATION_FAILED) on - // failure — same shape and layer as meta-schema failures, so a meta-only - // failure path stays the only thing left to handle inline. - const body = parsePlatformProfile(bodyParsed, `profile "${name}"`) - const meta = profileMetaSchema.safeParse(metaParsed) - if (!meta.success) { - throw new UcpError({ - layer: 'client', - code: ErrorCodes.SCHEMA_VALIDATION_FAILED, - message: `profile "${name}" meta.json failed schema validation: ${formatZodIssues(meta.error.issues)}`, - }) + // parsePlatformProfile is shared with non-store callers. Re-wrap its two + // stable failures so global CLI middleware can distinguish a broken local + // document and retain the actual-name repair CTA. + if ( + err instanceof UcpError && + (err.code === ErrorCodes.SCHEMA_VALIDATION_FAILED || + err.code === ErrorCodes.AGENT_PROFILE_VERSION_UNSUPPORTED) + ) { + throw storedProfileBodyError(name, err) + } + throw err } - return { name, body, meta: meta.data } + // Classify the RAW parsed JSON, not `body`: `parsePlatformProfile` is + // allowed to fill schema defaults, and a fingerprint of a value the user + // never wrote is a fingerprint of nothing. + const { kind, needsMarker } = classifyStoredProfile(bodyParsed, meta) + const resolvedMeta = await stampProfileMeta(name, meta, kind, needsMarker, opts) + if (kind === 'managed') return { name, meta: resolvedMeta, kind } + return { name, body, meta: resolvedMeta, kind } } export interface SaveProfileInput { @@ -238,11 +437,18 @@ export async function saveUserProfile( }) } await mkdir(dir, { recursive: true, mode: 0o700 }) - await Promise.all([ - writeFile(join(dir, 'profile.json'), `${JSON.stringify(input.body, null, 2)}\n`, 'utf-8'), - writeFile(join(dir, 'meta.json'), `${JSON.stringify(input.meta, null, 2)}\n`, 'utf-8'), - ]) - return { name: input.name, body: input.body, meta: input.meta } + // Per-file atomic, body BEFORE meta, sequential — not `Promise.all`. + // `meta.json` is what says how `profile.json` is read (including the + // format marker), so a crash between the two must leave a stale + // description of a real body, never a marker describing bytes that were + // never written. + await writeFileAtomic(join(dir, 'profile.json'), `${JSON.stringify(input.body, null, 2)}\n`) + await writeFileAtomic(join(dir, 'meta.json'), `${JSON.stringify(input.meta, null, 2)}\n`) + // Classify what we just wrote rather than assume: the returned record has + // to agree with what the next `readUserProfile` will say about the same + // two files, and only `meta` can carry a marker that overrides the body. + const { kind } = classifyStoredProfile(input.body, input.meta) + return { name: input.name, body: input.body, meta: input.meta, kind } } // ─── Active session (active.yaml) ───────────────────────────────────────── diff --git a/src/core/profile.test.ts b/src/core/profile.test.ts index 70e9498..f8e35d5 100644 --- a/src/core/profile.test.ts +++ b/src/core/profile.test.ts @@ -8,8 +8,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { CacheEntry } from './cache.js' -import { type BusinessProfile, fetchBusinessProfile, parsePlatformProfile } from './profile.js' +import { createAdHocProfile, createDiyProfile, createManagedProfile } from './agent.js' +import { type CacheEntry, urlToFilename } from './cache.js' +import { + type BusinessProfile, + fetchBusinessProfile, + fetchCompatibleBusinessProfile, + parsePlatformProfile, +} from './profile.js' import { RELEASES, SUPPORTED_VERSIONS } from './releases.js' // `profile init --version ` writes `RELEASES[v].agentProfileJson` verbatim. @@ -138,8 +144,9 @@ describe('fetchBusinessProfile', () => { expect(profile.ucp.version).toBe('2026-08-25') expect(mock.calls[0]?.url).toBe('https://shop.example.com/.well-known/ucp') + const profileUrl = 'https://shop.example.com/.well-known/ucp' const cached = JSON.parse( - await readFile(join(cacheDir, 'shop.example.com.json'), 'utf-8'), + await readFile(join(cacheDir, `${urlToFilename(profileUrl)}.json`), 'utf-8'), ) as CacheEntry expect(cached.body.ucp.version).toBe('2026-08-25') }) @@ -218,11 +225,12 @@ describe('fetchBusinessProfile', () => { expect(mock.calls).toHaveLength(2) }) - it('uses URL.origin for cache filename (different paths share a cache entry)', async () => { + it('resolves business inputs to the canonical well-known URL before caching', async () => { const mock = makeMockFetch([ { body: SAMPLE_PROFILE, headers: { 'cache-control': 'max-age=300' } }, ]) - // First fetch with extra path; second with bare origin — both map to the same cache file. + // A path on the business input never replaces the fixed discovery path, + // so both calls request the same full URL and share its cache entry. await fetchBusinessProfile('https://shop.example.com/some/other/path', { cacheDir, fetch: mock.fn, @@ -239,4 +247,57 @@ describe('fetchBusinessProfile', () => { layer: 'client', }) }) + + it('gives managed guidance before considering URL override provenance', async () => { + const mock = makeMockFetch([{ body: { ucp: { version: '2026-12-01' } } }]) + + await expect( + fetchCompatibleBusinessProfile('https://shop.example.com', { + cacheDir, + fetch: mock.fn, + profile: { ...createManagedProfile(), urlOverride: true }, + }), + ).rejects.toMatchObject({ + code: 'PROTOCOL_VERSION_INCOMPATIBLE', + message: expect.stringContaining('The managed Profile already offers every rendering'), + }) + }) + + it('gives reachable URL-only guidance for an ad-hoc Profile', async () => { + const mock = makeMockFetch([{ body: { ucp: { version: '2026-04-08' } } }]) + + await expect( + fetchCompatibleBusinessProfile('https://shop.example.com', { + cacheDir, + fetch: mock.fn, + profile: createAdHocProfile(RELEASES['2026-08-25'].defaultAgentProfileUrl), + }), + ).rejects.toMatchObject({ + code: 'PROTOCOL_VERSION_INCOMPATIBLE', + message: expect.stringContaining( + 'A URL-only ad-hoc Profile is pinned to this one bundled rendering', + ), + }) + }) + + it('retains authored-body guidance for a named DIY Profile with a URL override', async () => { + const mock = makeMockFetch([{ body: { ucp: { version: '2026-04-08' } } }]) + const profile = createDiyProfile({ + name: 'mine', + body: JSON.parse(RELEASES['2026-08-25'].agentProfileJson), + url: 'https://agent.example.com/profile.json', + urlOverride: true, + }) + + await expect( + fetchCompatibleBusinessProfile('https://shop.example.com', { + cacheDir, + fetch: mock.fn, + profile, + }), + ).rejects.toMatchObject({ + code: 'PROTOCOL_VERSION_INCOMPATIBLE', + message: expect.stringMatching(/editable local DIY body.*authored document/), + }) + }) }) diff --git a/src/core/profile.ts b/src/core/profile.ts index e5ef565..fddabf1 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -8,12 +8,12 @@ // publishes at `/.well-known/ucp`. Service endpoints are required before we can // dispatch. // -// Version model (spec "Protocol Version"): exact-version equality. The agent -// profile declares ONE `ucp.version`; a business is negotiable iff it offers -// that exact version — as its top-level rendering or as a `supported_versions` -// leaf. Compatibility is never inferred from date order. Every parse below -// selects its schema from the release registry (releases.ts) by the document's -// own `ucp.version`. +// Version model (spec "Protocol Version"): exact-version equality. A runtime +// Profile contributes one or more exact AgentProfile renderings; a Business +// contributes its root version plus `supported_versions` keys. Negotiation +// selects the newest installed version in that intersection. Compatibility is +// never inferred from date order, and selection is never retried at an older +// version after the chosen rendering fails. import { join } from 'node:path' @@ -22,7 +22,7 @@ import { z } from 'incur' import { ErrorCodes, UcpError } from '../lib/errors.js' import { omitUndefined } from '../lib/omit-undefined.js' import { formatZodIssues } from '../lib/zod-format.js' -import { type AgentProfile, agentLabel } from './agent.js' +import type { AgentProfile, Profile } from './agent.js' import { fetchCached, ucpHomeDir } from './cache.js' import { type BusinessProfile, @@ -92,12 +92,11 @@ export function parsePlatformProfile(input: unknown, label = 'platform profile') // There is deliberately NO `parseBusinessProfile`. Every business document // the CLI touches arrives through `fetchCompatibleBusinessProfile` below, -// which selects the release from the AGENT profile's version (exact-version -// equality) and reports a version it cannot serve as -// PROTOCOL_VERSION_INCOMPATIBLE. A standalone "parse a business document at -// whatever version it claims" helper had no callers, and the error code that -// existed only for it (`PROFILE_VERSION_UNSUPPORTED`) invited the idea that a -// business's own version selects our schema — it does not. +// which selects the newest exact release shared by the runtime Profile and +// Business offer. A standalone "parse a business document at whatever version +// it claims" helper had no callers, and the error code that existed only for +// it (`PROFILE_VERSION_UNSUPPORTED`) invited the idea that a Business's root +// version alone selects our schema — it does not. /** * Default catalog business URL — the origin whose `/.well-known/ucp` discovery @@ -138,10 +137,9 @@ const PROFILE_ERROR_CODES = { /** * Fetch and validate a business profile from an explicit document URL: the * canonical `/.well-known/ucp`, or a version-specific document linked - * from `supported_versions`. Callers pick `cacheDir`; `fetchCached` names the - * file after the URL's origin, so documents that share an origin need - * distinct directories. `schema` selects the release to validate against; - * defaults to the latest release's business schema. + * from `supported_versions`. Callers pick `cacheDir`; `fetchCached` names each + * file with a hash of the canonical full URL. `schema` selects the release to + * validate against; defaults to the latest release's business schema. */ export async function fetchBusinessProfileFromUrl( profileUrl: string, @@ -192,15 +190,17 @@ const profileEnvelopeSchema = z .catchall(z.unknown()) export interface ResolveProfileOptions extends FetchProfileOptions { - /** The fetched, validated agent identity to negotiate for. */ - agent: AgentProfile + /** Runtime Profile whose exact renderings are eligible for selection. */ + profile: Profile } export interface ResolvedBusinessProfile { profile: BusinessProfile + /** The exact AgentProfile rendering selected for this Business. */ + agentProfile: AgentProfile /** URL of the document `profile` was parsed from. */ profileUrl: string - /** Negotiated protocol version — always the agent profile's exact version. */ + /** Negotiated protocol version shared by both selected renderings. */ version: Version /** `ucp.version` of the top-level `/.well-known/ucp` rendering. */ businessVersion: string @@ -208,39 +208,69 @@ export interface ResolvedBusinessProfile { source: 'well-known' | 'supported_versions' } +function profileLabel(profile: Profile): string { + if (profile.source === 'managed') { + return profile.name === undefined ? 'managed Profile' : `managed Profile '${profile.name}'` + } + if (profile.source === 'url') { + const rendering = Object.values(profile.renderings)[0] + return rendering === undefined + ? 'profile URL override' + : `profile URL override ${rendering.url}` + } + if (profile.name !== undefined) return `profile '${profile.name}'` + const rendering = Object.values(profile.renderings)[0] + return rendering === undefined ? 'DIY Profile' : `agent profile ${rendering.url}` +} + +function incompatibleProfileRemedy(profile: Profile): string { + // Body provenance determines whether the declaration is editable. Check it + // before independent URL-override provenance, matching service remedies. + if (profile.source === 'managed') { + return "The managed Profile already offers every rendering installed in this ucp-cli build, so no local Profile using this build's installed versions can recover; install a ucp-cli build that supports a Business-offered release." + } + if (profile.source === 'url') { + return 'A URL-only ad-hoc Profile is pinned to this one bundled rendering. The explicit --profile-url/UCP_AGENT_PROFILE_URL override outranks stored meta/profile-name switching. Remove --profile-url/UCP_AGENT_PROFILE_URL or make that URL serve the intended exact authored/bundled rendering; to edit the declaration itself, create a DIY Profile and a Profile URL you control.' + } + if (profile.urlOverride) { + return 'This is still an editable local DIY body. Update profile.json, then upload that authored document to the active --profile-url/UCP_AGENT_PROFILE_URL override. It outranks stored meta/profile-name switching. Remove --profile-url/UCP_AGENT_PROFILE_URL or make that URL serve the intended exact authored/bundled rendering.' + } + return 'A DIY Profile is pinned to this one rendering.' +} + /** - * Fetch the business profile rendering that matches the agent profile's exact - * version, per the spec's "Protocol Version" rules: + * Fetch the newest exact Business/Agent rendering pair, per the spec's + * "Protocol Version" rules: * - * 1. Fetch `/.well-known/ucp` (envelope parse only). The business offers - * `{ucp.version} ∪ keys(supported_versions)`. - * 2. If the agent's version IS the top-level version, that document is the - * profile (validated against the agent release's business schema). - * 3. Else if the agent's version is a `supported_versions` key, fetch the - * linked document (https only). Its `ucp.version` MUST equal the key, - * else the platform MUST NOT use it (`PROFILE_VERSION_MISMATCH`, - * kind: 'supported_versions'). Version-specific documents are leaves: their own - * `supported_versions` (if any) is logged and not followed. - * 4. Otherwise `PROTOCOL_VERSION_INCOMPATIBLE` — the business does not - * offer the version this profile speaks. Not by date order: a business - * one release AHEAD of us that publishes a leaf for our version - * negotiates fine via rule 3. + * 1. Fetch `/.well-known/ucp` and parse only its envelope. The Business + * offers `{ucp.version} ∪ keys(supported_versions)`. + * 2. Intersect that set with the runtime Profile's rendering keys and pick + * the newest installed release. + * 3. If selected version is the root, validate that document. Otherwise + * fetch the selected `supported_versions` leaf (https only), verify that + * its `ucp.version` equals its key, then validate it. Leaves are not + * traversed. + * 4. If the intersection is empty, fail `PROTOCOL_VERSION_INCOMPATIBLE`. * - * Cache layout: the top-level document lives at `/.json`; - * a version-specific document lives at `//.json` - * (origin of the linked URL, which may differ from the business origin). + * Once step 2 selects a version there is no fallback: a bad leaf, service, + * endpoint, or tools response fails the call rather than silently retrying an + * older protocol rendering. + * + * Cache layout: the top-level document lives at `/.json`; + * a version-specific document lives at + * `//.json`. */ export async function fetchCompatibleBusinessProfile( businessUrl: string, options: ResolveProfileOptions, ): Promise { - const { agent, ...fetchOptions } = options + const { profile: runtimeProfile, ...fetchOptions } = options const cacheDir = fetchOptions.cacheDir ?? defaultBusinessCacheDir() const baseUrl = parseHttpsUrl(businessUrl, 'business URL') const wellKnownUrl = new URL('/.well-known/ucp', baseUrl).toString() - const v = agent.version - const rel = agent.release + // Envelope first. Profile kind does not get a vote until the Business's + // complete offered-version set is known. const top = await fetchCached(wellKnownUrl, { cacheDir, schema: profileEnvelopeSchema, @@ -256,6 +286,43 @@ export async function fetchCompatibleBusinessProfile( const businessVersion = top.ucp.version const supported = top.ucp.supported_versions ?? {} const offered = [...new Set([businessVersion, ...Object.keys(supported)])].sort() + const offeredSet = new Set(offered) + const profileVersions = SUPPORTED_VERSIONS.filter((version) => + Object.hasOwn(runtimeProfile.renderings, version), + ) + const v = profileVersions.filter((version) => offeredSet.has(version)).at(-1) + + if (v === undefined) { + const soleVersion = profileVersions.length === 1 ? profileVersions[0] : undefined + const soleAgent = soleVersion === undefined ? undefined : runtimeProfile.renderings[soleVersion] + throw new UcpError({ + layer: 'transport', + code: ErrorCodes.PROTOCOL_VERSION_INCOMPATIBLE, + message: `${baseUrl.origin} offers UCP ${offered.join(', ')}; ${profileLabel(runtimeProfile)} offers ${profileVersions.join(', ') || 'no installed renderings'}. ucp-cli supports ${SUPPORTED_VERSIONS.join(', ')}. ${incompatibleProfileRemedy(runtimeProfile)}`, + context: { + business: baseUrl.origin, + businessVersion, + offered, + supported: [...SUPPORTED_VERSIONS], + profileVersions, + profileSource: runtimeProfile.source, + profileUrlOverride: runtimeProfile.urlOverride, + ...(soleAgent !== undefined + ? { agentVersion: soleAgent.version, agentProfileUrl: soleAgent.url } + : {}), + ...(runtimeProfile.name !== undefined ? { profileName: runtimeProfile.name } : {}), + }, + }) + } + + // `v` survived the own-key filter above, so this lookup is total by + // construction; the guard is an internal-invariant check in the same key + // as the supported_versions one below, not a compatibility fallback. + const agent = runtimeProfile.renderings[v] + if (agent === undefined) { + throw new Error(`selected UCP ${v} but the runtime profile has no rendering for it`) + } + const rel = agent.release if (v === businessVersion) { const result = rel.businessProfileSchema.safeParse(top) @@ -268,6 +335,7 @@ export async function fetchCompatibleBusinessProfile( } return { profile: result.data, + agentProfile: agent, profileUrl: wellKnownUrl, version: v, businessVersion, @@ -275,27 +343,12 @@ export async function fetchCompatibleBusinessProfile( } } + // `v` came from a supported_versions key because it is not the root. + // Keep the guard as an internal-invariant check, not a compatibility + // fallback: once selected, another rendering must never be attempted. const versionedUrl = supported[v] if (versionedUrl === undefined) { - throw new UcpError({ - layer: 'transport', - code: ErrorCodes.PROTOCOL_VERSION_INCOMPATIBLE, - // Both sets are in the MESSAGE, not just `context`: choosing between - // "upgrade the CLI" and "switch profile" needs `offered` AND - // `supported`, and cli.ts never serializes `context` to the wire. - message: `${baseUrl.origin} offers UCP ${offered.join(', ')}; ${agentLabel(agent)} uses ${v}. ucp-cli supports ${SUPPORTED_VERSIONS.join(', ')}`, - context: { - business: baseUrl.origin, - businessVersion, - /** Versions the BUSINESS offers. */ - offered, - /** Versions THIS BUILD ships schemas for — the other half of the choice. */ - supported: [...SUPPORTED_VERSIONS], - agentVersion: v, - agentProfileUrl: agent.url, - ...(agent.name !== undefined ? { profileName: agent.name } : {}), - }, - }) + throw new Error(`selected UCP ${v} but Business supplied no supported_versions URL`) } if (!acceptsHttpsUrl(versionedUrl)) { @@ -356,6 +409,7 @@ export async function fetchCompatibleBusinessProfile( return { profile: parsedLeaf.data, + agentProfile: agent, profileUrl: versionedUrl, version: v, businessVersion, diff --git a/src/core/releases.test.ts b/src/core/releases.test.ts index 509e128..5d9bc74 100644 --- a/src/core/releases.test.ts +++ b/src/core/releases.test.ts @@ -7,7 +7,14 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { isSupportedVersion, LATEST, RELEASES, release, SUPPORTED_VERSIONS } from './releases.js' +import { + isSupportedVersion, + LATEST, + RELEASES, + release, + releaseByDefaultAgentProfileUrl, + SUPPORTED_VERSIONS, +} from './releases.js' describe('release registry shape', () => { it('SUPPORTED_VERSIONS is sorted ascending, unique, and keys RELEASES exactly', () => { @@ -41,6 +48,23 @@ describe('release registry shape', () => { expect(() => new URL(entry.defaultAgentProfileUrl)).not.toThrow() } }) + + it('looks up release-default agent profile URLs after HTTPS canonicalization', () => { + for (const entry of Object.values(RELEASES)) { + expect(releaseByDefaultAgentProfileUrl(entry.defaultAgentProfileUrl)).toBe(entry) + const url = new URL(entry.defaultAgentProfileUrl) + const equivalent = `https://${url.hostname.toUpperCase()}:443${url.pathname}${url.search}` + expect(releaseByDefaultAgentProfileUrl(equivalent)).toBe(entry) + } + }) + + it('returns undefined for invalid URLs and valid nonmatches', () => { + expect(releaseByDefaultAgentProfileUrl('not a URL')).toBeUndefined() + expect(releaseByDefaultAgentProfileUrl('http://shopify.dev/profile.json')).toBeUndefined() + expect( + releaseByDefaultAgentProfileUrl('https://shopify.dev/ucp/agent-profiles/not-a-release.json'), + ).toBeUndefined() + }) }) describe('agent profile snapshots', () => { diff --git a/src/core/releases.ts b/src/core/releases.ts index 90cb133..15f4beb 100644 --- a/src/core/releases.ts +++ b/src/core/releases.ts @@ -61,6 +61,7 @@ import { platformProfileSchema as platformProfileSchema20260825, } from './generated/2026-08-25/platform_profile.zod.js' import { reverseDomainPattern as reverseDomainPattern20260825 } from './generated/2026-08-25/reverse_domain.js' +import { parseHttpsUrl } from './url.js' /** Spec releases this CLI ships schemas for. */ export type Version = '2026-04-08' | '2026-08-25' @@ -132,6 +133,27 @@ export const RELEASES: Readonly> = Object.freeze({ '2026-08-25': RELEASE_2026_08_25, }) +const RELEASES_BY_DEFAULT_AGENT_PROFILE_URL: ReadonlyMap = new Map( + Object.values(RELEASES).map((rel) => [ + parseHttpsUrl(rel.defaultAgentProfileUrl, 'agent profile URL').toString(), + rel, + ]), +) + +/** + * Release whose published default agent-profile URL matches `url` after HTTPS + * URL canonicalization; `undefined` for invalid URLs and valid nonmatches. + */ +export function releaseByDefaultAgentProfileUrl(url: string): SpecRelease | undefined { + try { + return RELEASES_BY_DEFAULT_AGENT_PROFILE_URL.get( + parseHttpsUrl(url, 'agent profile URL').toString(), + ) + } catch { + return undefined + } +} + /** Supported versions, sorted ascending (ISO dates sort lexicographically). */ export const SUPPORTED_VERSIONS: readonly Version[] = Object.freeze([ RELEASE_2026_04_08.version, diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 9193684..d689b4b 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -16,8 +16,10 @@ // IncurError with a required `layer: ErrorLayer` field. Every internal // throw site uses this so PROTOCOL §4.2's "MUST emit one of the four" // requirement is enforced at the call site, not via a centralized -// catch-and-translate that might miss code paths. Dispatcher middleware -// reads `.layer` off the caught error directly. +// catch-and-translate that might miss code paths. `layer` stays +// in-process — the dispatcher middleware never reads it and it is +// never serialized — so the throw sites plus `error-layers.test.ts` +// are what enforce it. // // 3. Incur's own `Errors` namespace — additional throwable classes // (ValidationError, ParseError) for cases that don't yet need a UCP layer @@ -34,15 +36,22 @@ // // ── Naming rule (adopted after the 2026-09 API review) ───────────────────── // -// PROFILE_* the BUSINESS's document (`/.well-known/ucp` and its -// `supported_versions` leaves). Merchant acts, or nobody. -// AGENT_PROFILE_* OUR hosted document (the URL we advertise as -// `meta.ucp-agent.profile`). The agent acts. +// PROFILE_FETCH_FAILED / PROFILE_INVALID_JSON / PROFILE_SCHEMA_INVALID / +// PROFILE_VERSION_MISMATCH concern the BUSINESS's discovery document +// (`/.well-known/ucp` and `supported_versions` leaves). +// AGENT_PROFILE_* concerns OUR hosted rendering (the URL advertised as +// `meta.ucp-agent.profile`). +// PROFILE_NOT_FOUND / PROFILE_ALREADY_EXISTS / PROFILE_INVALID_NAME / +// PROFILE_INIT_REQUIRES_NAME concern the local Profile store and commands. // -// No code may mean both. `context.kind` cannot distinguish them for callers: -// `cli.ts`'s error middleware emits `{code, message, retryable}` or `{code, -// message, cta}` and NEVER serializes `context`. Anything an agent must branch -// on has to live in `code`, `message`, or `cta`. +// The prefix alone is therefore not provenance; callers branch on the full +// code. No individual code may mean both sides. `context.kind` cannot rescue +// an ambiguous code because the emitted envelope is flat `{code, message}` +// plus an independently optional `retryable` and `cta` (both may appear on +// one error), and it NEVER serializes `layer`, `context`, or `http_status`. +// Anything an agent must branch on has to live in `code`, `message`, or +// `cta` — and even `cta` is advisory, since the CLI framework appends its own maintenance +// commands (skills staleness, update available) to whatever cta is emitted. /** * Public registry of CLI-emitted error codes. Pre-v1, this should still be @@ -62,11 +71,11 @@ export const ErrorCodes = { */ PROFILE_SCHEMA_INVALID: 'PROFILE_SCHEMA_INVALID', /** - * The business does not offer the exact version the active agent profile - * speaks: neither the top-level `/.well-known/ucp` `ucp.version` nor any - * `supported_versions` key equals the profile's `ucp.version`. Recovery is - * agent-side: switch to a profile whose version the business offers (the - * message carries a hint when a local profile qualifies) or upgrade the CLI. + * The Business offer has no exact release in common with the active runtime + * Profile's eligible rendering set. A DIY or URL-only ad-hoc Profile + * contributes one rendering; managed contributes every installed rendering. + * Body provenance and explicit URL precedence are independent: any active + * URL override must be removed/fixed before Profile switching can help. */ PROTOCOL_VERSION_INCOMPATIBLE: 'PROTOCOL_VERSION_INCOMPATIBLE', /** @@ -214,18 +223,19 @@ export const ErrorCodes = { */ AGENT_PROFILE_VERSION_UNSUPPORTED: 'AGENT_PROFILE_VERSION_UNSUPPORTED', /** - * OUR profile is internally inconsistent: a `dev.ucp.*` entry at a version - * other than the profile's own `ucp.version` (the snapshot rule we hold - * merchants to, applied to ourselves). The local `profile.json` is the - * declaration on every named-profile path, so this is always fatal. + * OUR selected rendering is internally inconsistent: a `dev.ucp.*` entry at + * a version other than its own `ucp.version` (the snapshot rule we hold + * merchants to, applied to ourselves). This is always fatal; bundled + * renderings are regression-tested never to contain the defect. */ AGENT_PROFILE_VERSION_MISMATCH: 'AGENT_PROFILE_VERSION_MISMATCH', /** OUR hosted document failed its release's platform-profile schema. */ AGENT_PROFILE_SCHEMA_INVALID: 'AGENT_PROFILE_SCHEMA_INVALID', /** * A service was explicitly requested (`--capability`, operation dispatch), - * the BUSINESS offers it, and OUR profile does not declare it — so there is - * no platform side to negotiate with. Add the service to the profile. + * the BUSINESS offers it, and OUR selected rendering does not declare it — + * so there is no platform side to negotiate with. A DIY document can be + * edited and published; managed/URL renderings require leaving that source. * * When neither side has the id the answer is `CAPABILITY_NOT_OFFERED` * instead: a typo is not a "go edit your profile" problem. @@ -324,23 +334,26 @@ export interface UcpErrorOptions { /** Underlying cause for the cause chain. */ cause?: Error /** - * HTTP status when the error originated from an HTTP response. - * Surfaces in `error.http_status` on the wire envelope (PROTOCOL §4.3 - * transport layer). Only meaningful for `layer: 'transport'`. + * HTTP status when the error originated from an HTTP response. Only + * meaningful for `layer: 'transport'`. IN-PROCESS DIAGNOSTIC ONLY: the + * current CLI middleware does not serialize it, so anything a caller must + * see has to be folded into `message` (or `cta`) at the throw site. */ http_status?: number /** * Diagnostic context — response body, validation field-paths, anything - * that helps the caller act on the error. Surfaces unchanged as - * `error.context` on the wire envelope (PROTOCOL §4.3, transport - * layer). Distinct from incur `BaseError.details: string`, which is - * the cause-chain message extraction; ours is structured payload. + * that helps in-process code act on the error (the CLI middleware reads + * `context.kind` to pick a recovery cta, for instance). IN-PROCESS ONLY: + * it is never serialized onto the emitted error, which is why + * `src/cli-errors.test.ts` pins remedies to `code`/`message`/`cta`. + * Distinct from incur `BaseError.details: string`, which is the + * cause-chain message extraction; ours is structured payload. */ context?: unknown /** * Recovery hint — what the agent should do next. First-class field - * (not nested in `context`) so agents can reliably destructure - * `error.cta` without spelunking diagnostic blobs. Wire shape matches + * (not nested in `context`) so agents can reliably destructure the flat + * `cta` without spelunking diagnostic blobs. Wire shape matches * {@link CtaBlock} on error envelopes (PROTOCOL §4.3): `description` * plus an ordered `commands[]`, each with `command` + optional * `description`. @@ -350,9 +363,12 @@ export interface UcpErrorOptions { /** * Throwable error carrying a {@link ErrorLayer}. Extends incur's IncurError - * so it flows through `cli.serve()`'s existing catch path; dispatcher - * dispatcher middleware reads `.layer`, `.http_status`, and `.context` - * to populate the outbound error envelope. + * so it flows through `cli.serve()`'s existing catch path. `.layer`, + * `.http_status`, and `.context` are in-process fields, never serialized: + * `cli.ts`'s error middleware reads `.context` (and `.cta`) to choose what to + * re-emit and never reads `.layer`, which is the per-site classification that + * the throw sites and `error-layers.test.ts` enforce. Only `code`, `message`, + * `retryable`, and `cta` are emitted on the error the caller sees. * * Throw at the call site that knows the layer, not at a centralized * wrapper. A required field at construction can't be skipped. diff --git a/src/services/shopping.test.ts b/src/services/shopping.test.ts index 04813c3..cb4c0fe 100644 --- a/src/services/shopping.test.ts +++ b/src/services/shopping.test.ts @@ -23,7 +23,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentProfileFixture } from '../test-utils.js' +import { profileFixture } from '../test-utils.js' import { cancelCart, cancelCheckout, @@ -46,9 +46,9 @@ const PROFILE_URL = 'https://agent.example.com/.well-known/ucp' // The platform side of negotiation: Shopify's published 2026-08-25 agent // profile, fetched-and-validated. It declares `dev.ucp.shopping` over mcp at // that exact version, which is what the PROFILE fixture below offers. -const AGENT = agentProfileFixture({ version: '2026-08-25' }) +const RUNTIME_PROFILE = profileFixture({ version: '2026-08-25', url: PROFILE_URL }) -const PROFILE = { +const BUSINESS_PROFILE = { ucp: { version: '2026-08-25', services: { @@ -208,7 +208,7 @@ function makeFetch(toolName: string, schema: object, resultBody: unknown = { ok: const body = typeof init.body === 'string' ? (JSON.parse(init.body) as Record) : undefined if (body !== undefined) calls.push(body) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) if (body?.method === 'tools/list') { return jsonResponse({ jsonrpc: '2.0', @@ -233,7 +233,7 @@ describe.each(ROWS)('$tool', ({ fn, tool, input, schema }) => { it('dispatches via the expected tool name with user input passed through', async () => { const { fetch, calls } = makeFetch(tool, schema) - await fn(BUSINESS_URL, input, { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }) + await fn(BUSINESS_URL, input, { cacheDir, profile: RUNTIME_PROFILE, fetch }) const toolsCall = calls.find((c) => c.method === 'tools/call') expect(toolsCall).toBeDefined() @@ -248,7 +248,7 @@ describe.each(ROWS)('$tool', ({ fn, tool, input, schema }) => { it('throws OPERATION_NOT_OFFERED when tools/list omits the expected tool', async () => { const fetch = vi.fn(async (url: string | URL | Request, init: RequestInit = {}) => { const u = String(url) - if (u.endsWith('/.well-known/ucp')) return jsonResponse(PROFILE) + if (u.endsWith('/.well-known/ucp')) return jsonResponse(BUSINESS_PROFILE) const body = typeof init.body === 'string' ? (JSON.parse(init.body) as { id?: unknown }) : undefined return jsonResponse({ @@ -259,7 +259,7 @@ describe.each(ROWS)('$tool', ({ fn, tool, input, schema }) => { }) as unknown as typeof globalThis.fetch await expect( - fn(BUSINESS_URL, input, { cacheDir, agent: AGENT, fetch, profileUrl: PROFILE_URL }), + fn(BUSINESS_URL, input, { cacheDir, profile: RUNTIME_PROFILE, fetch }), ).rejects.toMatchObject({ code: 'OPERATION_NOT_OFFERED', layer: 'transport' }) }) }) diff --git a/src/test-utils.ts b/src/test-utils.ts index 5da3e33..b8aebd2 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -8,9 +8,21 @@ import type { ProfileCliDependencies } from './cli/profile.js' import type { createUcpCli } from './cli.js' -import { type AgentProfile, loadAgentProfile } from './core/agent.js' +import { + type AgentProfile, + createDiyProfile, + loadAgentProfile, + type Profile, + type ProfileSource, +} from './core/agent.js' +import { classifyStoredProfile, type ProfileKind } from './core/legacy-profile.js' import type { PlatformProfile } from './core/profile.js' -import type { ActiveSession, UserProfile } from './core/profile-store.js' +import type { + ActiveSession, + DiyUserProfile, + ProfileMeta, + UserProfile, +} from './core/profile-store.js' import { LATEST, RELEASES, type Version } from './core/releases.js' const BLANK_BODY: PlatformProfile = { @@ -18,17 +30,45 @@ const BLANK_BODY: PlatformProfile = { keys: [], } -const BLANK_META = { +const BLANK_META: ProfileMeta = { created_at: '2026-05-01T00:00:00.000Z', } -export function userProfile(name: string, overrides: Partial = {}): UserProfile { - return { name, body: BLANK_BODY, meta: BLANK_META, ...overrides } +interface UserProfileOverrides { + body?: PlatformProfile | undefined + meta?: ProfileMeta | undefined + kind?: ProfileKind | undefined +} + +/** + * A stubbed `readUserProfile` result. `kind` is CLASSIFIED, not defaulted, so + * a stub cannot claim a kind the real store would never return for the same + * two documents; pass `kind` explicitly to model a marked profile. + */ +export function userProfile(name: string): DiyUserProfile +export function userProfile(name: string, overrides: UserProfileOverrides): UserProfile +export function userProfile(name: string, overrides: UserProfileOverrides = {}): UserProfile { + const body = overrides.body ?? BLANK_BODY + const meta = overrides.meta ?? BLANK_META + const kind = overrides.kind ?? classifyStoredProfile(body, meta).kind + if (kind === 'managed') { + return { + name, + meta, + kind, + ...(overrides.body !== undefined ? { body: overrides.body } : {}), + } + } + return { name, body, meta, kind } } export interface AgentProfileFixtureOptions { /** Spec release the profile declares. Defaults to {@link LATEST}. */ version?: Version + /** Body provenance. Defaults to a locally authored DIY rendering. */ + source?: ProfileSource + /** Explicit URL-override provenance. Defaults false. */ + urlOverride?: boolean /** Local profile name used in messages. Defaults to `'agent'`. */ name?: string /** Hosted URL. Defaults to the release's published agent-profile URL. */ @@ -58,10 +98,41 @@ export function agentProfileFixture(options: AgentProfileFixtureOptions = {}): A return loadAgentProfile({ body, url: options.url ?? release.defaultAgentProfileUrl, + source: options.source ?? 'diy', + urlOverride: options.urlOverride ?? false, name: options.name ?? 'agent', }) } +/** + * One rendering of a runtime {@link Profile}, asserting it exists. + * `Profile.renderings` is `Partial` by contract (a DIY Profile fills exactly + * one key), so tests that know which key they seeded say so here instead of + * spreading non-null assertions. + */ +export function rendering(profile: Profile, version: Version): AgentProfile { + const found = profile.renderings[version] + if (found === undefined) { + throw new Error( + `test fixture has no UCP ${version} rendering (has: ${Object.keys(profile.renderings).join(', ') || 'none'})`, + ) + } + return found +} + +export type ProfileFixtureOptions = Omit + +/** Build a singleton DIY runtime Profile around {@link agentProfileFixture}. */ +export function profileFixture(options: ProfileFixtureOptions = {}): Profile { + const agent = agentProfileFixture(options) + return createDiyProfile({ + body: agent.body, + url: agent.url, + urlOverride: agent.urlOverride, + name: agent.name ?? 'agent', + }) +} + export async function serveCli( cli: ReturnType, argv: string[], @@ -88,10 +159,12 @@ export function defaultProfileDeps(): ProfileCliDependencies { listProfiles: async () => [], profileExists: async () => false, readUserProfile: async (name: string) => userProfile(name), + readProfileMeta: async () => BLANK_META, saveUserProfile: async (input) => userProfile(input.name, { meta: input.meta, body: input.body }), readActive: async () => ({}), writeActive: async () => {}, + env: {}, } } diff --git a/test/fixtures/legacy-profiles/PROVENANCE.md b/test/fixtures/legacy-profiles/PROVENANCE.md new file mode 100644 index 0000000..593a4c7 --- /dev/null +++ b/test/fixtures/legacy-profiles/PROVENANCE.md @@ -0,0 +1,60 @@ +# Legacy `profile.json` fixtures + +Frozen copies of every body `ucp profile init` has ever generated in a build +that predates the managed/DIY split. `src/core/legacy-profile.ts` recognizes +them by sha256 over canonical JSON; `src/core/legacy-profile.test.ts` re-derives +those digests from these files, so the table and the fixtures cannot drift +apart silently. + +Bytes are exactly what the old `saveUserProfile` wrote: +`JSON.stringify(body, null, 2) + "\n"`. + +## How these were derived + +Historical `profile init` did `const body = localAgentProfileBody()` — a source +literal in `src/core/profile.ts` interpolated with tsup build defines +(`__PROTOCOL_MAX__`, `__SPEC_VERSION__`, from `package.json#ucp`). Each fixture +was produced by extracting that function at the named git ref, evaluating it +with that ref's own `package.json` defines, and serializing as above. + +Every commit reachable from `main`, plus each release tag, was evaluated this +way. The complete set of distinct results for pre-split builds is the three +files below. + +Published npm versions of `@shopify/ucp-cli` (registry.npmjs.org, verified): +`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`. + +| 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` | + +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 + `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. + +## Current templates are NOT frozen here + +`src/core/releases.ts` already carries the current per-release published +documents (`agentProfileJson`, byte-verbatim, CI drift-gated). The classifier +hashes those at load instead of duplicating them, so `pnpm gen:schemas` cannot +turn a freshly-initialized profile into "user-authored". + +## What ships + +Nothing. `package.json#files` publishes `dist`, `README.md`, `skills`, and +`src` minus tests; `test/` is not included. diff --git a/test/fixtures/legacy-profiles/stock-a-2026-04-08.json b/test/fixtures/legacy-profiles/stock-a-2026-04-08.json new file mode 100644 index 0000000..11d64a9 --- /dev/null +++ b/test/fixtures/legacy-profiles/stock-a-2026-04-08.json @@ -0,0 +1,99 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "success", + "services": { + "dev.ucp.shopping": [ + { + "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" + } + ] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/checkout", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/cart", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/fulfillment", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/fulfillment.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ] + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/discount", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/discount.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ] + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/catalog", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_search.json" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/catalog", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_lookup.json" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specs/shopping/order", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/order.json" + } + ], + "dev.shopify.catalog": [ + { + "version": "2026-04-08", + "spec": "https://shopify.dev/docs/agents/catalog/storefront-catalog-extension", + "schema": "https://shopify.dev/ucp/schemas/2026-04-08/shopify_catalog.json", + "extends": [ + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] + } + ], + "dev.shopify.catalog.global": [ + { + "version": "2026-04-08", + "spec": "https://shopify.dev/docs/agents/catalog/global-catalog-extension", + "schema": "https://shopify.dev/ucp/schemas/2026-04-08/shopify_catalog_global.json", + "extends": [ + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] + } + ] + }, + "payment_handlers": {} + }, + "signing_keys": [] +} diff --git a/test/fixtures/legacy-profiles/stock-a0-prerelease-2026-04-08.json b/test/fixtures/legacy-profiles/stock-a0-prerelease-2026-04-08.json new file mode 100644 index 0000000..835c25b --- /dev/null +++ b/test/fixtures/legacy-profiles/stock-a0-prerelease-2026-04-08.json @@ -0,0 +1,100 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "success", + "services": { + "dev.ucp.shopping": [ + { + "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", + "endpoint": "https://example.invalid/agent/no-endpoint" + } + ] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/checkout", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/cart", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/fulfillment", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/fulfillment.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ] + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/discount", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/discount.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ] + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/catalog", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_search.json" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specification/catalog", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_lookup.json" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/2026-04-08/specs/shopping/order", + "schema": "https://ucp.dev/2026-04-08/schemas/shopping/order.json" + } + ], + "dev.shopify.catalog": [ + { + "version": "2026-04-08", + "spec": "https://shopify.dev/docs/agents/catalog/storefront-catalog-extension", + "schema": "https://shopify.dev/ucp/schemas/2026-04-08/shopify_catalog.json", + "extends": [ + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] + } + ], + "dev.shopify.catalog.global": [ + { + "version": "2026-04-08", + "spec": "https://shopify.dev/docs/agents/catalog/global-catalog-extension", + "schema": "https://shopify.dev/ucp/schemas/2026-04-08/shopify_catalog_global.json", + "extends": [ + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] + } + ] + }, + "payment_handlers": {} + }, + "signing_keys": [] +} diff --git a/test/fixtures/legacy-profiles/stock-b-2026-08-25.json b/test/fixtures/legacy-profiles/stock-b-2026-08-25.json new file mode 100644 index 0000000..61fdc61 --- /dev/null +++ b/test/fixtures/legacy-profiles/stock-b-2026-08-25.json @@ -0,0 +1,99 @@ +{ + "ucp": { + "version": "2026-08-25", + "status": "success", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/overview", + "transport": "mcp", + "schema": "https://ucp.dev/2026-08-25/services/shopping/mcp.openrpc.json" + } + ] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/checkout", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.cart": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/cart", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/fulfillment", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/fulfillment.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ] + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/discount", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/discount.json", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart" + ] + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/catalog/search", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/catalog_search.json" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/catalog/lookup", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/catalog_lookup.json" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-08-25", + "spec": "https://ucp.dev/2026-08-25/specification/order", + "schema": "https://ucp.dev/2026-08-25/schemas/shopping/order.json" + } + ], + "dev.shopify.catalog": [ + { + "version": "2026-08-25", + "spec": "https://shopify.dev/docs/agents/catalog/storefront-catalog", + "schema": "https://shopify.dev/ucp/schemas/2026-08-25/shopify_catalog.json", + "extends": [ + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] + } + ], + "dev.shopify.catalog.global": [ + { + "version": "2026-08-25", + "spec": "https://shopify.dev/docs/agents/catalog/global-catalog", + "schema": "https://shopify.dev/ucp/schemas/2026-08-25/shopify_catalog_global.json", + "extends": [ + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] + } + ] + }, + "payment_handlers": {} + }, + "keys": [] +} diff --git a/test/fixtures/mock-business.ts b/test/fixtures/mock-business.ts index b8040fa..46bb7dd 100644 --- a/test/fixtures/mock-business.ts +++ b/test/fixtures/mock-business.ts @@ -16,9 +16,17 @@ interface Route { handler: Handler } +export interface MockBusinessRequest { + method: string + path: string + url: string +} + export interface MockBusiness { url: string port: number + /** Requests received in arrival order. The array remains live until close(). */ + requests: MockBusinessRequest[] setRoute(method: string, path: string, handler: Handler): void reset(): void close(): Promise @@ -30,10 +38,12 @@ export interface MockBusinessOptions { export async function startMockBusiness(options: MockBusinessOptions = {}): Promise { const routes: Route[] = [] + const requests: MockBusinessRequest[] = [] const server: Server = createServer(async (req, res) => { const url = req.url ?? '' const path = url.split('?')[0] ?? '' const method = req.method ?? 'GET' + requests.push({ method, path, url }) const route = routes.find((r) => r.method === method && r.path === path) if (route === undefined) { res.statusCode = 404 @@ -65,11 +75,13 @@ export async function startMockBusiness(options: MockBusinessOptions = {}): Prom return { url: `http://127.0.0.1:${addr.port}`, port: addr.port, + requests, setRoute(method, path, handler) { routes.push({ method, path, handler }) }, reset() { routes.length = 0 + requests.length = 0 }, close() { return new Promise((resolve, reject) => { diff --git a/test/fixtures/mock-ucp-shopping.ts b/test/fixtures/mock-ucp-shopping.ts index 9d261b8..4be6738 100644 --- a/test/fixtures/mock-ucp-shopping.ts +++ b/test/fixtures/mock-ucp-shopping.ts @@ -422,11 +422,29 @@ async function readBody(req: IncomingMessage): Promise { return Buffer.concat(chunks).toString('utf-8') } +export interface MockRpcRequest { + id: unknown + method: string + params?: { name?: string; arguments?: Record } + /** `meta.ucp-agent.profile`, when the request carries the UCP identity envelope. */ + agentProfileUrl?: string +} + +function profileUrlFromParams(params: MockRpcRequest['params']): string | undefined { + const meta = params?.arguments?.meta + if (typeof meta !== 'object' || meta === null) return undefined + const agent = (meta as Record)['ucp-agent'] + if (typeof agent !== 'object' || agent === null) return undefined + const profile = (agent as Record).profile + return typeof profile === 'string' ? profile : undefined +} + // MCP JSON-RPC handler: dispatches tools/list and tools/call. async function handleMcp( req: IncomingMessage, res: ServerResponse, businessUrl: string, + rpcRequests: MockRpcRequest[], ): Promise { const body = JSON.parse(await readBody(req)) as { jsonrpc: string @@ -435,6 +453,13 @@ async function handleMcp( params?: { name?: string; arguments?: Record } } const { id, method, params } = body + const agentProfileUrl = profileUrlFromParams(params) + rpcRequests.push({ + id, + method, + ...(params !== undefined ? { params } : {}), + ...(agentProfileUrl !== undefined ? { agentProfileUrl } : {}), + }) res.setHeader('content-type', 'application/json') @@ -498,6 +523,8 @@ export type MockShoppingServiceEntries = 'conformant' | 'stale-entry' | 'mixed-v export interface MockUcpShoppingOptions { /** Defaults to `'conformant'`. */ serviceEntries?: MockShoppingServiceEntries + /** Override the URL advertised for {@link MOCK_LEGACY_VERSION} in `supported_versions`. */ + legacyProfileUrl?: string } const SPEC_URLS = { @@ -531,17 +558,13 @@ export interface MockUcpShopping extends MockBusiness { mcpEndpoint: string /** URL of the `supported_versions` leaf for {@link MOCK_LEGACY_VERSION}. */ legacyProfileUrl: string + /** Parsed upstream MCP requests in arrival order. The array remains live until close(). */ + rpcRequests: MockRpcRequest[] /** - * Where this server also hosts the AGENT's profile, so an end-to-end test - * has a reachable `meta.profile_url`. - * - * Under profile-driven negotiation the CLI GETs its own hosted profile - * before every invocation (the business fetches the same URL), so a fixture - * whose `profile_url` points at a non-existent host fails every command with - * `AGENT_PROFILE_UNREACHABLE` before touching the business at all. Serving - * it here keeps the whole journey inside the fixture. The body is the - * VERBATIM published 2026-08-25 Shopify agent profile from the release - * registry — the same document the CLI negotiates against by default. + * Where this fixture can host an agent profile for explicit DIY/doctor tests. + * Commerce requests do not fetch their own Profile: ucp-cli advertises this + * URL in `meta.ucp-agent.profile`, and a Business may dereference and cache + * it. This lightweight mock records that URL but does not dereference it. */ agentProfileUrl: string } @@ -562,7 +585,8 @@ export async function startMockUcpShopping( const mock = await startMockBusiness() const { url } = mock const endpoint = `${url}/mcp` - const legacyProfileUrl = `${url}${MOCK_LEGACY_PROFILE_PATH}` + const legacyProfileUrl = options.legacyProfileUrl ?? `${url}${MOCK_LEGACY_PROFILE_PATH}` + const rpcRequests: MockRpcRequest[] = [] // Current rendering, validated against the 2026-08-25 business schema. const profile = { @@ -610,13 +634,14 @@ export async function startMockUcpShopping( }) mock.setRoute('POST', '/mcp', (req, res) => { - return handleMcp(req, res, url) + return handleMcp(req, res, url, rpcRequests) }) return { ...mock, mcpEndpoint: endpoint, legacyProfileUrl, + rpcRequests, agentProfileUrl: `${url}${MOCK_AGENT_PROFILE_PATH}`, } } diff --git a/test/fixtures/subprocess-env.ts b/test/fixtures/subprocess-env.ts new file mode 100644 index 0000000..a9158b8 --- /dev/null +++ b/test/fixtures/subprocess-env.ts @@ -0,0 +1,25 @@ +const AMBIENT_UCP_STATE = new Set([ + 'UCP_PROFILE', + 'UCP_AGENT_PROFILE_URL', + 'UCP_BUSINESS', + 'UCP_DEFAULT_CATALOG', +]) + +/** + * Inherit the host process environment without inheriting UCP routing state. + * Tests that claim a fresh install must not accidentally use the developer's + * active Profile, agent URL, Business, or catalog override. + */ +export function freshUcpEnv( + home: string, + overrides: Record = {}, +): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !AMBIENT_UCP_STATE.has(key)) env[key] = value + } + env.UCP_HOME = home + // Allow local HTTP fixtures through the production HTTPS-only URL guard. + env.UCP_TEST_ALLOW_INSECURE_LOCALHOST = 'true' + return { ...env, ...overrides } +} diff --git a/test/integration/catalog-live.integration.test.ts b/test/integration/catalog-live.integration.test.ts index 1e77656..d4a19dc 100644 --- a/test/integration/catalog-live.integration.test.ts +++ b/test/integration/catalog-live.integration.test.ts @@ -9,9 +9,9 @@ // Run with: UCP_LIVE_TESTS=1 pnpm test:integration catalog-live // // What this checks end-to-end: -// 1. Fresh UCP_HOME, run `profile init`, NO --business: bare `ucp catalog -// search` should resolve through the local profile's runtime -// DEFAULT_CATALOG_URL fallback and reach the live endpoint. +// 1. Fresh UCP_HOME, NO profile init and NO --business: bare `ucp catalog +// search` should use the managed Profile, resolve through the runtime +// DEFAULT_CATALOG_URL fallback, and reach the live endpoint. // 2. The response (a) is a valid UCP envelope (dispatch identity + result // payload present) and (b) carries a CTA. Specific variant shapes // (seller, checkout_url) @@ -19,11 +19,9 @@ // results for a query, and forcing inventory expectations would make this // a brittle gate on Shopify's merchandising state. // -// catalog.shopify.com fetches the advertised `meta.ucp-agent.profile` during -// dispatch, so the URL must be reachable. `profile init` writes the release -// default there unless `--profile-url` names one you host yourself, and a -// profile authored by an older CLI with no `profile_url` falls back to the -// same release default. +// catalog.shopify.com may dereference and cache the selected managed Profile +// URL advertised in `meta.ucp-agent.profile`; ucp-cli itself does not fetch +// that URL on the commerce path. import { execFile } from 'node:child_process' import { mkdtemp } from 'node:fs/promises' @@ -32,16 +30,17 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' +import { freshUcpEnv } from '../fixtures/subprocess-env.js' const execFileAsync = promisify(execFile) const CLI_PATH = fileURLToPath(new URL('../../dist/bin.js', import.meta.url)) const LIVE = process.env.UCP_LIVE_TESTS === '1' || process.env.UCP_LIVE_TESTS === 'true' -async function run(env: Record, args: string[]) { +async function run(home: string, args: string[]) { try { const { stdout, stderr } = await execFileAsync('node', [CLI_PATH, ...args], { - env: { ...process.env, ...env }, + env: freshUcpEnv(home), }) return { stdout, stderr, code: 0 } } catch (err) { @@ -51,17 +50,11 @@ async function run(env: Record, args: string[]) { } describe.skipIf(!LIVE)('live: Shopify global catalog (UCP_LIVE_TESTS=1)', () => { - it('initialized local profile routes catalog search through DEFAULT_CATALOG_URL', async () => { - // Pristine UCP_HOME, then explicit profile init. No `ucp use`; catalog ops - // fall through to the runtime DEFAULT_CATALOG_URL synthesized for the local - // profile. + it('managed default routes catalog search through DEFAULT_CATALOG_URL', async () => { + // Pristine UCP_HOME: no profile directory, `ucp use`, or --business. const home = await mkdtemp(join(tmpdir(), 'ucp-cli-live-')) - const env = { UCP_HOME: home } - const init = await run(env, ['profile', 'init', '--name', 'agent']) - expect(init.code).toBe(0) - - const search = await run(env, ['catalog', 'search', '--set', '/query=trail map']) + const search = await run(home, ['catalog', 'search', '--set', '/query=trail map']) // Working means: catalog-fallback rung dispatched to the live endpoint // and returned a structured UCP envelope with dispatch identity // (`business`) and the catalog payload (`result`). The previous diff --git a/test/integration/managed-profile.integration.test.ts b/test/integration/managed-profile.integration.test.ts new file mode 100644 index 0000000..06e0fb4 --- /dev/null +++ b/test/integration/managed-profile.integration.test.ts @@ -0,0 +1,379 @@ +// Compiled managed/DIY Profile journeys. +// +// These tests cross the process boundary deliberately: they exercise dist/bin.js, +// the on-disk profile migration and caches, and a real local HTTP/JSON-RPC mock. +// Runs after `pnpm test:integration` builds the package. + +import { execFile } from 'node:child_process' +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 { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +import { RELEASES, SUPPORTED_VERSIONS } from '../../src/core/releases.js' +import { jsonResponse, startMockBusiness } from '../fixtures/mock-business.js' +import { + MOCK_LEGACY_PROFILE_PATH, + type MockUcpShopping, + startMockUcpShopping, +} from '../fixtures/mock-ucp-shopping.js' +import { freshUcpEnv } from '../fixtures/subprocess-env.js' + +const execFileAsync = promisify(execFile) +const CLI = fileURLToPath(new URL('../../dist/bin.js', import.meta.url)) +const VERSION_04 = '2026-04-08' as const +const VERSION_08 = '2026-08-25' as const +const MANAGED_08_URL = RELEASES[VERSION_08].defaultAgentProfileUrl + +interface CliRun { + code: number + stdout: string + stderr: string + json: unknown +} + +async function runCli( + home: string, + args: string[], + extraEnv: Record = {}, +): Promise { + let stdout = '' + let stderr = '' + let code = 0 + try { + const result = await execFileAsync('node', [CLI, ...args], { + env: freshUcpEnv(home, extraEnv), + }) + stdout = result.stdout + stderr = result.stderr + } catch (err) { + const failure = err as { stdout?: string; stderr?: string; code?: number } + stdout = failure.stdout ?? '' + stderr = failure.stderr ?? '' + code = failure.code ?? -1 + } + + let json: unknown = null + try { + json = JSON.parse(stdout) + } catch { + // Keep raw output in the result so assertion diagnostics explain a failure. + } + return { code, stdout, stderr, json } +} + +function diagnostic(run: CliRun): string { + return `stdout:\n${run.stdout}\nstderr:\n${run.stderr}` +} + +interface DiscoveryResult { + protocol: { + version: string + source: string + agentProfileUrl: string + businessProfileUrl: string + } +} + +function discovery(run: CliRun): DiscoveryResult { + return (run.json as { result: DiscoveryResult }).result +} + +function businessProfileGets(mock: MockUcpShopping): number { + return mock.requests.filter( + (request) => request.method === 'GET' && request.path.startsWith('/.well-known/ucp'), + ).length +} + +function toolsListRequests(mock: MockUcpShopping) { + return mock.rpcRequests.filter((request) => request.method === 'tools/list') +} + +async function removeHomeAndClose(home: string, mock: MockUcpShopping): Promise { + await mock.close() + await rm(home, { recursive: true, force: true }) +} + +const HISTORICAL_STOCK = [ + { + label: 'v0.7 STOCK-A', + path: fileURLToPath( + new URL('../fixtures/legacy-profiles/stock-a-2026-04-08.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), + ), + }, +] as const + +describe('managed Profile: compiled integration journeys', () => { + it('uses the newest managed rendering from an empty home, then hits persistent caches across processes', async () => { + const mock = await startMockUcpShopping() + const home = await mkdtemp(join(tmpdir(), 'ucp-managed-empty-')) + try { + const first = await runCli(home, ['discover', '--business', mock.url]) + expect(first.code, diagnostic(first)).toBe(0) + expect(discovery(first).protocol).toMatchObject({ + version: VERSION_08, + source: 'well-known', + agentProfileUrl: MANAGED_08_URL, + }) + expect(businessProfileGets(mock)).toBe(1) + expect(toolsListRequests(mock)).toHaveLength(1) + expect(toolsListRequests(mock)[0]?.agentProfileUrl).toBe(MANAGED_08_URL) + + // A second dist/bin.js process shares only UCP_HOME. No in-memory memo + // can satisfy this: both Business discovery and tools/list must come + // directly from their on-disk cache entries. + const second = await runCli(home, ['discover', '--business', mock.url]) + expect(second.code, diagnostic(second)).toBe(0) + expect(discovery(second).protocol).toMatchObject({ + version: VERSION_08, + source: 'well-known', + agentProfileUrl: MANAGED_08_URL, + }) + expect(businessProfileGets(mock)).toBe(1) + expect(toolsListRequests(mock)).toHaveLength(1) + } finally { + await removeHomeAndClose(home, mock) + } + }) + + it('isolates same-origin supported_versions leaves by full URL across compiled calls', async () => { + const leafHost = await startMockBusiness() + const leafPathA = `/profiles/business-a/${VERSION_04}.json` + const leafPathB = `/profiles/business-b/${VERSION_04}.json` + const leafUrlA = `${leafHost.url}${leafPathA}` + const leafUrlB = `${leafHost.url}${leafPathB}` + const [businessA, businessB] = await Promise.all([ + startMockUcpShopping({ legacyProfileUrl: leafUrlA }), + startMockUcpShopping({ legacyProfileUrl: leafUrlB }), + ]) + const home = await mkdtemp(join(tmpdir(), 'ucp-shared-leaf-cache-')) + + const leafProfile = (endpoint: string) => ({ + ucp: { + version: VERSION_04, + status: 'success', + services: { + 'dev.ucp.shopping': [ + { + version: VERSION_04, + spec: 'https://ucp.dev/specification/overview/', + schema: 'https://ucp.dev/services/shopping/openrpc.json', + transport: 'mcp', + endpoint, + }, + ], + }, + payment_handlers: {}, + }, + keys: [], + }) + leafHost.setRoute('GET', leafPathA, (_req, res) => { + jsonResponse(res, 200, leafProfile(businessA.mcpEndpoint)) + }) + leafHost.setRoute('GET', leafPathB, (_req, res) => { + jsonResponse(res, 200, leafProfile(businessB.mcpEndpoint)) + }) + + try { + expect(businessA.url).not.toBe(businessB.url) + const initialized = await runCli(home, [ + 'profile', + 'init', + '--name', + 'legacy', + '--version', + VERSION_04, + '--activate', + ]) + expect(initialized.code, diagnostic(initialized)).toBe(0) + + const first = await runCli(home, [ + 'catalog', + 'search', + '--business', + businessA.url, + '--set', + '/query=business-a', + ]) + expect(first.code, diagnostic(first)).toBe(0) + expect(businessA.rpcRequests.map((request) => request.method)).toEqual([ + 'tools/list', + 'tools/call', + ]) + const requestsToA = businessA.rpcRequests.length + + const second = await runCli(home, [ + 'catalog', + 'search', + '--business', + businessB.url, + '--set', + '/query=business-b', + ]) + expect(second.code, diagnostic(second)).toBe(0) + + expect(leafHost.requests.map((request) => request.path)).toEqual([leafPathA, leafPathB]) + expect(businessA.rpcRequests).toHaveLength(requestsToA) + expect(businessB.rpcRequests.map((request) => request.method)).toEqual([ + 'tools/list', + 'tools/call', + ]) + } finally { + await Promise.all([businessA.close(), businessB.close(), leafHost.close()]) + await rm(home, { recursive: true, force: true }) + } + }) + + it.each(HISTORICAL_STOCK)( + '$label is marked managed once, gains every installed rendering, and preserves user bytes', + async ({ path }) => { + const mock = await startMockUcpShopping() + const home = await mkdtemp(join(tmpdir(), 'ucp-managed-upgrade-')) + const name = 'historical' + const dir = join(home, 'profiles', name) + const profileBytes = await readFile(path) + const headersBytes = Buffer.from( + '{\n\t"default": {"X-Integration": "legacy"},\n\t"businesses": {}\n}', + ) + const originalMeta = { + created_at: '2026-01-01T00:00:00.000Z', + historical_note: 'preserve this field', + } + try { + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'profile.json'), profileBytes) + await writeFile(join(dir, 'meta.json'), `${JSON.stringify(originalMeta, null, 2)}\n`) + await writeFile(join(dir, 'headers.json'), headersBytes) + await writeFile( + join(home, 'active.yaml'), + `profile: ${name}\nbusiness: ${mock.url}\n`, + 'utf-8', + ) + + const discovered = await runCli(home, ['discover']) + expect(discovered.code, diagnostic(discovered)).toBe(0) + expect(discovery(discovered).protocol).toMatchObject({ + version: VERSION_08, + source: 'well-known', + agentProfileUrl: MANAGED_08_URL, + }) + expect(toolsListRequests(mock)[0]?.agentProfileUrl).toBe(MANAGED_08_URL) + + const markedMetaBytes = await readFile(join(dir, 'meta.json')) + expect(JSON.parse(markedMetaBytes.toString())).toMatchObject({ + ...originalMeta, + format_version: 2, + kind: 'managed', + }) + expect(await readFile(join(dir, 'profile.json'))).toEqual(profileBytes) + expect(await readFile(join(dir, 'headers.json'))).toEqual(headersBytes) + + // A second compiled read consumes the marker instead of classifying or + // rewriting again, and exposes the complete managed rendering set. + const shown = await runCli(home, ['profile', 'show', name]) + expect(shown.code, diagnostic(shown)).toBe(0) + expect(shown.json).toMatchObject({ + name, + kind: 'managed', + renderings: SUPPORTED_VERSIONS.map((version) => ({ + version, + profile_url: RELEASES[version].defaultAgentProfileUrl, + })), + }) + expect(await readFile(join(dir, 'meta.json'))).toEqual(markedMetaBytes) + expect(await readFile(join(dir, 'profile.json'))).toEqual(profileBytes) + expect(await readFile(join(dir, 'headers.json'))).toEqual(headersBytes) + } finally { + await removeHomeAndClose(home, mock) + } + }, + ) + + it('keeps an explicit 04-08 DIY Profile pinned to its custom URL and leaves its files untouched', async () => { + const mock = await startMockUcpShopping() + const home = await mkdtemp(join(tmpdir(), 'ucp-diy-pinned-')) + const customUrl = 'https://agent.example.test/custom-04-profile.json' + const activePath = join(home, 'active.yaml') + try { + // `profile init` is intentionally the one integration journey that keeps + // setup ceremony: it proves current init writes marked DIY Profiles and + // changes the active selection only when --activate is present. + const businessOnly = `business: ${mock.url}\n` + await writeFile(activePath, businessOnly, 'utf-8') + const dormant = await runCli(home, [ + 'profile', + 'init', + '--name', + 'dormant', + '--version', + VERSION_04, + ]) + expect(dormant.code, diagnostic(dormant)).toBe(0) + expect(dormant.json).toMatchObject({ created: true, activated: false }) + expect(await readFile(activePath, 'utf-8')).toBe(businessOnly) + + const initialized = await runCli(home, [ + 'profile', + 'init', + '--name', + 'custom', + '--version', + VERSION_04, + '--profile-url', + customUrl, + '--activate', + ]) + expect(initialized.code, diagnostic(initialized)).toBe(0) + expect(initialized.json).toMatchObject({ + name: 'custom', + created: true, + activated: true, + version: VERSION_04, + profile_url: customUrl, + }) + + const dir = join(home, 'profiles', 'custom') + expect(JSON.parse(await readFile(join(dir, 'meta.json'), 'utf-8'))).toMatchObject({ + format_version: 2, + kind: 'diy', + profile_url: customUrl, + }) + expect(await readFile(activePath, 'utf-8')).toContain('profile: custom') + + const before = { + profile: await readFile(join(dir, 'profile.json')), + meta: await readFile(join(dir, 'meta.json')), + active: await readFile(activePath), + } + + const discovered = await runCli(home, ['discover']) + expect(discovered.code, diagnostic(discovered)).toBe(0) + expect(discovery(discovered).protocol).toEqual({ + version: VERSION_04, + source: 'supported_versions', + agentProfileUrl: customUrl, + businessProfileUrl: `${mock.url}${MOCK_LEGACY_PROFILE_PATH}`, + }) + expect( + mock.requests.some( + (request) => request.method === 'GET' && request.path === MOCK_LEGACY_PROFILE_PATH, + ), + ).toBe(true) + expect(toolsListRequests(mock)[0]?.agentProfileUrl).toBe(customUrl) + + expect(await readFile(join(dir, 'profile.json'))).toEqual(before.profile) + expect(await readFile(join(dir, 'meta.json'))).toEqual(before.meta) + expect(await readFile(activePath)).toEqual(before.active) + } finally { + await removeHomeAndClose(home, mock) + } + }) +}) diff --git a/test/integration/purchase-journey.integration.test.ts b/test/integration/purchase-journey.integration.test.ts index 833deef..14d96d4 100644 --- a/test/integration/purchase-journey.integration.test.ts +++ b/test/integration/purchase-journey.integration.test.ts @@ -37,18 +37,19 @@ import { MOCK_CURRENT_VERSION, MOCK_ESCALATION_URL, MOCK_VARIANT_ID, + type MockUcpShopping, type MockUcpShoppingOptions, startMockUcpShopping, } from '../fixtures/mock-ucp-shopping.js' +import { freshUcpEnv } from '../fixtures/subprocess-env.js' const execFileAsync = promisify(execFile) const CLI = fileURLToPath(new URL('../../dist/bin.js', import.meta.url)) -/** The document the mock serves at `agentProfileUrl` — and the local copy of it. */ -const AGENT_PROFILE_JSON = RELEASES[MOCK_CURRENT_VERSION].agentProfileJson interface Journey { businessUrl: string ucpHome: string + mock: MockUcpShopping run( args: string[], extraEnv?: Record, @@ -60,36 +61,11 @@ async function setupJourney(options: MockUcpShoppingOptions = {}): Promise = {} - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined) baseEnv[k] = v - } - baseEnv.UCP_HOME = ucpHome - // Allow the mock server's http://127.0.0.1 URL through the https-only guard. - // TEST infix is intentional: this is not a production/deployment knob. - baseEnv.UCP_TEST_ALLOW_INSECURE_LOCALHOST = 'true' + // Bind only the Business. With no profile directory or profile selection, + // the compiled CLI must transact on its virtual managed Profile. + await writeFile(join(ucpHome, 'active.yaml'), `business: ${mock.url}\n`, 'utf-8') + + const baseEnv = freshUcpEnv(ucpHome) // Intentionally do not set UCP_ON_ESCALATION. Hook resolution + spawn is // unit-tested; here we only assert the CLI handles requires_escalation // correctly (envelope, CTA, exit code, stderr quiet by default). @@ -120,6 +96,7 @@ async function setupJourney(options: MockUcpShoppingOptions = {}): Promise { expect(products[0]?.title).toContain('Trail Map') expect(products[0]?.variants[0]?.id).toBe(MOCK_VARIANT_ID) + // No profile was initialized. Both discovery and dispatch must advertise + // the newest managed rendering to the Business. + const upstream = j.mock.rpcRequests.filter( + (request) => request.method === 'tools/list' || request.method === 'tools/call', + ) + expect(upstream.map((request) => request.agentProfileUrl)).toEqual([ + RELEASES[MOCK_CURRENT_VERSION].defaultAgentProfileUrl, + RELEASES[MOCK_CURRENT_VERSION].defaultAgentProfileUrl, + ]) + // CTA at root level (merged by incur). Cart create is primary (up-funnel exploration). const cta = envelope.cta as { commands: Array<{ command: string }> } | undefined expect(cta?.commands?.[0]?.command.includes('cart create')).toBe(true) diff --git a/test/integration/smoke.integration.test.ts b/test/integration/smoke.integration.test.ts index 92b9d8b..93c2544 100644 --- a/test/integration/smoke.integration.test.ts +++ b/test/integration/smoke.integration.test.ts @@ -7,13 +7,20 @@ // test:integration does so for you). import { execFile, spawn } from 'node:child_process' -import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { platform, tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' +import { RELEASES } from '../../src/core/releases.js' import { startMockBusiness } from '../fixtures/mock-business.js' +import { + MOCK_CART_ID, + MOCK_LEGACY_PROFILE_PATH, + startMockUcpShopping, +} from '../fixtures/mock-ucp-shopping.js' +import { freshUcpEnv } from '../fixtures/subprocess-env.js' const execFileAsync = promisify(execFile) const CLI_PATH = fileURLToPath(new URL('../../dist/bin.js', import.meta.url)) @@ -91,15 +98,11 @@ describe('smoke: compiled binary', () => { // module evaluation, and any module-scope CTA constant not yet initialized // silently drops out of the wire envelope. Unit tests import the module // first and cannot see it — only the compiled binary can, so this runs it. - // - // Profile init is required before any dispatch, so this exercises the - // compiled binary's structured CTA path for that first-run failure. - it('emits PROFILE_NOT_FOUND with structured cta when no profile is initialized', async () => { - const env: Record = {} - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined && k !== 'UCP_BUSINESS') env[k] = v - } - env.UCP_HOME = await mkdtemp(join(tmpdir(), 'ucp-no-session-')) + // A fresh install has a managed Profile, so the first missing session leg is + // now the Business target. + it('emits BUSINESS_NOT_RESOLVED with structured cta when no Business is selected', async () => { + const home = await mkdtemp(join(tmpdir(), 'ucp-no-session-')) + const env = freshUcpEnv(home) const { stdout, code } = await new Promise<{ stdout: string; code: number }>((resolve) => { execFile('node', [CLI_PATH, 'cart', 'create'], { env }, (err, out) => { const e = err as { code?: number } | null @@ -108,25 +111,17 @@ describe('smoke: compiled binary', () => { }) expect(code).toBe(1) const parsed = JSON.parse(stdout) as { code: string; cta?: { commands?: unknown[] } } - expect(parsed.code).toBe('PROFILE_NOT_FOUND') + expect(parsed.code).toBe('BUSINESS_NOT_RESOLVED') expect(parsed.cta?.commands?.length ?? 0).toBeGreaterThan(0) }) // --input-schema is the agent's introspection lever; it short-circuits before // dispatch but still flows through the same session resolver. Exercising - // it via the compiled binary confirms the flag survives the build and - // lands on the same BUSINESS_NOT_RESOLVED path as a normal op when no - // business is bound — i.e. agents trying to introspect first won't get - // a different error shape than agents trying to dispatch. - // - // `--input-schema` still goes through session resolution, so it requires a local - // profile before it can discover a business schema. - it('--input-schema still requires an initialized profile', async () => { - const env: Record = {} - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined && k !== 'UCP_BUSINESS') env[k] = v - } - env.UCP_HOME = await mkdtemp(join(tmpdir(), 'ucp-describe-no-session-')) + // it via the compiled binary confirms the flag survives the build and lands + // on the same missing-Business path as a normal operation. + it('--input-schema also reports BUSINESS_NOT_RESOLVED on a fresh install', async () => { + const home = await mkdtemp(join(tmpdir(), 'ucp-describe-no-session-')) + const env = freshUcpEnv(home) const { stdout, code } = await new Promise<{ stdout: string; code: number }>((resolve) => { execFile('node', [CLI_PATH, 'cart', 'create', '--input-schema'], { env }, (err, out) => { const e = err as { code?: number } | null @@ -135,49 +130,46 @@ describe('smoke: compiled binary', () => { }) expect(code).toBe(1) const parsed = JSON.parse(stdout) as { code: string; cta?: { commands?: unknown[] } } - expect(parsed.code).toBe('PROFILE_NOT_FOUND') + expect(parsed.code).toBe('BUSINESS_NOT_RESOLVED') expect(parsed.cta?.commands?.length ?? 0).toBeGreaterThan(0) }) - // `doctor` signals a `fail` check by exiting nonzero while printing the - // unchanged checks envelope on stdout. The mechanism is `process.exitCode` - // (incur's error sentinel would replace `data` with `{code, message}` and - // delete the checks array), so it only works if nothing downstream resets - // it — which only the real binary, exiting for real, can prove. - it('doctor exits 1 when a check fails, keeping the checks envelope on stdout', async () => { - const env: Record = {} - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined && k !== 'UCP_BUSINESS') env[k] = v - } - env.UCP_HOME = await mkdtemp(join(tmpdir(), 'ucp-doctor-fail-')) + it('doctor --skip-network exits 0 on a fresh managed install', async () => { + const home = await mkdtemp(join(tmpdir(), 'ucp-doctor-ok-')) + const env = freshUcpEnv(home) const { stdout, code } = await new Promise<{ stdout: string; code: number }>((resolve) => { execFile('node', [CLI_PATH, 'doctor', '--skip-network'], { env }, (err, out) => { const e = err as { code?: number } | null resolve({ stdout: out, code: e?.code ?? 0 }) }) }) - expect(code).toBe(1) + expect(code).toBe(0) const parsed = JSON.parse(stdout) as { ok: boolean; checks: { id: string; status: string }[] } - expect(parsed.ok).toBe(false) - // No profile is initialized in this fresh home. - expect(parsed.checks.find((c) => c.id === 'active-profile')?.status).toBe('fail') + expect(parsed.ok).toBe(true) + expect(parsed.checks.find((c) => c.id === 'active-profile')?.status).toBe('ok') }) - it('doctor exits 0 when every check passes', async () => { - const env: Record = {} - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined && k !== 'UCP_BUSINESS') env[k] = v - } - env.UCP_HOME = await mkdtemp(join(tmpdir(), 'ucp-doctor-ok-')) - await execFileAsync('node', [CLI_PATH, 'profile', 'init', '--name', 'agent'], { env }) + // A named selection is explicit and must never silently fall back to + // managed. This also pins the compiled doctor's nonzero exit mechanism. + it('doctor exits 1 for a ghost explicit active Profile, preserving checks', async () => { + const home = await mkdtemp(join(tmpdir(), 'ucp-doctor-ghost-')) + const env = freshUcpEnv(home) + await writeFile(join(home, 'active.yaml'), 'profile: ghost\n', 'utf-8') const { stdout, code } = await new Promise<{ stdout: string; code: number }>((resolve) => { execFile('node', [CLI_PATH, 'doctor', '--skip-network'], { env }, (err, out) => { const e = err as { code?: number } | null resolve({ stdout: out, code: e?.code ?? 0 }) }) }) - expect(code).toBe(0) - expect((JSON.parse(stdout) as { ok: boolean }).ok).toBe(true) + expect(code).toBe(1) + const parsed = JSON.parse(stdout) as { + ok: boolean + checks: { id: string; status: string; detail: string }[] + } + expect(parsed.ok).toBe(false) + const activeProfile = parsed.checks.find((check) => check.id === 'active-profile') + expect(activeProfile?.status).toBe('fail') + expect(activeProfile?.detail).toContain('ghost') }) it('--help advertises --input-schema on op commands', async () => { @@ -298,12 +290,7 @@ describe('smoke: --mcp stdio', () => { } function envFor(home: string): Record { - const env: Record = {} - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined && k !== 'UCP_BUSINESS') env[k] = v - } - env.UCP_HOME = home - return env + return freshUcpEnv(home) } // incur defaults MCP tool discovery to 'progressive', which publishes four @@ -313,9 +300,7 @@ describe('smoke: --mcp stdio', () => { // stopped taking effect and the contract silently changed. it('exposes one tool per command under tools/list (direct discovery)', async () => { const home = await mkdtemp(join(tmpdir(), 'ucp-mcp-tools-')) - await mkdir(home, { recursive: true }) const env = envFor(home) - await execFileAsync('node', [CLI_PATH, 'profile', 'init', '--name', 'agent'], { env }) const mcp = launch(env) try { @@ -347,19 +332,15 @@ describe('smoke: --mcp stdio', () => { }) // active.yaml is process-global while an MCP server may serve many unrelated - // conversations. An omitted business must fail closed instead of inheriting - // whichever target a local CLI invocation most recently selected. + // conversations. Omitted routing must fail closed instead of inheriting + // either active leg. The missing profile name also makes a leak distinguishable + // from the managed default. it('ignores all active.yaml session state during tools/call', async () => { const home = await mkdtemp(join(tmpdir(), 'ucp-mcp-session-')) - await mkdir(home, { recursive: true }) const env = envFor(home) - await execFileAsync('node', [CLI_PATH, 'profile', 'init', '--name', 'agent'], { env }) - // Seed values that would be unmistakable if ambient state leaked into - // dispatch. Calls below omit both fields, then supply only the profile, so - // the assertions independently cover profile and business resolution. await writeFile( `${home}/active.yaml`, - 'profile: agent\nbusiness: https://shop.example.invalid\n', + 'profile: ghost\nbusiness: https://shop.example.invalid\n', 'utf-8', ) @@ -375,37 +356,98 @@ describe('smoke: --mcp stdio', () => { arguments: { input: '{"line_items":[]}' }, }, }) - const withoutProfile = (await mcp.waitForResponseId(1)) as { - result: { content: { text: string }[]; isError: boolean } - } - expect(withoutProfile.result.isError).toBe(true) - expect(withoutProfile.result.content[0]?.text).toMatch(/no local profile selected/) - - mcp.send({ - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'cart_create', - arguments: { profile: 'agent', input: '{"line_items":[]}' }, - }, - }) - const withoutBusiness = (await mcp.waitForResponseId(2)) as { + const response = (await mcp.waitForResponseId(1)) as { result: { content: { text: string }[]; isError: boolean } } - expect(withoutBusiness.result.isError).toBe(true) - const text = withoutBusiness.result.content[0]?.text ?? '' + expect(response.result.isError).toBe(true) + const text = response.result.content[0]?.text ?? '' expect(text).toMatch(/no target business resolved/) expect(text).toMatch(/Pass business in this tool call/) expect(text).toMatch(/UCP_BUSINESS/) - expect(text).not.toMatch(/ucp use|active\.yaml/) + expect(text).not.toMatch(/no local profile|ghost|ucp use|active\.yaml/) expect(text).not.toMatch(/shop\.example\.invalid|fetch failed/) } finally { await mcp.close() } }) - it('surfaces PROFILE_NOT_FOUND message when no profile is initialized', async () => { + it('ignores active DIY routing and uses managed 08 for an explicit Business', async () => { + const mock = await startMockUcpShopping() + const home = await mkdtemp(join(tmpdir(), 'ucp-managed-mcp-')) + const profileName = 'ambient-diy' + const profileDir = join(home, 'profiles', profileName) + try { + await mkdir(profileDir, { recursive: true }) + await writeFile( + join(profileDir, 'profile.json'), + RELEASES['2026-04-08'].agentProfileJson, + 'utf-8', + ) + await writeFile( + join(profileDir, 'meta.json'), + `${JSON.stringify( + { + format_version: 2, + kind: 'diy', + profile_url: 'https://agent.example.test/ambient-04-profile.json', + }, + null, + 2, + )}\n`, + 'utf-8', + ) + await writeFile( + join(home, 'active.yaml'), + `profile: ${profileName}\nbusiness: https://wrong-business.example.invalid\n`, + 'utf-8', + ) + + const mcp = launch(envFor(home)) + try { + await initialize(mcp) + mcp.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'cart_create', + // No Profile argument: MCP must ignore active.yaml and default managed. + arguments: { business: mock.url, input: '{"line_items":[]}' }, + }, + }) + const response = (await mcp.waitForResponseId(1)) as { + result: { content: Array<{ text: string }>; isError?: boolean } + } + expect(response.result.isError).not.toBe(true) + expect(response.result.content.map((item) => item.text).join('\n')).toContain(MOCK_CART_ID) + + const managedUrl = RELEASES['2026-08-25'].defaultAgentProfileUrl + expect(mock.rpcRequests.map((request) => request.method)).toEqual([ + 'tools/list', + 'tools/call', + ]) + expect(mock.rpcRequests.map((request) => request.agentProfileUrl)).toEqual([ + managedUrl, + managedUrl, + ]) + expect( + mock.requests.filter( + (request) => request.method === 'GET' && request.path.startsWith('/.well-known/ucp'), + ), + ).toHaveLength(1) + expect(mock.requests.some((request) => request.path === MOCK_LEGACY_PROFILE_PATH)).toBe( + false, + ) + } finally { + await mcp.close() + } + } finally { + await mock.close() + await rm(home, { recursive: true, force: true }) + } + }) + + it('defaults to managed and reports the missing Business on a fresh home', async () => { const home = await mkdtemp(join(tmpdir(), 'ucp-mcp-nosession-')) const mcp = launch(envFor(home)) try { @@ -424,8 +466,7 @@ describe('smoke: --mcp stdio', () => { } expect(response.result.isError).toBe(true) // MCP path strips the structured envelope; message text is all we get. - // Tracked separately — see README caveats. - expect(response.result.content[0]?.text).toMatch(/no local profile selected/) + expect(response.result.content[0]?.text).toMatch(/no target business resolved/) } finally { await mcp.close() }