diff --git a/.changeset/custom-request-headers.md b/.changeset/custom-request-headers.md new file mode 100644 index 0000000..b9f328b --- /dev/null +++ b/.changeset/custom-request-headers.md @@ -0,0 +1,18 @@ +--- +"@shopify/ucp-cli": minor +--- + +Custom HTTP headers on UCP requests, with a built-in User-Agent default. + +Adds a four-source resolver merged into a single header bag per dispatch: + +1. CLI built-in: `User-Agent: @shopify/ucp-cli/` (lowest priority — identifies CLI traffic in merchant logs / WAFs). +2. `~/.ucp/profiles//headers.json` `default` block — apply to every request. +3. `~/.ucp/profiles//headers.json` `businesses[]` block — per-origin add/override. +4. `--header 'Name: Value'` (repeatable) — per-call (highest priority). + +Higher source wins on header-name conflict (case-insensitive); non-conflicting headers from every source ship. Empty values unset for that scope. `${ENV_VAR}` interpolation in config values keeps secrets out of the file. Reserved framing headers (`Content-Type`, `Accept`, `Host`, `Connection`, hop-by-hop, `MCP-Protocol-Version`) are silently dropped from user sources. Sensitive header values (`Authorization`, `Cookie`, and any name ending in `-Token`, `-Key`, `-Secret`, `-Password`) are redacted in verbose traces. + +One generic mechanism, no per-feature aliases. Bearer auth is just `--header 'Authorization: Bearer '` — the same shape works for any merchant's chosen scheme without growing the CLI flag surface per auth pattern. + +Outbound requests now includes `User-Agent` on every fetch: `tools/call`, `tools/list`, ..., discovery. diff --git a/README.md b/README.md index 03b08b4..732c4e2 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,46 @@ ucp cart update --business https:// \ 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. +### Custom request headers (auth, tenancy, tracing) + +UCP requests attach a built-in `User-Agent: @shopify/ucp-cli/`. Override or extend with two user sources, merged with the built-in by priority (lowest to highest): + +0. CLI built-in `User-Agent` +1. `~/.ucp/profiles//headers.json` `default` block — apply to every request +2. `~/.ucp/profiles//headers.json` `businesses[]` block — per-origin add/override +3. `--header 'Name: Value'` (repeatable) — per-call + +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 ""]`: + +```json +{ + "default": { + "Trace-Id": "my-agent-${HOSTNAME}" + }, + "businesses": { + "https://shop.example.com": { + "Authorization": "Bearer ${EXAMPLE_TOKEN}" + } + } +} +``` + +Values support `${ENV_VAR}` interpolation so the file stays free of secrets. + +Per-call usage: + +```sh +# Bearer auth (covers the most common case in one keystroke) +ucp catalog search --header "Authorization: Bearer $TOKEN" --set /query='surf boards' + +# Multiple headers (repeat the flag) +ucp catalog search --header "Authorization: Bearer $TOKEN" --header 'Trace-Id: req-abc' +``` + +There is no `--auth-bearer` flag and no `UCP_AUTH_BEARER` env var. `--header` is the only knob; bearer auth is just one shape it can carry. This keeps the CLI surface from growing every time a merchant picks a different auth header. + ### Environment variables | Variable | Effect | diff --git a/skills/ucp/SKILL.md b/skills/ucp/SKILL.md index 800cf3d..9b6f224 100644 --- a/skills/ucp/SKILL.md +++ b/skills/ucp/SKILL.md @@ -256,6 +256,10 @@ export UCP_ON_ESCALATION='jq -r .url | tee >(xargs open) | xargs -I{} osascript The agent's job, beyond configuration, is to surface the escalation to the buyer with context: what was being done, why it stopped here, and what the buyer needs to do next. The hook handles delivery; the agent handles framing. +### Custom request headers + +Pass `--header 'Name: Value'` (repeatable) on any op when a merchant requires a custom HTTP header — e.g. `--header "Authorization: Bearer $TOKEN"` or `--header "Api-Key: $KEY"`. + ### Agent-initiated escalation ("this is as far as I got you") When the CLI returns a blocking error — auth the CLI cannot perform, an unrecoverable operation error, or a merchant without the needed operation — stop retrying that blocked operation and hand the buyer off. Use the most specific buyer URL you already have; never invent one. A checkout auth/permission gate does **not** invalidate earlier unauthenticated work: preserve cart ids, selected variants, cart-stage shipping estimates, discounts, and totals you already obtained. diff --git a/src/cli.test.ts b/src/cli.test.ts index ef8eb50..071ddc7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -58,7 +58,9 @@ describe('createUcpCli', () => { expect(JSON.parse(output)).toMatchObject({ result: { business: 'https://shop.example.com', negotiated: {} }, }) - expect(calls).toEqual([['https://shop.example.com', { force: false, profileUrl: PROFILE_URL }]]) + expect(calls).toMatchObject([ + ['https://shop.example.com', { force: false, profileUrl: PROFILE_URL }], + ]) }) it('wires catalog search args and options to the search helper', async () => { @@ -350,6 +352,41 @@ describe('createUcpCli — business resolution', () => { ]) }) + it('discover: forwards --header onto the discover call so auth-gated /.well-known/ucp works', async () => { + // Regression: top-level `ucp discover` is the natural first command an + // agent runs to probe a merchant. If discovery itself is auth-gated + // (some merchants require auth even on /.well-known/ucp or tools/list), + // --header must reach discoverImpl. Mirrors the operation-command path. + const calls: unknown[] = [] + const cli = createUcpCli({ + resolveSession: stubSession('https://session.example.com'), + discover: async (...args) => { + calls.push(args) + return { business: args[0], profile: {}, negotiated: {} } as never + }, + }) + const { exitCode } = await serveCli(cli, [ + 'discover', + '--business', + 'https://merchant.example.com', + '--header', + 'Authorization: Bearer probe-token', + '--header', + 'Tenant-Id: acme', + ]) + expect(exitCode).toBe(0) + const firstCall = calls[0] as [string, { headers?: Record }] + expect(firstCall[0]).toBe('https://merchant.example.com') + expect(firstCall[1]?.headers).toMatchObject({ + Authorization: 'Bearer probe-token', + 'Tenant-Id': 'acme', + }) + // Built-in User-Agent ships at lowest priority on every dispatch, including + // discover. Asserting it here guards against future refactors that + // accidentally drop the built-in identification layer. + expect(firstCall[1]?.headers?.['User-Agent']).toMatch(/^@shopify\/ucp-cli\//) + }) + it('discover: emits BUSINESS_NOT_RESOLVED with CTA when nothing resolves', async () => { const cli = createUcpCli({ resolveSession: stubSession(undefined), diff --git a/src/cli.ts b/src/cli.ts index edaa332..4a774c6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,7 @@ import { resolveEscalationHook, runEscalationHook, } from './core/escalation.js' +import { canonicalizeOrigin, type HeaderMap, resolveHeaders } from './core/headers.js' import { isDryRunPreview } from './core/operation.js' import { DEFAULT_AGENT_CAPABILITY_IDS } from './core/profile.js' import { acceptsHttpsUrl, parseHttpsUrl } from './core/url.js' @@ -78,6 +79,8 @@ export type ShoppingHelperDep = ( force: boolean profileUrl: string dryRun?: boolean + /** Resolved outbound HTTP headers; see {@link resolveHeaders}. */ + headers?: Record /** Internal-only side-channel; see CallOperationCallerOptions._onDiscover. */ _onDiscover?: (discovered: DiscoveredBusiness) => void }, @@ -232,6 +235,12 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { .boolean() .default(false) .describe('Bypass the local profile cache and re-fetch from the business.'), + header: z + .array(z.string()) + .default([]) + .describe( + 'Add an outbound HTTP header on the discovery + tools/list requests this command makes. Repeatable. Format: --header "Name: Value". Same semantics as on operation commands; required when a merchant gates `/.well-known/ucp` or tools/list behind auth. For bearer auth: --header "Authorization: Bearer $TOKEN".', + ), view: z .string() .optional() @@ -275,9 +284,11 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { // fired had `meta.defaults.catalog` been set, so when it didn't, the init // CTA is the recovery path. if (businessUrl === undefined) return c.error(businessNotResolvedError()) + const headers = await resolveCallHeaders(c.options, session, businessUrl) const discoverResult = await discoverImpl(businessUrl, { force: c.options.refresh, profileUrl: requireProfileUrl(session.profile.profileUrl), + headers, }) return c.ok(applyView({ result: discoverResult }, viewState)) }, @@ -343,6 +354,12 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { .describe( 'Shell command to invoke when a checkout response returns result.status === "requires_escalation". Receives a compact escalation payload as JSON on stdin. Auth errors use CTA handoff guidance and do not fire this hook. Overrides UCP_ON_ESCALATION / config.yaml / hooks file. No-op in --mcp mode.', ), + header: z + .array(z.string()) + .default([]) + .describe( + 'Add an outbound HTTP header on UCP requests. Repeatable. Format: --header "Name: Value". Overrides headers.json (default + per-business). Reserved framing headers (Content-Type, Accept, Host, etc.) are dropped silently. Values may not contain CR/LF. For bearer auth: --header "Authorization: Bearer $TOKEN".', + ), view: z .string() .optional() @@ -400,6 +417,11 @@ 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, + ) // 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 @@ -410,6 +432,7 @@ export function createUcpCli(deps: UcpCliDependencies = {}) { const result = await helper(prep.business, merged, { force: prep.force, profileUrl: prep.profileUrl, + headers, ...(c.options.dryRun ? { dryRun: true } : {}), _onDiscover: (d) => { discovered = d @@ -665,6 +688,7 @@ interface OperationOptions { inputSchema: boolean dryRun: boolean onEscalation?: string | undefined + header: string[] view?: string | undefined } @@ -702,6 +726,8 @@ type ShoppingHelper = { force: boolean profileUrl: string dryRun?: boolean + /** Resolved outbound HTTP headers; see {@link resolveHeaders}. */ + headers?: Record /** Internal-only side-channel; see CallOperationCallerOptions._onDiscover. */ _onDiscover?: (discovered: DiscoveredBusiness) => void }, @@ -766,6 +792,7 @@ type PreparedOperation = ok: true input: Record business: string + profileName: string profileUrl: string force: boolean } @@ -820,11 +847,28 @@ async function prepareOperation( ok: true, input, business, + profileName: session.profile.name, profileUrl: requireProfileUrl(session.profile.profileUrl), force: c.options.refresh, } } +// `?? businessUrl` is defensive only: every caller has already routed the URL +// through session resolution / parseHttpsUrl, so canonicalizeOrigin should not +// fail here. Failing closed (throwing) would break dispatch on a non-bug; the +// raw string is a safe last-resort origin key against headers.json. +async function resolveCallHeaders( + options: { header: string[] }, + session: { profile: { name: string } }, + businessUrl: string, +): Promise { + return resolveHeaders({ + argFlags: options.header, + origin: canonicalizeOrigin(businessUrl) ?? businessUrl, + profile: session.profile.name, + }) +} + // Implements `--input-schema`: short-circuit before dispatch and return the // upstream tool's `inputSchema` so agents can compose payloads without a // trial-and-error round through schema validation. Discovery is lazy — if @@ -873,10 +917,12 @@ async function inputSchemaOperation( } const profileUrl = requireProfileUrl(session.profile.profileUrl) + const headers = await resolveCallHeaders(c.options, session, businessUrl) const resolved = await discoverImpl(businessUrl, { capabilities: [helper.capability], profileUrl, force: c.options.refresh, + headers, }) const negotiated = resolved.negotiated[helper.capability] const tool = negotiated?.tools[helper.toolName] diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 62966ee..2679de9 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -13,6 +13,7 @@ import { access, constants, mkdir } from 'node:fs/promises' import { join } from 'node:path' +import { ucpFetch } from '../core/http-client.js' import { activeYamlPath, type ProfileStoreOptions, @@ -172,7 +173,16 @@ async function checkProfileUrl( const ac = new AbortController() const timer = setTimeout(() => ac.abort(), HEAD_TIMEOUT_MS) try { - const res = await fetchImpl(url, { method: 'HEAD', signal: ac.signal }) + // Route through ucpFetch so the agent-profile probe carries the same + // built-in User-Agent that every other outbound request sends. No + // merchant-scoped --header overrides apply here — this URL belongs to + // the agent's own identity, not a merchant. + const res = await ucpFetch(url, { + method: 'HEAD', + signal: ac.signal, + fetch: fetchImpl, + traceLabel: 'doctor', + }) if (res.ok) { return { id: 'profile-url', status: 'ok', detail: `${url} → ${res.status}` } } diff --git a/src/core/cache.ts b/src/core/cache.ts index 3cdede6..c34f2a4 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -21,6 +21,7 @@ import { z } from 'incur' import { type ErrorCode, UcpError } from '../lib/errors.js' import type { ErrorLayer } from '../lib/types.js' import { formatZodIssues } from '../lib/zod-format.js' +import { ucpFetch } from './http-client.js' import { vlog } from './verbose.js' /** @@ -131,6 +132,14 @@ export interface FetchCachedOptions { signal?: AbortSignal /** Per-request timeout in milliseconds. Default 30 s. */ timeoutMs?: number + /** + * Additional outbound headers (auth, tenancy, etc). Spread between the + * built-in User-Agent default and the framing `Accept` header, so a + * caller-supplied User-Agent overrides the built-in but no source can + * clobber the dispatcher's `Accept`. Reserved-header filtering is the + * caller's responsibility (see {@link resolveHeaders}). + */ + headers?: Record } /** @@ -145,7 +154,6 @@ export async function fetchCached( url: string, options: FetchCachedOptions, ): Promise { - const fetchImpl = options.fetch ?? fetch const layer: ErrorLayer = options.errorLayer ?? 'transport' const cachePath = join(options.cacheDir, `${originToFilename(url)}.json`) @@ -167,9 +175,12 @@ export async function fetchCached( let response: Response try { - response = await fetchImpl(url, { - headers: { Accept: 'application/json' }, + response = await ucpFetch(url, { + ...(options.headers !== undefined && { headers: options.headers }), + framing: { Accept: 'application/json' }, signal, + ...(options.fetch !== undefined && { fetch: options.fetch }), + traceLabel: 'cache', }) } catch (err) { throw new UcpError({ diff --git a/src/core/discover.ts b/src/core/discover.ts index 6f742f5..ad15023 100644 --- a/src/core/discover.ts +++ b/src/core/discover.ts @@ -87,6 +87,13 @@ export interface DiscoverOptions { signal?: AbortSignal /** Platform profile URL advertised to the business during MCP discovery. */ profileUrl?: string + /** + * Outbound headers (auth, tenancy, etc) attached to every HTTP call made + * during discovery: the `/.well-known/ucp` GET and any `tools/list` POSTs. + * Some merchants require auth even on discovery, so the same resolved bag + * that flows to `tools/call` flows here too. + */ + headers?: Record /** Injectable for tests (forwarded to `fetchBusinessProfile` and `mcpRpc`). */ fetch?: typeof fetch } @@ -115,7 +122,12 @@ export async function discover( const profile = await fetchBusinessProfile(normalizedBusiness.origin, { cacheDir: profileCacheDir, - ...omitUndefined({ fetch: options.fetch, signal: options.signal, force: options.force }), + ...omitUndefined({ + fetch: options.fetch, + signal: options.signal, + force: options.force, + headers: options.headers, + }), }) const services = profile.ucp.services as Record | undefined @@ -157,6 +169,7 @@ export async function discover( force: options.force, fetch: options.fetch, signal: options.signal, + headers: options.headers, }), }) const tools = Object.keys(negotiated[capability].tools).sort() @@ -177,6 +190,7 @@ interface HydrateOptions { fetch?: typeof fetch signal?: AbortSignal profileUrl?: string + headers?: Record } async function hydrateCapability(opts: HydrateOptions): Promise { @@ -194,6 +208,7 @@ async function hydrateCapability(opts: HydrateOptions): Promise { + const dir = join(homeDir, 'profiles', profile) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'headers.json'), JSON.stringify(body), 'utf-8') +} + +describe('defaultUserAgent', () => { + it('returns "@shopify/ucp-cli/"', () => { + expect(defaultUserAgent()).toMatch(/^@shopify\/ucp-cli\/\d+\.\d+\.\d+/) + }) +}) + +describe('parseHeaderFlag', () => { + it('splits on the FIRST colon so values may contain colons', () => { + expect(parseHeaderFlag('Trace-Id: req:abc:123')).toEqual({ + name: 'Trace-Id', + value: 'req:abc:123', + }) + }) + + it('trims whitespace around name and value but preserves embedded whitespace', () => { + expect(parseHeaderFlag(' Authorization : Bearer eyJ.abc ')).toEqual({ + name: 'Authorization', + value: 'Bearer eyJ.abc', + }) + }) + + it('accepts no-space form "Name:Value"', () => { + expect(parseHeaderFlag('Api-Key:abc123')).toEqual({ name: 'Api-Key', value: 'abc123' }) + }) + + it('rejects input without a colon', () => { + expect(() => parseHeaderFlag('No-Colon-Here')).toThrow(/expected "Name: Value"/) + }) + + it('rejects empty header name', () => { + expect(() => parseHeaderFlag(': only-value')).toThrow(/header name cannot be empty/) + }) + + it('rejects header names with non-RFC-7230 token chars', () => { + expect(() => parseHeaderFlag('Foo Bar: baz')).toThrow(/invalid header name/) + expect(() => parseHeaderFlag('Foo(comment): bar')).toThrow(/invalid header name/) + expect(() => parseHeaderFlag('Foo@At: bar')).toThrow(/invalid header name/) + }) + + it('rejects header values with CR or LF (injection guard)', () => { + expect(() => parseHeaderFlag('Trace-Id: a\nb')).toThrow(/CR or LF/) + expect(() => parseHeaderFlag('Trace-Id: a\rb')).toThrow(/CR or LF/) + }) +}) + +describe('isReservedHeader', () => { + it.each([ + ['Content-Type'], + ['content-type'], + ['CONTENT-TYPE'], + ['Accept'], + ['Host'], + ['Connection'], + ['Keep-Alive'], + ['Transfer-Encoding'], + ['TE'], + ['Upgrade'], + ['Proxy-Connection'], + ['MCP-Protocol-Version'], + ])('reserved: %s', (name) => { + expect(isReservedHeader(name)).toBe(true) + }) + + it.each([ + ['User-Agent'], + ['Authorization'], + ['Api-Key'], + ['Cookie'], + ['Custom-Anything'], + ])('not reserved: %s', (name) => { + expect(isReservedHeader(name)).toBe(false) + }) +}) + +describe('isSensitiveHeaderName', () => { + it.each([ + 'Authorization', + 'authorization', + 'AUTHORIZATION', + 'Cookie', + 'Proxy-Authorization', + 'Api-Key', + 'API-KEY', + 'Access-Token', + 'Client-Secret', + 'User-Password', + ])('sensitive: %s', (name) => { + expect(isSensitiveHeaderName(name)).toBe(true) + }) + + it.each([ + 'User-Agent', + 'Accept', + 'Trace-Id', + 'Tenant-Id', + 'Region', + ])('not sensitive: %s', (name) => { + expect(isSensitiveHeaderName(name)).toBe(false) + }) +}) + +describe('redactHeadersForLog', () => { + it('replaces sensitive header values with , leaves names intact', () => { + const redacted = redactHeadersForLog({ + 'User-Agent': '@shopify/ucp-cli/1.0.0', + Authorization: 'Bearer eyJ.SHOULD.NOT.LEAK', + 'Api-Key': 'sk_live_SHOULD_NOT_LEAK', + 'Trace-Id': 'req-abc', + }) + expect(redacted).toEqual({ + 'User-Agent': '@shopify/ucp-cli/1.0.0', + Authorization: '', + 'Api-Key': '', + 'Trace-Id': 'req-abc', + }) + }) + + it('produces no string in any value that contains a real token substring', () => { + const redacted = redactHeadersForLog({ + Authorization: 'Bearer top-secret-token-12345', + 'Api-Key': 'sk_test_SUPER_SECRET', + }) + const dump = JSON.stringify(redacted) + expect(dump).not.toContain('top-secret-token-12345') + expect(dump).not.toContain('SUPER_SECRET') + }) +}) + +describe('formatHeadersForTrace', () => { + it('renders names + values with sensitive values redacted, sorted by name', () => { + const line = formatHeadersForTrace({ + 'Trace-Id': 'abc', + Authorization: 'Bearer SHOULD-NOT-LEAK', + 'User-Agent': '@shopify/ucp-cli/0.5.0', + 'Api-Key': 'sk_test_SHOULD-NOT-LEAK', + }) + expect(line).toBe( + 'Api-Key: , Authorization: , Trace-Id: abc, User-Agent: @shopify/ucp-cli/0.5.0', + ) + expect(line).not.toContain('SHOULD-NOT-LEAK') + }) + + it('renders for the empty bag', () => { + expect(formatHeadersForTrace({})).toBe('') + }) +}) + +describe('canonicalizeOrigin', () => { + it('strips path / query / hash', () => { + expect(canonicalizeOrigin('https://shop.example.com/foo?bar#baz')).toBe( + 'https://shop.example.com', + ) + }) + + it('preserves non-default ports', () => { + expect(canonicalizeOrigin('https://shop.example.com:8443/x')).toBe( + 'https://shop.example.com:8443', + ) + }) + + it('returns undefined for unparseable input', () => { + expect(canonicalizeOrigin('not a url')).toBeUndefined() + expect(canonicalizeOrigin('')).toBeUndefined() + }) +}) + +describe('resolveHeaders — built-in', () => { + it('seeds User-Agent at the lowest priority', async () => { + const result = await resolveHeaders({ env: {}, origin: ORIGIN }) + expect(result['User-Agent']).toMatch(/^@shopify\/ucp-cli\//) + }) + + it('built-in User-Agent is overridable by every other source', async () => { + // Flag wins over business wins over default wins over built-in. + const homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-headers-test-')) + try { + await writeHeadersFile(homeDir, 'eval', { + default: { 'User-Agent': 'from-default' }, + businesses: { [ORIGIN]: { 'User-Agent': 'from-business' } }, + }) + const result = await resolveHeaders({ + env: {}, + homeDir, + profile: 'eval', + origin: ORIGIN, + argFlags: ['User-Agent: from-flag'], + }) + expect(result['User-Agent']).toBe('from-flag') + } finally { + await rm(homeDir, { recursive: true, force: true }) + } + }) +}) + +describe('resolveHeaders — --header flag', () => { + it('values land on the wire verbatim', async () => { + const result = await resolveHeaders({ + env: {}, + origin: ORIGIN, + argFlags: ['Trace-Id: abc-123', 'Tenant-Id: acme'], + }) + expect(result).toMatchObject({ 'Trace-Id': 'abc-123', 'Tenant-Id': 'acme' }) + }) + + it('multiple flags compose (no conflict, no order surprise)', async () => { + const result = await resolveHeaders({ + env: {}, + origin: ORIGIN, + argFlags: ['Authorization: Bearer eyJ.abc', 'Tenant-Id: acme'], + }) + expect(result).toMatchObject({ + Authorization: 'Bearer eyJ.abc', + 'Tenant-Id': 'acme', + }) + }) + + it('repeated --header with same name keeps last-set value (highest-priority within flag source)', async () => { + const result = await resolveHeaders({ + env: {}, + origin: ORIGIN, + argFlags: ['Trace-Id: first', 'Trace-Id: last'], + }) + expect(result['Trace-Id']).toBe('last') + }) + + it('silently drops reserved framing headers', async () => { + const result = await resolveHeaders({ + env: {}, + origin: ORIGIN, + argFlags: ['Content-Type: text/plain', 'Host: attacker.example.com', 'Trace-Id: ok'], + }) + expect(result['Content-Type']).toBeUndefined() + expect(result.Host).toBeUndefined() + expect(result['Trace-Id']).toBe('ok') + }) + + it('empty value clears any lower-priority value for that header', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-headers-test-')) + try { + await writeHeadersFile(homeDir, 'eval', { + default: { Authorization: 'Bearer from-config' }, + }) + const result = await resolveHeaders({ + env: {}, + homeDir, + profile: 'eval', + origin: ORIGIN, + argFlags: ['Authorization:'], + }) + expect(result.Authorization).toBeUndefined() + } finally { + await rm(homeDir, { recursive: true, force: true }) + } + }) +}) + +describe('resolveHeaders — config source', () => { + let homeDir: string + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-headers-test-')) + }) + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }) + }) + + it('default headers apply to every origin', async () => { + await writeHeadersFile(homeDir, 'eval', { + default: { 'Trace-Id': 'global' }, + }) + const result = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }) + expect(result['Trace-Id']).toBe('global') + const other = await resolveHeaders({ + env: {}, + homeDir, + profile: 'eval', + origin: OTHER_ORIGIN, + }) + expect(other['Trace-Id']).toBe('global') + }) + + it('businesses[] adds and overrides default', async () => { + await writeHeadersFile(homeDir, 'eval', { + default: { 'Trace-Id': 'global', Foo: 'global-foo' }, + businesses: { [ORIGIN]: { 'Trace-Id': 'shopify-only', Bar: 'shopify-bar' } }, + }) + const result = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }) + expect(result).toMatchObject({ + 'Trace-Id': 'shopify-only', + Foo: 'global-foo', + Bar: 'shopify-bar', + }) + }) + + it('env-var interpolation in config values substitutes from process env', async () => { + await writeHeadersFile(homeDir, 'eval', { + businesses: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${VAR} is the interpolation syntax under test + [ORIGIN]: { Authorization: 'Bearer ${SHOPIFY_TOKEN}' }, + }, + }) + const result = await resolveHeaders({ + env: { SHOPIFY_TOKEN: 'token-from-env' }, + homeDir, + profile: 'eval', + origin: ORIGIN, + }) + expect(result.Authorization).toBe('Bearer token-from-env') + }) + + it('unset env-var in config substitutes to empty (which unsets the header for that scope)', async () => { + await writeHeadersFile(homeDir, 'eval', { + default: { 'Trace-Id': 'default-value' }, + businesses: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${VAR} is the interpolation syntax under test + [ORIGIN]: { 'Trace-Id': '${UNSET_VAR_XYZ}' }, + }, + }) + const result = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }) + // Empty per-origin value unsets the default for this origin. + expect(result['Trace-Id']).toBeUndefined() + }) + + it('empty-string value at config level unsets a lower default for the same scope', async () => { + await writeHeadersFile(homeDir, 'eval', { + default: { 'User-Agent': '' }, + }) + const result = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }) + expect(result['User-Agent']).toBeUndefined() + }) + + it('reserved headers in config are silently dropped', async () => { + await writeHeadersFile(homeDir, 'eval', { + default: { + 'Content-Type': 'text/plain', + Host: 'attacker.example.com', + 'Ok-Marker': 'kept', + }, + }) + const result = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }) + expect(result['Content-Type']).toBeUndefined() + expect(result.Host).toBeUndefined() + expect(result['Ok-Marker']).toBe('kept') + }) + + it('missing headers.json file is not an error (no-config path)', async () => { + const result = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }) + expect(result).toMatchObject({}) + expect(result['User-Agent']).toMatch(/^@shopify\/ucp-cli\//) + }) + + it('throws INVALID_INPUT when headers.json is not valid JSON', async () => { + const dir = join(homeDir, 'profiles', 'eval') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'headers.json'), '{not json', 'utf-8') + await expect( + resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }), + ).rejects.toThrow(/not valid JSON/) + }) + + it('throws INVALID_INPUT when top-level is not an object', async () => { + await writeHeadersFile(homeDir, 'eval', ['default', 'oops']) + await expect( + resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }), + ).rejects.toThrow(/must be a JSON object/) + }) + + it('throws INVALID_INPUT on unknown top-level key (typo guard)', async () => { + await writeHeadersFile(homeDir, 'eval', { defaults: { Foo: 'oops' } }) + await expect( + resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }), + ).rejects.toThrow(/unknown top-level key/) + }) + + it('throws INVALID_INPUT when a business key has a path component', async () => { + await writeHeadersFile(homeDir, 'eval', { + businesses: { 'https://shop.example.com/api': { Foo: 'bar' } }, + }) + await expect( + resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }), + ).rejects.toThrow(/bare origin/) + }) + + it('throws INVALID_INPUT when a header value is non-string', async () => { + await writeHeadersFile(homeDir, 'eval', { default: { Foo: 42 } }) + await expect( + resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }), + ).rejects.toThrow(/must be a string/) + }) + + it('throws INVALID_INPUT when a header value contains CR/LF', async () => { + await writeHeadersFile(homeDir, 'eval', { default: { Foo: 'a\nb' } }) + await expect( + resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }), + ).rejects.toThrow(/CR or LF/) + }) + + it('throws INVALID_INPUT when a header name has invalid chars', async () => { + await writeHeadersFile(homeDir, 'eval', { default: { 'Foo Bar': 'bar' } }) + await expect( + resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }), + ).rejects.toThrow(/invalid header name/) + }) + + it('does NOT read headers.json when profile is undefined', async () => { + await writeHeadersFile(homeDir, 'eval', { default: { 'Trace-Id': 'config' } }) + const result = await resolveHeaders({ env: {}, homeDir, origin: ORIGIN }) + expect(result['Trace-Id']).toBeUndefined() + }) +}) + +describe('resolveHeaders — full merge order', () => { + let homeDir: string + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ucp-cli-headers-merge-')) + }) + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }) + }) + + it('low-to-high priority: builtin < default < business < flag', async () => { + // Three layers conflict on Foo + Authorization. Verify flag wins. + await writeHeadersFile(homeDir, 'eval', { + default: { Foo: 'from-default', Authorization: 'Bearer from-default' }, + businesses: { + [ORIGIN]: { Foo: 'from-business', Authorization: 'Bearer from-business' }, + }, + }) + const result = await resolveHeaders({ + env: {}, + homeDir, + profile: 'eval', + origin: ORIGIN, + argFlags: ['Foo: from-flag', 'Authorization: Bearer from-flag'], + }) + expect(result.Foo).toBe('from-flag') + expect(result.Authorization).toBe('Bearer from-flag') + }) + + it('non-conflicting headers from all sources all ship', async () => { + await writeHeadersFile(homeDir, 'eval', { + default: { 'Default-Marker': 'd' }, + businesses: { [ORIGIN]: { 'Business-Marker': 'b' } }, + }) + const result = await resolveHeaders({ + env: {}, + homeDir, + profile: 'eval', + origin: ORIGIN, + argFlags: ['Flag-Marker: f'], + }) + expect(result).toMatchObject({ + 'Default-Marker': 'd', + 'Business-Marker': 'b', + 'Flag-Marker': 'f', + }) + expect(result['User-Agent']).toMatch(/^@shopify\/ucp-cli\//) + }) + + it('merge is case-insensitive on header name (last-set casing emitted)', async () => { + await writeHeadersFile(homeDir, 'eval', { + default: { foo: 'lowercase' }, + }) + const result = await resolveHeaders({ + env: {}, + homeDir, + profile: 'eval', + origin: ORIGIN, + argFlags: ['FOO: uppercase-wins'], + }) + expect(result.FOO).toBe('uppercase-wins') + expect(result.foo).toBeUndefined() + }) + + it('per-origin block selection is exact-match on canonical origin', async () => { + await writeHeadersFile(homeDir, 'eval', { + businesses: { + [ORIGIN]: { 'Primary-Marker': 'yes' }, + [OTHER_ORIGIN]: { 'Secondary-Marker': 'yes' }, + }, + }) + const a = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: ORIGIN }) + expect(a['Primary-Marker']).toBe('yes') + expect(a['Secondary-Marker']).toBeUndefined() + const b = await resolveHeaders({ env: {}, homeDir, profile: 'eval', origin: OTHER_ORIGIN }) + expect(b['Secondary-Marker']).toBe('yes') + expect(b['Primary-Marker']).toBeUndefined() + }) +}) diff --git a/src/core/headers.ts b/src/core/headers.ts new file mode 100644 index 0000000..3bf82fc --- /dev/null +++ b/src/core/headers.ts @@ -0,0 +1,413 @@ +// Custom HTTP headers for UCP requests. +// +// Four priority sources merged into a single header bag per dispatch. Higher +// source wins on header-name conflict (case-insensitive). Sources not in +// conflict on a name all contribute. Empty string at any source unsets that +// header for the matching scope. +// +// 0. CLI built-in: User-Agent: @shopify/ucp-cli/ (lowest) +// 1. headers.json `default` apply to every request from this profile +// 2. headers.json `businesses[]` per-origin add/override +// 3. --header 'Name: Value' flag (highest) +// +// One generic mechanism, no per-feature aliases. Bearer auth is just +// `--header 'Authorization: Bearer '` like any other header. Persistent +// setup goes in headers.json; CI scripts pass their token via `--header` +// directly. We deliberately do not ship a `--auth-bearer` flag or a +// `UCP_AUTH_BEARER` env var — either would be one-keystroke sugar for the +// universal pattern at the cost of forever maintaining a per-auth-scheme +// surface that grows every time a merchant chooses a different scheme. +// +// The persistent file lives at ~/.ucp/profiles//headers.json so different +// profiles (= agent identities) can carry different merchant credentials. The +// `default` + `businesses` shape mirrors git's `[http]` and `[http ""]` +// model — one well-precedented pattern, not a new invention. +// +// Reserved transport headers (Content-Type, Accept, Host, Connection, +// hop-by-hop, MCP-Protocol-Version) are silently dropped from all +// user-controlled sources — those are framing-level concerns owned by the +// dispatcher, and overriding them would only ever break things. User-Agent is +// NOT reserved: the built-in default exists so merchants can identify ucp-cli +// traffic, but users may legitimately want to claim a different identity. +// +// Sensitive header values (`Authorization`, `Cookie`, suffixes `-Token`, +// `-Key`, `-Secret`, `-Password`) are redacted by {@link redactHeadersForLog} +// for verbose-mode tracing. The resolver itself does not log. + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { ErrorCodes, UcpError } from '../lib/errors.js' +import { profileStoreHome } from './profile-store.js' + +/** Wire-shape header map. Names are emitted as last-set casing; HTTP is case-insensitive. */ +export type HeaderMap = Record + +export interface ResolveHeadersOptions { + /** Raw "Name: Value" strings from --header (repeatable). Parsed individually. */ + argFlags?: readonly string[] | undefined + /** + * Override env (test injection). Defaults to process.env. Only consulted for + * `${VAR}` interpolation inside config values; no env var directly seeds a + * header (see header comment for rationale). + */ + env?: NodeJS.ProcessEnv | undefined + /** Canonical "scheme://host[:port]" of the dispatch target. Used to select per-origin block. */ + origin: string + /** Active profile name. If undefined, the headers.json file is not read. */ + profile?: string | undefined + /** Override `~/.ucp/` (test injection). Defaults to UCP_HOME or homedir. */ + homeDir?: string | undefined +} + +/** + * Walk the four sources in priority order, return the merged header map. + * Reserved headers are silently dropped; empty values unset. The built-in + * User-Agent is always seeded as the lowest source so an unconfigured CLI + * still identifies itself. + */ +export async function resolveHeaders(opts: ResolveHeadersOptions): Promise { + const env = opts.env ?? process.env + const bag = createBag() + + // Source 0: built-in. + setHeader(bag, 'User-Agent', defaultUserAgent()) + + // Sources 1 + 2: persistent file. Missing file is not an error; corrupt + // file is — surfacing the parse/shape failure beats silently dispatching + // without the headers the user thought were configured. + if (opts.profile !== undefined && opts.profile.length > 0) { + const file = await loadHeadersFile(opts.homeDir, opts.profile) + if (file !== undefined) { + applyHeadersToBag(bag, file.default ?? {}, env) + const perOrigin = file.businesses?.[opts.origin] + if (perOrigin !== undefined) { + applyHeadersToBag(bag, perOrigin, env) + } + } + } + + // Source 3: per-call --header flags. + for (const raw of opts.argFlags ?? []) { + const parsed = parseHeaderFlag(raw) + setHeader(bag, parsed.name, parsed.value) + } + + return mapFromBag(bag) +} + +/** Build-time CLI version is the only legitimate moving part of the UA. */ +export function defaultUserAgent(): string { + return `@shopify/ucp-cli/${__CLI_VERSION__}` +} + +/** + * Parse one `-H` / `--header` argument. Split on the FIRST colon so values + * containing colons (URLs, timestamps, base64 with padding) survive intact. + * Whitespace around the name and value is trimmed; embedded whitespace in + * the value is preserved. + */ +export function parseHeaderFlag(raw: string): { name: string; value: string } { + const idx = raw.indexOf(':') + if (idx === -1) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `--header expected "Name: Value", got: ${JSON.stringify(raw)}`, + }) + } + const name = raw.slice(0, idx).trim() + const value = raw.slice(idx + 1).trim() + validateHeaderName(name, '--header') + validateHeaderValue(value, '--header') + return { name, value } +} + +/** True if the dispatcher owns this header — user sources can't set it. */ +export function isReservedHeader(name: string): boolean { + return RESERVED_HEADERS.has(name.toLowerCase()) +} + +/** + * True if logging this header's value risks leaking a secret. Patterns: + * exact match on `Authorization`, `Cookie`, `Proxy-Authorization`; suffix + * match on `-token`, `-key`, `-secret`, `-password` (case-insensitive). + * + * Used by {@link redactHeadersForLog} for any future verbose/trace path. + * Keep the rule case-insensitive on the suffix so `Api-Key`, `API-KEY`, + * `api-key` all redact consistently. + */ +export function isSensitiveHeaderName(name: string): boolean { + const lower = name.toLowerCase() + if (SENSITIVE_EXACT.has(lower)) return true + return SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix)) +} + +/** + * Build a logging-safe copy of headers with sensitive VALUES replaced by + * ``. Names are preserved verbatim so a verbose trace still tells + * you which headers were attached, just not what the secret was. + */ +export function redactHeadersForLog(headers: HeaderMap): HeaderMap { + const result: HeaderMap = {} + for (const [name, value] of Object.entries(headers)) { + result[name] = isSensitiveHeaderName(name) ? '' : value + } + return result +} + +/** + * Render a one-line header trace suitable for `vlog`. Sensitive values are + * already redacted by {@link redactHeadersForLog}; names are sorted so the + * line is stable across runs (helps when grepping vlog output). + * + * Returns `""` for the empty bag rather than an empty string so the + * trace is grep-able even when no headers were attached. + */ +export function formatHeadersForTrace(headers: HeaderMap): string { + const entries = Object.entries(redactHeadersForLog(headers)) + if (entries.length === 0) return '' + entries.sort(([a], [b]) => a.localeCompare(b)) + return entries.map(([name, value]) => `${name}: ${value}`).join(', ') +} + +/** + * Canonicalize a URL string to `scheme://host[:port]`. Returns undefined if + * the input isn't a parseable absolute URL. The result is the same form used + * as a key under `businesses[]` in headers.json. + */ +export function canonicalizeOrigin(input: string): string | undefined { + try { + return new URL(input).origin + } catch { + return undefined + } +} + +// ─── internals ──────────────────────────────────────────────────────────── + +interface HeadersFile { + default?: Record + businesses?: Record> +} + +// Internal bag preserves case-insensitive lookup while remembering the +// last-set casing for emission. Map keyed by lower-cased name. +type HeaderBag = Map + +function createBag(): HeaderBag { + return new Map() +} + +function setHeader(bag: HeaderBag, name: string, value: string): void { + if (isReservedHeader(name)) return + const lower = name.toLowerCase() + if (value.length === 0) { + bag.delete(lower) + return + } + bag.set(lower, { name, value }) +} + +function applyHeadersToBag( + bag: HeaderBag, + source: Record, + env: NodeJS.ProcessEnv, +): void { + for (const [name, rawValue] of Object.entries(source)) { + setHeader(bag, name, interpolate(rawValue, env)) + } +} + +function mapFromBag(bag: HeaderBag): HeaderMap { + const result: HeaderMap = {} + for (const { name, value } of bag.values()) { + result[name] = value + } + return result +} + +// ${VAR_NAME} only — the same simple shell-style form used everywhere config +// values reference secrets. Unset variables become empty strings (which then +// trip the empty-unsets rule). Curly braces are required to keep the grammar +// unambiguous next to surrounding text. +const ENV_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g + +function interpolate(value: string, env: NodeJS.ProcessEnv): string { + return value.replace(ENV_VAR_RE, (_, varName: string) => env[varName] ?? '') +} + +// RFC 7230 token grammar. Matches every char allowed in a header field-name. +const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ + +function validateHeaderName(name: string, source: string): void { + if (name.length === 0) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `${source}: header name cannot be empty`, + }) + } + if (!HEADER_NAME_RE.test(name)) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `${source}: invalid header name ${JSON.stringify(name)} (RFC 7230 token chars only)`, + }) + } +} + +function validateHeaderValue(value: string, source: string): void { + // CR/LF in a header value is the textbook HTTP request-splitting vector. + if (/[\r\n]/.test(value)) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `${source}: header value cannot contain CR or LF`, + }) + } +} + +const RESERVED_HEADERS: ReadonlySet = new Set([ + 'content-type', + 'accept', + 'host', + 'connection', + 'keep-alive', + 'transfer-encoding', + 'te', + 'upgrade', + 'proxy-connection', + 'mcp-protocol-version', +]) + +const SENSITIVE_EXACT: ReadonlySet = new Set([ + 'authorization', + 'cookie', + 'proxy-authorization', +]) + +const SENSITIVE_SUFFIXES = ['-token', '-key', '-secret', '-password'] + +// ─── headers.json loading ───────────────────────────────────────────────── + +async function loadHeadersFile( + homeDir: string | undefined, + profile: string, +): Promise { + const home = profileStoreHome({ ...(homeDir !== undefined && { homeDir }) }) + const path = join(home, 'profiles', profile, 'headers.json') + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch { + // Missing file is the no-config path — not an error. + return undefined + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `headers.json is not valid JSON: ${path}`, + cause: err as Error, + }) + } + return validateHeadersFile(parsed, path) +} + +function validateHeadersFile(parsed: unknown, path: string): HeadersFile { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `headers.json must be a JSON object: ${path}`, + }) + } + const obj = parsed as Record + + // Reject unknown top-level keys so a typo in `default` doesn't silently + // do nothing. Two known keys is a small surface; expanding it is a + // deliberate code change. + for (const key of Object.keys(obj)) { + if (key !== 'default' && key !== 'businesses') { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `headers.json: unknown top-level key ${JSON.stringify(key)} (allowed: "default", "businesses"): ${path}`, + }) + } + } + + const result: HeadersFile = {} + if ('default' in obj) { + result.default = validateHeaderRecord(obj.default, `${path}: "default"`) + } + if ('businesses' in obj) { + result.businesses = validateBusinessesMap(obj.businesses, path) + } + return result +} + +function validateHeaderRecord(input: unknown, label: string): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `${label} must be a JSON object`, + }) + } + const result: Record = {} + for (const [name, value] of Object.entries(input)) { + if (typeof value !== 'string') { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `${label}: header value for ${JSON.stringify(name)} must be a string`, + }) + } + validateHeaderName(name, label) + validateHeaderValue(value, label) + result[name] = value + } + return result +} + +function validateBusinessesMap( + input: unknown, + path: string, +): Record> { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `headers.json: "businesses" must be a JSON object: ${path}`, + }) + } + const result: Record> = {} + for (const [origin, headers] of Object.entries(input)) { + if (!isBareOrigin(origin)) { + throw new UcpError({ + layer: 'client', + code: ErrorCodes.INVALID_INPUT, + message: `headers.json: business key must be a bare origin (scheme://host[:port], no path/query/hash): ${JSON.stringify(origin)} in ${path}`, + }) + } + result[origin] = validateHeaderRecord(headers, `${path}: businesses[${JSON.stringify(origin)}]`) + } + return result +} + +function isBareOrigin(input: string): boolean { + let url: URL + try { + url = new URL(input) + } catch { + return false + } + // url.origin canonicalizes to "scheme://host[:port]" — anything past that + // (path, query, hash, trailing slash) makes the input not bare. + return url.origin === input +} diff --git a/src/core/http-client.test.ts b/src/core/http-client.test.ts new file mode 100644 index 0000000..960ec4f --- /dev/null +++ b/src/core/http-client.test.ts @@ -0,0 +1,92 @@ +// ucpFetch: the single outbound HTTP path. Mirrors the layering rules +// documented in src/core/http-client.ts. + +import { describe, expect, it } from 'vitest' + +import { ucpFetch } from './http-client.js' + +function captureFetch(): { + fetch: typeof fetch + calls: Array<{ url: string; init: RequestInit }> +} { + const calls: Array<{ url: string; init: RequestInit }> = [] + const fakeFetch: typeof fetch = async (input, init) => { + calls.push({ url: String(input), init: init ?? {} }) + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) + } + return { fetch: fakeFetch, calls } +} + +describe('ucpFetch', () => { + it('seeds the built-in User-Agent at lowest priority', async () => { + const { fetch, calls } = captureFetch() + await ucpFetch('https://example.com/x', { fetch, traceLabel: 'test' }) + const headers = new Headers(calls[0]?.init.headers as Record) + expect(headers.get('user-agent')).toMatch(/^@shopify\/ucp-cli\//) + }) + + it('caller-supplied User-Agent overrides the built-in', async () => { + const { fetch, calls } = captureFetch() + await ucpFetch('https://example.com/x', { + fetch, + traceLabel: 'test', + headers: { 'User-Agent': 'my-agent/1.0' }, + }) + const headers = new Headers(calls[0]?.init.headers as Record) + expect(headers.get('user-agent')).toBe('my-agent/1.0') + }) + + it('framing wins over caller headers (dispatcher-owned)', async () => { + const { fetch, calls } = captureFetch() + await ucpFetch('https://example.com/x', { + fetch, + traceLabel: 'test', + headers: { 'Content-Type': 'text/plain' }, + framing: { 'Content-Type': 'application/json' }, + }) + const headers = new Headers(calls[0]?.init.headers as Record) + expect(headers.get('content-type')).toBe('application/json') + }) + + it('caller headers ship alongside framing when names do not conflict', async () => { + const { fetch, calls } = captureFetch() + await ucpFetch('https://example.com/x', { + fetch, + traceLabel: 'test', + headers: { Authorization: 'Bearer abc', 'Trace-Id': 'req-1' }, + framing: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }) + const headers = new Headers(calls[0]?.init.headers as Record) + expect(headers.get('authorization')).toBe('Bearer abc') + expect(headers.get('trace-id')).toBe('req-1') + expect(headers.get('content-type')).toBe('application/json') + expect(headers.get('accept')).toBe('application/json') + }) + + it('forwards method, body, and signal verbatim', async () => { + const { fetch, calls } = captureFetch() + const ac = new AbortController() + await ucpFetch('https://example.com/rpc', { + fetch, + traceLabel: 'test', + method: 'POST', + body: '{"id":1}', + signal: ac.signal, + }) + expect(calls[0]?.url).toBe('https://example.com/rpc') + expect(calls[0]?.init.method).toBe('POST') + expect(calls[0]?.init.body).toBe('{"id":1}') + expect(calls[0]?.init.signal).toBe(ac.signal) + }) + + it('omits method/body/signal entirely when undefined (matches fetch() defaults)', async () => { + // exactOptionalPropertyTypes: present-as-undefined and absent are different. + // We pass nothing through unless the caller set it. + const { fetch, calls } = captureFetch() + await ucpFetch('https://example.com/x', { fetch, traceLabel: 'test' }) + const init = calls[0]?.init ?? {} + expect('method' in init).toBe(false) + expect('body' in init).toBe(false) + expect('signal' in init).toBe(false) + }) +}) diff --git a/src/core/http-client.ts b/src/core/http-client.ts new file mode 100644 index 0000000..44e8876 --- /dev/null +++ b/src/core/http-client.ts @@ -0,0 +1,88 @@ +// Single outbound HTTP entry point. Every fetch the CLI makes at runtime +// flows through here so the header bag is built in exactly one place. +// +// What this owns: +// +// - Built-in User-Agent at the lowest priority on every request. Merchants +// identifying our traffic in their access logs / WAFs should see +// `@shopify/ucp-cli/` regardless of which call site reached out. +// - Merging caller-supplied resolved headers (from --header, +// headers.json, env, etc — see resolveHeaders) over the built-in. +// - Applying framing headers (Content-Type, Accept) the dispatcher owns, +// spread LAST so no user source can clobber them. +// - Verbose trace of the outgoing header bag with sensitive values +// redacted (via formatHeadersForTrace). +// +// What this DOES NOT own (intentional — each caller has different needs): +// +// - Timeout / AbortSignal composition. Callers compose their own (cache: +// 30 s, mcp: 30 s, doctor: 5 s). +// - Response parsing, error mapping, status checks, caching, schema +// validation. Those stay in the call-site modules (mcp-client.ts, +// cache.ts, etc.) because their semantics differ. +// - Response-side verbose trace (status, latency, body length). That +// requires call-site knowledge of how to interpret the body. +// +// Adding a NEW outbound fetch site: import and call `ucpFetch`. Bypassing it +// means losing User-Agent identification, header merging, and verbose +// tracing all at once — which is the trap that motivated this module. + +import { defaultUserAgent, formatHeadersForTrace } from './headers.js' +import { vlog } from './verbose.js' + +export interface UcpFetchOptions { + /** HTTP method. Defaults to GET to match `fetch()`. */ + method?: string + /** Request body (string or bytes). Pass undefined for GET/HEAD. */ + body?: string | Uint8Array + /** + * Caller-supplied resolved outbound headers. Already filtered through + * {@link resolveHeaders} for reserved-header rejection, ${VAR} expansion, + * and source merging. Spread between the built-in User-Agent and the + * dispatcher-owned framing block, so a caller-supplied User-Agent (e.g. + * from a user --header override) wins over the built-in but no source + * can replace framing. + */ + headers?: Record + /** + * Dispatcher-owned framing headers (Content-Type, Accept). Spread LAST so + * user sources can never replace them. Optional because some call sites + * (HEAD probes) intentionally send no body and want no Content-Type. + */ + framing?: Record + /** AbortSignal forwarded to fetch. Callers compose their own timeouts. */ + signal?: AbortSignal + /** Injectable fetch for tests. */ + fetch?: typeof fetch + /** + * Short label included in the verbose-mode header trace line so a single + * `UCP_VERBOSE=1` run can be grepped by call site (e.g. `mcp:`, `cache:`, + * `doctor:`). Required because the trace line is the main observability + * benefit of routing through one client. + */ + traceLabel: string +} + +/** + * Outbound fetch with built-in User-Agent, merged caller headers, framing, + * and a redacted verbose trace. See module header for layering rules. + */ +export async function ucpFetch(url: string, opts: UcpFetchOptions): Promise { + const fetchImpl = opts.fetch ?? fetch + // Construct the final header bag locally so the verbose trace and the wire + // request are guaranteed to be identical. Order: User-Agent first (so any + // caller-supplied UA overrides), caller headers next, framing last (so the + // dispatcher always wins on framing). + const requestHeaders: Record = { + 'User-Agent': defaultUserAgent(), + ...opts.headers, + ...opts.framing, + } + vlog(`${opts.traceLabel}: headers: ${formatHeadersForTrace(requestHeaders)}`) + return fetchImpl(url, { + ...(opts.method !== undefined && { method: opts.method }), + headers: requestHeaders, + ...(opts.body !== undefined && { body: opts.body }), + ...(opts.signal !== undefined && { signal: opts.signal }), + }) +} diff --git a/src/core/mcp-client.test.ts b/src/core/mcp-client.test.ts index 2be029a..4e97de8 100644 --- a/src/core/mcp-client.test.ts +++ b/src/core/mcp-client.test.ts @@ -61,7 +61,7 @@ describe('mcpRpc — wire format', () => { endpoint: ENDPOINT, method: 'tools/call', params: { name: 'search_catalog', arguments: { query: 'hat' } }, - headers: { 'X-Trace-Id': 'abc123' }, + headers: { 'Trace-Id': 'abc123' }, fetch, id: 'x', }) @@ -71,7 +71,7 @@ describe('mcpRpc — wire format', () => { const headers = new Headers(captured.init.headers as Record) expect(headers.get('content-type')).toBe('application/json') expect(headers.get('accept')).toBe('application/json') - expect(headers.get('x-trace-id')).toBe('abc123') + expect(headers.get('trace-id')).toBe('abc123') const body = JSON.parse(captured.init.body as string) as { params: unknown } expect(body.params).toEqual({ name: 'search_catalog', arguments: { query: 'hat' } }) diff --git a/src/core/mcp-client.ts b/src/core/mcp-client.ts index aa32124..4782d56 100644 --- a/src/core/mcp-client.ts +++ b/src/core/mcp-client.ts @@ -14,6 +14,7 @@ import { ErrorCodes, UcpError } from '../lib/errors.js' import type { CtaBlock } from '../lib/types.js' +import { ucpFetch } from './http-client.js' import { parseHttpsUrl } from './url.js' import { vlog } from './verbose.js' @@ -67,7 +68,6 @@ export interface McpRpcOptions { export async function mcpRpc(opts: McpRpcOptions): Promise { const endpoint = parseHttpsUrl(opts.endpoint, 'MCP endpoint').toString() - const fetchImpl = opts.fetch ?? fetch const id = opts.id ?? nextRequestId++ const body: JsonRpcRequest = { @@ -89,15 +89,14 @@ export async function mcpRpc(opts: McpRpcOptions): Promise { let response: Response try { - response = await fetchImpl(endpoint, { + response = await ucpFetch(endpoint, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...opts.headers, - }, body: requestBody, + ...(opts.headers !== undefined && { headers: opts.headers }), + framing: { 'Content-Type': 'application/json', Accept: 'application/json' }, signal, + ...(opts.fetch !== undefined && { fetch: opts.fetch }), + traceLabel: 'mcp', }) } catch (err) { throw new UcpError({ diff --git a/src/core/operation.ts b/src/core/operation.ts index fd9dab6..c0b7799 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -20,7 +20,7 @@ import { mcpRpc } from './mcp-client.js' export type CallOperationCallerOptions = Pick< DiscoverOptions, - 'agentRange' | 'cacheDir' | 'fetch' | 'force' | 'profileUrl' | 'signal' + 'agentRange' | 'cacheDir' | 'fetch' | 'force' | 'headers' | 'profileUrl' | 'signal' > & { /** * `--dry-run`: run the full pre-flight (discover → meta inject → schema @@ -66,6 +66,7 @@ export function forwardCallOptions( cacheDir: options.cacheDir, fetch: options.fetch, force: options.force, + headers: options.headers, signal: options.signal, dryRun: options.dryRun, _onDiscover: options._onDiscover, @@ -122,7 +123,10 @@ export function serviceOp(capability: string, toolName: string, opName: string): } export interface CallOperationOptions - extends Pick { + extends Pick< + DiscoverOptions, + 'agentRange' | 'cacheDir' | 'fetch' | 'force' | 'headers' | 'signal' + > { profileUrl: string dryRun?: boolean /** See {@link CallOperationCallerOptions._onDiscover}. */ @@ -183,6 +187,7 @@ export async function callOperation( cacheDir: options.cacheDir, fetch: options.fetch, force: options.force, + headers: options.headers, signal: options.signal, }), }) @@ -239,7 +244,11 @@ export async function callOperation( name: tool.name, arguments: args, }, - ...omitUndefined({ fetch: options.fetch, signal: options.signal }), + ...omitUndefined({ + fetch: options.fetch, + signal: options.signal, + headers: options.headers, + }), }) // MCP tools/call wraps results in { content:[{type:'text',text:''}], isError? }. // Unwrap to the inner UCP envelope so callers never see the transport wrapper. diff --git a/src/core/profile.ts b/src/core/profile.ts index c6a9c87..4c0be8b 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -186,6 +186,8 @@ export interface FetchProfileOptions { fetch?: typeof fetch /** Skip the cache read. Cache is still written on success. */ force?: boolean + /** Outbound headers (auth, tenancy, etc); forwarded to `fetchCached`. */ + headers?: Record } function defaultBusinessCacheDir(): string { @@ -210,6 +212,11 @@ export async function fetchBusinessProfile( schemaInvalid: ErrorCodes.PROFILE_SCHEMA_INVALID, }, errorLayer: 'transport', - ...omitUndefined({ force: options.force, fetch: options.fetch, signal: options.signal }), + ...omitUndefined({ + force: options.force, + fetch: options.fetch, + signal: options.signal, + headers: options.headers, + }), }) }