fix(coding-agent): preserve explicit extensions in isolation
This commit is contained in:
@@ -98,8 +98,19 @@ extensions:
|
||||
|
||||
Behavior split:
|
||||
|
||||
- SDK: when `disableExtensionDiscovery=true`, it still loads `additionalExtensionPaths` via `loadExtensions()`.
|
||||
- CLI path building (`main.ts`) currently clears CLI extension paths when `--no-extensions` is set, so explicit `-e/--hook` are not forwarded in that mode.
|
||||
- SDK: when `disableExtensionDiscovery=true`, ambient extension factories are
|
||||
excluded, while `additionalExtensionPaths` are still resolved normally
|
||||
(including package directories with `package.json#omp.extensions`).
|
||||
- CLI: `--no-extensions` follows the same explicit-only contract. Explicit
|
||||
`-e/--extension` and `--hook` paths still load, and only sibling capability
|
||||
roots from explicitly named extension packages remain eligible. Project/user
|
||||
`extensions:` settings and installed OMP extension packages are excluded from
|
||||
that sibling surface.
|
||||
|
||||
This flag governs extension factories and OMP extension-package sibling roots;
|
||||
it is not a whole-process capability-isolation switch. Skills, MCP servers,
|
||||
tools, prompts, and rules owned by other discovery subsystems retain their own
|
||||
enable/disable controls.
|
||||
|
||||
### Disable specific extension modules
|
||||
|
||||
|
||||
@@ -681,6 +681,11 @@
|
||||
- Fixed the Cursor-backed advisor losing entire turns when it selected server-native tools (`bash`, `grep`, etc.) outside its grant: exec-resolved native blocks are already rejected in-band by the advisor-scoped bridge, so they no longer trip the unavailable-tool quarantine and discard the `advise` emitted in the same turn ([#5900](https://github.com/can1357/oh-my-pi/issues/5900)).
|
||||
- Fixed custom `anthropic-messages` OAuth providers being unable to opt into configured Claude Code fingerprint header overrides. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888))
|
||||
- Fixed authoritative providers (e.g. `openai-codex`) keeping unsupported bundled models selectable when a fresh model cache and an expired OAuth token coincided: built-in discovery now forces the OAuth refresh so the provider's model manager is constructed and prunes stale bundled entries (e.g. `gpt-5.4-nano`) instead of waiting out the cache TTL. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364))
|
||||
### Fixed
|
||||
|
||||
- Preserved explicit `-e`/`--extension` and `--hook` packages under
|
||||
`--no-extensions` while excluding ambient extension factories and sibling
|
||||
capabilities from settings or installed OMP packages.
|
||||
|
||||
## [17.0.5] - 2026-07-18
|
||||
|
||||
|
||||
@@ -17,12 +17,7 @@ import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { ModelRegistry } from "../config/model-registry";
|
||||
import { Settings } from "../config/settings";
|
||||
import {
|
||||
discoverAndLoadExtensions,
|
||||
ExtensionRunner,
|
||||
emitSessionShutdownEvent,
|
||||
loadExtensions,
|
||||
} from "../extensibility/extensions";
|
||||
import { discoverAndLoadExtensions, ExtensionRunner, emitSessionShutdownEvent } from "../extensibility/extensions";
|
||||
import { discoverAuthStorage } from "../sdk";
|
||||
import { SessionManager } from "../session/session-manager";
|
||||
import { EventBus } from "../utils/event-bus";
|
||||
@@ -283,7 +278,7 @@ export interface RunModelsListingOptions {
|
||||
settingsExtensions?: string[];
|
||||
/** Disabled extension ids from settings (`disabledExtensions`). */
|
||||
disabledExtensionIds?: string[];
|
||||
/** When true, skip discovery and only load `additionalExtensionPaths`. */
|
||||
/** When true, exclude ambient factories and resolve only `additionalExtensionPaths`. */
|
||||
disableExtensionDiscovery?: boolean;
|
||||
}
|
||||
|
||||
@@ -301,14 +296,16 @@ export async function runModelsListing(options: RunModelsListingOptions): Promis
|
||||
} = options;
|
||||
|
||||
const eventBus = new EventBus();
|
||||
const extensionsResult = disableExtensionDiscovery
|
||||
? await loadExtensions(additionalExtensionPaths, cwd, eventBus)
|
||||
: await discoverAndLoadExtensions(
|
||||
[...additionalExtensionPaths, ...settingsExtensions],
|
||||
cwd,
|
||||
eventBus,
|
||||
disabledExtensionIds,
|
||||
);
|
||||
const configuredPaths = disableExtensionDiscovery
|
||||
? additionalExtensionPaths
|
||||
: [...additionalExtensionPaths, ...settingsExtensions];
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
configuredPaths,
|
||||
cwd,
|
||||
eventBus,
|
||||
disableExtensionDiscovery ? undefined : disabledExtensionIds,
|
||||
{ ambient: !disableExtensionDiscovery },
|
||||
);
|
||||
const extensionRunner =
|
||||
extensionsResult.extensions.length > 0
|
||||
? new ExtensionRunner(
|
||||
@@ -370,7 +367,7 @@ export async function runModelsCommand(command: ModelsCommandArgs): Promise<void
|
||||
}
|
||||
await modelRegistry.refresh(action === "refresh" ? "online" : "online-if-uncached");
|
||||
|
||||
const cliExtensionPaths = command.flags.noExtensions ? [] : (command.flags.extensions ?? []);
|
||||
const cliExtensionPaths = command.flags.extensions ?? [];
|
||||
await runModelsListing({
|
||||
modelRegistry,
|
||||
cwd,
|
||||
|
||||
@@ -40,6 +40,18 @@ interface InjectedRoot {
|
||||
}
|
||||
|
||||
let injectedCliRoots: InjectedRoot[] = [];
|
||||
let injectedCliRootMode: "merge" | "explicit-only" = "merge";
|
||||
|
||||
export interface InjectOmpExtensionCliRootOptions {
|
||||
/**
|
||||
* `explicit-only` exposes only roots named by this CLI invocation. Use it
|
||||
* with `--no-extensions` so configured and installed packages cannot
|
||||
* contribute sibling capabilities through the `omp-plugins` provider.
|
||||
*/
|
||||
mode?: "merge" | "explicit-only";
|
||||
/** Replace roots from an earlier invocation instead of extending them. */
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
|
||||
@@ -50,7 +62,14 @@ let injectedCliRoots: InjectedRoot[] = [];
|
||||
* Call once during startup before any capability load. Repeated calls extend
|
||||
* the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
|
||||
*/
|
||||
export function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void {
|
||||
export function injectOmpExtensionCliRoots(
|
||||
paths: readonly string[],
|
||||
home: string,
|
||||
cwd: string,
|
||||
options: InjectOmpExtensionCliRootOptions = {},
|
||||
): void {
|
||||
if (options.mode) injectedCliRootMode = options.mode;
|
||||
if (options.replace) injectedCliRoots = [];
|
||||
if (paths.length === 0) return;
|
||||
const expanded = paths.map(raw => {
|
||||
const tilde = expandTilde(raw, home);
|
||||
@@ -68,6 +87,7 @@ export function injectOmpExtensionCliRoots(paths: readonly string[], home: strin
|
||||
/** Drop every CLI-injected root. Tests use this between cases. */
|
||||
export function clearOmpExtensionCliRoots(): void {
|
||||
injectedCliRoots = [];
|
||||
injectedCliRootMode = "merge";
|
||||
}
|
||||
|
||||
/** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
|
||||
@@ -134,19 +154,21 @@ async function isDirectory(p: string): Promise<boolean> {
|
||||
* other sources still surface.
|
||||
*/
|
||||
export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]> {
|
||||
const { project, user } = scopeDirs(ctx);
|
||||
const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
|
||||
readSettingsExtensions(path.join(project, "settings.json")),
|
||||
readSettingsExtensions(path.join(user, "settings.json")),
|
||||
listInstalledPluginRoots(ctx),
|
||||
]);
|
||||
|
||||
const candidates: InjectedRoot[] = [
|
||||
...injectedCliRoots,
|
||||
...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
|
||||
...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
|
||||
...installedPlugins,
|
||||
];
|
||||
let candidates: InjectedRoot[] = [...injectedCliRoots];
|
||||
if (injectedCliRootMode === "merge") {
|
||||
const { project, user } = scopeDirs(ctx);
|
||||
const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
|
||||
readSettingsExtensions(path.join(project, "settings.json")),
|
||||
readSettingsExtensions(path.join(user, "settings.json")),
|
||||
listInstalledPluginRoots(ctx),
|
||||
]);
|
||||
candidates = [
|
||||
...candidates,
|
||||
...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
|
||||
...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
|
||||
...installedPlugins,
|
||||
];
|
||||
}
|
||||
|
||||
// First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence.
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -522,10 +522,16 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
|
||||
* `LoadExtensionsResult` directly would reuse handlers/tools/commands that
|
||||
* closed over the parent's `cwd` and event bus.
|
||||
*/
|
||||
export interface DiscoverExtensionPathOptions {
|
||||
/** Include ambient native extensions, hooks, and installed plugins. */
|
||||
ambient?: boolean;
|
||||
}
|
||||
|
||||
export async function discoverExtensionPaths(
|
||||
configuredPaths: string[],
|
||||
cwd: string,
|
||||
disabledExtensionIds?: string[],
|
||||
options: DiscoverExtensionPathOptions = {},
|
||||
): Promise<string[]> {
|
||||
const allPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -549,33 +555,41 @@ export async function discoverExtensionPaths(
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Discover extension modules via capability API (native .omp/.pi only).
|
||||
// Scope the load to the native provider — the extension-module capability
|
||||
// also has claude/codex/gemini/opencode providers, and their items were
|
||||
// discarded here anyway (see #4198). The provider filter skips the walk
|
||||
// entirely instead of running four foreign directory scans and dropping
|
||||
// the results.
|
||||
const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, {
|
||||
...loadOptions,
|
||||
providers: ["native"],
|
||||
});
|
||||
for (const ext of discovered.items) {
|
||||
addPath(ext.path);
|
||||
const ambient = options.ambient !== false;
|
||||
if (ambient) {
|
||||
// 1. Discover extension modules via capability API (native .omp/.pi only).
|
||||
// Scope the load to the native provider — the extension-module capability
|
||||
// also has claude/codex/gemini/opencode providers, and their items were
|
||||
// discarded here anyway (see #4198). The provider filter skips the walk
|
||||
// entirely instead of running four foreign directory scans and dropping
|
||||
// the results.
|
||||
const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, {
|
||||
...loadOptions,
|
||||
providers: ["native"],
|
||||
});
|
||||
for (const ext of discovered.items) {
|
||||
addPath(ext.path);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Discover JS/TS hook factories from hookCapability and bind them through
|
||||
// the extension runner, which owns the current runtime event bus. Hook
|
||||
// capability loading already applies hook-specific disabled ids; do not also
|
||||
// filter them through extension-module names.
|
||||
const hooks = await loadCapability<Hook>(hookCapability.id, loadOptions);
|
||||
// 2. Discover JS/TS hook factories and bind them through the extension
|
||||
// runner, which owns the current runtime event bus. Explicit-only discovery
|
||||
// still loads hooks from explicitly injected OMP package roots, while
|
||||
// excluding every ambient hook provider.
|
||||
const hooks = await loadCapability<Hook>(
|
||||
hookCapability.id,
|
||||
ambient ? loadOptions : { ...loadOptions, providers: ["omp-plugins"] },
|
||||
);
|
||||
for (const hookPath of hooks.items
|
||||
.map(hook => hook.path)
|
||||
.filter(hookPath => isExtensionFile(path.basename(hookPath)))) {
|
||||
addPath(hookPath);
|
||||
}
|
||||
|
||||
// 3. Discover extension entry points from installed plugins
|
||||
addPaths(await getAllPluginExtensionPaths(cwd));
|
||||
// 3. Discover extension entry points from installed plugins.
|
||||
if (ambient) {
|
||||
addPaths(await getAllPluginExtensionPaths(cwd));
|
||||
}
|
||||
|
||||
// 4. Explicitly configured paths
|
||||
for (const configuredPath of configuredPaths) {
|
||||
@@ -618,7 +632,8 @@ export async function discoverAndLoadExtensions(
|
||||
cwd: string,
|
||||
eventBus?: EventBus,
|
||||
disabledExtensionIds?: string[],
|
||||
options: DiscoverExtensionPathOptions = {},
|
||||
): Promise<LoadExtensionsResult> {
|
||||
const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds);
|
||||
const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds, options);
|
||||
return loadExtensions(paths, cwd, eventBus);
|
||||
}
|
||||
|
||||
@@ -1110,14 +1110,13 @@ export async function buildSessionOptions(
|
||||
}
|
||||
|
||||
// Additional extension paths from CLI
|
||||
const cliExtensionPaths = parsed.noExtensions ? [] : [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])];
|
||||
const cliExtensionPaths = [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])];
|
||||
if (cliExtensionPaths.length > 0) {
|
||||
options.additionalExtensionPaths = cliExtensionPaths;
|
||||
}
|
||||
|
||||
if (parsed.noExtensions) {
|
||||
options.disableExtensionDiscovery = true;
|
||||
options.additionalExtensionPaths = [];
|
||||
}
|
||||
|
||||
return options;
|
||||
@@ -1197,13 +1196,13 @@ export async function runRootCommand(
|
||||
// Register CLI-provided extension package paths (`--extension`, `--hook`) so
|
||||
// the `omp-plugins` discovery provider can surface their `skills/`, `hooks/`,
|
||||
// `tools/`, `commands/`, `rules/`, `prompts/`, and `.mcp.json` sub-trees.
|
||||
// `--no-extensions` short-circuits both the factory load and the sub-discovery.
|
||||
if (!parsedArgs.noExtensions) {
|
||||
const cliExtensions = [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])];
|
||||
if (cliExtensions.length > 0) {
|
||||
injectOmpExtensionCliRoots(cliExtensions, home, getProjectDir());
|
||||
}
|
||||
}
|
||||
// Explicit roots remain authorized under `--no-extensions`; only ambient
|
||||
// extension discovery is disabled.
|
||||
const cliExtensions = [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])];
|
||||
injectOmpExtensionCliRoots(cliExtensions, home, getProjectDir(), {
|
||||
mode: parsedArgs.noExtensions ? "explicit-only" : "merge",
|
||||
replace: true,
|
||||
});
|
||||
|
||||
let cwd = getProjectDir();
|
||||
const settingsInstance =
|
||||
|
||||
@@ -695,12 +695,15 @@ export async function discoverSessionExtensionPaths(
|
||||
cwd: string,
|
||||
settings: Settings,
|
||||
): Promise<string[]> {
|
||||
if (options.disableExtensionDiscovery) {
|
||||
return options.additionalExtensionPaths ?? [];
|
||||
}
|
||||
const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])];
|
||||
const disabledExtensionIds = settings.get("disabledExtensions") ?? [];
|
||||
return discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds);
|
||||
const configuredPaths = options.disableExtensionDiscovery
|
||||
? (options.additionalExtensionPaths ?? [])
|
||||
: [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])];
|
||||
const disabledExtensionIds = options.disableExtensionDiscovery
|
||||
? undefined
|
||||
: (settings.get("disabledExtensions") ?? []);
|
||||
return discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds, {
|
||||
ambient: !options.disableExtensionDiscovery,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-ai";
|
||||
import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { buildSessionOptions } from "@oh-my-pi/pi-coding-agent/main";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
let tempDir: TempDir;
|
||||
let authStorage: AuthStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await TempDir.create("@cli-explicit-extension-isolation-");
|
||||
authStorage = await AuthStorage.create(tempDir.join("auth.db"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
authStorage.close();
|
||||
await tempDir.remove();
|
||||
});
|
||||
|
||||
test("buildSessionOptions retains explicit extensions and hooks under --no-extensions", async () => {
|
||||
const extensionPath = tempDir.join("extension-package");
|
||||
const hookPath = tempDir.join("hook.ts");
|
||||
const parsed = parseArgs(["--no-extensions", "--extension", extensionPath, "--hook", hookPath]);
|
||||
const settings = Settings.isolated();
|
||||
const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml"));
|
||||
|
||||
const options = await buildSessionOptions(parsed, [], SessionManager.inMemory(), modelRegistry, settings);
|
||||
|
||||
expect(options.disableExtensionDiscovery).toBe(true);
|
||||
expect(options.additionalExtensionPaths).toEqual([extensionPath, hookPath]);
|
||||
});
|
||||
@@ -31,7 +31,9 @@ import "@oh-my-pi/pi-coding-agent/discovery";
|
||||
import {
|
||||
clearOmpExtensionCliRoots,
|
||||
injectOmpExtensionCliRoots,
|
||||
listOmpExtensionRoots,
|
||||
} from "@oh-my-pi/pi-coding-agent/discovery/omp-extension-roots";
|
||||
import { discoverExtensionPaths } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
|
||||
import { getConfigRootDir, removeSyncWithRetries, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const PROVIDER_ID = "omp-plugins";
|
||||
@@ -62,21 +64,22 @@ async function loadFromPlugin<T>(capabilityId: string, ctx: LoadContext): Promis
|
||||
return result.items as T[];
|
||||
}
|
||||
|
||||
function buildExtensionPackage(packageDir: string): void {
|
||||
function buildExtensionPackage(packageDir: string, skillName = "my-skill"): void {
|
||||
writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: path.basename(packageDir), omp: { extensions: ["./src/main.ts"] } }),
|
||||
);
|
||||
writeFile(path.join(packageDir, "src", "main.ts"), "export default function (_pi) {}\n");
|
||||
writeFile(
|
||||
path.join(packageDir, "skills", "my-skill", "SKILL.md"),
|
||||
"---\nname: my-skill\ndescription: Hello from extension skill\n---\nbody\n",
|
||||
path.join(packageDir, "skills", skillName, "SKILL.md"),
|
||||
`---\nname: ${skillName}\ndescription: Hello from extension skill\n---\nbody\n`,
|
||||
);
|
||||
writeFile(path.join(packageDir, "commands", "greet.md"), "---\ndescription: greet user\n---\nHello {{name}}\n");
|
||||
writeFile(path.join(packageDir, "rules", "style.md"), "---\ndescription: style rule\n---\nUse tabs.\n");
|
||||
writeFile(path.join(packageDir, "prompts", "review.md"), "Review this code.\n");
|
||||
writeFile(path.join(packageDir, "hooks", "pre", "bash.sh"), "#!/bin/sh\necho pre\n");
|
||||
writeFile(path.join(packageDir, "hooks", "post", "edit.sh"), "#!/bin/sh\necho post\n");
|
||||
writeFile(path.join(packageDir, "hooks", "pre", "extension.ts"), "export default function (_pi) {}\n");
|
||||
writeFile(path.join(packageDir, "tools", "wcount.sh"), "#!/bin/sh\nwc -w\n");
|
||||
writeFile(path.join(packageDir, "tools", "deep-tool", "index.ts"), "export default { name: 'deep-tool' };\n");
|
||||
writeFile(
|
||||
@@ -155,6 +158,43 @@ test("`--extension` CLI injection is wired through the same provider", async ()
|
||||
expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(["wcount", "deep-tool"]));
|
||||
});
|
||||
|
||||
test("explicit-only CLI roots replace stale state and exclude every ambient package source", async () => {
|
||||
const stale = path.join(tempDir, "stale-extension");
|
||||
const projectExt = path.join(tempDir, "project-extension");
|
||||
const userExt = path.join(tempDir, "user-extension");
|
||||
const installed = path.join(home, ".omp", "plugins", "node_modules", "installed-extension");
|
||||
buildExtensionPackage(stale, "stale-skill");
|
||||
buildExtensionPackage(projectExt, "project-skill");
|
||||
buildExtensionPackage(userExt, "user-skill");
|
||||
buildExtensionPackage(installed, "installed-skill");
|
||||
writeFile(path.join(project, ".omp", "settings.json"), JSON.stringify({ extensions: [projectExt] }));
|
||||
writeFile(path.join(home, ".omp", "agent", "settings.json"), JSON.stringify({ extensions: [userExt] }));
|
||||
writeFile(
|
||||
path.join(home, ".omp", "plugins", "package.json"),
|
||||
JSON.stringify({ name: "omp-plugins", dependencies: { "installed-extension": "1.0.0" } }),
|
||||
);
|
||||
|
||||
injectOmpExtensionCliRoots([stale], home, project);
|
||||
injectOmpExtensionCliRoots([ext], home, project, { mode: "explicit-only", replace: true });
|
||||
|
||||
const roots = await listOmpExtensionRoots(ctx());
|
||||
const skills = await loadFromPlugin<{ name: string }>(skillCapability.id, ctx());
|
||||
const extensionPaths = await discoverExtensionPaths([ext], project, undefined, { ambient: false });
|
||||
|
||||
expect(roots).toHaveLength(1);
|
||||
expect(path.basename(roots[0].path)).toBe("my-extension");
|
||||
expect(skills.map(skill => skill.name)).toContain("my-skill");
|
||||
expect(skills.map(skill => skill.name)).not.toEqual(
|
||||
expect.arrayContaining(["stale-skill", "project-skill", "user-skill", "installed-skill"]),
|
||||
);
|
||||
expect(extensionPaths).toContain(path.join(ext, "hooks", "pre", "extension.ts"));
|
||||
expect(
|
||||
extensionPaths.some(candidate =>
|
||||
[stale, projectExt, userExt, installed].some(ambientRoot => candidate.startsWith(ambientRoot)),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("file-extension entrypoints contribute zero sub-surface (the file has no siblings to scan)", async () => {
|
||||
const standaloneFile = path.join(tempDir, "standalone.ts");
|
||||
fs.writeFileSync(standaloneFile, "export default function (_pi) {}\n");
|
||||
|
||||
@@ -138,6 +138,31 @@ describe("extensions discovery", () => {
|
||||
expect(result.extensions[0].path).toContain("main.ts");
|
||||
});
|
||||
|
||||
it("explicit-only discovery resolves a package manifest and excludes ambient factories", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "ambient.ts"), extensionCodeWithTool("ambient-tool"));
|
||||
const packageDir = path.join(tempDir.path(), "explicit-package");
|
||||
const sourceDir = path.join(packageDir, "src");
|
||||
fs.mkdirSync(sourceDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sourceDir, "main.ts"), extensionCodeWithTool("explicit-tool"));
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "explicit-package",
|
||||
omp: {
|
||||
extensions: ["./src/main.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([packageDir], tempDir.path(), undefined, undefined, {
|
||||
ambient: false,
|
||||
});
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions.map(extension => extension.path)).toEqual([path.join(sourceDir, "main.ts")]);
|
||||
expect(result.extensions.flatMap(extension => [...extension.tools.keys()])).toEqual(["explicit-tool"]);
|
||||
});
|
||||
|
||||
it("discovers a symlinked extension package directory", async () => {
|
||||
const packageDir = path.join(tempDir.path(), "linked-package");
|
||||
const sourceDir = path.join(packageDir, "src");
|
||||
|
||||
@@ -21,6 +21,8 @@ import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
let tmp: TempDir;
|
||||
let extPath: string;
|
||||
let explicitPackagePath: string;
|
||||
let ambientExtPath: string;
|
||||
let dbPath: string;
|
||||
let shutdownExtPath: string;
|
||||
let shutdownPath: string;
|
||||
@@ -58,6 +60,53 @@ beforeAll(async () => {
|
||||
await Bun.write(${JSON.stringify(shutdownPath)}, "shutdown");
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
explicitPackagePath = tmp.join("explicit-package");
|
||||
ambientExtPath = tmp.join("ambient.ts");
|
||||
await fs.mkdir(tmp.join("explicit-package", "src"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
tmp.join("explicit-package", "package.json"),
|
||||
JSON.stringify({ name: "explicit-package", omp: { extensions: ["./src/main.ts"] } }),
|
||||
);
|
||||
await fs.writeFile(
|
||||
tmp.join("explicit-package", "src", "main.ts"),
|
||||
`export default function (pi) {
|
||||
pi.registerProvider("explicit-gw", {
|
||||
baseUrl: "https://explicit.example.com/v1",
|
||||
apiKey: "literal-test-key",
|
||||
api: "openai-completions",
|
||||
models: [{
|
||||
id: "explicit-model",
|
||||
name: "Explicit Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
}],
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
ambientExtPath,
|
||||
`export default function (pi) {
|
||||
pi.registerProvider("ambient-gw", {
|
||||
baseUrl: "https://ambient.example.com/v1",
|
||||
apiKey: "literal-test-key",
|
||||
api: "openai-completions",
|
||||
models: [{
|
||||
id: "ambient-model",
|
||||
name: "Ambient Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
}],
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
});
|
||||
@@ -118,6 +167,40 @@ test("omp models emits extension shutdown after listing (issue #6297)", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("omp models explicit-only mode resolves a package and excludes settings providers", async () => {
|
||||
const authStorage = await AuthStorage.create(":memory:");
|
||||
try {
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const captured: string[] = [];
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
captured.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
try {
|
||||
await runModelsListing({
|
||||
modelRegistry,
|
||||
cwd: tmp.path(),
|
||||
action: "ls",
|
||||
additionalExtensionPaths: [explicitPackagePath],
|
||||
settingsExtensions: [ambientExtPath],
|
||||
disableExtensionDiscovery: true,
|
||||
});
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
|
||||
const output = captured.join("");
|
||||
expect(output).toContain("explicit-gw");
|
||||
expect(output).toContain("explicit-model");
|
||||
expect(output).not.toContain("ambient-gw");
|
||||
expect(output).not.toContain("ambient-model");
|
||||
} finally {
|
||||
authStorage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("omp models prints invalid models.yml schema errors before listing output", async () => {
|
||||
const modelsPath = tmp.join("invalid-models.yml");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -139,4 +139,36 @@ describe("discoverAgents", () => {
|
||||
expect(collide?.description).toBe("from-cli");
|
||||
expect(collide?.filePath).toBe(path.join(cliExt, "agents", "collide.md"));
|
||||
});
|
||||
|
||||
test("explicit-only CLI roots expose only explicitly named package agents", async () => {
|
||||
const staleExt = path.join(tempHome, "stale-ext");
|
||||
const explicitExt = path.join(tempHome, "explicit-ext");
|
||||
const settingsExt = path.join(tempHome, "settings-ext");
|
||||
for (const [root, name] of [
|
||||
[staleExt, "stale-agent"],
|
||||
[explicitExt, "explicit-agent"],
|
||||
[settingsExt, "settings-agent"],
|
||||
] as const) {
|
||||
await fs.mkdir(path.join(root, "agents"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "agents", `${name}.md`),
|
||||
["---", `name: ${name}`, `description: ${name}`, "---", `${name} body`].join("\n"),
|
||||
);
|
||||
}
|
||||
await fs.mkdir(path.join(projectDir, ".omp"), { recursive: true });
|
||||
await fs.writeFile(path.join(projectDir, ".omp", "settings.json"), JSON.stringify({ extensions: [settingsExt] }));
|
||||
await writeOmpPluginAgent(tempHome);
|
||||
|
||||
injectOmpExtensionCliRoots([staleExt], tempHome, projectDir);
|
||||
injectOmpExtensionCliRoots([explicitExt], tempHome, projectDir, {
|
||||
mode: "explicit-only",
|
||||
replace: true,
|
||||
});
|
||||
|
||||
const { agents } = await discoverAgents(projectDir, tempHome);
|
||||
const names = agents.map(agent => agent.name);
|
||||
|
||||
expect(names).toContain("explicit-agent");
|
||||
expect(names).not.toEqual(expect.arrayContaining(["stale-agent", "settings-agent", "loom-verify-spec"]));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user