fix(extensions): isolate codex hook scripts so process.exit cannot kill OMP startup
Codex discovery surfaced every `~/.codex/hooks/*.{ts,js}` file as an OMP
hook (silently defaulting untyped names to `pre:<basename>`), and
`discoverExtensionPaths` then handed those paths to `loadExtension` for
dynamic import. A standalone Codex hook script with a top-level
`process.exit(0)` terminated the host CLI cleanly — `try/catch` around
`await import()` cannot intercept a synchronous exit, so OMP died at the
`loadExtensions:start` startup marker with no error surface.
Two-layer fix:
- `packages/coding-agent/src/extensibility/utils.ts`: new
`withExitGuard` helper patches `process.exit` for the duration of a
guarded callback so an exit raises `ExtensionExitError` instead of
terminating the process; nested and concurrent guards restore correctly
via depth counter.
- `extensibility/extensions/loader.ts`, `extensibility/hooks/loader.ts`,
and `extensibility/plugins/manager.ts` wrap their dynamic-import sites
in `withExitGuard` so the existing per-module `try/catch` records the
intercepted exit as a load error and OMP keeps starting.
- `discovery/codex.ts:loadHooks` no longer treats arbitrary files as
OMP hooks: only `pre-<tool>.{ts,js}` and `post-<tool>.{ts,js}` are
registered. Files like `memory-bank-reminder.ts` are silently skipped
rather than imported as extension factories.
Adds regression coverage:
- `test/extension-loader-process-exit.test.ts` — `loadExtensions` /
`loadHooks` return errors and leave `process.exit` restored when a
module exits at import time; sibling modules still load.
- `test/discovery/codex-hooks-discovery.test.ts` — codex provider
registers `pre-*` / `post-*` files and drops everything else.
Fixes #3680
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OMP exiting silently during startup when `~/.codex/hooks/*.{ts,js}` contained standalone Codex hook scripts: untyped scripts are no longer treated as OMP hooks, and dynamic imports of extension/hook modules are wrapped in a `process.exit` guard so a top-level exit raises a recoverable load error instead of terminating the host. ([#3680](https://github.com/can1357/oh-my-pi/issues/3680))
|
||||
|
||||
## [16.2.2] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -342,12 +342,20 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
||||
const codexDir = getProjectCodexDir(ctx);
|
||||
const projectHooksDir = path.join(codexDir, "hooks");
|
||||
|
||||
// OMP hooks must be named `pre-<tool>.<ts|js>` or `post-<tool>.<ts|js>`.
|
||||
// Files without that prefix are not OMP hooks (e.g. the standalone Codex
|
||||
// hook scripts users keep alongside) — silently dropping the prefix and
|
||||
// defaulting to `pre:<basename>` caused those scripts to be imported as
|
||||
// extension factories and any top-level `process.exit()` killed startup
|
||||
// (#3680).
|
||||
const transformHook =
|
||||
(level: "user" | "project") => (name: string, _content: string, path: string, source: SourceMeta) => {
|
||||
(level: "user" | "project") =>
|
||||
(name: string, _content: string, path: string, source: SourceMeta): Hook | null => {
|
||||
const baseName = name.replace(/\.(ts|js)$/, "");
|
||||
const match = baseName.match(/^(pre|post)-(.+)$/);
|
||||
const hookType = (match?.[1] as "pre" | "post") || "pre";
|
||||
const toolName = match?.[2] || baseName;
|
||||
if (!match) return null;
|
||||
const hookType = match[1] as "pre" | "post";
|
||||
const toolName = match[2];
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
@@ -359,11 +367,11 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
loadFilesFromDir(ctx, userHooksDir, PROVIDER_ID, "user", {
|
||||
loadFilesFromDir<Hook>(ctx, userHooksDir, PROVIDER_ID, "user", {
|
||||
extensions: ["ts", "js"],
|
||||
transform: transformHook("user"),
|
||||
}),
|
||||
loadFilesFromDir(ctx, projectHooksDir, PROVIDER_ID, "project", {
|
||||
loadFilesFromDir<Hook>(ctx, projectHooksDir, PROVIDER_ID, "project", {
|
||||
extensions: ["ts", "js"],
|
||||
transform: transformHook("project"),
|
||||
}),
|
||||
|
||||
@@ -24,7 +24,7 @@ import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/leg
|
||||
import { getAllPluginExtensionPaths } from "../plugins/loader";
|
||||
import * as TypeBox from "../typebox";
|
||||
|
||||
import { resolvePath } from "../utils";
|
||||
import { resolvePath, withExitGuard } from "../utils";
|
||||
import type {
|
||||
AssistantThinkingRenderer,
|
||||
Extension,
|
||||
@@ -290,7 +290,7 @@ async function loadExtension(
|
||||
): Promise<{ extension: Extension | null; error: string | null }> {
|
||||
const resolvedPath = resolvePath(extensionPath, cwd);
|
||||
try {
|
||||
const module = (await loadLegacyPiModule(resolvedPath)) as LoadedExtensionModule;
|
||||
const module = (await withExitGuard(() => loadLegacyPiModule(resolvedPath))) as LoadedExtensionModule;
|
||||
const factory = getExtensionFactory(module);
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { loadCapability } from "../../discovery";
|
||||
import * as PiCodingAgent from "../../index";
|
||||
import type { HookMessage } from "../../session/messages";
|
||||
import * as typebox from "../typebox";
|
||||
import { resolvePath } from "../utils";
|
||||
import { resolvePath, withExitGuard } from "../utils";
|
||||
import { execCommand } from "./runner";
|
||||
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types";
|
||||
|
||||
@@ -149,7 +149,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
|
||||
|
||||
try {
|
||||
// Import the module using native Bun import
|
||||
const module = await import(resolvedPath);
|
||||
const module = await withExitGuard(() => import(resolvedPath));
|
||||
const factory = module.default as HookFactory;
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isEnoent,
|
||||
logger,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { withExitGuard } from "../utils";
|
||||
import { type GitSource, parseGitUrl } from "./git-url";
|
||||
import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
|
||||
import { resolvePluginManifestEntries } from "./loader";
|
||||
@@ -363,7 +364,7 @@ export class PluginManager {
|
||||
installLegacyPiSpecifierShim();
|
||||
for (const extensionPath of loadable) {
|
||||
try {
|
||||
const module = await loadLegacyPiModule(extensionPath);
|
||||
const module = await withExitGuard(() => loadLegacyPiModule(extensionPath));
|
||||
if (!hasExtensionFactoryExport(module)) {
|
||||
errors.push(`${extensionPath}: extension does not export a valid factory function`);
|
||||
}
|
||||
|
||||
@@ -42,3 +42,56 @@ export function createNoOpUIContext(): HookUIContext {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised by {@link withExitGuard} when a guarded callback synchronously
|
||||
* attempts to terminate the host process. Callers catch this like any other
|
||||
* import-time failure so the extension/hook is skipped with a logged error
|
||||
* instead of taking the CLI down with it.
|
||||
*/
|
||||
export class ExtensionExitError extends Error {
|
||||
readonly code: number | string | undefined;
|
||||
constructor(code: number | string | undefined) {
|
||||
super(
|
||||
`Module called process.exit(${code === undefined ? "" : String(code)}) at import time; ` +
|
||||
`OMP extension/hook modules must not terminate the host process.`,
|
||||
);
|
||||
this.name = "ExtensionExitError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
let exitGuardDepth = 0;
|
||||
let exitGuardOriginal: typeof process.exit | null = null;
|
||||
|
||||
/**
|
||||
* Run `fn` with `process.exit` patched so any synchronous attempt to terminate
|
||||
* the host raises {@link ExtensionExitError} instead. Restored in `finally`.
|
||||
*
|
||||
* Guards the dynamic-import sites that load third-party extension / hook
|
||||
* modules — a top-level `process.exit(0)` in a stranger's script (e.g. a
|
||||
* Codex hook script that happens to live next to OMP-shaped modules) would
|
||||
* otherwise kill OMP during startup with no error surface, since `try/catch`
|
||||
* around `await import()` cannot intercept a synchronous exit.
|
||||
*
|
||||
* Nested and concurrent guard windows are safe: only the outermost guard
|
||||
* restores the real `process.exit`.
|
||||
*/
|
||||
export async function withExitGuard<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (exitGuardDepth === 0) {
|
||||
exitGuardOriginal = process.exit;
|
||||
process.exit = ((code?: number | string): never => {
|
||||
throw new ExtensionExitError(code);
|
||||
}) as typeof process.exit;
|
||||
}
|
||||
exitGuardDepth++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
exitGuardDepth--;
|
||||
if (exitGuardDepth === 0 && exitGuardOriginal) {
|
||||
process.exit = exitGuardOriginal;
|
||||
exitGuardOriginal = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Codex hook discovery (`packages/coding-agent/src/discovery/codex.ts`) walks
|
||||
* `~/.codex/hooks/*.{ts,js}` flatly. Before #3680 it defaulted every untyped
|
||||
* filename to a `pre:<basename>` hook, and `discoverExtensionPaths` then
|
||||
* imported those scripts as extension factories — a top-level `process.exit()`
|
||||
* in any stranger script (Codex hook scripts, scratch files, …) killed OMP
|
||||
* during startup. These tests pin the new behavior: only `pre-*` / `post-*`
|
||||
* prefixed files are surfaced; everything else is silently skipped.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type Hook, hookCapability } from "@oh-my-pi/pi-coding-agent/capability/hook";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
|
||||
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("codex hook discovery", () => {
|
||||
let tempHome = "";
|
||||
let tempCwd = "";
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSettingsForTest();
|
||||
originalHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-codex-hooks-home-"));
|
||||
tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-codex-hooks-cwd-"));
|
||||
process.env.HOME = tempHome;
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
const settings = await Settings.init({ inMemory: true, cwd: tempCwd });
|
||||
initializeWithSettings(settings);
|
||||
await fs.mkdir(path.join(tempHome, ".codex", "hooks"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetSettingsForTest();
|
||||
vi.restoreAllMocks();
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
await removeWithRetries(tempHome);
|
||||
await removeWithRetries(tempCwd);
|
||||
});
|
||||
|
||||
const codexHook = (name: string, body = "export default function (api) {}\n"): Promise<void> =>
|
||||
fs.writeFile(path.join(tempHome, ".codex", "hooks", name), body);
|
||||
|
||||
const codexHooks = async (): Promise<Hook[]> => {
|
||||
const result = await loadCapability<Hook>(hookCapability.id, {
|
||||
cwd: tempCwd,
|
||||
providers: ["codex"],
|
||||
});
|
||||
return result.items;
|
||||
};
|
||||
|
||||
test("registers pre-* and post-* prefixed scripts with the parsed type and tool", async () => {
|
||||
await codexHook("pre-bash.ts");
|
||||
await codexHook("post-write.js");
|
||||
|
||||
const items = await codexHooks();
|
||||
const summary = items
|
||||
.map(h => ({ name: h.name, type: h.type, tool: h.tool }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
expect(summary).toEqual([
|
||||
{ name: "post-write.js", type: "post", tool: "write" },
|
||||
{ name: "pre-bash.ts", type: "pre", tool: "bash" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips untyped Codex hook scripts so they never reach the extension loader (#3680)", async () => {
|
||||
// The reporter's scripts (memory-bank-reminder.ts, skill-activation-prompt.ts)
|
||||
// and the minimal repro (process.exit at module scope) live alongside any
|
||||
// OMP-shaped pre-*/post-* files but do not match the prefix.
|
||||
await codexHook("repro.ts", "process.exit(0)\n");
|
||||
await codexHook("memory-bank-reminder.ts");
|
||||
await codexHook("skill-activation-prompt.ts");
|
||||
await codexHook("pre-bash.ts");
|
||||
|
||||
const items = await codexHooks();
|
||||
expect(items.map(h => h.name)).toEqual(["pre-bash.ts"]);
|
||||
});
|
||||
|
||||
test("returns nothing when the codex hooks directory is empty", async () => {
|
||||
const items = await codexHooks();
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Regression test for #3680: third-party extension / hook modules that call
|
||||
* `process.exit()` at the top level must not terminate the host OMP process.
|
||||
*
|
||||
* The harness intercepts the load via `withExitGuard`; this test pins that the
|
||||
* intercepted error surfaces as a per-module load failure (so OMP keeps going)
|
||||
* instead of crashing the test runner.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
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 { ExtensionExitError, withExitGuard } from "@oh-my-pi/pi-coding-agent/extensibility/utils";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("extension/hook loader process.exit guard (#3680)", () => {
|
||||
let project: TempDir | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
project = TempDir.createSync("@omp-exit-guard-");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
project?.removeSync();
|
||||
project = undefined;
|
||||
});
|
||||
|
||||
const writeModule = (relativePath: string, source: string): string => {
|
||||
expect(project).toBeDefined();
|
||||
const filePath = path.join(project!.path(), relativePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, source);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
it("converts a top-level process.exit in an extension into a load error", async () => {
|
||||
const ext = writeModule("rogue-extension.ts", "process.exit(0)\n");
|
||||
const cwd = project!.path();
|
||||
const originalExit = process.exit;
|
||||
|
||||
const result = await loadExtensions([ext], cwd);
|
||||
|
||||
expect(process.exit).toBe(originalExit);
|
||||
expect(result.extensions).toEqual([]);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].path).toBe(ext);
|
||||
expect(result.errors[0].error).toContain("process.exit(0)");
|
||||
});
|
||||
|
||||
it("converts a top-level process.exit in a hook into a load error", async () => {
|
||||
const hook = writeModule("rogue-hook.ts", "process.exit(42)\n");
|
||||
const cwd = project!.path();
|
||||
const originalExit = process.exit;
|
||||
|
||||
const result = await loadHooks([hook], cwd);
|
||||
|
||||
expect(process.exit).toBe(originalExit);
|
||||
expect(result.hooks).toEqual([]);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].path).toBe(hook);
|
||||
expect(result.errors[0].error).toContain("process.exit(42)");
|
||||
});
|
||||
|
||||
it("loads sibling modules even when one of them tries to exit", async () => {
|
||||
const bad = writeModule("rogue-extension.ts", "process.exit(0)\n");
|
||||
const good = writeModule(
|
||||
"good-extension.ts",
|
||||
"export default function(pi) { pi.registerCommand('ok', { handler: async () => {} }); }\n",
|
||||
);
|
||||
const cwd = project!.path();
|
||||
|
||||
const result = await loadExtensions([bad, good], cwd);
|
||||
|
||||
expect(result.errors.map(e => e.path)).toEqual([bad]);
|
||||
expect(result.extensions.map(e => path.basename(e.path))).toEqual(["good-extension.ts"]);
|
||||
});
|
||||
|
||||
it("restores process.exit after a synchronous throw inside the guarded callback", async () => {
|
||||
const originalExit = process.exit;
|
||||
|
||||
await expect(
|
||||
withExitGuard(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(process.exit).toBe(originalExit);
|
||||
});
|
||||
|
||||
it("raises ExtensionExitError when the guarded callback calls process.exit", async () => {
|
||||
const originalExit = process.exit;
|
||||
|
||||
await expect(withExitGuard(async () => process.exit(7))).rejects.toBeInstanceOf(ExtensionExitError);
|
||||
|
||||
expect(process.exit).toBe(originalExit);
|
||||
});
|
||||
|
||||
it("only the outermost guard restores process.exit when guards nest", async () => {
|
||||
const originalExit = process.exit;
|
||||
|
||||
await withExitGuard(async () => {
|
||||
const outer = process.exit;
|
||||
expect(outer).not.toBe(originalExit);
|
||||
|
||||
await withExitGuard(async () => {
|
||||
expect(process.exit).toBe(outer);
|
||||
});
|
||||
|
||||
expect(process.exit).toBe(outer);
|
||||
});
|
||||
|
||||
expect(process.exit).toBe(originalExit);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user