diff --git a/bin/aliasResolver.mjs b/bin/aliasResolver.mjs new file mode 100644 index 0000000000..4070e0c0a0 --- /dev/null +++ b/bin/aliasResolver.mjs @@ -0,0 +1,230 @@ +/** + * ESM path-alias resolver for global installs. + * + * Problem (#7791): when OmniRoute is installed via `npm i -g omniroute`, the + * package files live under `node_modules/omniroute/`. tsx's tsconfig-path + * resolution does not apply there, so specifiers like `@/shared/utils/featureFlags` + * (declared in tsconfig.json `paths` as `@/* → ./src/*`) or + * `@omniroute/open-sse/services/usage` fail with `ERR_MODULE_NOT_FOUND`. + * The CLI crashes before any command can run. + * + * Fix: register a Node ESM `resolve` hook that rewrites alias specifiers to + * absolute file URLs. Covers all tsconfig.json `paths` entries: + * - `@/*` → `./src/*` + * - `@omniroute/open-sse` → `./open-sse/index.ts` + * - `@omniroute/open-sse/*` → `./open-sse/*` + * The hook runs after tsx so `.ts` extensions are already handled, and only + * intercepts matched prefixes — everything else falls through to Node's + * default resolver. + * + * Exposed as pure functions so the mapping logic is unit-testable without a + * running module loader. + */ + +import { existsSync, statSync } from "node:fs"; +import { dirname, join, relative, isAbsolute } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** + * Alias mapping table — mirrors tsconfig.json `paths`. + * Processed top-to-bottom; first matching prefix wins. + * + * Each entry: + * prefix — specifier prefix to match (e.g. `"@/"`, `"@omniroute/open-sse/"`) + * target — directory name under the package root (e.g. `"src"`, `"open-sse"`) + * exact — if true, the prefix also matches when the specifier equals the + * prefix *without* a trailing slash (e.g. `@omniroute/open-sse` → + * `/open-sse/index.ts`). + * + * Exported for tests/consumers. + */ +export const ALIAS_MAP = [ + { prefix: "@/", target: "src", exact: false }, + { prefix: "@omniroute/open-sse/", target: "open-sse", exact: false }, + { prefix: "@omniroute/open-sse", target: "open-sse", exact: true }, +]; + +/** @deprecated Use ALIAS_MAP instead. Kept for backward compat. */ +export const ALIAS_PREFIX = "@/"; + +// This file is ESM (no CJS __dirname global) — derive it from import.meta.url +// so the pathToFileURL(join(__dirname, ...)) call below resolves correctly +// regardless of the caller's cwd. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Resolve an alias specifier to an absolute file URL. + * + * Rules mirror tsconfig.json `paths` via `ALIAS_MAP`: + * "@/..." → /src/... + * "@omniroute/open-sse/..." → /open-sse/... + * "@omniroute/open-sse" → /open-sse/index.* + * + * - Strips the matched alias prefix and joins the remainder against the + * corresponding target directory. + * - Probes the underlying filesystem for the actual source file: the specifier + * itself, then with common source extensions (`.ts`, `.tsx`, `.js`, `.mjs`, + * `.cjs`, `.json`), then `/index.*`. Returns the first existing match + * as a `file://` URL. + * - Returns `null` for specifiers that do not match any alias, for malformed + * escapes, for path-traversal attempts, or when no corresponding source + * file exists on disk. The caller treats `null` as "defer to the default + * resolver". + * + * @param {string} specifier Module specifier from an `import` statement. + * @param {string} root Absolute path to the package root. + * @returns {string|null} Absolute `file://` URL, or `null` when unresolved. + */ +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"]; + +export function resolveAlias(specifier, root) { + if (typeof specifier !== "string" || !root || typeof root !== "string") { + return null; + } + + // Find the first matching alias entry (top-to-bottom order). + let matchedEntry = null; + let rest = null; + for (const entry of ALIAS_MAP) { + if (specifier.startsWith(entry.prefix)) { + // For non-exact entries, require at least one char after the prefix + // to avoid matching bare "@/" as "nothing". + const after = specifier.slice(entry.prefix.length); + if (after.length === 0 && !entry.exact) continue; + matchedEntry = entry; + rest = after; + break; + } + } + if (!matchedEntry) return null; + + const targetDir = join(root, matchedEntry.target); + + // Exact match (e.g. `@omniroute/open-sse` with no trailing path) → + // resolve to `/index.*`. + if (rest === "" || rest === undefined) { + return probeIndex(targetDir); + } + + // Guard against absolute-ish escapes (`@//etc/passwd`, `@/\x00`). + if (rest.startsWith("/") || rest.startsWith("\\")) { + return null; + } + // Guard against path-traversal escapes (`@/../../../etc/hostname`). + const segments = rest.split(/[\\\/]+/); + if (segments.includes("..")) { + return null; + } + const base = join(targetDir, rest); + if (!isWithinRoot(targetDir, base)) { + return null; + } + return probeFile(base) ?? probeIndex(base) ?? null; +} + +/** + * Probe a bare path and its extension variants. Returns the first existing + * match as a `file://` URL, or `null`. + */ +function probeFile(base) { + // Try extension variants first — a bare `base` that happens to be a directory + // would match existsSync() but should NOT be returned as a file URL (the + // caller expects a file, not a directory). Extension-probing avoids this + // false positive (e.g. `usage` vs `usage.ts` vs `usage/`). + for (const ext of SOURCE_EXTENSIONS) { + const candidate = base + ext; + if (existsSync(candidate)) return pathToFileURL(candidate).href; + } + // Only accept the bare path if it is NOT a directory. + if (existsSync(base)) { + try { + const st = statSync(base); + if (!st.isDirectory()) return pathToFileURL(base).href; + } catch {} + } + return null; +} + +/** + * Probe a directory for an `index.*` entry. Returns the first existing + * match as a `file://` URL, or `null`. + */ +function probeIndex(dir) { + const indexBase = join(dir, "index"); + for (const ext of SOURCE_EXTENSIONS) { + const candidate = indexBase + ext; + if (existsSync(candidate)) return pathToFileURL(candidate).href; + } + return null; +} + +/** + * True when `candidate` resolves to a location inside `ancestor` (or is + * `ancestor` itself). Used as a second, path-normalization-aware layer of + * defense against traversal beyond the literal `..` segment check above. + * + * @param {string} ancestor + * @param {string} candidate + * @returns {boolean} + */ +function isWithinRoot(ancestor, candidate) { + const rel = relative(ancestor, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * Register the ESM resolve hook for the current process. Safe to call multiple + * times — subsequent calls are no-ops once the hook is installed. + * + * Uses Node's stable `module.register()` API (available since Node 20.6, + * required Node 22+ here). The hook runs in a worker thread but only reads the + * captured `root`, so no shared-state hazards. + * + * @param {string} root Absolute path to the package root. + * @returns {Promise} Resolves `true` once registered (or if already + * registered), `false` on environments where `module.register` is unavailable. + */ +let _registered = false; +export async function registerAliasResolver(root) { + // Validate input FIRST, before the _registered short-circuit. Otherwise the + // second call in the same process (e.g. a test suite that already registered + // once) would silently return `true` for invalid input instead of rejecting, + // masking programmer errors. Input validation must be unconditional. + if (!root || typeof root !== "string") { + throw new TypeError("registerAliasResolver: root must be a non-empty string"); + } + if (_registered) return true; + // if the directory does not exist we would only mask a real misconfiguration + // by installing a hook that rewrites to nowhere. + if (!existsSync(join(root, "src"))) { + return false; + } + + try { + const { register } = await import("node:module"); + // #7808: load the hook from a real file on disk via pathToFileURL() instead + // of building a `data:text/javascript,...` URL dynamically. CodeQL's + // `js/incomplete-url-substring-sanitization` flagged the interpolated + // `new URL(...)` call; a file URL produced by pathToFileURL() is a trusted, + // fully-parsed URL — no sanitization ambiguity. The hook source lives in + // `bin/aliasResolverHook.mjs` (sibling of this file), shipped via + // package.json "files": ["bin/"]. + const hookPath = join(__dirname, "aliasResolverHook.mjs"); + const hookUrl = pathToFileURL(hookPath); + register(hookUrl, { data: { root } }); + _registered = true; + return true; + } catch { + // Older Node or sandboxed env without module.register — fall back to the + // default resolver. The bug will resurface only in the exact global-install + // scenario, which is what we explicitly patched; other entry points still + // work because they import via relative paths. + return false; + } +} + +// #7808: the ESM loader hook source now lives in `bin/aliasResolverHook.mjs`, +// loaded via `pathToFileURL()` above. The previous inline `HOOK_SOURCE` template +// literal was removed because its `new URL(\`data:text/javascript,...\`)` wrapper +// triggered CodeQL `js/incomplete-url-substring-sanitization`. The hook logic +// itself is unchanged — see aliasResolverHook.mjs for the resolver behaviour. diff --git a/bin/aliasResolverHook.mjs b/bin/aliasResolverHook.mjs new file mode 100644 index 0000000000..8b3ae943ad --- /dev/null +++ b/bin/aliasResolverHook.mjs @@ -0,0 +1,126 @@ +/** + * ESM loader hook for path-alias resolution (#7791 + #7808). + * + * This file runs in Node's loader worker thread after being registered via + * `module.register(url, data)` from `bin/aliasResolver.mjs`. It MUST NOT import + * anything from the parent module — all inputs arrive through `initialize(data)`. + * + * Behaviour: + * - Rewrites alias specifiers to absolute filesystem paths, mirroring + * tsconfig.json `paths`: + * - `@/*` → /src/* + * - `@omniroute/open-sse` → /open-sse/index.* + * - `@omniroute/open-sse/*` → /open-sse/* + * - Probes the usual source extensions (`.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`, + * `.json`) plus `index.*` for directory imports. + * - Returns `shortCircuit: true` only when a candidate file exists on disk; + * otherwise delegates to the next resolver (tsx/Node) so unrelated imports + * and legitimate "module not found" errors pass through unchanged. + * + * Why a separate file instead of an inline `data:` URL? + * CodeQL's `js/incomplete-url-substring-sanitization` flags dynamic `new URL(...)` + * construction with interpolated strings. A real file URL produced by + * `pathToFileURL()` is a trusted, fully-parsed URL — no sanitization ambiguity. + */ +import { pathToFileURL } from "node:url"; +import { join, relative, isAbsolute } from "node:path"; +import { existsSync, statSync } from "node:fs"; + +let ROOT = ""; + +export function initialize(data) { + ROOT = (data && data.root) || ""; +} + +const EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"]; + +/** + * Alias prefix table — mirrors ALIAS_MAP in aliasResolver.mjs and + * tsconfig.json `paths`. Processed top-to-bottom; first match wins. + * + * @type {Array<{prefix: string, target: string, exact: boolean}>} + */ +const ALIAS_TABLE = [ + { prefix: "@/", target: "src", exact: false }, + { prefix: "@omniroute/open-sse/", target: "open-sse", exact: false }, + { prefix: "@omniroute/open-sse", target: "open-sse", exact: true }, +]; + +function tryResolveAliasFsPath(specifier) { + if (!ROOT || typeof specifier !== "string") return null; + + // Find the first matching alias entry. + let matchedEntry = null; + let rest = null; + for (const entry of ALIAS_TABLE) { + if (specifier.startsWith(entry.prefix)) { + const after = specifier.slice(entry.prefix.length); + if (after.length === 0 && !entry.exact) continue; + matchedEntry = entry; + rest = after; + break; + } + } + if (!matchedEntry) return null; + + const targetDir = join(ROOT, matchedEntry.target); + + // Exact match (e.g. `@omniroute/open-sse`) → resolve to `/index.*`. + if (rest === "" || rest === undefined) { + return probeIndex(targetDir); + } + + // Guard against absolute-ish escapes. + if (rest.startsWith("/") || rest.startsWith("\\")) return null; + // Guard against path-traversal escapes. + const segments = rest.split(/[\\\/]+/); + if (segments.includes("..")) return null; + + const base = join(targetDir, rest); + if (!isWithinRoot(targetDir, base)) return null; + return probeFile(base) ?? probeIndex(base) ?? null; +} + +function probeFile(base) { + // Extension variants first — avoids matching a bare directory name. + for (const ext of EXTENSIONS) { + const candidate = base + ext; + if (existsSync(candidate)) return candidate; + } + if (existsSync(base)) { + try { + const st = statSync(base); + if (!st.isDirectory()) return base; + } catch {} + } + return null; +} + +function probeIndex(dir) { + const indexBase = join(dir, "index"); + for (const ext of EXTENSIONS) { + const candidate = indexBase + ext; + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * True when `candidate` resolves to a location inside `ancestor` (or is + * `ancestor` itself). Path-normalization-aware defense against traversal. + */ +function isWithinRoot(ancestor, candidate) { + const rel = relative(ancestor, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +export function resolve(specifier, context, nextResolve) { + const fsPath = tryResolveAliasFsPath(specifier); + if (fsPath) { + return { + url: pathToFileURL(fsPath).href, + shortCircuit: true, + }; + } + return nextResolve(specifier, context); +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index a683f917df..ce2c011d9a 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -49,6 +49,15 @@ if (isVersionFastPath(process.argv)) { await import("tsx/esm"); await import("../open-sse/utils/setupPolyfill.ts"); +// #7791: tsx's tsconfig-path resolution does not apply when OmniRoute is +// installed globally (files live under node_modules/omniroute/), so bare +// `@/...` specifiers (declared in tsconfig.json paths as `@/* → ./src/*`) +// fail with ERR_MODULE_NOT_FOUND. Register an ESM resolve hook that maps +// `@/...` to absolute file URLs under /src/. Safe no-op in dev checkout +// (paths already resolve via tsconfig) and when ROOT has no `src/` dir. +const { registerAliasResolver } = await import("./aliasResolver.mjs"); +await registerAliasResolver(ROOT); + // MCP stdio transport uses stdout exclusively for JSON-RPC messages. // Redirect console.log/warn to stderr early (before loadEnvFile and DB init) // so no startup output corrupts the protocol. diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index fc814dd2fb..40d86de065 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -167,6 +167,7 @@ }, "zizmorFindings": { "value": 176, + "_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.", "_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.", "direction": "down", "dedicatedGate": true, @@ -181,10 +182,11 @@ "dedicatedGate": true }, "bundleSize": { - "value": 6534, + "value": 6762, "direction": "down", "dedicatedGate": true, - "_rebaseline_2026_07_07_v3846_release_close": "5601->6534 (+933). v3.8.46 release close: gzip of the 4 bin/*.mjs entrypoints (size-limit + @size-limit/file) grew from this cycle's feature/fix merges pulled transitively into the CLI entrypoints (new providers, combo pipeline strategy #6396, effort/thinking standardization #6241, catalog cache-invalidation #6408). Measured 6534 locally via `check:bundle-size --ratchet` (deterministic gzip, matches CI). Legitimate cycle growth; shrink is separate debt." + "_rebaseline_2026_07_07_v3846_release_close": "5601->6534 (+933). v3.8.46 release close: gzip of the 4 bin/*.mjs entrypoints (size-limit + @size-limit/file) grew from this cycle's feature/fix merges pulled transitively into the CLI entrypoints (new providers, combo pipeline strategy #6396, effort/thinking standardization #6241, catalog cache-invalidation #6408). Measured 6534 locally via `check:bundle-size --ratchet` (deterministic gzip, matches CI). Legitimate cycle growth; shrink is separate debt.", + "_rebaseline_2026_07_19_7808_codeql_alias_resolver_hook": "6534->6762 (+228). PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix): the ESM loader hook source moved out of the inline `HOOK_SOURCE` template literal in bin/aliasResolver.mjs into a real file bin/aliasResolverHook.mjs, loaded via pathToFileURL() instead of a dynamically-built `data:text/javascript,...` URL. The new file is now counted by size-limit as a 5th bin/*.mjs entrypoint. Net +228 = the hook's gzip size (previously hidden inside aliasResolver.mjs because the template literal was compressed away). Security-driven; no shrink opportunity." }, "openapiBreaking": { "value": 0, diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index aa1bc03318..2ac9dbf136 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -86,6 +86,12 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ ".env.example", "LICENSE", "README.md", + "bin/aliasResolver.mjs", + // #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL + // js/incomplete-url-substring-sanitization (the old code built a + // `data:text/javascript,...` URL dynamically). Loaded via pathToFileURL() at + // runtime; shipped via package.json "files", so it must be allowed here. + "bin/aliasResolverHook.mjs", "bin/mcp-server.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", @@ -175,6 +181,12 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "bin/mcp-server.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", + // #7808: aliasResolver + its hook file. bin/omniroute.mjs imports + // bin/aliasResolver.mjs at startup, which in turn registers + // bin/aliasResolverHook.mjs as the ESM loader. Both must ship in the tarball + // or the CLI fails to boot — list them REQUIRED so a regression is loud. + "bin/aliasResolver.mjs", + "bin/aliasResolverHook.mjs", "package.json", "scripts/build/native-binary-compat.mjs", "scripts/build/postinstall.mjs", diff --git a/tests/unit/cli/alias-resolver-7791.test.ts b/tests/unit/cli/alias-resolver-7791.test.ts new file mode 100644 index 0000000000..6a99522090 --- /dev/null +++ b/tests/unit/cli/alias-resolver-7791.test.ts @@ -0,0 +1,300 @@ +/** + * Unit tests for bin/aliasResolver.mjs — the ESM resolver hook that fixes + * `Cannot find package '@/shared'` when OmniRoute is installed globally + * (issue #7791). + * + * The hook is registered via module.register() and runs in a loader worker, + * which is hard to exercise directly from node:test. Instead we test: + * + * 1. The exported `resolveAlias` pure function — the mapping logic without + * needing the loader machinery. + * 2. The hook end-to-end by spawning a child process that imports a fixture + * using `@/...` specifiers after registering the resolver. This proves + * the integration works in a real Node process, not just in isolation. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + resolveAlias, + registerAliasResolver, + ALIAS_PREFIX, + ALIAS_MAP, +} from "../../../bin/aliasResolver.mjs"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const REPO_ROOT = join(__dirname, "..", "..", ".."); + +describe("aliasResolver.resolveAlias (pure)", () => { + it("returns null for non-@/ specifiers (lets Node/tsx handle them)", () => { + assert.equal(resolveAlias("node:fs", REPO_ROOT), null); + assert.equal(resolveAlias("./foo", REPO_ROOT), null); + assert.equal(resolveAlias("../foo", REPO_ROOT), null); + assert.equal(resolveAlias("react", REPO_ROOT), null); + assert.equal(resolveAlias("@scope/pkg", REPO_ROOT), null); + assert.equal(resolveAlias("", REPO_ROOT), null); + }); + + it("maps @/X to /src/X as a file URL", () => { + const got = resolveAlias("@/shared/utils/featureFlags", REPO_ROOT); + assert.ok(got, "expected a non-null URL"); + assert.ok(got.startsWith("file://"), "must be a file URL"); + const fsPath = fileURLToPath(got); + // The source file on disk is featureFlags.ts; the resolver probes source + // extensions and returns the first existing match. Assert against the + // known extension rather than the specifier's bare stem, so the test does + // not regress when the resolver's extension probe order changes. + assert.ok( + fsPath.endsWith(join("src", "shared", "utils", "featureFlags.ts")), + `unexpected path: ${fsPath}` + ); + }); + + it("exposes ALIAS_PREFIX as @/ for symmetry with the hook source", () => { + assert.equal(ALIAS_PREFIX, "@/"); + }); + + it("rejects non-string / empty input without throwing", () => { + assert.equal(resolveAlias(undefined, REPO_ROOT), null); + assert.equal(resolveAlias(null, REPO_ROOT), null); + assert.equal(resolveAlias(123, REPO_ROOT), null); + assert.equal(resolveAlias({}, REPO_ROOT), null); + }); + + it("rejects empty root", () => { + assert.equal(resolveAlias("@/shared", ""), null); + }); + + it("rejects @// escape attempts (absolute path injection)", () => { + // `@//etc/passwd` must NOT become `/src//etc/passwd` — after the + // path-join normalisation it would still be `src/etc/passwd` inside the + // root, which is fine, but we explicitly bail out so the hook never + // silently rewrites a malformed specifier. + assert.equal(resolveAlias("@//etc/passwd", REPO_ROOT), null); + assert.equal(resolveAlias("@/\\etc/passwd", REPO_ROOT), null); + }); + + it("rejects @/../ path-traversal escapes outside /src", () => { + // `@/../../../etc/hostname` must NOT escape /src onto the real + // filesystem — even though it does not start with `/` or `\`, `join()` + // would otherwise normalize it to a path outside the intended root. + assert.equal(resolveAlias("@/../../../etc/hostname", REPO_ROOT), null); + assert.equal(resolveAlias("@/..", REPO_ROOT), null); + assert.equal(resolveAlias("@/foo/../../bar", REPO_ROOT), null); + // A `..` segment that stays inside /src after normalization is + // still rejected — the guard is a literal segment check, not just a + // containment check, so it fails closed even in ambiguous cases. + assert.equal(resolveAlias("@/shared/../shared/utils/featureFlags", REPO_ROOT), null); + }); + + it("preserves an explicit .ts extension if the file exists", () => { + // `@/shared/utils/featureFlags.ts` exists on disk → resolves to it + const got = resolveAlias("@/shared/utils/featureFlags.ts", REPO_ROOT); + assert.ok(got); + assert.ok(fileURLToPath(got).endsWith("featureFlags.ts")); + }); + + it("probes source extensions when the specifier has none (#7791 core)", () => { + // `@/lib/db/core` → `/src/lib/db/core.ts` (file on disk has .ts) + const got = resolveAlias("@/lib/db/core", REPO_ROOT); + assert.ok(got, "expected non-null URL for @/lib/db/core"); + const fsPath = fileURLToPath(got); + assert.ok( + fsPath.endsWith(join("src", "lib", "db", "core.ts")), + `expected /src/lib/db/core.ts, got ${fsPath}` + ); + }); + + it("resolves directory imports to /index.*", () => { + // Pick a real directory that has an index file in src/ + // `@/shared` → src/shared/index.ts if it exists, else null. + const got = resolveAlias("@/shared", REPO_ROOT); + // We don't assert non-null (depends on repo layout) but the call must not + // throw and must return either null or a file URL. + if (got !== null) { + assert.ok(got.startsWith("file://")); + } + }); + + it("returns null when no candidate file exists on disk", () => { + // Real root, but specifier points at a non-existent path + assert.equal(resolveAlias("@/does/not/exist", REPO_ROOT), null); + }); +}); + +describe("aliasResolver.resolveAlias — @omniroute/open-sse aliases", () => { + it("exposes ALIAS_MAP with three entries matching tsconfig paths", () => { + assert.equal(ALIAS_MAP.length, 3, "must have 3 alias entries"); + // @/ + assert.equal(ALIAS_MAP[0].prefix, "@/"); + assert.equal(ALIAS_MAP[0].target, "src"); + assert.equal(ALIAS_MAP[0].exact, false); + // @omniroute/open-sse/ (subpath) + assert.equal(ALIAS_MAP[1].prefix, "@omniroute/open-sse/"); + assert.equal(ALIAS_MAP[1].target, "open-sse"); + assert.equal(ALIAS_MAP[1].exact, false); + // @omniroute/open-sse (exact package name) + assert.equal(ALIAS_MAP[2].prefix, "@omniroute/open-sse"); + assert.equal(ALIAS_MAP[2].target, "open-sse"); + assert.equal(ALIAS_MAP[2].exact, true); + }); + + it("resolves @omniroute/open-sse (bare) to open-sse/index.ts", () => { + const got = resolveAlias("@omniroute/open-sse", REPO_ROOT); + assert.ok(got, "expected non-null URL for @omniroute/open-sse"); + assert.ok(got.startsWith("file://"), "must be a file URL"); + const fsPath = fileURLToPath(got); + assert.ok( + fsPath.endsWith(join("open-sse", "index.ts")), + `expected /open-sse/index.ts, got ${fsPath}` + ); + }); + + it("resolves @omniroute/open-sse/services/usage to open-sse/services/usage.ts", () => { + const got = resolveAlias("@omniroute/open-sse/services/usage", REPO_ROOT); + assert.ok(got, "expected non-null URL"); + const fsPath = fileURLToPath(got); + assert.ok( + fsPath.endsWith(join("open-sse", "services", "usage.ts")), + `expected /open-sse/services/usage.ts, got ${fsPath}` + ); + }); + + it("resolves @omniroute/open-sse/utils/proxyFetch to open-sse/utils/proxyFetch.ts", () => { + const got = resolveAlias("@omniroute/open-sse/utils/proxyFetch", REPO_ROOT); + assert.ok(got, "expected non-null URL"); + const fsPath = fileURLToPath(got); + assert.ok( + fsPath.endsWith(join("open-sse", "utils", "proxyFetch.ts")), + `expected /open-sse/utils/proxyFetch.ts, got ${fsPath}` + ); + }); + + it("returns null for non-existent @omniroute/open-sse/* paths", () => { + assert.equal(resolveAlias("@omniroute/open-sse/does/not/exist", REPO_ROOT), null); + }); + + it("returns null for @omniroute/other (unmatched scope)", () => { + assert.equal(resolveAlias("@omniroute/other", REPO_ROOT), null); + assert.equal(resolveAlias("@omniroute/other/pkg", REPO_ROOT), null); + }); + + it("rejects path-traversal via @omniroute/open-sse/../../etc/passwd", () => { + assert.equal(resolveAlias("@omniroute/open-sse/../../etc/passwd", REPO_ROOT), null); + }); +}); + +describe("aliasResolver.registerAliasResolver", () => { + it("returns false when /src does not exist (no-op, does not throw)", async () => { + const tmp = mkdtempSync(join(tmpdir(), "alias-resolver-no-src-")); + try { + const ok = await registerAliasResolver(tmp); + assert.equal(ok, false, "must return false when there is no src/ dir"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("returns true and registers when /src exists", async () => { + const tmp = mkdtempSync(join(tmpdir(), "alias-resolver-with-src-")); + try { + mkdirSync(join(tmp, "src"), { recursive: true }); + const ok = await registerAliasResolver(tmp); + assert.equal(ok, true, "must register when src/ exists"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("throws TypeError when root is empty or non-string", async () => { + // Empty string triggers the guard; null/undefined are coerced by the + // function signature (no default), so they also reach the guard and + // reject with TypeError. (null is falsy → `!root` true; undefined same.) + await assert.rejects(() => registerAliasResolver(""), TypeError); + await assert.rejects(() => registerAliasResolver(null), TypeError); + await assert.rejects(() => registerAliasResolver(undefined), TypeError); + // Numbers/objects are explicitly rejected by the typeof check too. + await assert.rejects(() => registerAliasResolver(123), TypeError); + await assert.rejects(() => registerAliasResolver({}), TypeError); + }); +}); + +/** + * End-to-end regression for issue #7791: spawning a child Node process that + * mimics a global install (no tsconfig.json in CWD, no parent package.json) + * and confirms `@/...` specifiers resolve via the hook. + * + * This is a separate process because module.register() installs a process- + * wide hook and we do not want to pollute the test runner's loader. + */ +describe("aliasResolver end-to-end (#7791 regression)", () => { + function runChild(script) { + const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], { + cwd: REPO_ROOT, + env: { + ...process.env, + // Force a clean DATA_DIR so loadEnvFile() does not pick up dev .env + DATA_DIR: mkdtempSync(join(tmpdir(), "alias-resolver-e2e-")), + OMNIROUTE_CLI_SKIP_REPO_ENV: "1", + }, + encoding: "utf8", + }); + return { stdout: result.stdout, stderr: result.stderr, status: result.status }; + } + + it("resolves @/shared/network/outboundUrlGuard from a child process", () => { + // Reproduces the exact import chain that setup-opencode triggers. + // Before #7791 fix: `Cannot find package '@/shared'`. + // After fix: imports succeed and the module exports are reachable. + // + // The child process registers tsx/esm *before* the alias resolver: the + // loaded module's transitive relative imports (e.g. `./core` without an + // extension) need tsx to resolve, and tsx must be installed in the loader + // before any dynamic import() of a .ts file. This mirrors what + // bin/omniroute.mjs does (await import("tsx/esm") first, then register). + const script = ` + await import("tsx/esm"); + import { join } from "node:path"; + import { registerAliasResolver } from "${join(REPO_ROOT, "bin/aliasResolver.mjs").replace(/\\/g, "/")}"; + const ok = await registerAliasResolver(${JSON.stringify(REPO_ROOT)}); + if (!ok) { console.error("FAIL: registerAliasResolver returned false"); process.exit(2); } + try { + const m = await import(${JSON.stringify(join(REPO_ROOT, "src/shared/network/outboundUrlGuard.ts").replace(/\\/g, "/"))}); + const keys = Object.keys(m).sort().join(","); + console.log("OK:" + keys); + } catch (err) { + console.error("FAIL:" + (err && err.message || err)); + process.exit(3); + } + `; + const { stdout, stderr, status } = runChild(script); + assert.equal(status, 0, `expected exit 0, got ${status}. stderr=${stderr.slice(0, 500)}`); + const trimmed = stdout.trim(); + assert.match(trimmed, /^OK:/, `expected OK:, got: ${trimmed}`); + // Sanity: the module must actually export the documented symbols + assert.match( + trimmed, + /OutboundUrlGuardError|PROVIDER_URL_BLOCKED_MESSAGE/, + `unexpected exports: ${trimmed}` + ); + }); + + it("does not interfere with bare/relative specifiers (regression guard)", () => { + const script = ` + import { registerAliasResolver } from "${join(REPO_ROOT, "bin/aliasResolver.mjs").replace(/\\/g, "/")}"; + await registerAliasResolver(${JSON.stringify(REPO_ROOT)}); + // node:fs must still resolve via the default resolver + const fs = await import("node:fs"); + console.log("OK:" + typeof fs.readFile); + `; + const { stdout, status, stderr } = runChild(script); + assert.equal(status, 0, `node:fs broke: stderr=${stderr.slice(0, 500)}`); + // Node's console.log appends a newline; trim before matching the anchored regex. + assert.match(stdout.trim(), /^OK:function$/); + }); +}); diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index 978b0474d0..fb55754262 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -105,6 +105,8 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", // alphabetically (bin/ < dist/ < scripts/ < src/), minus the paths present // above (dist/server.js, bin/omniroute.mjs, package.json, the postinstall scripts). assert.deepEqual(missingPaths, [ + "bin/aliasResolver.mjs", + "bin/aliasResolverHook.mjs", "bin/cli/data-dir.mjs", "bin/cli/program.mjs", "bin/cli/utils/storageKeyProvision.mjs",