From 78aafc3bdbc4699167237c69534d88b64ed6a384 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 11 Sep 2026 20:33:18 +0000 Subject: [PATCH 01/14] feat: bridge hook-guard policy into native OMP hooks --- CHANGELOG.md | 1 + README.md | 53 +++++++++++- graphify/__main__.py | 3 +- graphify/install.py | 20 +++++ graphify/omp/index.ts | 134 ++++++++++++++++++++++++++++++ graphify/omp/package.json | 13 +++ pyproject.toml | 3 +- tests/omp.test.ts | 168 ++++++++++++++++++++++++++++++++++++++ tests/test_omp_install.py | 45 ++++++++++ 9 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 graphify/omp/index.ts create mode 100644 graphify/omp/package.json create mode 100644 tests/omp.test.ts create mode 100644 tests/test_omp_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515f..fd420caa7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.58 (2026-09-10) +- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-run context guidance without building indexes. - Fix: a call to a Python function defined nested inside another function now resolves to that inner definition per lexical scope, instead of leaking to a same-named function elsewhere; direct recursion is preserved as a self-loop (#3410, thanks @hopstreax). - Fix: submodule imports inside a PEP 420 namespace package (a directory with no `__init__.py`) now resolve to the target module instead of being dropped (#3429, thanks @flaukowski). - Fix: a bare-name import of a module sitting next to the importing file (a flat script dir with no package) now resolves to that sibling — matching CPython's `sys.path[0]` behavior — without over-resolving a genuine third-party name (#3430, thanks @hopstreax). diff --git a/README.md b/README.md index acbd57ecb3..cfc6b873e6 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ for example `graphify claude install --project` or `graphify codex install --pro | Agent Skills (cross-framework) | `graphify install --platform agents` (alias `--platform skills`) | | Kiro IDE/CLI | `graphify kiro install` | | Pi coding agent | `graphify install --platform pi` | +| Oh My Pi (native guard) | `graphify omp install` | | Cursor | `graphify cursor install` | | Devin CLI | `graphify devin install` | | Google Antigravity | `graphify antigravity install` | @@ -303,6 +304,7 @@ Run this once in your project after building a graph: | Agent Skills (cross-framework) | `graphify agents install` (alias `graphify skills install`) | | Kiro IDE/CLI | `graphify kiro install` | | Pi coding agent | `graphify pi install` | +| Oh My Pi (native guard) | `graphify omp install` | | Devin CLI | `graphify devin install` | | Google Antigravity | `graphify antigravity install` | @@ -321,7 +323,53 @@ This writes a small config file that tells your assistant to consult the knowled **Cursor** writes `.cursor/rules/graphify.mdc` with `alwaysApply: true`, so Cursor includes it in every conversation automatically, no hook needed. -To remove graphify from all platforms at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`). +### Oh My Pi (OMP) native guard + +Install Graphify into a persistent environment with `uv tool install graphifyy` or +`pipx install graphifyy`, and install OMP separately. Then run: + +```bash +graphify omp install +``` + +This delegates to OMP's supported `omp plugin install ` +route. The Python wheel and source distribution contain `graphify/omp/package.json` +with explicit `omp.extensions` and its TypeScript entry point; OMP links that +directory and discovers the extension. No npm adapter package, shell hook, Pi API +shim, or hand-edited OMP settings are required. `graphify omp path` prints the same +directory for manual `omp plugin install` or one-session `omp -e` use. Restart OMP +after installing/upgrading, and rerun the installer if the Python environment +moves. Do not link from an ephemeral `uvx` environment. + +Before native `read`, `glob`, `grep`, and search-style `bash` calls, the extension +runs the installed `graphify hook-guard read|search` CLI. The existing Python +policy owns fresh/stale graph decisions and strict-mode denials: start OMP with +`GRAPHIFY_HOOK_STRICT=1` to enable its once-per-session indexed-read block. +Denials become OMP `block`/`reason`; guidance becomes one deduplicated context +message, cleared for each new user run and session navigation. In-flight hooks +are cancelled on these boundaries. No graph is created or updated automatically. +The guard package intentionally declares no skills; the existing cross-framework +skill remains available separately through `graphify agents install`. + +Only local filesystem targets are inspected, using OMP's selector/path helpers. +URLs and internal resources are excluded. Hooks require an installed `graphify` +on an absolute PATH entry outside the project (including outside a project-local +virtualenv); there is no project Python or command fallback. Each native tool call +has a 256 KiB JSON-input cap, a 64 KiB output cap and a two-second subprocess budget +with forced termination. Missing commands, invalid output and failures fail open. + +The extension checks `ctx.isProjectTrusted()` before execution and context +injection, but **current OMP exposes this compatibility method as always true**: +it is not an enforced trust sandbox. Enable this integration only in projects you +trust. The lifecycle contract is based on OMP integration commit +`6aef0e8ad51b3bc5ea7a5f2a255c3d48e4c5af72`; the `18.1.17` version string alone +does not establish those lifecycle fixes. + +Remove this host-managed link with `omp plugin uninstall graphify-omp` **before** +uninstalling the Python package. `graphify uninstall` handles Graphify-managed +platform files, not OMP's plugin registry. + +To remove Graphify-managed platform files at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`). OMP links are removed separately as described above. --- @@ -762,6 +810,9 @@ graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md graphify kiro uninstall graphify pi install # skill file (Pi coding agent) graphify pi uninstall +graphify omp install # native guard package, registered by OMP +graphify omp path # shipped package directory for manual linking +omp plugin uninstall graphify-omp # remove the host-managed link graphify devin install # skill file + .windsurf/rules/graphify.md (Devin CLI) graphify devin uninstall graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity) diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240f..05a1b62dbc 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -517,7 +517,7 @@ def _run_cli() -> None: # Skip during install/uninstall (hook writes trigger a fresh check anyway). # Skip during hook-check — it runs on every editor tool use and must be silent. # Deduplicate paths so platforms sharing the same install dir don't warn twice. - _silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"} + _silent_cmds = {"install", "uninstall", "hook-check", "hook-guard", "omp"} if not any(arg in _silent_cmds for arg in sys.argv): # Resolve each platform's real user-scope destination so per-platform # overrides (gemini, opencode, devin, antigravity, amp) check the dir @@ -536,6 +536,7 @@ def _run_cli() -> None: print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)") print(" uninstall remove graphify from all detected platforms in one shot") print(" --purge also delete graphify-out/ directory") + print(" omp [install|path] install the native Oh My Pi guard, or print its package path") print(" path \"A\" \"B\" shortest path between two nodes in graph.json") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" explain \"X\" plain-language explanation of a node and its neighbors") diff --git a/graphify/install.py b/graphify/install.py index 2fb2192760..e798c51ed3 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -2088,6 +2088,7 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals "kilo", "kiro", "opencode", + "omp", "pi", "skills", "trae", @@ -2105,6 +2106,25 @@ def dispatch_install_cli(cmd: str) -> bool: """ if cmd not in _CLI_INSTALL_COMMANDS: return False + if cmd == "omp": + # OMP owns package registration; do not duplicate its config/paths here. + args = sys.argv[2:] + if args not in (["path"], ["install"]): + print("Usage: graphify omp [path|install]", file=sys.stderr) + sys.exit(1) + package_path = Path(__file__).resolve().parent / "omp" + if args == ["path"]: + print(package_path) + return True + omp = shutil.which("omp") + if not omp: + print("error: install Oh My Pi (omp) and add it to PATH first", file=sys.stderr) + sys.exit(1) + import subprocess + result = subprocess.run([omp, "plugin", "install", str(package_path)], check=False) + if result.returncode: + sys.exit(result.returncode) + return True if cmd == "install": # Default to windows platform on Windows, claude elsewhere default_platform = "windows" if platform.system() == "Windows" else "claude" diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts new file mode 100644 index 0000000000..a018b20a19 --- /dev/null +++ b/graphify/omp/index.ts @@ -0,0 +1,134 @@ +import { execFile } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { delimiter, isAbsolute, relative, sep } from "node:path"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import { + expandDelimitedPathEntries, + isInternalUrlPath, + isReadableUrlPath, + normalizePathLikeInput, + parseSearchPath, + resolveReadPath, + splitPathAndSelPreferringLiteral, +} from "@oh-my-pi/pi-coding-agent/tools/path-utils"; + +const INPUT_LIMIT = 256 * 1024; +const OUTPUT_LIMIT = 64 * 1024; +const TIMEOUT_MS = 2000; +const CONTEXT_TYPE = "graphify-guard"; +const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const; + +function isRemote(path: string): boolean { + return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://"); +} + +function installedCommand(cwd: string): string | undefined { + // Never resolve a bare/relative PATH entry against the project or use a + // project-supplied Python module as a fallback for an absent installation. + const path = (process.env.PATH ?? "").split(delimiter).filter(isAbsolute).join(delimiter); + const found = path ? Bun.which("graphify", { PATH: path }) : null; + if (!found) return; + const command = realpathSync(found); + const within = relative(realpathSync(cwd), command); + if (!within || (!within.startsWith(`..${sep}`) && !isAbsolute(within))) return; + return command; +} + +function runGuard(command: string, kind: string, payload: string, cwd: string, signal: AbortSignal, timeout: number, maxBuffer: number): Promise { + if (Buffer.byteLength(payload) > INPUT_LIMIT || signal.aborted) return Promise.resolve(undefined); + const { promise, resolve } = Promise.withResolvers(); + const child = execFile(command, ["hook-guard", kind], { + cwd, encoding: "utf8", timeout, killSignal: "SIGKILL", maxBuffer, signal, + env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }, + }, (error, stdout) => resolve(error ? undefined : stdout.trim() || undefined)); + child.stdin?.on("error", () => {}); + child.stdin?.end(payload); + return promise; +} + +export default function graphify(api: ExtensionAPI): void { + const guidance = new Set(); + let guidanceBytes = 0; + let generation = 0; + let controller = new AbortController(); + const reset = () => { + generation++; + controller.abort(); + controller = new AbortController(); + guidance.clear(); + guidanceBytes = 0; + }; + api.on("session_start", reset); + api.on("session_switch", reset); + api.on("session_branch", reset); + api.on("session_tree", reset); + api.on("session_shutdown", reset); + api.on("before_agent_start", reset); + + api.on("tool_call", async (event, ctx) => { + if (!ctx.isProjectTrusted()) { reset(); return; } + if (!Object.hasOwn(TOOL_NAMES, event.toolName)) return; + const current = generation; + const signal = controller.signal; + const deadline = performance.now() + TIMEOUT_MS; + try { + if (Buffer.byteLength(JSON.stringify(event.input)) > INPUT_LIMIT) return; + const command = installedCommand(ctx.cwd); + if (!command) return; + const toolName = TOOL_NAMES[event.toolName as keyof typeof TOOL_NAMES]; + const rawPath = typeof event.input.path === "string" ? normalizePathLikeInput(event.input.path) : "."; + if (isRemote(rawPath)) return; + const paths = toolName === "Read" || toolName === "Bash" ? [rawPath] : + await expandDelimitedPathEntries([rawPath], ctx.cwd, { splitter: parseSearchPath }); + let remainingOutput = OUTPUT_LIMIT; + for (const path of paths) { + if (isRemote(path)) continue; + const input: Record = { ...event.input }; + if (toolName !== "Bash") { + const target = toolName === "Glob" ? path : (await splitPathAndSelPreferringLiteral(path, ctx.cwd)).path; + const resolved = resolveReadPath(target, ctx.cwd); + delete input.path; + if (toolName === "Read") input.file_path = resolved; + else if (toolName === "Glob") input.pattern = resolved; + else input.path = resolved; + } + const timeout = Math.floor(deadline - performance.now()); + if (current !== generation || !ctx.isProjectTrusted() || timeout <= 0 || remainingOutput <= 0) return; + const output = await runGuard(command, toolName === "Bash" || toolName === "Grep" ? "search" : "read", JSON.stringify({ + session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, tool_name: toolName, tool_input: input, + }), ctx.cwd, signal, timeout, remainingOutput); + if (current !== generation || !ctx.isProjectTrusted()) return; + if (!output) continue; + remainingOutput -= Buffer.byteLength(output); + const hook = JSON.parse(output)?.hookSpecificOutput; + if (hook?.hookEventName !== "PreToolUse") continue; + if (hook.permissionDecision === "deny" && typeof hook.permissionDecisionReason === "string" && hook.permissionDecisionReason.trim()) { + return { block: true, reason: hook.permissionDecisionReason }; + } + if (typeof hook.additionalContext === "string" && hook.additionalContext.trim() && !guidance.has(hook.additionalContext)) { + const bytes = Buffer.byteLength(hook.additionalContext) + 2; + if (guidanceBytes + bytes <= OUTPUT_LIMIT) { + guidance.add(hook.additionalContext); + guidanceBytes += bytes; + } + } + // The search CLI does not inspect individual targets; one call suffices. + if (toolName === "Grep") break; + } + } catch { + // Optional guidance must not break native tools on missing executables, + // invalid paths, malformed hook output, timeout, or cancellation. + } + }); + + api.on("context", (event, ctx) => { + if (!ctx.isProjectTrusted()) reset(); + // Context transforms are not persisted. Keep one current-run message across + // provider requests, and discard any prior generation's injected message. + const messages = event.messages.filter(message => message.role !== "custom" || message.customType !== CONTEXT_TYPE); + if (guidance.size) messages.push({ + role: "custom", customType: CONTEXT_TYPE, content: [...guidance].join("\n\n"), display: false, timestamp: Date.now(), + }); + if (guidance.size || messages.length !== event.messages.length) return { messages }; + }); +} diff --git a/graphify/omp/package.json b/graphify/omp/package.json new file mode 100644 index 0000000000..7decfb369e --- /dev/null +++ b/graphify/omp/package.json @@ -0,0 +1,13 @@ +{ + "name": "graphify-omp", + "version": "0.9.63", + "private": true, + "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", + "license": "Apache-2.0", + "type": "module", + "files": ["index.ts"], + "omp": { + "extensions": ["./index.ts"], + "skills": [] + } +} diff --git a/pyproject.toml b/pyproject.toml index 18558051bc..8b91ee7c69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,7 +147,8 @@ include-package-data = false # under graphify/skills//references/, and the always-on injection blocks # under graphify/always_on/. There is no graphify/skills//SKILL.md in the # repo, so no SKILL.md glob is needed here. -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md"] +# Native OMP package: linked by OMP itself from the Python installation. +graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md", "omp/package.json", "omp/index.ts"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/omp.test.ts b/tests/omp.test.ts new file mode 100644 index 0000000000..095d6cb8b6 --- /dev/null +++ b/tests/omp.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import graphify from "../graphify/omp/index.ts"; + +// Use the real installed Python CLI for policy tests. No mock of OMP path helpers. +const realCLI = process.env.GRAPHIFY_TEST_CLI ?? Bun.which("graphify"); +if (!realCLI) throw new Error("Install graphifyy or set GRAPHIFY_TEST_CLI to its executable before running this test"); +const savedPath = process.env.PATH; +const savedStrict = process.env.GRAPHIFY_HOOK_STRICT; +let root: string; +let cwd: string; +let control: string; +let started: string; +let fixture: string; + +type Result = { block?: boolean; reason?: string; messages?: { content: string }[] } | undefined; +type Handler = (event: Record, ctx: object) => Result | Promise; +function harness() { + const handlers = new Map(); + let trusted = true; + const ctx = { cwd, isProjectTrusted: () => trusted, sessionManager: { getSessionId: () => "omp-test-session" } }; + // Only the event registry is needed; every callback is the real extension's. + const api = { on: (event: string, handler: Handler) => handlers.set(event, handler) } as unknown as ExtensionAPI; + graphify(api); + return { + trust(value: boolean) { trusted = value; }, + async emit(event: string, payload: Record = {}) { return await handlers.get(event)?.({ type: event, ...payload }, ctx); }, + }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "graphify-omp-")); + cwd = join(root, "project"); + mkdirSync(cwd); + mkdirSync(join(root, "bin")); + control = join(root, "control.json"); + started = join(root, "started"); + fixture = join(root, "bin", "graphify"); + writeFileSync(control, JSON.stringify({ mode: "real" })); + // A controlled installed executable outside the project exercises the real + // subprocess boundary (including cancellation and output limits). + // Real subprocess timeouts cannot be advanced by the parent test's fake clock. + writeFileSync(fixture, `#!${process.execPath}\nimport {execFileSync} from "node:child_process"; +import {readFileSync,writeFileSync,existsSync} from "node:fs"; +const input = await Bun.stdin.text(); +const config = JSON.parse(readFileSync(${JSON.stringify(control)}, "utf8")); +writeFileSync(${JSON.stringify(started)}, input); +if (config.mode === "real") process.stdout.write(execFileSync(${JSON.stringify(realCLI)}, process.argv.slice(2), {input})); +else if (config.mode === "invalid") process.stdout.write("not json"); +else if (config.mode === "overflow") process.stdout.write("x".repeat(65537)); +else if (config.mode === "delayed") { + while (!existsSync(${JSON.stringify(join(root, "release"))})) await Bun.sleep(5); + process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "stale guidance"}})); +} else if (config.mode === "hung") { + process.on("SIGTERM", () => {}); + await Bun.sleep(60000); +} +`); + chmodSync(fixture, 0o755); + process.env.PATH = dirname(fixture); + delete process.env.GRAPHIFY_HOOK_STRICT; + writeFileSync(join(cwd, "source.py"), "def example(): pass\n"); + mkdirSync(join(cwd, "graphify-out")); + writeFileSync(join(cwd, "graphify-out", "graph.json"), JSON.stringify({ nodes: [], links: [] })); + writeFileSync(join(cwd, "graphify-out", "manifest.json"), JSON.stringify({ "source.py": {} })); + const fresh = new Date(Date.now() + 1000); + utimesSync(join(cwd, "graphify-out", "graph.json"), fresh, fresh); +}); + +afterEach(() => { + process.env.PATH = savedPath; + if (savedStrict === undefined) delete process.env.GRAPHIFY_HOOK_STRICT; + else process.env.GRAPHIFY_HOOK_STRICT = savedStrict; + rmSync(root, { recursive: true, force: true }); +}); + +test("real CLI strict denial blocks selector reads; subsequent guidance dedupes and resets", async () => { + process.env.GRAPHIFY_HOOK_STRICT = "1"; + const api = harness(); + await api.emit("before_agent_start"); + const event = { toolName: "read", input: { path: "source.py:1-5" } }; + const result = await api.emit("tool_call", event); + expect(result?.block).toBe(true); + expect(result?.reason).toContain("graphify"); + expect(existsSync(join(cwd, "graphify-out", "cache", "hook_sessions", "omp-test-session.denied"))).toBe(true); + expect(await api.emit("tool_call", event)).toBeUndefined(); + const first = await api.emit("context", { messages: [] }); + expect(first?.messages).toHaveLength(1); + await api.emit("tool_call", event); + const second = await api.emit("context", { messages: first?.messages }); + expect(second?.messages).toHaveLength(1); + expect(second?.messages?.[0].content).toBe(first?.messages?.[0].content); + await api.emit("before_agent_start"); + expect((await api.emit("context", { messages: second?.messages }))?.messages).toEqual([]); +}); + +test("native grep, bash search, and glob expose the installed CLI's actual guidance", async () => { + for (const [toolName, input, kind, legacyInput] of [ + ["grep", { pattern: "example", path: "source.py:1" }, "search", { pattern: "example" }], + ["bash", { command: "rg example ." }, "search", { command: "rg example ." }], + ["glob", { path: "**/*.py" }, "read", { pattern: "**/*.py" }], + ] as const) { + const api = harness(); + const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", kind], { + cwd, input: JSON.stringify({ tool_input: legacyInput }), encoding: "utf8", + })).hookSpecificOutput.additionalContext; + expect(await api.emit("tool_call", { toolName, input })).toBeUndefined(); + expect((await api.emit("context", { messages: [] }))?.messages?.[0].content).toBe(expected); + } +}); + +test("URLs, internal resources, literal selector-like names and false trust do not run project hooks", async () => { + const api = harness(); + for (const path of ["https://example.com/source.py", "www.example.com/source.py", "skill://graphify", "local:/source.py", "source.py; ssh://host/source.py"]) { + await api.emit("tool_call", { toolName: "read", input: { path } }); + expect(existsSync(started)).toBe(false); + } + api.trust(false); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + expect(existsSync(started)).toBe(false); + api.trust(true); + writeFileSync(join(cwd, "source.py:12"), "a real filename, not a selector"); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" } }); + expect(await api.emit("context", { messages: [] })).toBeUndefined(); +}); + +test("navigation cancels in-flight guidance before the next session's context", async () => { + for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { + rmSync(started, { force: true }); + writeFileSync(control, JSON.stringify({ mode: "delayed" })); + const api = harness(); + const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + const deadline = Date.now() + 1500; + while (!existsSync(started) && Date.now() < deadline) await Bun.sleep(5); + expect(existsSync(started)).toBe(true); + await api.emit(navigation); + expect(await pending).toBeUndefined(); + expect(await api.emit("context", { messages: [] })).toBeUndefined(); + } +}); + +test("oversized input never spawns, invalid/oversized output fails open, and a hung child is killed", async () => { + const api = harness(); + await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) } }); + expect(existsSync(started)).toBe(false); + for (const mode of ["invalid", "overflow", "hung"]) { + writeFileSync(control, JSON.stringify({ mode })); + const start = performance.now(); + expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } })).toBeUndefined(); + expect(performance.now() - start).toBeLessThan(3500); + expect(await api.emit("context", { messages: [] })).toBeUndefined(); + } +}, 10000); + +test("missing or project-local executables never fall back to project Python code", async () => { + process.env.PATH = cwd; + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + expect(existsSync(started)).toBe(false); + writeFileSync(join(cwd, "graphify"), readFileSync(fixture)); + chmodSync(join(cwd, "graphify"), 0o755); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + expect(existsSync(started)).toBe(false); +}); diff --git a/tests/test_omp_install.py b/tests/test_omp_install.py new file mode 100644 index 0000000000..af0edc4c2c --- /dev/null +++ b/tests/test_omp_install.py @@ -0,0 +1,45 @@ +"""OMP owns registration; Graphify supplies a wheel-contained native package.""" +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +from graphify.install import dispatch_install_cli + + +def test_omp_path_is_a_complete_native_package(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "path"]) + assert dispatch_install_cli("omp") + package = Path(capsys.readouterr().out.strip()) + manifest = json.loads((package / "package.json").read_text(encoding="utf-8")) + assert manifest["omp"]["extensions"] + for entry in manifest["omp"]["extensions"]: + assert (package / entry).is_file() + assert (package / entry).resolve().is_relative_to(package) + + +def test_omp_install_delegates_to_host_and_propagates_failure(monkeypatch): + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "install"]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: "/usr/bin/omp") + + def host(argv, *, check): + assert argv[:3] == ["/usr/bin/omp", "plugin", "install"] + assert (Path(argv[3]) / "package.json").is_file() + return SimpleNamespace(returncode=23) + + monkeypatch.setattr("subprocess.run", host) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("omp") + assert error.value.code == 23 + + +def test_omp_install_without_host_does_not_create_configuration(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "install"]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: None) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("omp") + assert error.value.code == 1 + assert not list(tmp_path.iterdir()) From 4d2cee6ffa116b11eca7543a8b7a88d801eb93f9 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 15 Sep 2026 10:13:10 +0000 Subject: [PATCH 02/14] test(omp): keep the embedded package version in sync --- tests/test_omp_install.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_omp_install.py b/tests/test_omp_install.py index af0edc4c2c..98a75edede 100644 --- a/tests/test_omp_install.py +++ b/tests/test_omp_install.py @@ -8,6 +8,11 @@ from graphify.install import dispatch_install_cli +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib # type: ignore[no-redef] + def test_omp_path_is_a_complete_native_package(monkeypatch, capsys): monkeypatch.setattr(sys, "argv", ["graphify", "omp", "path"]) @@ -15,6 +20,8 @@ def test_omp_path_is_a_complete_native_package(monkeypatch, capsys): package = Path(capsys.readouterr().out.strip()) manifest = json.loads((package / "package.json").read_text(encoding="utf-8")) assert manifest["omp"]["extensions"] + pyproject = tomllib.loads((package.parents[1] / "pyproject.toml").read_text(encoding="utf-8")) + assert manifest["version"] == pyproject["project"]["version"] for entry in manifest["omp"]["extensions"]: assert (package / entry).is_file() assert (package / entry).resolve().is_relative_to(package) From f38f25b7377d2fadc8aed270f067ef0676a729a6 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 16 Sep 2026 19:28:48 +0000 Subject: [PATCH 03/14] test(omp): run the native bridge tests in CI --- .github/workflows/ci.yml | 27 +++ .gitignore | 3 + graphify/omp/bun.lock | 385 ++++++++++++++++++++++++++++++++++++++ graphify/omp/package.json | 3 + 4 files changed, 418 insertions(+) create mode 100644 graphify/omp/bun.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f5e373f7..ebc2a258d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,33 @@ jobs: uv run --frozen graphify --help uv run --frozen graphify install + omp-bridge: + # tests/omp.test.ts drives the real OMP extension: bun resolves the + # path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent + # devDependency in graphify/omp/, and the policy assertions run against + # the real installed Python CLI, so this job needs both toolchains. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: uv sync --frozen + + - name: Install OMP extension dependencies + run: bun install --frozen-lockfile + working-directory: graphify/omp + + - name: Run OMP bridge tests + run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new # HIGH-severity finding or vulnerable dependency is caught on the PR that diff --git a/.gitignore b/.gitignore index 0a6775b2a8..dba5741e31 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ paper/ # macOS Finder metadata .DS_Store + +# OMP extension test dependency tree (graphify/omp) +graphify/omp/node_modules/ diff --git a/graphify/omp/bun.lock b/graphify/omp/bun.lock new file mode 100644 index 0000000000..41e2490504 --- /dev/null +++ b/graphify/omp/bun.lock @@ -0,0 +1,385 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "graphify-omp", + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "^18.2.2", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@huggingface/jinja": ["@huggingface/jinja@0.5.10", "", {}, "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw=="], + + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.2.0", "", {}, "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg=="], + + "@huggingface/transformers": ["@huggingface/transformers@4.3.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.10", "@huggingface/tokenizers": "^0.2.0", "onnxruntime-node": "1.30.0", "onnxruntime-web": "1.31.0-dev.20260914-8d85527a0", "sharp": "^0.35.4" } }, "sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-2NQ6Xe9M6859FMiUF9qiD2hG/Eht+N1sakJ5q1+liUdt0g/jNAmjErGTvGDKOXKq5niz362R+oLo4xrP0wJrFg=="], + + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.2.2", "", {}, "sha512-tG9W0Acl0Sg0iONdXG744J9TRjxU9qgKmfdivD/NfZ/0fQ1mr5hajx0yk/Jm4KzfVazwZErCqiH4Qce7OL02hg=="], + + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1" } }, "sha512-sEzkNqbKRvBb1EMhEG5n1+AGd9q4UT3B5vto21EGyZhF7dSNdYtcMAfqUMQZLSP/kgu2RzUnvXZTDHbvKWFZxw=="], + + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-3fqCIzHLL/g53MdsUZHR3RvWaQYh+VLSJe2D99KnlZGGWqzFRfXFG6YgSzLQg9UdjPkdyJfivFNi+D0Gt0lYew=="], + + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-FgUqjLdWIMRfBhtC3WzOOBgC5iWIeRer59eoPclMzlXc5WeV8AxIeZoEOQuYCsCpTembgPeDfTIzFKr1zf/mug=="], + + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.2.2", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/omp-stats": "18.2.2", "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-agent-core": "18.2.2", "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-mnemopi": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-tui": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-rZmmx2f9UZkK2rRteCFwxxcDAdxXfz9AD032fZJdAdyfa1CFWixns9tsMML3DdNOytD1619QILna/IxsMspbgA=="], + + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-+wgJYNsHwlaDXBWGqv66NpOnC+9/3XNzADoOihrlH+iZEse3P0PLsYozCvnjsXQCYViz58MFKV8Vnd0AWuqafQ=="], + + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.2.2", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.2.2", "@oh-my-pi/pi-natives-darwin-x64": "18.2.2", "@oh-my-pi/pi-natives-linux-arm64": "18.2.2", "@oh-my-pi/pi-natives-linux-x64": "18.2.2", "@oh-my-pi/pi-natives-win32-arm64": "18.2.2", "@oh-my-pi/pi-natives-win32-x64": "18.2.2" } }, "sha512-R1qzx/zG9iob90umYFI28SzznvzFhhqzWQ+xjv0Qyi5zAv4yEGtKIsN5EQYdZXd5SRf9oN7TVTPY00y9hywhXA=="], + + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JFpluDNuKF2RmkTNjrjiWcxRiz2CLB/zSyXaNlhVVKr4iFp/oTQ+2mufb8KaGpwaW6aIA0kAPohVONNWltQfuQ=="], + + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-37fwjJf/HUZ9k/WAShXc38GTzP0BWtBemoAvEsvR+i58Rd8yPMr0UhV2gEQHcFcyb7syJYryG43q5sTMmE4dpA=="], + + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jp9o4yGCND9Lki/mt+9fTThMfsOMWkm6SAKnEPZs78G6lxun0fK5wW8/7JgIjupMGQvMtVKT5Q7yMMWzTWEaLA=="], + + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IQFGd4vxbzRaw0qTIMADa7kJqEg/q99hHynczP4DFwS5nyemHWLL24evIBCA3XRpKgVrzGObBWslN3yrHxLtMA=="], + + "@oh-my-pi/pi-natives-win32-arm64": ["@oh-my-pi/pi-natives-win32-arm64@18.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-3Ms3K+iA0i6IYm7dW9VbLLiYYBLYLRISn0INjouvXZzq56I8rnUAnmk4VSqMJV7j+yVrwsYkZdx9+rYf3UwShQ=="], + + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DCLFXqUfiUj+DcquDMHRo/oVoR1B6UeIaVIGekBqMJBjmpZmYvwKbVjYXkRvcXwAdEyl1/MnQVj1XZg813j20A=="], + + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-PoBJEG7xStDgCKzIrUsjslG/rcrTGTJEAYnzwl2W8gUIRifXDYNnWwO5Ism+pKZcwzzZxX07GsKBwMOvsK/obg=="], + + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2" } }, "sha512-sRNb2VyioypyVTxStVSn7Hg4XkqIJT7ePpkFW41VQeLHkj6l0TL+GT51aCKzrlzKXEmztOWwsKm6g9NNyuYimg=="], + + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.2.2", "", {}, "sha512-xyHogMnMQhp6xMVYLBZ1v0WIOrsRZ1fS+T5Rid/nuawqWVBNAgbP7dYkRrEqyDphgmi6RTPI5C09AxyJjrL42Q=="], + + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-aDyidXAGRpL8cPhXuj0v+XxCKipfb6J+ZJIOEfMbeoQW7SHT/x+N3iAV1Rq2eFDe4l9sPtSol2gPXtcxFxOTIQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.11.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.11.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.11.0", "@opentelemetry/core": "2.11.0", "@opentelemetry/sdk-trace-base": "2.11.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + + "adm-zip": ["adm-zip@0.6.1", "", {}, "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lucide-react": ["lucide-react@1.46.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.30.0", "", {}, "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA=="], + + "onnxruntime-node": ["onnxruntime-node@1.30.0", "", { "dependencies": { "adm-zip": "^0.6.0", "global-agent": "^4.1.3", "onnxruntime-common": "1.30.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw=="], + + "onnxruntime-web": ["onnxruntime-web@1.31.0-dev.20260914-8d85527a0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.31.0-dev.20260911-2a43ec07e", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-Iy7rtadoBgxS/LLvDr3QW38DB1PNXRnr0GJMcL0TAt7c9qjgVQl83UlGCVyeAnK2InpmW8Uc3PL8XIuqtDeF6g=="], + + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], + + "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FPNgJMgnWVl/KhRTIhG3KL3A4Om63Rn4YKXc9/uHY7SzLcvqLJLc/h7UBWJwduXvv7K18t5NpxHR6XgXn4sjWw=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-7BLRpjM6w4f9W46/nmkmq8lEKUayhebvcpslCVQ+6QN2uReYlZEMDZlSpXMjme+hUFrPfRz8P3UNq8ep/4d19g=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Tlg7a70b/Wge3OF8IgTHF9jhSVCsLyKQKhwc4BsJ5A+dL/SrFtGBjzuHp4XeLhiiOT7afCxX5PdSn/D4c8Lnuw=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.8", "", { "os": "linux", "cpu": "x64" }, "sha512-6plnhjagsSeTntCgnlag86hWbs/uZE9Crms1LgOb68/1nKsIQjMd+WG519m+aPwT6TrsBOiEMzrx41t8sL5L5g=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-H0Ojln9hfvM+pxWqW0cnjB/XK8/JPq4/k0IWu2JpY5Z458M3zv+XC3Hu+wmot3AoH/K1Bgt23n/tTKtPV9x8qg=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.8", "", { "os": "win32", "cpu": "x64" }, "sha512-oZF1c9VPOKtMwn83Bboc5XSWL+76BRoyB3eUuVnCknBKxwSULZU2Foia9VHWzU+n4I12rPsP6z6H9Rp1hD9o8g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.31.0-dev.20260911-2a43ec07e", "", {}, "sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 7decfb369e..9989583fce 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -6,6 +6,9 @@ "license": "Apache-2.0", "type": "module", "files": ["index.ts"], + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "^18.2.2" + }, "omp": { "extensions": ["./index.ts"], "skills": [] From 50b4d736b455c3041f23a37843605d88321b96b2 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 18 Sep 2026 19:36:45 +0000 Subject: [PATCH 04/14] fix(omp): nudge every qualifying tool call via its persisted tool result Claude PreToolUse additionalContext parity: run the guard in tool_call as before (strict deny path unchanged), but deliver the nudge by appending it to that call's tool_result content instead of accumulating a deduped context-transform message. Every qualifying call now carries its own guidance inline, persisted across compaction like any tool output; the before_agent_start reset now only clears pending deliveries. --- .gitignore | 3 ++ CHANGELOG.md | 2 +- graphify/omp/index.ts | 42 +++++++++++------------- tests/omp.test.ts | 74 ++++++++++++++++++++++++++++--------------- 4 files changed, 71 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index dba5741e31..b5fec288b9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ paper/ # OMP extension test dependency tree (graphify/omp) graphify/omp/node_modules/ + +# Local symlink for the root-level bun tests (temporary, per AGENTS.md) +/node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index fd420caa7f..0335bb3c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.58 (2026-09-10) -- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-run context guidance without building indexes. +- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-call guidance appended to each tool result without building indexes. - Fix: a call to a Python function defined nested inside another function now resolves to that inner definition per lexical scope, instead of leaking to a same-named function elsewhere; direct recursion is preserved as a self-loop (#3410, thanks @hopstreax). - Fix: submodule imports inside a PEP 420 namespace package (a directory with no `__init__.py`) now resolve to the target module instead of being dropped (#3429, thanks @flaukowski). - Fix: a bare-name import of a module sitting next to the importing file (a flat script dir with no package) now resolves to that sibling — matching CPython's `sys.path[0]` behavior — without over-resolving a genuine third-party name (#3430, thanks @hopstreax). diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index a018b20a19..784aa1d59e 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -15,7 +15,6 @@ import { const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; const TIMEOUT_MS = 2000; -const CONTEXT_TYPE = "graphify-guard"; const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const; function isRemote(path: string): boolean { @@ -47,16 +46,19 @@ function runGuard(command: string, kind: string, payload: string, cwd: string, s } export default function graphify(api: ExtensionAPI): void { - const guidance = new Set(); - let guidanceBytes = 0; + // Claude PreToolUse additionalContext parity: `tool_call` captures the guard's + // guidance for that call, `tool_result` appends it to the persisted tool + // result. Every qualifying call carries its own nudge — no dedup — and the + // text lands inline with the call it belongs to, surviving compaction like + // any other tool output. + const pending = new Map(); let generation = 0; let controller = new AbortController(); const reset = () => { generation++; controller.abort(); controller = new AbortController(); - guidance.clear(); - guidanceBytes = 0; + pending.clear(); }; api.on("session_start", reset); api.on("session_switch", reset); @@ -80,7 +82,6 @@ export default function graphify(api: ExtensionAPI): void { if (isRemote(rawPath)) return; const paths = toolName === "Read" || toolName === "Bash" ? [rawPath] : await expandDelimitedPathEntries([rawPath], ctx.cwd, { splitter: parseSearchPath }); - let remainingOutput = OUTPUT_LIMIT; for (const path of paths) { if (isRemote(path)) continue; const input: Record = { ...event.input }; @@ -93,24 +94,20 @@ export default function graphify(api: ExtensionAPI): void { else input.path = resolved; } const timeout = Math.floor(deadline - performance.now()); - if (current !== generation || !ctx.isProjectTrusted() || timeout <= 0 || remainingOutput <= 0) return; + if (current !== generation || !ctx.isProjectTrusted() || timeout <= 0) return; const output = await runGuard(command, toolName === "Bash" || toolName === "Grep" ? "search" : "read", JSON.stringify({ session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, tool_name: toolName, tool_input: input, - }), ctx.cwd, signal, timeout, remainingOutput); + }), ctx.cwd, signal, timeout, OUTPUT_LIMIT); if (current !== generation || !ctx.isProjectTrusted()) return; if (!output) continue; - remainingOutput -= Buffer.byteLength(output); const hook = JSON.parse(output)?.hookSpecificOutput; if (hook?.hookEventName !== "PreToolUse") continue; if (hook.permissionDecision === "deny" && typeof hook.permissionDecisionReason === "string" && hook.permissionDecisionReason.trim()) { return { block: true, reason: hook.permissionDecisionReason }; } - if (typeof hook.additionalContext === "string" && hook.additionalContext.trim() && !guidance.has(hook.additionalContext)) { - const bytes = Buffer.byteLength(hook.additionalContext) + 2; - if (guidanceBytes + bytes <= OUTPUT_LIMIT) { - guidance.add(hook.additionalContext); - guidanceBytes += bytes; - } + if (typeof hook.additionalContext === "string" && hook.additionalContext.trim()) { + const previous = pending.get(event.toolCallId); + pending.set(event.toolCallId, previous ? `${previous}\n\n${hook.additionalContext}` : hook.additionalContext); } // The search CLI does not inspect individual targets; one call suffices. if (toolName === "Grep") break; @@ -121,14 +118,11 @@ export default function graphify(api: ExtensionAPI): void { } }); - api.on("context", (event, ctx) => { - if (!ctx.isProjectTrusted()) reset(); - // Context transforms are not persisted. Keep one current-run message across - // provider requests, and discard any prior generation's injected message. - const messages = event.messages.filter(message => message.role !== "custom" || message.customType !== CONTEXT_TYPE); - if (guidance.size) messages.push({ - role: "custom", customType: CONTEXT_TYPE, content: [...guidance].join("\n\n"), display: false, timestamp: Date.now(), - }); - if (guidance.size || messages.length !== event.messages.length) return { messages }; + api.on("tool_result", (event, ctx) => { + if (!ctx.isProjectTrusted()) { pending.clear(); return; } + const nudge = pending.get(event.toolCallId); + if (nudge === undefined) return; + pending.delete(event.toolCallId); + return { content: [...event.content, { type: "text" as const, text: nudge }] }; }); } diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 095d6cb8b6..659cb837e9 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -17,7 +17,7 @@ let control: string; let started: string; let fixture: string; -type Result = { block?: boolean; reason?: string; messages?: { content: string }[] } | undefined; +type Result = { block?: boolean; reason?: string; content?: { type: string; text: string }[] } | undefined; type Handler = (event: Record, ctx: object) => Result | Promise; function harness() { const handlers = new Map(); @@ -55,6 +55,11 @@ else if (config.mode === "overflow") process.stdout.write("x".repeat(65537)); else if (config.mode === "delayed") { while (!existsSync(${JSON.stringify(join(root, "release"))})) await Bun.sleep(5); process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "stale guidance"}})); +} else if (config.mode === "varying") { + const counterFile = ${JSON.stringify(join(root, "count"))}; + const n = existsSync(counterFile) ? Number(readFileSync(counterFile, "utf8")) || 0 : 0; + writeFileSync(counterFile, String(n + 1)); + process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "guidance " + (n + 1)}})); } else if (config.mode === "hung") { process.on("SIGTERM", () => {}); await Bun.sleep(60000); @@ -78,24 +83,26 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); }); -test("real CLI strict denial blocks selector reads; subsequent guidance dedupes and resets", async () => { +test("real CLI strict denial blocks selector reads; every later call nudges via its tool result and resets", async () => { process.env.GRAPHIFY_HOOK_STRICT = "1"; const api = harness(); await api.emit("before_agent_start"); - const event = { toolName: "read", input: { path: "source.py:1-5" } }; + const event = { toolName: "read", input: { path: "source.py:1-5" }, toolCallId: "call-1" }; const result = await api.emit("tool_call", event); expect(result?.block).toBe(true); expect(result?.reason).toContain("graphify"); expect(existsSync(join(cwd, "graphify-out", "cache", "hook_sessions", "omp-test-session.denied"))).toBe(true); - expect(await api.emit("tool_call", event)).toBeUndefined(); - const first = await api.emit("context", { messages: [] }); - expect(first?.messages).toHaveLength(1); - await api.emit("tool_call", event); - const second = await api.emit("context", { messages: first?.messages }); - expect(second?.messages).toHaveLength(1); - expect(second?.messages?.[0].content).toBe(first?.messages?.[0].content); + // After the one-time deny, every qualifying call still carries its own guidance. + expect(await api.emit("tool_call", { ...event, toolCallId: "call-2" })).toBeUndefined(); + const second = await api.emit("tool_result", { toolCallId: "call-2", content: [{ type: "text", text: "ok" }] }); + expect(second?.content).toHaveLength(2); + expect(second?.content?.[1].text).toContain("graphify"); + expect(await api.emit("tool_call", { ...event, toolCallId: "call-3" })).toBeUndefined(); + const third = await api.emit("tool_result", { toolCallId: "call-3", content: [{ type: "text", text: "ok" }] }); + expect(third?.content).toHaveLength(2); + // Navigation resets pending guidance: a result arriving after the reset is untouched. await api.emit("before_agent_start"); - expect((await api.emit("context", { messages: second?.messages }))?.messages).toEqual([]); + expect(await api.emit("tool_result", { toolCallId: "call-3", content: [{ type: "text", text: "ok" }] })).toBeUndefined(); }); test("native grep, bash search, and glob expose the installed CLI's actual guidance", async () => { @@ -108,61 +115,78 @@ test("native grep, bash search, and glob expose the installed CLI's actual guida const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", kind], { cwd, input: JSON.stringify({ tool_input: legacyInput }), encoding: "utf8", })).hookSpecificOutput.additionalContext; - expect(await api.emit("tool_call", { toolName, input })).toBeUndefined(); - expect((await api.emit("context", { messages: [] }))?.messages?.[0].content).toBe(expected); + const toolCallId = `${toolName}-1`; + expect(await api.emit("tool_call", { toolName, input, toolCallId })).toBeUndefined(); + const result = await api.emit("tool_result", { toolCallId, content: [{ type: "text", text: "ok" }] }); + expect(result?.content).toHaveLength(2); + expect(result?.content?.[1].text).toBe(expected); } }); +test("multi-target glob calls surface every target's guidance", async () => { + // The real CLI returns identical fresh text per glob target (its stale check + // keys on file_path, which glob payloads do not carry), so a varying fixture + // mode distinguishes the per-target guard invocations the extension must join. + writeFileSync(control, JSON.stringify({ mode: "varying" })); + const api = harness(); + expect(await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" })).toBeUndefined(); + const result = await api.emit("tool_result", { toolCallId: "call-1", content: [{ type: "text", text: "ok" }] }); + expect(result?.content).toHaveLength(2); + expect(result?.content?.[1].text).toContain("guidance 1"); + expect(result?.content?.[1].text).toContain("guidance 2"); +}); + test("URLs, internal resources, literal selector-like names and false trust do not run project hooks", async () => { const api = harness(); for (const path of ["https://example.com/source.py", "www.example.com/source.py", "skill://graphify", "local:/source.py", "source.py; ssh://host/source.py"]) { - await api.emit("tool_call", { toolName: "read", input: { path } }); + await api.emit("tool_call", { toolName: "read", input: { path }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); + expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); } api.trust(false); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); expect(existsSync(started)).toBe(false); api.trust(true); writeFileSync(join(cwd, "source.py:12"), "a real filename, not a selector"); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" } }); - expect(await api.emit("context", { messages: [] })).toBeUndefined(); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" }, toolCallId: "call-3" }); + expect(await api.emit("tool_result", { toolCallId: "call-3", content: [] })).toBeUndefined(); }); -test("navigation cancels in-flight guidance before the next session's context", async () => { +test("navigation cancels in-flight guidance before the next session's tool results", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); writeFileSync(control, JSON.stringify({ mode: "delayed" })); const api = harness(); - const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); const deadline = Date.now() + 1500; while (!existsSync(started) && Date.now() < deadline) await Bun.sleep(5); expect(existsSync(started)).toBe(true); await api.emit(navigation); expect(await pending).toBeUndefined(); - expect(await api.emit("context", { messages: [] })).toBeUndefined(); + expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); } }); test("oversized input never spawns, invalid/oversized output fails open, and a hung child is killed", async () => { const api = harness(); - await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) } }); + await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); for (const mode of ["invalid", "overflow", "hung"]) { writeFileSync(control, JSON.stringify({ mode })); const start = performance.now(); - expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } })).toBeUndefined(); + expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" })).toBeUndefined(); expect(performance.now() - start).toBeLessThan(3500); - expect(await api.emit("context", { messages: [] })).toBeUndefined(); + expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); } }, 10000); test("missing or project-local executables never fall back to project Python code", async () => { process.env.PATH = cwd; const api = harness(); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); writeFileSync(join(cwd, "graphify"), readFileSync(fixture)); chmodSync(join(cwd, "graphify"), 0o755); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); expect(existsSync(started)).toBe(false); }); From 90eebe0433b49fdaaa79753deaa031b4804f92e8 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 18 Sep 2026 20:24:44 +0000 Subject: [PATCH 05/14] docs(omp): match guidance wording to per-call tool-result delivery Copilot review finding: the README still described the replaced deduplicated context message. Describe the actual behavior: each qualifying call's guidance is appended to that call's persisted tool result. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cfc6b873e6..08b80a9fe3 100644 --- a/README.md +++ b/README.md @@ -345,8 +345,10 @@ Before native `read`, `glob`, `grep`, and search-style `bash` calls, the extensi runs the installed `graphify hook-guard read|search` CLI. The existing Python policy owns fresh/stale graph decisions and strict-mode denials: start OMP with `GRAPHIFY_HOOK_STRICT=1` to enable its once-per-session indexed-read block. -Denials become OMP `block`/`reason`; guidance becomes one deduplicated context -message, cleared for each new user run and session navigation. In-flight hooks +Denials become OMP `block`/`reason`; each qualifying call carries its own +guidance, appended to that call's persisted tool result (Claude +`PreToolUse` additionalContext parity), and pending deliveries are cleared for +each new user run and session navigation. In-flight hooks are cancelled on these boundaries. No graph is created or updated automatically. The guard package intentionally declares no skills; the existing cross-framework skill remains available separately through `graphify agents install`. From 1a73b5088a6eb2068e801fab1c492b9b11967c95 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 08:52:41 +0000 Subject: [PATCH 06/14] fix(omp): import isReadableUrlPath from its current pi-tui location Upstream OMP moved isReadableUrlPath from @oh-my-pi/pi-coding-agent/tools/path-utils to @oh-my-pi/pi-tui/tools/read. At the 18.2.2 devDependency floor path-utils still re-exported it, so CI stayed green, but every OMP install past that point (18.2.6 is current) throws SyntaxError: Export named 'isReadableUrlPath' not found before the extension's api.on("tool_call", ...) ever registers, silently disabling the hook-guard nudge. Import it from @oh-my-pi/pi-tui/tools/read directly, raise the @oh-my-pi/pi-coding-agent floor to ^18.2.6 (the first version pinned by this fix where the export lives at its new home), and declare @oh-my-pi/pi-tui ^18.2.6 as its own devDependency since the bridge now imports from it directly rather than relying on it as an undeclared transitive dependency of pi-coding-agent. --- graphify/omp/bun.lock | 39 ++++++++++++++++++++------------------- graphify/omp/index.ts | 2 +- graphify/omp/package.json | 3 ++- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/graphify/omp/bun.lock b/graphify/omp/bun.lock index 41e2490504..3e723793ba 100644 --- a/graphify/omp/bun.lock +++ b/graphify/omp/bun.lock @@ -5,7 +5,8 @@ "": { "name": "graphify-omp", "devDependencies": { - "@oh-my-pi/pi-coding-agent": "^18.2.2", + "@oh-my-pi/pi-coding-agent": "^18.2.6", + "@oh-my-pi/pi-tui": "^18.2.6", }, }, }, @@ -92,41 +93,41 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], - "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-2NQ6Xe9M6859FMiUF9qiD2hG/Eht+N1sakJ5q1+liUdt0g/jNAmjErGTvGDKOXKq5niz362R+oLo4xrP0wJrFg=="], + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-5jh/b4hDF2rK67gSpMKR7qBCTMQyI7FLU0jtDScBY6ILdCMQnrHIs6BsP09j984lR/HnnrLBGFOWZqMMkXZsvA=="], - "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.2.2", "", {}, "sha512-tG9W0Acl0Sg0iONdXG744J9TRjxU9qgKmfdivD/NfZ/0fQ1mr5hajx0yk/Jm4KzfVazwZErCqiH4Qce7OL02hg=="], + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.2.6", "", {}, "sha512-4VUGOSr791W1lXNpU1hQXrKd4BqaEQlz2OHXYecZ6MTENb6Hx9JC00Sf7Db4dxPjkEzyibD1YjVUXqK3DQeO+g=="], - "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1" } }, "sha512-sEzkNqbKRvBb1EMhEG5n1+AGd9q4UT3B5vto21EGyZhF7dSNdYtcMAfqUMQZLSP/kgu2RzUnvXZTDHbvKWFZxw=="], + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6", "@oh-my-pi/snapcompact": "18.2.6", "@opentelemetry/api": "^1.9.1" } }, "sha512-BeakwQjJaF/w+8ISIQSbPRJJy+hSMM4XVB5kgx+qUKpykyrKXbw4zgG+tOF9+3VyCnDZZemv92FQ0Wdnmabchg=="], - "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-3fqCIzHLL/g53MdsUZHR3RvWaQYh+VLSJe2D99KnlZGGWqzFRfXFG6YgSzLQg9UdjPkdyJfivFNi+D0Gt0lYew=="], + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.2.6", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6" } }, "sha512-SkQ5Uc5ABS5WiKPVdPAmxawhZf1kDSH53CrXrjlgshpCT6r6jDs/CI+VvwdhQHoEQcokM8pmWDGPzGfKTa7K4w=="], - "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-FgUqjLdWIMRfBhtC3WzOOBgC5iWIeRer59eoPclMzlXc5WeV8AxIeZoEOQuYCsCpTembgPeDfTIzFKr1zf/mug=="], + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.2.6", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6" } }, "sha512-jYg/cqztVXPWa3soLvjGxKs/4oIm8vDwuvue0tfPv/qiEWzxPy+PsI+9zbKZZEipfVlQUAyD+8LnkQdGlbqIDg=="], - "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.2.2", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/omp-stats": "18.2.2", "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-agent-core": "18.2.2", "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-mnemopi": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-tui": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-rZmmx2f9UZkK2rRteCFwxxcDAdxXfz9AD032fZJdAdyfa1CFWixns9tsMML3DdNOytD1619QILna/IxsMspbgA=="], + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.2.6", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/omp-stats": "18.2.6", "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-agent-core": "18.2.6", "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-mnemopi": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-tui": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6", "@oh-my-pi/snapcompact": "18.2.6", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-dk298PZNnD1QigR4lKvQpWVAAbIo2bCfdNOlZwGFZsxHbP+XYrUS+AxYxfWkfqSnFRliP9Ho+h94DHV3o6lxRg=="], - "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-+wgJYNsHwlaDXBWGqv66NpOnC+9/3XNzADoOihrlH+iZEse3P0PLsYozCvnjsXQCYViz58MFKV8Vnd0AWuqafQ=="], + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-JD4iVlqUVnoSsMJn7Fy+Hn3zUMKH8i6qpWImoTTnyuEJ0iqL1n6H1W96GqVnhfEDoBf98/mPwoZM2mDHNKEfYg=="], - "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.2.2", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.2.2", "@oh-my-pi/pi-natives-darwin-x64": "18.2.2", "@oh-my-pi/pi-natives-linux-arm64": "18.2.2", "@oh-my-pi/pi-natives-linux-x64": "18.2.2", "@oh-my-pi/pi-natives-win32-arm64": "18.2.2", "@oh-my-pi/pi-natives-win32-x64": "18.2.2" } }, "sha512-R1qzx/zG9iob90umYFI28SzznvzFhhqzWQ+xjv0Qyi5zAv4yEGtKIsN5EQYdZXd5SRf9oN7TVTPY00y9hywhXA=="], + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.2.6", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.2.6", "@oh-my-pi/pi-natives-darwin-x64": "18.2.6", "@oh-my-pi/pi-natives-linux-arm64": "18.2.6", "@oh-my-pi/pi-natives-linux-x64": "18.2.6", "@oh-my-pi/pi-natives-win32-arm64": "18.2.6", "@oh-my-pi/pi-natives-win32-x64": "18.2.6" } }, "sha512-oZtxz7cU6CJvwdGJgpeHDIvhKOBoGVwRzmb7RQt+NdFD7tVe7PrObgbQdAOpmo3LwbJ3Md/jiicWmaxboFSRTQ=="], - "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JFpluDNuKF2RmkTNjrjiWcxRiz2CLB/zSyXaNlhVVKr4iFp/oTQ+2mufb8KaGpwaW6aIA0kAPohVONNWltQfuQ=="], + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PVrTHpeVc8n3kOCNUd5Qq5bUyo1y8BtFRcm1UKL8LGF8OzHOrob280iz6K0LIiv8ovbfSjZSvCjYI/KSaM3YMQ=="], - "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-37fwjJf/HUZ9k/WAShXc38GTzP0BWtBemoAvEsvR+i58Rd8yPMr0UhV2gEQHcFcyb7syJYryG43q5sTMmE4dpA=="], + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-qfZeZ0Xz9aONtp6x7eiWUQMlm2blEqauth2SntLplV9cdYTKailWHjpGBoyvfs/yptddA9Pbj6iN9GlzmpbgOQ=="], - "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jp9o4yGCND9Lki/mt+9fTThMfsOMWkm6SAKnEPZs78G6lxun0fK5wW8/7JgIjupMGQvMtVKT5Q7yMMWzTWEaLA=="], + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-hHdJnF0pw3GlgTpscPCeRT9fcf/yXbrwcvdFgVg482y6xDq8KBT5rzG+V/ntUbcjj/qfBQQiH1do2XlrbL6nFw=="], - "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IQFGd4vxbzRaw0qTIMADa7kJqEg/q99hHynczP4DFwS5nyemHWLL24evIBCA3XRpKgVrzGObBWslN3yrHxLtMA=="], + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-mV2DTOE1Hn96iXNIbrzYHzLy9CbeAbjLCGPtc5tzFzzuD7UJ3TMfP4pieSo0WtgZnPNxisT6ncR8c/BqNbkXdg=="], - "@oh-my-pi/pi-natives-win32-arm64": ["@oh-my-pi/pi-natives-win32-arm64@18.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-3Ms3K+iA0i6IYm7dW9VbLLiYYBLYLRISn0INjouvXZzq56I8rnUAnmk4VSqMJV7j+yVrwsYkZdx9+rYf3UwShQ=="], + "@oh-my-pi/pi-natives-win32-arm64": ["@oh-my-pi/pi-natives-win32-arm64@18.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-nk1ZUvF27HZJNwL8TwUMLUdmFuf8vsv1StAMsHQ9zdgLuRJ1NwWESol6wXzRntvDzsmpDQbnf3R8HcY5CzUf9w=="], - "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DCLFXqUfiUj+DcquDMHRo/oVoR1B6UeIaVIGekBqMJBjmpZmYvwKbVjYXkRvcXwAdEyl1/MnQVj1XZg813j20A=="], + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-syDZJnTiTxFIkTteT4U5edq/hdPrPjOYmyG8Kq4a/LwHoD59eDpkE3+kqVy33GRfOwzJb0b6d6Hxrp8we75b9g=="], - "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-PoBJEG7xStDgCKzIrUsjslG/rcrTGTJEAYnzwl2W8gUIRifXDYNnWwO5Ism+pKZcwzzZxX07GsKBwMOvsK/obg=="], + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.2.6", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-agent-core": "18.2.6", "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6", "@oh-my-pi/snapcompact": "18.2.6" } }, "sha512-CsmP4SM1Qt19s2aIJ0gBTnSFLm2nXNHkaLFqL5m+3+bTcF3++G+CAEImotsjSnmajeH/Q2pq2w+FlNUD2ZjByA=="], - "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2" } }, "sha512-sRNb2VyioypyVTxStVSn7Hg4XkqIJT7ePpkFW41VQeLHkj6l0TL+GT51aCKzrlzKXEmztOWwsKm6g9NNyuYimg=="], + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.6" } }, "sha512-jvj8w/Q9aECXpHVjUc4rWaQlFPV+uWCgII6QZjDfroo5xvt0d8ks64cH8gINmMwRieCLz7KU6JKGReuFT54aTw=="], - "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.2.2", "", {}, "sha512-xyHogMnMQhp6xMVYLBZ1v0WIOrsRZ1fS+T5Rid/nuawqWVBNAgbP7dYkRrEqyDphgmi6RTPI5C09AxyJjrL42Q=="], + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.2.6", "", {}, "sha512-CfKosiyS2L3EfqVAidDYjfpBEVRuHAueYIlydEySARSLj61euYSqCZFMNhCPcA+QFXhfG0J0N5xGRuMk5FzRaA=="], - "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-aDyidXAGRpL8cPhXuj0v+XxCKipfb6J+ZJIOEfMbeoQW7SHT/x+N3iAV1Rq2eFDe4l9sPtSol2gPXtcxFxOTIQ=="], + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6" } }, "sha512-hPL6N9YNxejTY1jOm8+FbcbuLjtgPI/v591tunKed7gIcQgOOuztNvTcM/Xbc5xc50CBcWoD6q+j4Hm0iuChlQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 784aa1d59e..2779fc0bf1 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -5,12 +5,12 @@ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; import { expandDelimitedPathEntries, isInternalUrlPath, - isReadableUrlPath, normalizePathLikeInput, parseSearchPath, resolveReadPath, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; +import { isReadableUrlPath } from "@oh-my-pi/pi-tui/tools/read"; const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 9989583fce..116193ca8d 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -7,7 +7,8 @@ "type": "module", "files": ["index.ts"], "devDependencies": { - "@oh-my-pi/pi-coding-agent": "^18.2.2" + "@oh-my-pi/pi-coding-agent": "^18.2.6", + "@oh-my-pi/pi-tui": "^18.2.6" }, "omp": { "extensions": ["./index.ts"], From 2904921c991c6d200cbf0a73c36df667d5eb1144 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 08:52:48 +0000 Subject: [PATCH 07/14] ci(omp): run the OMP bridge tests against latest OMP too (advisory) bun install --frozen-lockfile in the omp-bridge job pins an exact known-good OMP version, so CI can never observe a future upstream symbol move (this is exactly how the pi-tui export move shipped unnoticed: the lockfile pinned 18.2.2, where isReadableUrlPath still lived in pi-coding-agent). Add a step that installs whatever OMP publishes today and re-runs the suite against it, continue-on-error like the security-scan job's advisory checks, so an unrelated upstream release cannot block a merge while still surfacing a real break. --- .github/workflows/ci.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebc2a258d8..c90e4b0633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,9 +80,10 @@ jobs: omp-bridge: # tests/omp.test.ts drives the real OMP extension: bun resolves the - # path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent - # devDependency in graphify/omp/, and the policy assertions run against - # the real installed Python CLI, so this job needs both toolchains. + # path-utils and pi-tui helpers it imports from the pinned + # @oh-my-pi/pi-coding-agent and @oh-my-pi/pi-tui devDependencies in + # graphify/omp/, and the policy assertions run against the real installed + # Python CLI, so this job needs both toolchains. runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -105,6 +106,19 @@ jobs: - name: Run OMP bridge tests run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + - name: Run OMP bridge tests against the latest OMP (advisory) + # bun install --frozen-lockfile above only proves the bridge works + # against the exact pinned version. Upstream OMP has moved exported + # symbols between minor releases before (isReadableUrlPath: pi-coding-agent + # -> pi-tui) without the frozen lockfile ever seeing it. Install + # whatever OMP publishes today and re-run the suite so a future move + # surfaces here. continue-on-error: an unrelated upstream release must + # not block a merge; the frozen run above stays the blocking gate. + continue-on-error: true + run: | + bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest @oh-my-pi/pi-tui@latest + GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new # HIGH-severity finding or vulnerable dependency is caught on the PR that From 8bac09c6cd1a6c2124e4d75ecc306332d733fb82 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:12:22 +0000 Subject: [PATCH 08/14] fix(hooks): stop URL-shaped read targets from bypassing the out-of-project guard _is_cwd_relative answered "no root and no drive" to decide whether a file_path/path value is cwd-anchored. A URL is rootless and driveless by that exact same test (https://x, myscheme://x, and a bare www.host/path all have no root/drive), so it short-circuited straight to "in project" and the containment check below never ran. - _normalize_hook_path: trims/de-quotes input and strips a leading file:// scheme to the local path it names (OMP's own pipeline resolves file:// to a local path, not an external URL -- it must still nudge). - _is_foreign_url_scheme: rejects any other whole-value scheme:// prefix. Deliberately does not enumerate a harness's internal-scheme vocabulary (local://, artifact://, ...) -- an unrecognized scheme is simply treated as not-a-local-source-file. - _has_embedded_url_scheme_segment: catches the same URL after an upstream host has already glued it onto an absolute prefix (/local:/x), which looks exactly like a real file to the (unchanged) containment check. - _is_external_www_target: a bare www.host/path carries no :// at all, so it is handled separately, existence-gated to mirror OMP's own "an existing local path wins over URL" precedence. Containment, the extension allow-list, and staleness/strict-deny logic are untouched. --- graphify/cli.py | 118 +++++++++++++++++++ tests/test_hook_url_paths.py | 218 +++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 tests/test_hook_url_paths.py diff --git a/graphify/cli.py b/graphify/cli.py index 5503a9185a..a722cd5bca 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -13,6 +13,7 @@ import time from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT from pathlib import Path, PurePosixPath, PureWindowsPath +from urllib.parse import unquote, urlsplit _SEARCH_NUDGE = json.dumps({ @@ -894,10 +895,26 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: if explicit: in_project = False for v in explicit: + v = _normalize_hook_path(v) + # A bare www.host/... value is remote only when nothing + # identically named exists locally, mirroring OMP's own + # "an existing local path wins over URL" precedence + # (resolveToolSearchScope). Decided here, not inside + # _is_cwd_relative, which has no cwd/root of its own. + if _is_external_www_target(v, root): + continue p = Path(v) if _is_cwd_relative(v): in_project = True # relative -> anchored at cwd == in project break + # _is_cwd_relative already rejects a *whole-value* + # scheme://... prefix above (a rootless URL never reaches + # here). This catches the same URL after some upstream + # host has already glued it onto an absolute prefix + # (`/local:/x`), which looks exactly like a real + # file to the containment check below. + if _has_embedded_url_scheme_segment(v): + continue try: p.resolve().relative_to(root) in_project = True @@ -942,6 +959,55 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: pass +_URL_SCHEME_PREFIX_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") +_URL_SCHEME_SEGMENT_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:$") +_FILE_URL_RE = re.compile(r"^file://", re.IGNORECASE) +_WWW_HOST_RE = re.compile(r"^www\.", re.IGNORECASE) + + +def _normalize_hook_path(value: str) -> str: + r"""Trim padding whitespace and a matching pair of outer double quotes, + then strip a leading ``file://`` scheme down to the local path it names. + + Mirrors what OMP's own path pipeline already does to a raw tool argument + before a hook would ever see it: ``normalizePathLikeInput`` (trim + + de-quote) and ``file://`` -> ``url.fileURLToPath`` (its own + ``strictExternalUrlRe`` deliberately omits ``file``, routing it through + the ordinary local-file path instead of the external-URL one). The guard + must classify identical text identically whether or not a given host + bothered to normalize it first -- a classifier that only agrees with its + own host after trimming/de-quoting is exactly the kind of gap a prior + review flagged. + """ + value = value.strip() + if len(value) > 1 and value[0] == value[-1] == '"': + value = value[1:-1] + if _FILE_URL_RE.match(value): + path = unquote(urlsplit(value).path) or "/" + # file:///C:/proj/a.py -> C:/proj/a.py: drop the URL's extra root + # slash in front of a Windows drive letter. + if os.name == "nt" and re.match(r"^/[A-Za-z]:", path): + path = path[1:] + return path + return value + + +def _is_foreign_url_scheme(value: str) -> bool: + """Whether *value* is itself a ``scheme://...`` value -- everything + except ``file://``, which ``_normalize_hook_path`` has already reduced + to a plain local path by the time this runs, so it never matches here. + + Deliberately does not enumerate OMP's (or any other harness's) internal + scheme allow-list (``local://``, ``artifact://``, ...): this guard is + embedded by multiple hosts (#522), and hard-coding one of them would + silently stop matching the day that host adds a scheme. Any scheme this + function doesn't specifically know to be local -- only ``file://`` is -- + is treated as not-a-local-source-file: a missed nudge on a remote URL is + a far safer wrong answer than a wrong nudge on an unrelated file. + """ + return bool(_URL_SCHEME_PREFIX_RE.match(value)) + + def _is_cwd_relative(value: str) -> bool: r"""Whether *value* is anchored at the current working directory. @@ -964,11 +1030,63 @@ def _is_cwd_relative(value: str) -> bool: so ``paths.is_absolute_any_platform`` (for stored, portable paths) is deliberately not used. On POSIX ``root`` is set exactly when the path is absolute and ``drive`` is always empty, so this is unchanged there. + + A URL is rootless and driveless by that exact same test -- ``https://x`` + and ``myscheme://x`` have no ``root`` and no ``drive`` in either flavour + -- so without checking the scheme first, this rule alone short-circuits + the caller straight to "in project" for a value that names no filesystem + path at all (the bug this guards against, #522 follow-up). + ``_normalize_hook_path`` and ``_is_foreign_url_scheme`` reject that + shape, and strip a ``file://`` wrapper down to its local path, before + the root/drive test below ever runs. """ + value = _normalize_hook_path(value) + if _is_foreign_url_scheme(value): + return False pure = PureWindowsPath(value) if os.name == "nt" else PurePosixPath(value) return not pure.root and not pure.drive +def _has_embedded_url_scheme_segment(value: str) -> bool: + """Whether an already-absolute *value* still carries a collapsed + ``scheme://`` marker as one of its OWN path segments, e.g. + ``/local:/x`` -- what a naive host-side path-join leaves behind + when it glues a rootless ``scheme://...`` value onto a root/cwd prefix + instead of routing it through a URL handler, defeating the whole-value + check in ``_is_cwd_relative`` above. + + Only meaningful once a value is already known to have a root or drive + (``_is_cwd_relative`` returned ``False``): the leading component IS that + root/drive marker, so it is skipped here on purpose -- a genuinely + relative, colon-bearing POSIX filename like ``C:/proj/a.py`` never + reaches this function at all (it takes the cwd-relative shortcut above + and stays in-project, matching POSIX semantics where a colon has no + special meaning). + """ + pure = PureWindowsPath(value) if os.name == "nt" else PurePosixPath(value) + parts = pure.parts[1:] if (pure.root or pure.drive) else pure.parts + return any(_URL_SCHEME_SEGMENT_RE.match(part) for part in parts) + + +def _is_external_www_target(value: str, root: "Path") -> bool: + """Whether *value* is a bare ``www.host/...`` external-read shape with no + identically named local file to override it. + + Mirrors OMP's own ``isReadableUrlPath``/``resolveToolSearchScope`` + precedence: a ``www.`` value carries no ``://`` at all, so no scheme rule + catches it, and it is remote only when nothing local shares its exact + name ("an existing local path wins over URL"). A project that genuinely + has a top-level path named e.g. ``www.example.com`` stays classified as + local; only the common case -- no such path -- is treated as external. + """ + if not _WWW_HOST_RE.match(value): + return False + try: + return not (root / value).exists() + except OSError: + return False + + def _target_is_indexed(file_path: str, root: "Path") -> bool: """Guard the strict deny: only block a read of a file the graph actually indexes. Reads manifest.json (cheap, capped); on any doubt (missing/corrupt/oversized diff --git a/tests/test_hook_url_paths.py b/tests/test_hook_url_paths.py new file mode 100644 index 0000000000..cdd1ffaa8a --- /dev/null +++ b/tests/test_hook_url_paths.py @@ -0,0 +1,218 @@ +r"""URL-shaped read targets must not fool the out-of-project guard. + +`_is_cwd_relative` answers "no root and no drive" to decide whether a +`file_path`/`path` value is cwd-anchored. A URL is rootless and driveless by +that exact same test: `https://example.com/source.py`, +`myscheme://x.py` and even a bare `www.example.com/source.py` all have no +`root` and no `drive`, so -- unmodified -- they short-circuit `_run_hook_guard` +straight to "in project" and the (correct, untouched) containment check below +never runs. Reproduced live against `graphify hook-guard read`, cwd at a +graphed project: + + https://example.com/source.py -> NUDGE (wrong) + www.example.com/source.py -> NUDGE (wrong) + /local:/source.py -> NUDGE (wrong; a naive + host-side path-join + glues a rootless + local://source.py onto + an absolute prefix, + which the containment + check can't tell apart + from a real file) + /artifact:/5.py -> NUDGE (wrong, same) + /etc/elsewhere/source.py -> silent (right) + src/usr/local/pkg/pfblockerng/pfb_unbound.py -> NUDGE (right) + +Three traps a naive "rootless value carrying `scheme://` is not cwd-relative" +rule would get wrong: + + 1. `file://` is LOCAL. OMP's own pipeline strips it and resolves the + underlying path (its `strictExternalUrlRe` deliberately omits `file`), + so `file:////src/foo.py` must still nudge -- silencing it would + be a false negative worse than the bug this fixes. + 2. `www.host/path` carries no `://` at all, so no scheme rule fires on it. + Decision recorded here: it IS handled, existence-gated exactly like + OMP's own `resolveToolSearchScope` ("an existing local path wins over + URL") -- `_is_external_www_target` below, not `_is_cwd_relative` itself, + which has no cwd/root context to make that call. + 3. An unrecognized scheme (`myscheme://x`) is LOCAL to OMP's own + classifier, but this guard deliberately diverges: harness-agnostic + silence beats a harness-specific scheme allow-list. + +Input is normalized (trim + de-quote, `_normalize_hook_path`) before any +shape test, so a quoted or whitespace-padded value classifies identically to +its literal form. +""" +import io +import json +import sys +import time + +import pytest + +import graphify.cli as cli +from graphify.cli import ( + _has_embedded_url_scheme_segment, + _is_cwd_relative, + _is_external_www_target, + _normalize_hook_path, +) + + +# --------------------------------------------------------------------------- +# Classification units +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value", [ + "https://example.com/source.py", + "myscheme://x.py", # unrecognized scheme (trap 3): still rejected + ' "https://example.com/source.py" ', # quoted + whitespace-padded +]) +def test_url_prefixed_values_are_not_cwd_relative(value): + assert _is_cwd_relative(value) is False, value + + +def test_file_url_is_not_naively_cwd_relative(tmp_path): + """file:// (trap 1) strips to an absolute local path, so it is NOT the + naive "no root, no drive -> in project" shortcut -- it goes through the + real containment check below, exercised end-to-end further down.""" + target = tmp_path / "src" / "foo.py" + assert _is_cwd_relative(f"file://{target.as_posix()}") is False + + +def test_in_project_relative_paths_are_unaffected(): + for value in ["src/mod.py", "a.py", "./rel.py", ' "src/mod.py" ']: + assert _is_cwd_relative(value) is True, value + + +def test_absolute_out_of_project_path_is_unaffected(): + """Control: an ordinary absolute path outside the project, no URL + involved -- must keep working exactly as before.""" + assert _is_cwd_relative("/etc/elsewhere/source.py") is False + + +def test_embedded_scheme_segment_detects_a_mangled_absolute_url(): + """/local:/source.py and /artifact:/5.py: what a naive + host-side path-join leaves behind when it glues a rootless scheme://... + value onto an absolute prefix instead of routing it through a URL + handler. _is_cwd_relative alone can't catch these (they DO have a root), + so this is the companion check the call site also runs.""" + assert _has_embedded_url_scheme_segment("/root/git/pfBlockerNG/local:/source.py") is True + assert _has_embedded_url_scheme_segment("/root/git/pfBlockerNG/artifact:/5.py") is True + + +def test_embedded_scheme_segment_is_silent_on_ordinary_absolute_paths(): + assert _has_embedded_url_scheme_segment("/etc/elsewhere/source.py") is False + assert _has_embedded_url_scheme_segment("/root/git/pfBlockerNG/src/mod.py") is False + + +def test_www_target_is_external_only_without_a_matching_local_file(tmp_path): + """Trap 2, decided: www. IS handled, gated on local existence (mirrors + OMP's own "an existing local path wins over URL" precedence).""" + assert _is_external_www_target("www.example.com/source.py", tmp_path) is True + local = tmp_path / "www.example.com" / "source.py" + local.parent.mkdir(parents=True) + local.write_text("# real file", encoding="utf-8") + assert _is_external_www_target("www.example.com/source.py", tmp_path) is False + assert _is_external_www_target("src/mod.py", tmp_path) is False + + +def test_normalize_hook_path_strips_padding_quotes_and_file_scheme(): + assert _normalize_hook_path(' "src/mod.py" ') == "src/mod.py" + assert _normalize_hook_path("src/mod.py") == "src/mod.py" + assert _normalize_hook_path("file:///abs/proj/foo.py") == "/abs/proj/foo.py" + + +# --------------------------------------------------------------------------- +# End-to-end through the guard (real stdin JSON, cwd at a graphed project) +# --------------------------------------------------------------------------- + +def _project(tmp_path): + src = tmp_path / "src" + src.mkdir() + f = src / "mod.py" + f.write_text("def x():\n return 1\n", encoding="utf-8") + out = tmp_path / "graphify-out" + out.mkdir() + (out / "manifest.json").write_text( + json.dumps({"src/mod.py": {"mtime": 1}}), encoding="utf-8") + time.sleep(0.02) + (out / "graph.json").write_text('{"nodes":[],"links":[]}', encoding="utf-8") + return f + + +def _invoke(tmp_path, monkeypatch, file_path): + monkeypatch.chdir(tmp_path) + payload = {"session_id": "s1", "tool_name": "Read", + "tool_input": {"file_path": str(file_path)}} + + class _Stdin: + buffer = io.BytesIO(json.dumps(payload).encode()) + monkeypatch.setattr(sys, "stdin", _Stdin()) + buf = io.StringIO() + monkeypatch.setattr(sys, "stdout", buf) + cli._run_hook_guard("read") + return buf.getvalue() + + +def test_https_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "https://example.com/source.py").strip() == "" + + +def test_www_host_with_no_local_match_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "www.example.com/source.py").strip() == "" + + +def test_mangled_absolute_local_scheme_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + target = f"{tmp_path}/local:/source.py" + assert _invoke(tmp_path, monkeypatch, target).strip() == "" + + +def test_mangled_absolute_artifact_scheme_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + target = f"{tmp_path}/artifact:/5.py" + assert _invoke(tmp_path, monkeypatch, target).strip() == "" + + +def test_unrecognized_scheme_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "myscheme://x.py").strip() == "" + + +def test_absolute_out_of_project_path_stays_silent(tmp_path, monkeypatch): + """Control (right today, must stay right): containment already works.""" + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "/etc/elsewhere/source.py").strip() == "" + + +def test_in_project_relative_path_still_nudges(tmp_path, monkeypatch): + """Control (right today, must stay right).""" + _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, "src/mod.py") + + +def test_file_url_to_an_indexed_in_project_file_still_nudges(tmp_path, monkeypatch): + """Trap 1: file:// must NOT regress to silent.""" + f = _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, f"file://{f.as_posix()}") + + +def test_quoted_and_padded_https_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, ' "https://example.com/source.py" ').strip() == "" + + +def test_padded_in_project_path_still_nudges(tmp_path, monkeypatch): + """Leading whitespace only (not quotes, not trailing padding): the + extension allow-list this guard must not touch splits on the final "/" + and matches the tail verbatim, so trailing junk after ".py" fails IT + regardless of classification -- that is pre-existing, out-of-scope + behaviour, not the URL-classification bug this file covers. Quoted and + doubly-padded input is proven at the classification layer directly in + test_in_project_relative_paths_are_unaffected above. + """ + _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, " src/mod.py") From ea3e24054487d125596022c0c2c95830a07c7ee1 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:12:29 +0000 Subject: [PATCH 09/14] refactor(omp): simplify the bridge now that the guard defends itself - isRemote gets a file:// carve-out: OMP's own resolveReadPathAsync already reduces file:// to the real local path (its strictExternalUrlRe deliberately excludes file), so routing it through yields the correct absolute path instead of silently dropping every file:// read. isInternalUrlPath/isReadableUrlPath keep earning their place -- resolveReadPathAsync still pre-resolves a rootless *foreign*-scheme value onto cwd into something that reads as a real in-project file, so the bridge still must not hand it those. - resolveReadPath -> resolveReadPathAsync: same variant order/winner semantics, non-blocking probes instead of statSync per candidate on the tool_call hot path (read.ts itself already uses the async variant). - Drop the explicit { splitter: parseSearchPath } argument to expandDelimitedPathEntries -- already its default. Adds one bridge-level regression test: an in-project file:// target still nudges, an out-of-project one still stays silent. --- graphify/omp/index.ts | 21 +++++++++++++++++---- tests/omp.test.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 2779fc0bf1..67a3d552b0 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -6,8 +6,7 @@ import { expandDelimitedPathEntries, isInternalUrlPath, normalizePathLikeInput, - parseSearchPath, - resolveReadPath, + resolveReadPathAsync, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; import { isReadableUrlPath } from "@oh-my-pi/pi-tui/tools/read"; @@ -16,8 +15,22 @@ const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; const TIMEOUT_MS = 2000; const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const; +const FILE_URL_RE = /^file:\/\//i; +// isInternalUrlPath/isReadableUrlPath still earn their place even though the +// guard now defends itself against URL-shaped input: resolveReadPathAsync +// below pre-resolves a rootless value to an absolute path +// (`path.resolve(cwd, x)`) before the guard ever sees it, which for a +// *foreign*-scheme value glues it onto cwd into something that looks +// exactly like a real in-project file (`/https:/example.com/x`, +// `/local:/x`) -- a guard fix the bridge then defeats by mangling its +// input first is not a fix. file:// is the one exception: OMP's own +// resolveReadPathAsync already strips it down to the real local path (its +// resolveToCwd -> expandPath -> stripFileUrl, deliberately excluded from +// the external-URL fast path), so routing it through here yields the +// correct absolute path rather than a mangled one. function isRemote(path: string): boolean { + if (FILE_URL_RE.test(path)) return false; return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://"); } @@ -81,13 +94,13 @@ export default function graphify(api: ExtensionAPI): void { const rawPath = typeof event.input.path === "string" ? normalizePathLikeInput(event.input.path) : "."; if (isRemote(rawPath)) return; const paths = toolName === "Read" || toolName === "Bash" ? [rawPath] : - await expandDelimitedPathEntries([rawPath], ctx.cwd, { splitter: parseSearchPath }); + await expandDelimitedPathEntries([rawPath], ctx.cwd); for (const path of paths) { if (isRemote(path)) continue; const input: Record = { ...event.input }; if (toolName !== "Bash") { const target = toolName === "Glob" ? path : (await splitPathAndSelPreferringLiteral(path, ctx.cwd)).path; - const resolved = resolveReadPath(target, ctx.cwd); + const resolved = await resolveReadPathAsync(target, ctx.cwd); delete input.path; if (toolName === "Read") input.file_path = resolved; else if (toolName === "Glob") input.pattern = resolved; diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 659cb837e9..4fbfd29e10 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -152,6 +152,21 @@ test("URLs, internal resources, literal selector-like names and false trust do n expect(await api.emit("tool_result", { toolCallId: "call-3", content: [] })).toBeUndefined(); }); +test("a file:// read target resolves to the real local path and still reaches the guard", async () => { + // file:// is the one scheme isRemote lets through to resolveReadPathAsync + // (which OMP's own path pipeline also resolves down to a local path, not + // an external URL) -- an in-project file:// target must still nudge... + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + const inProject = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); + expect(inProject?.content?.[0]?.text).toContain("graphify"); + // ...while one outside the project still resolves (the CLI subprocess + // runs), but the guard's own containment check stays silent, same as any + // other out-of-project absolute path. + await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(root, "outside.py")}` }, toolCallId: "call-2" }); + expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); +}); + test("navigation cancels in-flight guidance before the next session's tool results", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); From f7626382ef00f31537c1d228709b16a042423ca0 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:59:09 +0000 Subject: [PATCH 10/14] fix(omp): drop unresolvable @oh-my-pi/pi-tui import from the bridge The OMP host loads graphify/omp/index.ts from its installed site-packages location, which ships no node_modules (see package.json's "files" list). The host provides @oh-my-pi/pi-coding-agent to legacy extensions but never @oh-my-pi/pi-tui, so the bridge's `isReadableUrlPath` import from @oh-my-pi/pi-tui/tools/read failed at runtime with `Cannot find package '@oh-my-pi/pi-tui'`, disabling the extension entirely. Vendor the two-line isReadableUrlPath predicate locally instead of importing it, drop the now-unused @oh-my-pi/pi-tui devDependency and regenerate bun.lock, and update the CI advisory step that used to bump both packages. Add a regression test asserting index.ts only imports @oh-my-pi/pi-coding-agent and node: builtins, so a future reintroduction of an unresolvable package fails the suite instead of only the host. --- .github/workflows/ci.yml | 25 +++++++++++++++---------- graphify/omp/bun.lock | 1 - graphify/omp/index.ts | 11 ++++++++++- graphify/omp/package.json | 3 +-- tests/omp.test.ts | 14 ++++++++++++++ 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c90e4b0633..62c2288841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,10 +80,12 @@ jobs: omp-bridge: # tests/omp.test.ts drives the real OMP extension: bun resolves the - # path-utils and pi-tui helpers it imports from the pinned - # @oh-my-pi/pi-coding-agent and @oh-my-pi/pi-tui devDependencies in - # graphify/omp/, and the policy assertions run against the real installed - # Python CLI, so this job needs both toolchains. + # path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent + # devDependency in graphify/omp/ (the only package the host provides; + # graphify/omp/index.ts vendors isReadableUrlPath locally instead of + # importing it from @oh-my-pi/pi-tui, which the host never resolves), and + # the policy assertions run against the real installed Python CLI, so + # this job needs both toolchains. runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -109,14 +111,17 @@ jobs: - name: Run OMP bridge tests against the latest OMP (advisory) # bun install --frozen-lockfile above only proves the bridge works # against the exact pinned version. Upstream OMP has moved exported - # symbols between minor releases before (isReadableUrlPath: pi-coding-agent - # -> pi-tui) without the frozen lockfile ever seeing it. Install - # whatever OMP publishes today and re-run the suite so a future move - # surfaces here. continue-on-error: an unrelated upstream release must - # not block a merge; the frozen run above stays the blocking gate. + # symbols between minor releases before (isReadableUrlPath moved from + # pi-coding-agent to pi-tui) without the frozen lockfile ever seeing + # it; index.ts now vendors that predicate locally instead of tracking + # whichever package currently exports it. Install whatever OMP + # publishes today and re-run the suite so a future move to + # @oh-my-pi/pi-coding-agent's exports still surfaces here. + # continue-on-error: an unrelated upstream release must not block a + # merge; the frozen run above stays the blocking gate. continue-on-error: true run: | - bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest @oh-my-pi/pi-tui@latest + bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts security-scan: diff --git a/graphify/omp/bun.lock b/graphify/omp/bun.lock index 3e723793ba..88a2bc7fc8 100644 --- a/graphify/omp/bun.lock +++ b/graphify/omp/bun.lock @@ -6,7 +6,6 @@ "name": "graphify-omp", "devDependencies": { "@oh-my-pi/pi-coding-agent": "^18.2.6", - "@oh-my-pi/pi-tui": "^18.2.6", }, }, }, diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 67a3d552b0..d4b7407340 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -9,7 +9,16 @@ import { resolveReadPathAsync, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; -import { isReadableUrlPath } from "@oh-my-pi/pi-tui/tools/read"; + +// Mirrors the upstream isReadableUrlPath predicate, kept as a same-named +// local copy (not imported): the wheel ships no node_modules, so this +// extension may import only host-provided packages, and the host provides +// @oh-my-pi/pi-coding-agent alone -- a scoped subpath import of any other +// package is unresolvable at runtime. Named to match so a future reader can +// diff them. +function isReadableUrlPath(value: string): boolean { + return /^https?:\/\/?/i.test(value) || /^www\./i.test(value); +} const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 116193ca8d..20619dd33b 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -7,8 +7,7 @@ "type": "module", "files": ["index.ts"], "devDependencies": { - "@oh-my-pi/pi-coding-agent": "^18.2.6", - "@oh-my-pi/pi-tui": "^18.2.6" + "@oh-my-pi/pi-coding-agent": "^18.2.6" }, "omp": { "extensions": ["./index.ts"], diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 4fbfd29e10..493e72081a 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -205,3 +205,17 @@ test("missing or project-local executables never fall back to project Python cod await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); expect(existsSync(started)).toBe(false); }); + +test("index.ts imports only packages the host actually provides -- no dependency the wheel's node_modules-less install can never resolve", () => { + // The extension loads from a site-packages install with no node_modules at + // all (see graphify/omp/package.json's "files" list). The host supplies + // exactly @oh-my-pi/pi-coding-agent to legacy extensions; anything else + // bare-imported here -- however cleanly `bun test` resolves it via this + // repo's own dev node_modules -- is unresolvable in production. + const source = readFileSync(join(import.meta.dir, "../graphify/omp/index.ts"), "utf8"); + const specifiers = [...source.matchAll(/from\s+"([^"]+)"/g)].map(m => m[1]); + const allowed = (specifier: string) => + specifier.startsWith("node:") || specifier === "@oh-my-pi/pi-coding-agent" || specifier.startsWith("@oh-my-pi/pi-coding-agent/"); + expect(specifiers.length).toBeGreaterThan(0); + expect(specifiers.filter(specifier => !allowed(specifier))).toEqual([]); +}); From e92062905ecfe8c17ae9b2803d1d2d1a13e95bf5 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 16:11:04 +0000 Subject: [PATCH 11/14] fix(omp): use resolveReadPath, not the async sibling missing from older host copies Two @oh-my-pi/pi-coding-agent copies are reachable on this machine (18.1.17 and 18.2.6) and we cannot determine from outside the host which one it binds. Auditing every bridge import against the older 18.1.17 copy found resolveReadPathAsync missing -- it was only added in a later release as a non-blocking-probe performance optimization (per its own upstream docstring: "identical variant order and winner semantics, but non-blocking probes"), not a correctness fix. Revert to the synchronous resolveReadPath, present in both copies, so the extension loads regardless of which copy the host binds. A marginally faster extension that fails to load is worth nothing. Strengthen the import allow-list test with a version-floor check: every named import the bridge takes from @oh-my-pi/pi-coding-agent/tools/path-utils must be exported by the oldest host-provided copy. Parses the real import list out of index.ts rather than hardcoding it, so a future added symbol is checked automatically. Skips cleanly when that copy is not present on the machine running the suite. --- graphify/omp/index.ts | 10 ++++++---- tests/omp.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index d4b7407340..24906460fc 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -2,11 +2,13 @@ import { execFile } from "node:child_process"; import { realpathSync } from "node:fs"; import { delimiter, isAbsolute, relative, sep } from "node:path"; import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +// resolveReadPath, not the async sibling: the async variant is missing from +// older host-provided pi-coding-agent copies -- a blocked statSync beats an unloadable extension. import { expandDelimitedPathEntries, isInternalUrlPath, normalizePathLikeInput, - resolveReadPathAsync, + resolveReadPath, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; @@ -27,14 +29,14 @@ const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const FILE_URL_RE = /^file:\/\//i; // isInternalUrlPath/isReadableUrlPath still earn their place even though the -// guard now defends itself against URL-shaped input: resolveReadPathAsync +// guard now defends itself against URL-shaped input: resolveReadPath // below pre-resolves a rootless value to an absolute path // (`path.resolve(cwd, x)`) before the guard ever sees it, which for a // *foreign*-scheme value glues it onto cwd into something that looks // exactly like a real in-project file (`/https:/example.com/x`, // `/local:/x`) -- a guard fix the bridge then defeats by mangling its // input first is not a fix. file:// is the one exception: OMP's own -// resolveReadPathAsync already strips it down to the real local path (its +// resolveReadPath already strips it down to the real local path (its // resolveToCwd -> expandPath -> stripFileUrl, deliberately excluded from // the external-URL fast path), so routing it through here yields the // correct absolute path rather than a mangled one. @@ -109,7 +111,7 @@ export default function graphify(api: ExtensionAPI): void { const input: Record = { ...event.input }; if (toolName !== "Bash") { const target = toolName === "Glob" ? path : (await splitPathAndSelPreferringLiteral(path, ctx.cwd)).path; - const resolved = await resolveReadPathAsync(target, ctx.cwd); + const resolved = resolveReadPath(target, ctx.cwd); delete input.path; if (toolName === "Read") input.file_path = resolved; else if (toolName === "Glob") input.pattern = resolved; diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 493e72081a..03298e98eb 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -219,3 +219,28 @@ test("index.ts imports only packages the host actually provides -- no dependency expect(specifiers.length).toBeGreaterThan(0); expect(specifiers.filter(specifier => !allowed(specifier))).toEqual([]); }); + +test("every named import from @oh-my-pi/pi-coding-agent/tools/path-utils is exported by the oldest host-provided copy", () => { + // Being on the allow-list above is not enough: @oh-my-pi/pi-coding-agent + // itself is allow-listed, but a symbol added to the bridge's import list + // can still be missing from an older copy the live host actually binds + // (see resolveReadPathAsync, absent from 18.1.17). Parse the real import + // list out of index.ts -- never hardcode it -- so a future added symbol + // is checked automatically. + const oldestRoot = "/root/.omp/plugins/node_modules/@oh-my-pi/pi-coding-agent"; + const oldestPathUtils = join(oldestRoot, "src/tools/path-utils.ts"); + if (!existsSync(oldestPathUtils)) { + console.warn(`skipping: oldest pi-coding-agent copy not found at ${oldestPathUtils}`); + return; + } + const source = readFileSync(join(import.meta.dir, "../graphify/omp/index.ts"), "utf8"); + const importBlock = source.match(/import\s*{([^}]+)}\s*from\s*"@oh-my-pi\/pi-coding-agent\/tools\/path-utils"/); + expect(importBlock).not.toBeNull(); + const imported = importBlock![1].split(",").map(name => name.trim()).filter(Boolean); + expect(imported.length).toBeGreaterThan(0); + const exportedSource = readFileSync(oldestPathUtils, "utf8"); + const exported = new Set( + [...exportedSource.matchAll(/^export\s+(?:async\s+function|function)\s+(\w+)/gm)].map(m => m[1]), + ); + expect(imported.filter(name => !exported.has(name))).toEqual([]); +}); From 62a8197794acdbd1f3ffce642bec94087a8f9988 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:01 +0000 Subject: [PATCH 12/14] fix(hooks): keep a remote file:// authority from aliasing an in-project file _normalize_hook_path unconditionally reduced any file:// value to its path component, discarding the authority. file://evil.com/ then classified identically to file:///: the same absolute path, silently treated as local. RFC 8089 / Node's url.fileURLToPath (ERR_INVALID_FILE_URL_HOST) say a file:// URL is local only when its authority is empty or localhost. Reduce only those two forms; leave any other authority intact so _is_foreign_url_scheme classifies it as the remote reference it is. --- graphify/cli.py | 32 +++++++++++++++++++++++++------- tests/test_hook_url_paths.py | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index a722cd5bca..b1d3845824 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -967,7 +967,8 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: def _normalize_hook_path(value: str) -> str: r"""Trim padding whitespace and a matching pair of outer double quotes, - then strip a leading ``file://`` scheme down to the local path it names. + then strip a *local* leading ``file://`` scheme down to the local path + it names. Mirrors what OMP's own path pipeline already does to a raw tool argument before a hook would ever see it: ``normalizePathLikeInput`` (trim + @@ -978,12 +979,24 @@ def _normalize_hook_path(value: str) -> str: bothered to normalize it first -- a classifier that only agrees with its own host after trimming/de-quoting is exactly the kind of gap a prior review flagged. + + Per RFC 8089, a ``file://`` URL is local only when its authority is + empty (``file:///path``) or ``localhost``; Node's own + ``url.fileURLToPath`` enforces exactly this, throwing + ``ERR_INVALID_FILE_URL_HOST`` for any other host. Any other authority + names a *remote* host, not a local path -- reducing it here would + discard the host and let ``file://evil.com/`` alias a + real local file, so it is left untouched for ``_is_foreign_url_scheme`` + to classify instead. """ value = value.strip() if len(value) > 1 and value[0] == value[-1] == '"': value = value[1:-1] if _FILE_URL_RE.match(value): - path = unquote(urlsplit(value).path) or "/" + split = urlsplit(value) + if split.hostname and split.hostname.lower() != "localhost": + return value + path = unquote(split.path) or "/" # file:///C:/proj/a.py -> C:/proj/a.py: drop the URL's extra root # slash in front of a Windows drive letter. if os.name == "nt" and re.match(r"^/[A-Za-z]:", path): @@ -994,16 +1007,21 @@ def _normalize_hook_path(value: str) -> str: def _is_foreign_url_scheme(value: str) -> bool: """Whether *value* is itself a ``scheme://...`` value -- everything - except ``file://``, which ``_normalize_hook_path`` has already reduced - to a plain local path by the time this runs, so it never matches here. + except a *local* ``file://`` (empty or ``localhost`` authority, RFC + 8089), which ``_normalize_hook_path`` has already reduced to a plain + local path by the time this runs, so it never matches here. A + ``file://`` with any other authority is NOT reduced by + ``_normalize_hook_path`` and so matches here like any other foreign + scheme, naming a remote host rather than a local file. Deliberately does not enumerate OMP's (or any other harness's) internal scheme allow-list (``local://``, ``artifact://``, ...): this guard is embedded by multiple hosts (#522), and hard-coding one of them would silently stop matching the day that host adds a scheme. Any scheme this - function doesn't specifically know to be local -- only ``file://`` is -- - is treated as not-a-local-source-file: a missed nudge on a remote URL is - a far safer wrong answer than a wrong nudge on an unrelated file. + function doesn't specifically know to be local -- only a local + ``file://`` is -- is treated as not-a-local-source-file: a missed nudge + on a remote URL is a far safer wrong answer than a wrong nudge on an + unrelated file. """ return bool(_URL_SCHEME_PREFIX_RE.match(value)) diff --git a/tests/test_hook_url_paths.py b/tests/test_hook_url_paths.py index cdd1ffaa8a..5d88381200 100644 --- a/tests/test_hook_url_paths.py +++ b/tests/test_hook_url_paths.py @@ -123,6 +123,18 @@ def test_normalize_hook_path_strips_padding_quotes_and_file_scheme(): assert _normalize_hook_path("file:///abs/proj/foo.py") == "/abs/proj/foo.py" +def test_normalize_hook_path_file_url_authority_rows(): + """RFC 8089 / Node's ``url.fileURLToPath`` (throws + ``ERR_INVALID_FILE_URL_HOST`` for anything else): a ``file://`` URL is + local only when its authority is empty or ``localhost``. Only those two + forms may be reduced to their bare path component; any other authority + names a remote host and must be left intact for `_is_foreign_url_scheme` + to classify.""" + assert _normalize_hook_path("file:///abs/proj/foo.py") == "/abs/proj/foo.py" + assert _normalize_hook_path("file://localhost/abs/proj/foo.py") == "/abs/proj/foo.py" + assert _normalize_hook_path("file://evil.com/abs/proj/foo.py") == "file://evil.com/abs/proj/foo.py" + + # --------------------------------------------------------------------------- # End-to-end through the guard (real stdin JSON, cwd at a graphed project) # --------------------------------------------------------------------------- @@ -200,6 +212,19 @@ def test_file_url_to_an_indexed_in_project_file_still_nudges(tmp_path, monkeypat assert "MANDATORY" in _invoke(tmp_path, monkeypatch, f"file://{f.as_posix()}") +def test_file_url_with_remote_authority_is_silent(tmp_path, monkeypatch): + """VALID #4: a non-local authority (RFC 8089) names a remote host, not + the in-project file it happens to share a path with -- must NOT nudge.""" + f = _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, f"file://evil.com{f.as_posix()}").strip() == "" + + +def test_file_url_with_localhost_authority_still_nudges(tmp_path, monkeypatch): + """localhost is local per RFC 8089 / Node's url.fileURLToPath.""" + f = _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, f"file://localhost{f.as_posix()}") + + def test_quoted_and_padded_https_url_is_silent(tmp_path, monkeypatch): _project(tmp_path) assert _invoke(tmp_path, monkeypatch, ' "https://example.com/source.py" ').strip() == "" From 08a2d4249774da193df0cd36c3c5acaf7706b01d Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:06 +0000 Subject: [PATCH 13/14] fix(omp): treat every file:// authority but local as remote in isRemote isRemote's file:// carve-out matched the scheme alone: any authority was treated as local, so file://evil.com/ reached resolveReadPath, resolved to the real local path, and reached the guard as if it named a file in the project. Mirror Node's own url.fileURLToPath (ERR_INVALID_FILE_URL_HOST): only an empty or localhost authority is local. Any other authority now falls through to the generic scheme:// remote check, same as it would for https:// or any other foreign scheme. --- graphify/omp/index.ts | 21 ++++++++++++++++++--- tests/omp.test.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 24906460fc..9bb68d165a 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -35,13 +35,28 @@ const FILE_URL_RE = /^file:\/\//i; // *foreign*-scheme value glues it onto cwd into something that looks // exactly like a real in-project file (`/https:/example.com/x`, // `/local:/x`) -- a guard fix the bridge then defeats by mangling its -// input first is not a fix. file:// is the one exception: OMP's own -// resolveReadPath already strips it down to the real local path (its +// input first is not a fix. A *local* file:// is the one exception: OMP's +// own resolveReadPath already strips it down to the real local path (its // resolveToCwd -> expandPath -> stripFileUrl, deliberately excluded from // the external-URL fast path), so routing it through here yields the // correct absolute path rather than a mangled one. + +// RFC 8089 / Node's url.fileURLToPath (throws ERR_INVALID_FILE_URL_HOST for +// anything else): a file:// URL is local only when its authority is empty +// or `localhost`. Any other authority names a remote host and must fall +// through to the generic `scheme://` check below like any other foreign +// scheme -- treating every file:// as local let +// `file://evil.com/` alias a real local file. function isRemote(path: string): boolean { - if (FILE_URL_RE.test(path)) return false; + if (FILE_URL_RE.test(path)) { + try { + const { hostname } = new URL(path); + if (hostname === "" || hostname.toLowerCase() === "localhost") return false; + } catch { + // Malformed file:// URL: not a trustworthy local path either, fall + // through to the generic checks below. + } + } return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://"); } diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 03298e98eb..a5a02952a3 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -167,6 +167,24 @@ test("a file:// read target resolves to the real local path and still reaches th expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); }); +test("a file:// read target with a non-local authority never reaches the guard", async () => { + // VALID #2: RFC 8089 / Node's url.fileURLToPath (ERR_INVALID_FILE_URL_HOST) + // -- only an empty or `localhost` authority names a local file. Any other + // authority names a remote host and must not alias an in-project file + // merely by sharing its path component. + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: `file://evil.com${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + expect(existsSync(started)).toBe(false); + expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); +}); + +test("a file:// read target with an explicit localhost authority still reaches the guard", async () => { + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: `file://localhost${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + const result = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); + expect(result?.content?.[0]?.text).toContain("graphify"); +}); + test("navigation cancels in-flight guidance before the next session's tool results", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); From fd869d8a82be884c101cc05d13736008675c9397 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:11 +0000 Subject: [PATCH 14/14] ci: stop executing an unpinned latest OMP install in the advisory step The advisory job installed @oh-my-pi/pi-coding-agent@latest and ran the bridge suite against it on every CI run -- mutable, unpinned third-party code executing unconditionally. Replace the install-and- run with a download-only check: fetch the package with install scripts disabled, then statically compare index.ts's imported path-utils symbols against that file's declared exports as plain text. The downloaded package is never imported or executed, but the same upstream symbol move (isReadableUrlPath, pi-coding-agent -> pi-tui) that motivated this step still surfaces. --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62c2288841..94bcde0c32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,21 +108,40 @@ jobs: - name: Run OMP bridge tests run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts - - name: Run OMP bridge tests against the latest OMP (advisory) - # bun install --frozen-lockfile above only proves the bridge works - # against the exact pinned version. Upstream OMP has moved exported - # symbols between minor releases before (isReadableUrlPath moved from + - name: Check for upstream symbol moves (advisory, no execution) + # The frozen run above only proves the bridge works against the + # exact pinned version. Upstream OMP has moved exported symbols + # between minor releases before (isReadableUrlPath moved from # pi-coding-agent to pi-tui) without the frozen lockfile ever seeing - # it; index.ts now vendors that predicate locally instead of tracking - # whichever package currently exports it. Install whatever OMP - # publishes today and re-run the suite so a future move to - # @oh-my-pi/pi-coding-agent's exports still surfaces here. + # it; index.ts now vendors that predicate locally instead of + # tracking whichever package currently exports it. This step used + # to install @latest AND execute the whole suite against it -- + # unpinned, mutable third-party code running on every CI invocation. + # It now only downloads the published package (--ignore-scripts: + # no install hooks run) and statically compares index.ts's imported + # path-utils symbols against that file's declared exports as plain + # text -- the downloaded code is never imported or executed, but a + # future symbol move still surfaces here. # continue-on-error: an unrelated upstream release must not block a # merge; the frozen run above stays the blocking gate. continue-on-error: true run: | - bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest - GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest --ignore-scripts + bun -e ' + const fs = require("node:fs"); + const index = fs.readFileSync("graphify/omp/index.ts", "utf8"); + const block = index.match(/import\s*{([^}]+)}\s*from\s*"@oh-my-pi\/pi-coding-agent\/tools\/path-utils"/); + if (!block) throw new Error("no path-utils import block found in index.ts"); + const imported = block[1].split(",").map((s) => s.trim()).filter(Boolean); + const source = fs.readFileSync("graphify/omp/node_modules/@oh-my-pi/pi-coding-agent/src/tools/path-utils.ts", "utf8"); + const exported = new Set([...source.matchAll(/^export\s+(?:async\s+function|function)\s+(\w+)/gm)].map((m) => m[1])); + const missing = imported.filter((name) => !exported.has(name)); + if (missing.length) { + console.error(`@oh-my-pi/pi-coding-agent@latest no longer exports: ${missing.join(", ")}`); + process.exit(1); + } + console.log(`ok: every imported symbol is exported by @latest (${imported.join(", ")})`); + ' security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new