Merge remote-tracking branch 'origin/farm/39009a49/fix-extension-validation-shims'

This commit is contained in:
can1357
2026-06-18 04:52:33 +02:00
7 changed files with 296 additions and 23 deletions
+4
View File
@@ -95,6 +95,10 @@
- Fixed WSL2 TUI stutter by adding a `git.enabled` setting and skipping footer/status-line git probes when disabled or when no git-backed status segment is visible ([#2847](https://github.com/can1357/oh-my-pi/issues/2847)).
- Fixed JSON-mode startup notices (export/resume/session-picker messages) writing to stdout before the JSON event stream; they now route to stderr so stdout remains newline-delimited JSON.
### Fixed
- Fixed legacy plugin validation for extensions that import `defineTool`, `StringEnum`, frontmatter helpers, `SettingsManager`, `createCodingTools`, or the bare `typebox` package through the hosted Pi compatibility shims ([#2858](https://github.com/can1357/oh-my-pi/issues/2858)).
## [16.0.4] - 2026-06-17
### Fixed
@@ -11,14 +11,54 @@
* bare package root. Subpath imports (`@oh-my-pi/pi-ai/oauth`, etc.)
* continue to resolve directly against the bundled pi-ai package.
*
* The `Type` runtime is borrowed from the Zod-backed TypeBox shim that
* already serves bare `@sinclair/typebox` imports for the same extension
* class, keeping the legacy-compat surface internally consistent.
* The `Type` runtime and legacy `StringEnum()` helper are borrowed from the
* Zod-backed TypeBox shim that already serves TypeBox imports for the same
* extension class, keeping the legacy-compat surface internally consistent.
*
* Type-level `Static` and `TSchema` continue to come from pi-ai's own
* `types.ts` via the `export *` below — pi-ai still exports both as types,
* only the runtime `Type` builder was removed.
* only the runtime `Type` builder and `StringEnum()` helper were removed.
*/
import { z } from "zod/v4";
import { type TSchema, Type } from "./typebox";
export interface StringEnumOptions<T extends string> {
description?: string;
default?: T;
examples?: T[];
[key: string]: unknown;
}
function stringEnumWireSchema<T extends string>(values: readonly T[], options: StringEnumOptions<T> | undefined) {
const schema: Record<string, unknown> = {
type: "string",
enum: [...values],
};
if (!options) return schema;
for (const key in options) {
if (options[key] !== undefined) {
schema[key] = options[key];
}
}
return schema;
}
export function StringEnum<T extends string>(values: readonly T[], options?: StringEnumOptions<T>): TSchema {
let schema: TSchema =
values.length === 0
? z.never()
: z.enum(values).describe(options?.description ?? "Legacy string enum compatibility schema");
if (options && "default" in options) {
schema = schema.default(options.default);
}
Object.defineProperty(schema, "toJSON", {
value: () => stringEnumWireSchema(values, options),
enumerable: false,
writable: true,
configurable: true,
});
return schema;
}
export * from "@oh-my-pi/pi-ai";
export { Type } from "./typebox";
export { Type };
@@ -12,4 +12,117 @@
* the same module identity as a direct `@oh-my-pi/pi-coding-agent` import.
*/
import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { TSchema } from "@oh-my-pi/pi-ai";
import { parseFrontmatter as parseOmpFrontmatter } from "@oh-my-pi/pi-utils";
import { Settings } from "../config/settings";
import { BUILTIN_TOOLS, type Tool, type ToolSession } from "../tools";
import type { ToolDefinition } from "./extensions/types";
import { Type } from "./typebox";
const TOOL_DEFINITION_MARKER = "__isToolDefinition";
const LEGACY_BUILTIN_TOOL_MARKER = "__ompLegacyBuiltinTool";
const LEGACY_CODING_TOOL_NAMES = ["read", "bash", "edit", "write"] as const;
type LegacyCodingToolName = (typeof LEGACY_CODING_TOOL_NAMES)[number];
type LegacyBuiltinToolDefinition = ToolDefinition & { [LEGACY_BUILTIN_TOOL_MARKER]: true };
function markToolDefinition<TParams extends TSchema, TDetails>(
tool: ToolDefinition<TParams, TDetails>,
): ToolDefinition<TParams, TDetails> {
Object.defineProperty(tool, TOOL_DEFINITION_MARKER, {
value: true,
enumerable: false,
writable: false,
configurable: true,
});
return tool;
}
function legacyToolSession(cwd: string): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: Settings.isolated(),
};
}
function createBuiltinTool(cwd: string, name: LegacyCodingToolName): Tool {
const tool = BUILTIN_TOOLS[name](legacyToolSession(cwd));
if (tool instanceof Promise) {
throw new Error(`Built-in ${name} tool factory unexpectedly returned a promise.`);
}
if (!tool) {
throw new Error(`Built-in ${name} tool is unavailable.`);
}
return tool;
}
async function executeBuiltinTool(
cwd: string,
name: LegacyCodingToolName,
toolCallId: string,
params: unknown,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback | undefined,
) {
const tool = createBuiltinTool(cwd, name);
return tool.execute(toolCallId, params, signal, onUpdate);
}
function legacyBuiltinTool(cwd: string, name: LegacyCodingToolName): ToolDefinition {
const tool = createBuiltinTool(cwd, name);
const definition: LegacyBuiltinToolDefinition = {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters,
hidden: tool.hidden,
deferrable: tool.deferrable,
approval: tool.approval,
execute: (toolCallId, params, signal, onUpdate) =>
executeBuiltinTool(cwd, name, toolCallId, params, signal, onUpdate),
[LEGACY_BUILTIN_TOOL_MARKER]: true,
};
return markToolDefinition(definition);
}
export interface ParsedFrontmatter<T extends Record<string, unknown> = Record<string, unknown>> {
frontmatter: T;
body: string;
}
export function parseFrontmatter<T extends Record<string, unknown> = Record<string, unknown>>(
content: string,
): ParsedFrontmatter<T> {
const { frontmatter, body } = parseOmpFrontmatter(content, { level: "fatal" });
return { frontmatter: frontmatter as T, body };
}
export function stripFrontmatter(content: string): string {
return parseFrontmatter(content).body;
}
export function defineTool<TParams extends TSchema = TSchema, TDetails = unknown>(
tool: ToolDefinition<TParams, TDetails>,
): ToolDefinition<TParams, TDetails> {
return markToolDefinition(tool);
}
export function createCodingTools(cwd: string): ToolDefinition[] {
return LEGACY_CODING_TOOL_NAMES.map(name => legacyBuiltinTool(cwd, name));
}
export const SettingsManager = {
create(cwd: string, agentDir?: string): Promise<Settings> {
return Settings.init({ cwd, agentDir });
},
inMemory(): Settings {
return Settings.isolated();
},
} as const;
export * from "../index";
export { Type };
@@ -64,14 +64,14 @@ const packageRootCache = new Map<string, string | null>();
const packageImportsCache = new Map<string, Record<string, unknown> | null>();
const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
// Extensions that imported `@sinclair/typebox` directly used to resolve against a
// real `@sinclair/typebox` install. The runtime dep was replaced with the Zod-backed
// shim under `extensibility/typebox.ts`; plugins still importing the public name
// are redirected to that shim so existing extensions keep working without code
// changes. Submodules like `@sinclair/typebox/compiler` are intentionally not
// remapped — those expose TypeBox-only APIs the shim does not provide and plugins
// relying on them must vendor `@sinclair/typebox` directly.
const TYPEBOX_SPECIFIER_FILTER = /^@sinclair\/typebox$/;
// Extensions that imported TypeBox directly used to resolve against a real
// `@sinclair/typebox` or `typebox` install. The runtime dep was replaced with
// the Zod-backed shim under `extensibility/typebox.ts`; plugins still importing
// either public name are redirected to that shim so existing extensions keep
// working without code changes. Submodules like `@sinclair/typebox/compiler`
// are intentionally not remapped — those expose TypeBox-only APIs the shim does
// not provide and plugins relying on them must vendor TypeBox directly.
const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/;
// Compat shim and bundled-package paths used in compiled-binary mode. The shim
// paths must point at files that ship inside the bunfs root; in dev /
@@ -295,14 +295,14 @@ function rewriteLegacyPiImports(source: string): string {
);
}
// Match the bare `@sinclair/typebox` import specifier (static + dynamic).
// Subpath imports like `@sinclair/typebox/compiler` are intentionally excluded —
// they expose TypeBox-only APIs the Zod-backed shim does not provide.
const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g;
// Match the bare TypeBox import specifiers (static + dynamic). Subpath imports
// like `@sinclair/typebox/compiler` are intentionally excluded — they expose
// TypeBox-only APIs the Zod-backed shim does not provide.
const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox|typebox)(["'])/g;
/**
* Rewrite the extension-owned specifiers OMP must host-resolve — legacy
* `@(scope)/pi-*`, bare `@sinclair/typebox`, and package `imports` aliases like
* `@(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.
+16 -4
View File
@@ -528,6 +528,11 @@ export interface CreateAgentSessionOptions {
/** Settings instance. Default: Settings.init({ cwd, agentDir }) */
settings?: Settings;
/**
* Legacy alias for `settings`. Older Pi extensions pass SettingsManager.create(...)
* through this field; accept it so their SDK calls keep the configured settings.
*/
settingsManager?: Settings | Promise<Settings>;
/** Whether UI is available (enables interactive tools like ask). Default: false */
hasUI?: boolean;
@@ -874,6 +879,10 @@ function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool {
return !(tool as any).__isToolDefinition;
}
function isLegacyBuiltinToolDefinition(tool: CustomTool | ToolDefinition): boolean {
return !isCustomTool(tool) && "__ompLegacyBuiltinTool" in tool && tool.__ompLegacyBuiltinTool === true;
}
const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition");
/** Matches the truncation applied to per-server instructions inside `rebuildSystemPrompt`. */
@@ -1138,7 +1147,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
startupCredentialDisabledEvents.push(event);
}
});
const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
const settings = await (options.settings ??
options.settingsManager ??
logger.time("settings", Settings.init, { cwd, agentDir }));
logger.time("initializeWithSettings", initializeWithSettings, settings);
if (!options.modelRegistry) {
modelRegistry.refreshInBackground();
@@ -2008,12 +2019,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const toolContextStore = new ToolContextStore(getSessionContext);
const registeredTools = extensionRunner.getAllRegisteredTools();
const sdkCustomTools = options.customTools?.filter(tool => !isLegacyBuiltinToolDefinition(tool)) ?? [];
const allCustomTools = [
...registeredTools,
...(options.customTools?.map(tool => {
...sdkCustomTools.map(tool => {
const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool;
return { definition, extensionPath: "<sdk>" };
}) ?? []),
}),
];
// `wrapToolWithMetaNotice` runs the centralized large-output → artifact spill.
// Built-in tools get it in `createTools`; extension, SDK-custom, image-gen,
@@ -2291,7 +2303,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Custom tools and extension-registered tools are always included regardless of toolNames filter
const alwaysInclude: string[] = [
...(options.customTools?.map(t => (isCustomTool(t) ? t.name : t.name)) ?? []),
...sdkCustomTools.map(t => (isCustomTool(t) ? t.name : t.name)),
...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
];
for (const name of alwaysInclude) {
@@ -88,6 +88,29 @@ describe("legacy-pi @(scope)/pi-ai root `Type` remap (issue #1437)", () => {
expect(loaded.zodObj.safeParse({ name: "ok" }).success).toBe(true);
expect(loaded.zodObj.safeParse({}).success).toBe(false);
});
it("preserves legacy StringEnum root imports as plain string enum schemas", async () => {
const entry = await writeFixtureExtension(
[
'import { StringEnum } from "@earendil-works/pi-ai";',
'export const schema = StringEnum(["upstream", "downstream"] as const, { default: "upstream" });',
"export const wireSchema = JSON.parse(JSON.stringify(schema));",
].join("\n"),
);
const loaded = (await loadLegacyPiModule(entry)) as {
schema: { safeParse: (input: unknown) => { success: boolean } };
wireSchema: { type?: string; enum?: string[]; default?: string; anyOf?: unknown };
};
expect(loaded.schema.safeParse("downstream").success).toBe(true);
expect(loaded.schema.safeParse("sideways").success).toBe(false);
expect(loaded.wireSchema).toEqual({
type: "string",
enum: ["upstream", "downstream"],
default: "upstream",
});
expect(loaded.wireSchema.anyOf).toBeUndefined();
});
it("does not redirect subpath imports such as @oh-my-pi/pi-ai/utils/schema", async () => {
const entry = await writeFixtureExtension(
@@ -124,6 +147,68 @@ describe("legacy pi package root remaps (issue #1474)", () => {
expect(loaded.loadedVersion).toMatch(/^\d+\.\d+\.\d+/);
});
it("preserves legacy defineTool root imports and usable coding tools", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-legacy-coding-tools-"));
tempRoots.push(dir);
await fs.writeFile(path.join(dir, "sample.txt"), "legacy read body", "utf8");
const entry = path.join(dir, "index.ts");
await fs.writeFile(
entry,
[
'import { dirname } from "node:path";',
'import { fileURLToPath } from "node:url";',
'import { createCodingTools, defineTool, Type } from "@earendil-works/pi-coding-agent";',
"const definition = {",
'\tname: "legacy_define_tool",',
'\tlabel: "Legacy Define Tool",',
'\tdescription: "legacy helper probe",',
"\tparameters: Type.Object({}),",
'\texecute: async () => ({ content: [{ type: "text", text: "ok" }] }),',
"};",
"const cwd = dirname(fileURLToPath(import.meta.url));",
"const codingTools = createCodingTools(cwd);",
"const readTool = codingTools.find(tool => tool.name === 'read');",
"export const tool = defineTool(definition);",
"export const sameReference = tool === definition;",
"export const codingToolNames = codingTools.map(tool => tool.name);",
"export const readResult = await readTool?.execute('legacy-read', { path: 'sample.txt' });",
].join("\n"),
"utf8",
);
const loaded = (await loadLegacyPiModule(entry)) as {
tool: { name: string; parameters: { safeParse: (input: unknown) => { success: boolean } } };
sameReference: boolean;
codingToolNames: string[];
readResult: { content: Array<{ type: string; text?: string }> };
};
expect(loaded.sameReference).toBe(true);
expect(loaded.tool.name).toBe("legacy_define_tool");
expect(loaded.codingToolNames).toEqual(["read", "bash", "edit", "write"]);
expect(loaded.readResult.content[0]?.text).toContain("legacy read body");
});
it("preserves legacy frontmatter helper root imports", async () => {
const entry = await writeFixtureExtension(
[
'import { parseFrontmatter, stripFrontmatter } from "@earendil-works/pi-coding-agent";',
"const content = ['---', 'name: demo', '---', '# Body'].join('\\n');",
"export const parsed = parseFrontmatter(content);",
"export const stripped = stripFrontmatter(content);",
].join("\n"),
);
const loaded = (await loadLegacyPiModule(entry)) as {
parsed: { frontmatter: { name?: string }; body: string };
stripped: string;
};
expect(loaded.parsed.frontmatter.name).toBe("demo");
expect(loaded.parsed.body).toBe("# Body");
expect(loaded.stripped).toBe("# Body");
});
it("falls back to legacy-scoped subpath peers for direct plugin imports", async () => {
const realResolveSync = Bun.resolveSync.bind(Bun);
vi.spyOn(Bun, "resolveSync").mockImplementation((specifier: string, from: string) => {
@@ -31,7 +31,7 @@ async function writeFixtureExtension(source: string): Promise<string> {
return entry;
}
describe("legacy-pi @sinclair/typebox remap", () => {
describe("legacy-pi TypeBox remap", () => {
it("redirects bare @sinclair/typebox imports inside legacy extensions to the in-repo shim", async () => {
const entry = await writeFixtureExtension(
[
@@ -50,4 +50,23 @@ describe("legacy-pi @sinclair/typebox remap", () => {
expect(loaded.objectSchema.safeParse({ name: "ok" }).success).toBe(true);
expect(loaded.objectSchema.safeParse({ name: "ok", extra: 1 }).success).toBe(false);
});
it("redirects bare typebox imports inside legacy extensions to the in-repo shim", async () => {
const entry = await writeFixtureExtension(
[
'import { Type } from "typebox";',
"export const probe = Type;",
"export const enumSchema = Type.Enum(['upstream', 'downstream']);",
].join("\n"),
);
const loaded = (await loadLegacyPiModule(entry)) as {
probe: typeof TypeBoxShimType;
enumSchema: { safeParse: (input: unknown) => { success: boolean } };
};
expect(loaded.probe).toBe(TypeBoxShimType);
expect(loaded.enumSchema.safeParse("upstream").success).toBe(true);
expect(loaded.enumSchema.safeParse("sideways").success).toBe(false);
});
});