fix(plugins): loaded legacy extensions safely
Restored the pi-ai OAuth device-code helper expected by legacy provider packages and rewrote extension-owned bare dependencies to file URLs during validation so compiled binaries do not rely on Bun's runtime bare resolver. Excluded worker entry modules from the compiled legacy bundled registry so validation does not import worker-only code on the main thread. Fixes #3508
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored the `pollOAuthDeviceCodeFlow` export from `@oh-my-pi/pi-ai/oauth` so legacy provider extensions can reuse the host OAuth device-code poller. ([#3508](https://github.com/can1357/oh-my-pi/issues/3508))
|
||||
|
||||
## [16.1.20] - 2026-06-25
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -13,6 +13,94 @@ import type {
|
||||
|
||||
export type * from "./types";
|
||||
|
||||
const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
|
||||
const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
|
||||
const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
|
||||
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
|
||||
const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
|
||||
const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
|
||||
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
||||
|
||||
/** Result returned by one OAuth device-code polling attempt. */
|
||||
export type OAuthDeviceCodePollResult<T> =
|
||||
| { status: "complete"; value: T }
|
||||
| { status: "pending" }
|
||||
| { status: "slow_down" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
/** Options for polling an RFC 8628-style OAuth device-code flow. */
|
||||
export interface OAuthDeviceCodeFlowOptions<T> {
|
||||
/** Poll the provider once and classify the response. */
|
||||
poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
|
||||
/** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
|
||||
intervalSeconds?: number;
|
||||
/** Provider-issued expiry window for the device code. */
|
||||
expiresInSeconds?: number;
|
||||
/** Cancels the flow with the legacy "Login cancelled" error. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
if (!signal) {
|
||||
await Bun.sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error(DEVICE_FLOW_CANCEL_MESSAGE);
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
let timer: Timer | undefined;
|
||||
const onAbort = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(new Error(DEVICE_FLOW_CANCEL_MESSAGE));
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
await promise;
|
||||
}
|
||||
|
||||
/** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
|
||||
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
|
||||
const deadline =
|
||||
typeof options.expiresInSeconds === "number"
|
||||
? Date.now() + options.expiresInSeconds * 1000
|
||||
: Number.POSITIVE_INFINITY;
|
||||
let intervalMs = Math.max(
|
||||
MINIMUM_DEVICE_FLOW_INTERVAL_MS,
|
||||
Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
|
||||
);
|
||||
let slowDownResponses = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error(DEVICE_FLOW_CANCEL_MESSAGE);
|
||||
}
|
||||
const result = await options.poll();
|
||||
if (result.status === "complete") {
|
||||
return result.value;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
if (result.status === "slow_down") {
|
||||
slowDownResponses += 1;
|
||||
intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
||||
}
|
||||
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
|
||||
}
|
||||
|
||||
throw new Error(slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE);
|
||||
}
|
||||
|
||||
const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter(
|
||||
provider => provider.login && provider.showInLoginList !== false,
|
||||
).map(provider => ({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { pollOAuthDeviceCodeFlow } from "@oh-my-pi/pi-ai/oauth";
|
||||
|
||||
describe("OAuth device-code polling", () => {
|
||||
it("exports the legacy device-code poll helper for external providers", async () => {
|
||||
const value = await pollOAuthDeviceCodeFlow({
|
||||
poll: () => ({ status: "complete", value: { access: "token" } }),
|
||||
});
|
||||
|
||||
expect(value).toEqual({ access: "token" });
|
||||
});
|
||||
|
||||
it("surfaces provider failure messages", async () => {
|
||||
expect(
|
||||
pollOAuthDeviceCodeFlow({
|
||||
poll: () => ({ status: "failed", message: "authorization denied" }),
|
||||
}),
|
||||
).rejects.toThrow("authorization denied");
|
||||
});
|
||||
|
||||
it("times out pending device flows", async () => {
|
||||
expect(
|
||||
pollOAuthDeviceCodeFlow({
|
||||
expiresInSeconds: 0.001,
|
||||
poll: () => ({ status: "pending" }),
|
||||
}),
|
||||
).rejects.toThrow("Device flow timed out");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed compiled-binary validation for legacy `pi.extensions` packages whose source imports worker-only coding-agent subpaths or extension-local package subpaths such as `typebox/value`; `omp install @charmland/pi-hyper-provider`, `omp plugin doctor`, and runtime provider discovery now use a main-thread-safe load path. ([#3508](https://github.com/can1357/oh-my-pi/issues/3508))
|
||||
|
||||
## [16.1.20] - 2026-06-25
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -117,6 +117,15 @@ function isSafeWildcardBasename(basename: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Worker entry modules intentionally throw when imported outside a Worker. The
|
||||
// bundled registry loads on the main thread during legacy extension validation,
|
||||
// so these exported subpaths must stay out of the static registry.
|
||||
const MAIN_THREAD_UNSAFE_WILDCARD_BASENAMES = new Set(["worker-entry"]);
|
||||
|
||||
function isMainThreadSafeWildcardBasename(basename: string): boolean {
|
||||
return !MAIN_THREAD_UNSAFE_WILDCARD_BASENAMES.has(basename);
|
||||
}
|
||||
|
||||
interface WildcardPattern {
|
||||
readonly exportPrefix: string;
|
||||
readonly exportSuffix: string;
|
||||
@@ -217,6 +226,7 @@ async function collectEntries(): Promise<RegistryEntry[]> {
|
||||
if (!match.endsWith(pattern.sourceSuffix)) continue;
|
||||
const basename = match.slice(0, match.length - pattern.sourceSuffix.length);
|
||||
if (!isSafeWildcardBasename(basename)) continue;
|
||||
if (!isMainThreadSafeWildcardBasename(basename)) continue;
|
||||
if (basename.includes("/")) continue;
|
||||
const subpath = `${pattern.exportPrefix}${basename}${pattern.exportSuffix}`;
|
||||
const key = `${pkg.name}/${subpath}`;
|
||||
|
||||
@@ -360,6 +360,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet<string> = new Set([
|
||||
"@oh-my-pi/pi-coding-agent/config/models-config",
|
||||
"@oh-my-pi/pi-coding-agent/config/prompt-templates",
|
||||
"@oh-my-pi/pi-coding-agent/config/resolve-config-value",
|
||||
"@oh-my-pi/pi-coding-agent/config/service-tier",
|
||||
"@oh-my-pi/pi-coding-agent/config/settings-schema",
|
||||
"@oh-my-pi/pi-coding-agent/config/settings",
|
||||
"@oh-my-pi/pi-coding-agent/dap/client",
|
||||
@@ -483,7 +484,6 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet<string> = new Set([
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/executor",
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/tool-bridge",
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/worker-core",
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/worker-entry",
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/worker-protocol",
|
||||
"@oh-my-pi/pi-coding-agent/eval/py/display",
|
||||
"@oh-my-pi/pi-coding-agent/eval/py/executor",
|
||||
|
||||
@@ -328,6 +328,7 @@ import * as bundledPiCodingAgentConfigModelsConfig from "@oh-my-pi/pi-coding-age
|
||||
import * as bundledPiCodingAgentConfigModelsConfigSchema from "@oh-my-pi/pi-coding-agent/config/models-config-schema";
|
||||
import * as bundledPiCodingAgentConfigPromptTemplates from "@oh-my-pi/pi-coding-agent/config/prompt-templates";
|
||||
import * as bundledPiCodingAgentConfigResolveConfigValue from "@oh-my-pi/pi-coding-agent/config/resolve-config-value";
|
||||
import * as bundledPiCodingAgentConfigServiceTier from "@oh-my-pi/pi-coding-agent/config/service-tier";
|
||||
import * as bundledPiCodingAgentConfigSettings from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as bundledPiCodingAgentConfigSettingsSchema from "@oh-my-pi/pi-coding-agent/config/settings-schema";
|
||||
import * as bundledPiCodingAgentDap from "@oh-my-pi/pi-coding-agent/dap";
|
||||
@@ -385,7 +386,6 @@ import * as bundledPiCodingAgentEvalJsContextManager from "@oh-my-pi/pi-coding-a
|
||||
import * as bundledPiCodingAgentEvalJsExecutor from "@oh-my-pi/pi-coding-agent/eval/js/executor";
|
||||
import * as bundledPiCodingAgentEvalJsToolBridge from "@oh-my-pi/pi-coding-agent/eval/js/tool-bridge";
|
||||
import * as bundledPiCodingAgentEvalJsWorkerCore from "@oh-my-pi/pi-coding-agent/eval/js/worker-core";
|
||||
import * as bundledPiCodingAgentEvalJsWorkerEntry from "@oh-my-pi/pi-coding-agent/eval/js/worker-entry";
|
||||
import * as bundledPiCodingAgentEvalJsWorkerProtocol from "@oh-my-pi/pi-coding-agent/eval/js/worker-protocol";
|
||||
import * as bundledPiCodingAgentEvalPyDisplay from "@oh-my-pi/pi-coding-agent/eval/py/display";
|
||||
import * as bundledPiCodingAgentEvalPyExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
@@ -1802,6 +1802,9 @@ export const BUNDLED_PI_REGISTRY: Readonly<Record<string, Readonly<Record<string
|
||||
bundledPiCodingAgentConfigPromptTemplates as unknown as Readonly<Record<string, unknown>>,
|
||||
"@oh-my-pi/pi-coding-agent/config/resolve-config-value":
|
||||
bundledPiCodingAgentConfigResolveConfigValue as unknown as Readonly<Record<string, unknown>>,
|
||||
"@oh-my-pi/pi-coding-agent/config/service-tier": bundledPiCodingAgentConfigServiceTier as unknown as Readonly<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
"@oh-my-pi/pi-coding-agent/config/settings-schema": bundledPiCodingAgentConfigSettingsSchema as unknown as Readonly<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
@@ -2100,9 +2103,6 @@ export const BUNDLED_PI_REGISTRY: Readonly<Record<string, Readonly<Record<string
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/worker-core": bundledPiCodingAgentEvalJsWorkerCore as unknown as Readonly<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/worker-entry": bundledPiCodingAgentEvalJsWorkerEntry as unknown as Readonly<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
"@oh-my-pi/pi-coding-agent/eval/js/worker-protocol": bundledPiCodingAgentEvalJsWorkerProtocol as unknown as Readonly<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from "node:fs";
|
||||
import { isBuiltin } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import * as url from "node:url";
|
||||
import { isCompiledBinary } from "@oh-my-pi/pi-utils";
|
||||
@@ -456,10 +457,11 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'
|
||||
|
||||
/**
|
||||
* Rewrite the extension-owned specifiers OMP must host-resolve — legacy
|
||||
* `@(scope)/pi-*`, bare TypeBox packages, and package `imports` aliases like
|
||||
* `#src/*` — to absolute `file://` URLs. Every other specifier (relative
|
||||
* siblings and third-party dependencies) is left untouched so Bun resolves it
|
||||
* natively from the extension's real on-disk location.
|
||||
* `@(scope)/pi-*`, bare TypeBox packages, package `imports` aliases like
|
||||
* `#src/*`, and extension-local bare dependencies — to absolute `file://` URLs
|
||||
* or compiled-mode virtual specifiers. Relative siblings and built-in modules
|
||||
* are left untouched so Bun resolves them from the extension's real on-disk
|
||||
* location.
|
||||
*/
|
||||
async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
|
||||
const withPi = rewriteLegacyPiImports(source);
|
||||
@@ -474,7 +476,12 @@ async function rewriteLegacyExtensionSource(source: string, importerPath: string
|
||||
`${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`,
|
||||
)
|
||||
: withPi;
|
||||
return rewriteExtensionPackageImports(withTypeBox, importerPath);
|
||||
return rewriteExtensionBareImports(await rewriteExtensionPackageImports(withTypeBox, importerPath), importerPath);
|
||||
}
|
||||
|
||||
/** Test seam for compiled-binary legacy extension source rewriting. */
|
||||
export async function __rewriteLegacyExtensionSourceForTests(source: string, importerPath: string): Promise<string> {
|
||||
return rewriteLegacyExtensionSource(source, importerPath);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -688,6 +695,183 @@ async function rewriteExtensionPackageImports(source: string, importerPath: stri
|
||||
return `${rewritten}${source.slice(lastIndex)}`;
|
||||
}
|
||||
|
||||
const BARE_EXTENSION_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g;
|
||||
|
||||
function isBareExtensionDependencySpecifier(specifier: string): boolean {
|
||||
if (
|
||||
specifier.startsWith(".") ||
|
||||
specifier.startsWith("/") ||
|
||||
specifier.startsWith("#") ||
|
||||
specifier.startsWith("node:") ||
|
||||
specifier.startsWith("bun:") ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(specifier)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
||||
return Boolean(packageName && !isBuiltin(packageName));
|
||||
}
|
||||
|
||||
interface BarePackageSpecifier {
|
||||
readonly name: string;
|
||||
readonly subpath: string | null;
|
||||
}
|
||||
|
||||
function splitBarePackageSpecifier(specifier: string): BarePackageSpecifier | null {
|
||||
const parts = specifier.split("/");
|
||||
if (specifier.startsWith("@")) {
|
||||
const [scope, name, ...rest] = parts;
|
||||
if (!scope || !name) return null;
|
||||
return { name: `${scope}/${name}`, subpath: rest.length > 0 ? rest.join("/") : null };
|
||||
}
|
||||
const [name, ...rest] = parts;
|
||||
if (!name) return null;
|
||||
return { name, subpath: rest.length > 0 ? rest.join("/") : null };
|
||||
}
|
||||
|
||||
async function findNodePackageRoot(packageName: string, importerPath: string): Promise<string | null> {
|
||||
let dir = path.dirname(importerPath);
|
||||
while (true) {
|
||||
const candidate = path.join(dir, "node_modules", packageName);
|
||||
if (await pathExists(path.join(candidate, "package.json"))) {
|
||||
return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
return null;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const manifest = await Bun.file(path.join(packageRoot, "package.json")).json();
|
||||
return isRecord(manifest) ? manifest : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePackageExportTarget(
|
||||
packageRoot: string,
|
||||
target: string,
|
||||
wildcard: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!target.startsWith("./")) {
|
||||
return null;
|
||||
}
|
||||
const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
|
||||
return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
|
||||
}
|
||||
|
||||
async function resolveNodePackageExport(
|
||||
packageRoot: string,
|
||||
subpath: string | null,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
const exportsField = manifest.exports;
|
||||
if (!isRecord(exportsField)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exactKey = subpath === null ? "." : `./${subpath}`;
|
||||
const exactTarget = selectPackageImportTarget(exportsField[exactKey]);
|
||||
if (exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED) {
|
||||
return resolvePackageExportTarget(packageRoot, exactTarget, null);
|
||||
}
|
||||
|
||||
for (const [key, entry] of Object.entries(exportsField)) {
|
||||
const starIndex = key.indexOf("*");
|
||||
if (starIndex === -1 || subpath === null) continue;
|
||||
const prefix = key.slice(2, starIndex);
|
||||
const suffix = key.slice(starIndex + 1);
|
||||
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
const target = selectPackageImportTarget(entry);
|
||||
if (target === null || target === PACKAGE_IMPORT_EXCLUDED) {
|
||||
continue;
|
||||
}
|
||||
return resolvePackageExportTarget(
|
||||
packageRoot,
|
||||
target,
|
||||
subpath.slice(prefix.length, subpath.length - suffix.length),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveNodePackageFallback(
|
||||
packageRoot: string,
|
||||
subpath: string | null,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
if (subpath !== null) {
|
||||
return resolveSourceModuleFile(path.join(packageRoot, subpath));
|
||||
}
|
||||
for (const field of ["module", "main"]) {
|
||||
const target = manifest[field];
|
||||
if (typeof target === "string") {
|
||||
const resolved = await resolveSourceModuleFile(path.resolve(packageRoot, target));
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return resolveSourceModuleFile(path.join(packageRoot, "index"));
|
||||
}
|
||||
|
||||
async function resolveNodePackageDependency(specifier: string, importerPath: string): Promise<string | null> {
|
||||
const parsed = splitBarePackageSpecifier(specifier);
|
||||
if (!parsed) return null;
|
||||
const packageRoot = await findNodePackageRoot(parsed.name, importerPath);
|
||||
if (!packageRoot) return null;
|
||||
const manifest = await readPackageManifest(packageRoot);
|
||||
if (!manifest) return null;
|
||||
return (
|
||||
(await resolveNodePackageExport(packageRoot, parsed.subpath, manifest)) ??
|
||||
(await resolveNodePackageFallback(packageRoot, parsed.subpath, manifest))
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveExtensionBareDependency(specifier: string, importerPath: string): Promise<string | null> {
|
||||
if (!isBareExtensionDependencySpecifier(specifier)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
|
||||
if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) {
|
||||
return resolved;
|
||||
}
|
||||
} catch {
|
||||
// Compiled binaries do not reliably resolve runtime extension node_modules.
|
||||
}
|
||||
return resolveNodePackageDependency(specifier, importerPath);
|
||||
}
|
||||
|
||||
async function rewriteExtensionBareImports(source: string, importerPath: string): Promise<string> {
|
||||
let rewritten = "";
|
||||
let lastIndex = 0;
|
||||
for (const match of source.matchAll(BARE_EXTENSION_IMPORT_SPECIFIER_REGEX)) {
|
||||
const matchIndex = match.index;
|
||||
if (matchIndex === undefined) continue;
|
||||
|
||||
const [fullMatch, prefix, specifier, suffix] = match;
|
||||
if (!prefix || !specifier || !suffix) continue;
|
||||
|
||||
const resolved = await resolveExtensionBareDependency(specifier, importerPath);
|
||||
if (!resolved) continue;
|
||||
|
||||
rewritten += source.slice(lastIndex, matchIndex);
|
||||
rewritten += `${prefix}${toImportSpecifier(resolved)}${suffix}`;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
if (lastIndex === 0) {
|
||||
return source;
|
||||
}
|
||||
return `${rewritten}${source.slice(lastIndex)}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () =>
|
||||
expect(BUNDLED_PI_REGISTRY_KEYS.has("@oh-my-pi/pi-coding-agent/main")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not bundle main-thread-unsafe worker entrypoints", () => {
|
||||
// Worker entry modules throw at top level unless `parentPort` exists.
|
||||
// The compiled legacy registry is imported on the main thread while
|
||||
// validating plugin extensions, so enumerating these files recreates the
|
||||
// `js worker-entry: missing parentPort` failure from #3508.
|
||||
expect(BUNDLED_PI_REGISTRY_KEYS.has("@oh-my-pi/pi-coding-agent/eval/js/worker-entry")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps every bundled key (minus shimmed roots + typebox) to its virtual specifier in compiled mode", () => {
|
||||
const overrides = __buildLegacyPiPackageRootOverrides(true);
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as url from "node:url";
|
||||
import { loadLegacyPiModule } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat";
|
||||
import {
|
||||
__rewriteLegacyExtensionSourceForTests,
|
||||
loadLegacyPiModule,
|
||||
} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat";
|
||||
|
||||
// Issue #1674: legacy Pi extensions load browser-UI assets (HTML/CSS) at module
|
||||
// init via `readFileSync(join(__dirname, "ui.html"))`. The compat layer must run
|
||||
@@ -103,6 +106,32 @@ describe("legacy-pi in-place module loading (issue #1674)", () => {
|
||||
expect(mod.hasZod).toBe(true);
|
||||
});
|
||||
|
||||
it("rewrites extension bare deps to file URLs for compiled-binary loading", async () => {
|
||||
const dir = await writePackage({
|
||||
"package.json": JSON.stringify({ name: "compiled-dep-ext", version: "1.0.0" }),
|
||||
"node_modules/esmdep/package.json": JSON.stringify({
|
||||
name: "esmdep",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
exports: { "./value": "./value.js" },
|
||||
}),
|
||||
"node_modules/esmdep/value.js": "export const value = 1;",
|
||||
"index.ts": "",
|
||||
});
|
||||
const importer = path.join(dir, "index.ts");
|
||||
const rewritten = await __rewriteLegacyExtensionSourceForTests(
|
||||
[
|
||||
'import * as path from "node:path";',
|
||||
'import { value } from "esmdep/value";',
|
||||
"export const loaded = value;",
|
||||
].join("\n"),
|
||||
importer,
|
||||
);
|
||||
|
||||
expect(rewritten).toContain(url.pathToFileURL(path.join(dir, "node_modules/esmdep/value.js")).href);
|
||||
expect(rewritten).toContain('from "node:path"');
|
||||
});
|
||||
|
||||
it("remaps legacy pi-ai utils/oauth subpaths to registry OAuth exports", async () => {
|
||||
const dir = await writePackage({
|
||||
"package.json": JSON.stringify({ name: "legacy-oauth-ext", version: "1.0.0" }),
|
||||
|
||||
Reference in New Issue
Block a user