fix(coding-agent): preserve explicit extensions in isolation

This commit is contained in:
Sunil Srivatsa
2026-08-01 12:32:19 -04:00
parent 09a7c86563
commit 8a7edb3f3d
12 changed files with 336 additions and 70 deletions
+13 -2
View File
@@ -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
+5
View File
@@ -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
+13 -16
View File
@@ -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);
}
+8 -9
View File
@@ -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 =
+9 -6
View File
@@ -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"]));
});
});