Files
oh-my-pi/packages/coding-agent/src/internal-urls/parse.ts
T
can1357 41ce810cff fix(mcp): preserved native resource URIs and opaque scheme routing
- Native (non-mcp://) resource URIs now pass through byte-for-byte via
  rawHref; slash elision applies only to the legacy mcp:// wrapper, so
  catalog://root/ style URIs match exact-equality server lookups.
- resources/templates/list failure no longer discards a successful
  resources/list (Promise.allSettled; templates retried later).
- Opaque RFC 3986 URIs (urn:doc, custom:item) are recognized by both
  the router and read-cli discovery gates, with drive-path and
  read-selector false positives guarded.
- Review follow-up for PR #6790.
2026-07-27 16:15:02 +02:00

104 lines
3.9 KiB
TypeScript

/**
* Internal URL parser that handles colons in the host segment.
*
* Standard `new URL()` interprets colons as port separators, which breaks
* namespaced internal URLs like `skill://plugin:name`. This parser extracts
* components via regex first, then falls back to a minimal URL-like object
* when `new URL()` fails.
*
* All code that parses internal URLs (router, protocol handlers, tools)
* MUST use this function instead of calling `new URL()` directly.
*/
import type { InternalUrl } from "./types";
const SCHEME_HOST_RE = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i;
const PATHNAME_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i;
// Opaque URI form (`urn:example:document`, `custom:item`) — an RFC 3986 scheme
// followed by `:` without `//`. Guarded separately in `extractUriScheme`.
const OPAQUE_URI_RE = /^([a-z][a-z0-9+.-]*):(.+)$/is;
// A read-tool selector chain (`12`, `1-20,30+5`, `raw`, `raw:2-4`, `conflicts`)
// so `Makefile:12` or `notes:raw` is not mistaken for an opaque URI.
const SELECTOR_CHUNK_SRC = String.raw`(?:raw|conflicts|-?\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)`;
const SELECTOR_CHAIN_RE = new RegExp(`^${SELECTOR_CHUNK_SRC}(?::${SELECTOR_CHUNK_SRC})*$`, "i");
/**
* Extract the lowercased scheme from a URI-shaped input, or `undefined` when
* the input does not look like a URI.
*
* Accepts both hierarchical (`scheme://…`) and opaque (`scheme:rest`) forms —
* MCP resource URIs may be opaque (`urn:example:document`, `custom:item`).
* The opaque form is guarded against path-like false positives:
* - Windows drive paths (`C:\…`, `C:/…`, `C:foo`) — single-letter scheme.
* - Filenames with extensions (`foo.ts:50`) — dot in the scheme segment.
* - Read-tool selector tails (`Makefile:12`, `README:raw:1-20`).
*/
export function extractUriScheme(input: string): string | undefined {
const hierarchical = input.match(SCHEME_HOST_RE);
if (hierarchical) return hierarchical[1].toLowerCase();
const opaque = input.match(OPAQUE_URI_RE);
if (!opaque) return undefined;
const [, scheme, rest] = opaque;
if (scheme.length === 1) return undefined;
if (scheme.includes(".")) return undefined;
if (SELECTOR_CHAIN_RE.test(rest)) return undefined;
return scheme.toLowerCase();
}
/**
* Parse an internal URL into an InternalUrl.
*
* Handles URLs where `new URL()` would fail (e.g., `skill://plugin:name`
* where the colon is not a port separator).
*/
export function parseInternalUrl(input: string): InternalUrl {
const hostMatch = input.match(SCHEME_HOST_RE);
const pathMatch = input.match(PATHNAME_RE);
let parsed: URL;
try {
parsed = new URL(input);
} catch {
// URL parse failed — build a minimal URL-like object from regex matches.
if (!hostMatch) {
throw new Error(`Invalid URL: ${input}`);
}
// Extract search and hash from the raw input before constructing the object.
const hashIdx = input.indexOf("#");
const hash = hashIdx !== -1 ? input.slice(hashIdx) : "";
const withoutHash = hashIdx !== -1 ? input.slice(0, hashIdx) : input;
const queryIdx = withoutHash.indexOf("?");
const search = queryIdx !== -1 ? withoutHash.slice(queryIdx) : "";
const queryString = search.slice(1); // strip leading ?
// Strip search/hash from pathname captured by regex.
let rawPathname = pathMatch?.[1] ?? "";
if (queryIdx !== -1 && rawPathname.includes("?")) {
rawPathname = rawPathname.slice(0, rawPathname.indexOf("?"));
}
parsed = {
protocol: `${hostMatch[1]}:`,
hostname: hostMatch[2] ?? "",
host: hostMatch[2] ?? "",
pathname: rawPathname,
href: input,
search,
hash,
searchParams: new URLSearchParams(queryString),
} as unknown as URL;
}
let rawHost = hostMatch ? hostMatch[2] : parsed.hostname;
try {
rawHost = decodeURIComponent(rawHost);
} catch {
// Leave rawHost as-is if decoding fails.
}
const result = parsed as InternalUrl;
result.rawHost = rawHost;
result.rawPathname = pathMatch?.[1] ?? parsed.pathname;
result.rawHref = input;
return result;
}