From 788910b7cd593f2f577f438cbf7702be328d412a Mon Sep 17 00:00:00 2001 From: jiwangyihao Date: Thu, 11 Jun 2026 16:17:01 +0800 Subject: [PATCH] fix(coding-agent): bind extension APIs to host runtime --- packages/coding-agent/CHANGELOG.md | 1 + .../extensibility/custom-commands/loader.ts | 4 +- .../extensibility/custom-commands/types.ts | 9 +- .../src/extensibility/custom-tools/loader.ts | 11 +- .../src/extensibility/custom-tools/types.ts | 12 +- .../src/extensibility/extensions/loader.ts | 3 +- .../src/extensibility/extensions/types.ts | 4 +- .../src/extensibility/hooks/loader.ts | 4 +- .../src/extensibility/hooks/types.ts | 12 +- .../test/extension-loader-self-import.test.ts | 130 ++++++++++++++++++ 10 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 packages/coding-agent/test/extension-loader-self-import.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c1b019efa..07853759d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -152,6 +152,7 @@ - Default API auto-retries now use 10 attempts with a 500ms Anthropic-style exponential backoff capped at 8s with jitter, so transient 502/gateway failures get a longer retry budget without multi-minute local sleeps. ### Fixed +- Fixed extension, custom-tool, custom-command, and hook loaders injecting `pi` through bare `@oh-my-pi/pi-coding-agent` self-imports, which could pull a different cached package version into global installs during plugin load and trigger mixed-runtime stack overflows. - Fixed `ask` question/result renders so option and answer rows are no longer duplicated when the component is re-rendered - Fixed streaming `write`/`diff` previews to keep line-number gutter widths stable while content grows, preventing already-rendered preview rows from being reflowed mid-stream diff --git a/packages/coding-agent/src/extensibility/custom-commands/loader.ts b/packages/coding-agent/src/extensibility/custom-commands/loader.ts index af14c706b..0c2ecd5a0 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/loader.ts @@ -10,6 +10,8 @@ import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils import * as zod from "zod/v4"; import { getConfigDirs } from "../../config"; import { execCommand } from "../../exec/exec"; +// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. +import * as PiCodingAgent from "../../index"; import * as typebox from "../typebox"; import { GreenCommand } from "./bundled/ci-green"; import { ReviewCommand } from "./bundled/review"; @@ -185,7 +187,7 @@ export async function loadCustomCommands(options: LoadCustomCommandsOptions = {} execCommand(command, args, execOptions?.cwd ?? cwd, execOptions), typebox, zod, - pi: await import("@oh-my-pi/pi-coding-agent"), + pi: PiCodingAgent, }; // 1. Load bundled commands first (lowest priority - can be overridden) diff --git a/packages/coding-agent/src/extensibility/custom-commands/types.ts b/packages/coding-agent/src/extensibility/custom-commands/types.ts index 152afc8cd..330f84f86 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/types.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/types.ts @@ -5,7 +5,10 @@ * Unlike markdown commands which expand to prompts, custom commands can execute * arbitrary logic with full access to the hook context. */ +import type * as Zod from "zod/v4"; import type { ExecOptions, ExecResult, HookCommandContext } from "../../extensibility/hooks/types"; +import type * as PiCodingAgent from "../../index"; +import type * as TypeBox from "../typebox"; // Re-export for custom commands to use export type { ExecOptions, ExecResult, HookCommandContext }; @@ -20,11 +23,11 @@ export interface CustomCommandAPI { /** Execute a shell command */ exec(command: string, args: string[], options?: ExecOptions): Promise; /** Injected zod-backed typebox shim (legacy/compat). */ - typebox: typeof import("../typebox"); + typebox: typeof TypeBox; /** Injected zod module for Zod-authored custom commands. */ - zod: typeof import("zod/v4"); + zod: typeof Zod; /** Injected pi-coding-agent exports */ - pi: typeof import("../.."); + pi: typeof PiCodingAgent; } /** diff --git a/packages/coding-agent/src/extensibility/custom-tools/loader.ts b/packages/coding-agent/src/extensibility/custom-tools/loader.ts index 8f30fd6bd..826e32162 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/loader.ts @@ -14,6 +14,8 @@ import type { ExecOptions } from "../../exec/exec"; import { execCommand } from "../../exec/exec"; import type { HookUIContext } from "../../extensibility/hooks/types"; import { getAllPluginToolPaths } from "../../extensibility/plugins/loader"; +// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. +import * as PiCodingAgent from "../../index"; import * as typebox from "../typebox"; import { createNoOpUIContext, resolvePath } from "../utils"; import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types"; @@ -88,7 +90,7 @@ export class CustomToolLoader { #seenNames: Set; constructor( - pi: typeof import("@oh-my-pi/pi-coding-agent"), + pi: typeof PiCodingAgent, cwd: string, builtInToolNames: string[], pushPendingAction?: (action: { @@ -174,12 +176,7 @@ export async function loadCustomTools( reject?(reason: string): Promise | undefined>; }) => void, ) { - const loader = new CustomToolLoader( - await import("@oh-my-pi/pi-coding-agent"), - cwd, - builtInToolNames, - pushPendingAction, - ); + const loader = new CustomToolLoader(PiCodingAgent, cwd, builtInToolNames, pushPendingAction); await loader.load(pathsWithSources); return { tools: loader.tools, diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index 41c496287..5013be14b 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -14,14 +14,18 @@ import type { import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { FetchImpl, Model, Static, TSchema } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; +import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; +import type * as Zod from "zod/v4"; import type { Rule } from "../../capability/rule"; import type { ModelRegistry } from "../../config/model-registry"; import type { Settings } from "../../config/settings"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { HookUIContext } from "../../extensibility/hooks/types"; +import type * as PiCodingAgent from "../../index"; import type { Theme } from "../../modes/theme/theme"; import type { ReadonlySessionManager } from "../../session/session-manager"; import type { TodoItem } from "../../tools/todo"; +import type * as TypeBox from "../typebox"; /** Alias for clarity */ export type CustomToolUIContext = HookUIContext; @@ -56,13 +60,13 @@ export interface CustomToolAPI { /** Whether UI is available (false in print/RPC mode) */ hasUI: boolean; /** File logger for error/warning/debug messages */ - logger: typeof import("@oh-my-pi/pi-utils").logger; + logger: typeof PiLogger; /** Injected zod-backed typebox shim (legacy/compat — Zod-authored tools are preferred). */ - typebox: typeof import("../typebox"); + typebox: typeof TypeBox; /** Injected zod module for Zod-authored custom tools. */ - zod: typeof import("zod/v4"); + zod: typeof Zod; /** Injected pi-coding-agent exports */ - pi: typeof import("../.."); + pi: typeof PiCodingAgent; /** Push a preview action that can later be resolved with the hidden resolve tool */ pushPendingAction(action: CustomToolPendingAction): void; } diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index 54639e380..616e12245 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -6,7 +6,6 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model, TextContent, TSchema } from "@oh-my-pi/pi-ai"; -import * as PiCodingAgent from "@oh-my-pi/pi-coding-agent"; import type { KeyId } from "@oh-my-pi/pi-tui"; import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils"; import * as Zod from "zod/v4"; @@ -15,6 +14,8 @@ import { loadCapability } from "../../discovery"; import { getExtensionNameFromPath } from "../../discovery/helpers"; import type { ExecOptions } from "../../exec/exec"; import { execCommand } from "../../exec/exec"; +// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. +import * as PiCodingAgent from "../../index"; import type { CustomMessage } from "../../session/messages"; import { EventBus } from "../../utils/event-bus"; import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat"; diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 7b4e19e4e..269e36a10 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -30,7 +30,6 @@ import type { TSchema, } from "@oh-my-pi/pi-ai"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; -import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; import type * as Zod from "zod/v4"; @@ -40,6 +39,7 @@ import type { EditToolDetails } from "../../edit"; import type { PythonResult } from "../../eval/py/executor"; import type { BashResult } from "../../exec/bash-executor"; import type { ExecOptions, ExecResult } from "../../exec/exec"; +import type * as PiCodingAgent from "../../index"; import type { MemoryRuntimeContext } from "../../memory-backend"; import type { CustomEditor } from "../../modes/components/custom-editor"; import type { Theme } from "../../modes/theme/theme"; @@ -893,7 +893,7 @@ export interface ExtensionAPI { zod: typeof Zod; /** Injected pi-coding-agent exports for accessing SDK utilities */ - pi: typeof piCodingAgent; + pi: typeof PiCodingAgent; // ========================================================================= // Event Subscription diff --git a/packages/coding-agent/src/extensibility/hooks/loader.ts b/packages/coding-agent/src/extensibility/hooks/loader.ts index 0103bb7a5..f500db89d 100644 --- a/packages/coding-agent/src/extensibility/hooks/loader.ts +++ b/packages/coding-agent/src/extensibility/hooks/loader.ts @@ -7,6 +7,8 @@ import * as zod from "zod/v4"; import { hookCapability } from "../../capability/hook"; import type { Hook } from "../../discovery"; import { loadCapability } from "../../discovery"; +// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. +import * as PiCodingAgent from "../../index"; import type { HookMessage } from "../../session/messages"; import type { SessionManager } from "../../session/session-manager"; import * as typebox from "../typebox"; @@ -138,7 +140,7 @@ async function createHookAPI( logger, typebox, zod, - pi: await import("@oh-my-pi/pi-coding-agent"), + pi: PiCodingAgent, } as HookAPI; return { diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 5f762fb3b..c8c7f41dc 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -1,8 +1,11 @@ import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import type { Component, TUI } from "@oh-my-pi/pi-tui"; +import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; +import type * as Zod from "zod/v4"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; import type { ExecOptions, ExecResult } from "../../exec/exec"; +import type * as PiCodingAgent from "../../index"; import type { Theme } from "../../modes/theme/theme"; import type { HookMessage } from "../../session/messages"; import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; @@ -39,6 +42,7 @@ import type { TurnEndEvent, TurnStartEvent, } from "../shared-events"; +import type * as TypeBox from "../typebox"; // Re-export for backward compatibility export type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -577,13 +581,13 @@ export interface HookAPI { exec(command: string, args: string[], options?: ExecOptions): Promise; /** File logger for error/warning/debug messages */ - logger: typeof import("@oh-my-pi/pi-utils").logger; + logger: typeof PiLogger; /** Injected zod-backed typebox shim (legacy/compat — prefer `zod`). */ - typebox: typeof import("../typebox"); + typebox: typeof TypeBox; /** Injected zod module for Zod-authored hooks. */ - zod: typeof import("zod/v4"); + zod: typeof Zod; /** Injected pi-coding-agent exports */ - pi: typeof import("../.."); + pi: typeof PiCodingAgent; } /** diff --git a/packages/coding-agent/test/extension-loader-self-import.test.ts b/packages/coding-agent/test/extension-loader-self-import.test.ts new file mode 100644 index 000000000..29bf13e6c --- /dev/null +++ b/packages/coding-agent/test/extension-loader-self-import.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as PiCodingAgent from "@oh-my-pi/pi-coding-agent"; +import { loadCustomCommands } from "@oh-my-pi/pi-coding-agent/extensibility/custom-commands/loader"; +import { loadCustomTools } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/loader"; +import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; +import { loadHooks } from "@oh-my-pi/pi-coding-agent/extensibility/hooks/loader"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +declare global { + var __ompHostPiForLoaderIdentityTest: typeof PiCodingAgent | undefined; +} + +describe("extension loader host runtime binding", () => { + let projectDir: TempDir | undefined; + + beforeEach(() => { + projectDir = TempDir.createSync("@loader-host-runtime-"); + globalThis.__ompHostPiForLoaderIdentityTest = PiCodingAgent; + }); + + afterEach(() => { + projectDir?.removeSync(); + projectDir = undefined; + globalThis.__ompHostPiForLoaderIdentityTest = undefined; + }); + + function writeModule(relativePath: string, source: string): string { + expect(projectDir).toBeDefined(); + const modulePath = path.join(projectDir!.path(), relativePath); + fs.mkdirSync(path.dirname(modulePath), { recursive: true }); + fs.writeFileSync(modulePath, source); + return modulePath; + } + + const identityGuard = ` + const expectedPi = globalThis.__ompHostPiForLoaderIdentityTest; + if (!expectedPi) throw new Error("missing host pi module"); + if (api.pi !== expectedPi) throw new Error("injected pi module did not match host module"); + `; + + it("passes the in-process host pi module through every loader API", async () => { + expect(projectDir).toBeDefined(); + const cwd = projectDir!.path(); + + const extensionPath = writeModule( + "extension.ts", + ` + export default function(api) { + ${identityGuard} + api.registerCommand("identity_extension", { handler: async () => {} }); + } + `, + ); + const extensionResult = await loadExtensions([extensionPath], cwd); + expect(extensionResult.errors).toEqual([]); + expect(extensionResult.extensions).toHaveLength(1); + expect(extensionResult.extensions[0].commands.has("identity_extension")).toBe(true); + + const toolPath = writeModule( + "tool.ts", + ` + export default function(api) { + ${identityGuard} + return { + name: "identity_tool", + label: "Identity Tool", + description: "Asserts injected pi identity", + parameters: api.zod.object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + }; + } + `, + ); + const toolResult = await loadCustomTools([{ path: toolPath }], cwd, []); + expect(toolResult.errors).toEqual([]); + expect(toolResult.tools.map(tool => tool.tool.name)).toEqual(["identity_tool"]); + + const agentDir = path.join(cwd, "agent"); + const commandPath = writeModule( + path.join("agent", "commands", "identity", "index.ts"), + ` + export default function(api) { + ${identityGuard} + return { + name: "identity_command", + description: "Asserts injected pi identity", + execute: () => "ok", + }; + } + `, + ); + const commandResult = await loadCustomCommands({ cwd, agentDir }); + expect(commandResult.errors.filter(error => error.path === commandPath)).toEqual([]); + expect(commandResult.commands.some(command => command.command.name === "identity_command")).toBe(true); + + const hookPath = writeModule( + "hook.ts", + ` + export default function(api) { + ${identityGuard} + api.on("identity:event", async () => "ok"); + } + `, + ); + const hookResult = await loadHooks([hookPath], cwd); + expect(hookResult.errors).toEqual([]); + expect(hookResult.hooks).toHaveLength(1); + expect(hookResult.hooks[0].handlers.has("identity:event")).toBe(true); + }); + + it("keeps runtime loaders free of bare package self-imports", async () => { + // Normal workspace resolution can make a bare self-import resolve to the same module, + // so keep a narrow static tripwire for the global-install mixed-version layout. + const loaderPaths = [ + path.join(import.meta.dir, "..", "src", "extensibility", "extensions", "loader.ts"), + path.join(import.meta.dir, "..", "src", "extensibility", "custom-tools", "loader.ts"), + path.join(import.meta.dir, "..", "src", "extensibility", "custom-commands", "loader.ts"), + path.join(import.meta.dir, "..", "src", "extensibility", "hooks", "loader.ts"), + ]; + + for (const loaderPath of loaderPaths) { + const source = await Bun.file(loaderPath).text(); + + expect(source).not.toMatch(/from\s+["']@oh-my-pi\/pi-coding-agent["']/); + expect(source).not.toMatch(/import\(\s*["']@oh-my-pi\/pi-coding-agent["']\s*\)/); + } + }); +});