Ctrl K
ComunidadGuías…

tests/pi.test.ts

Rama v8 · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { afterEach, beforeEach, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join, relative } from "node:path";
import graphify from "../graphify/pi/index.ts";

// Use the real installed Python CLI for policy tests. No mock of host 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; additionalContext?: string } | undefined;
type Handler = (event: Record<string, unknown>, ctx: object) => Result | Promise<Result>;
type Sent = { message: { customType: string; content: string; display: boolean }; options: { deliverAs: string } };
// `omp` hands the extension its own module as `api.pi` and takes passive context
// from tool_call; `pi` has no such channel and gets a steering message instead.
function harness(host: "omp" | "pi" = "omp") {
  const handlers = new Map<string, Handler>();
  const sent: Sent[] = [];
  let trusted = true;
  const ctx = { cwd, isProjectTrusted: () => trusted, sessionManager: { getSessionId: () => "omp-test-session" } };
  // Only the event registry and message sink are needed; every callback is the real extension's.
  const api = {
    on: (event: string, handler: Handler) => handlers.set(event, handler),
    sendMessage: (message: Sent["message"], options: Sent["options"]) => sent.push({ message, options }),
    ...(host === "omp" ? { pi: {} } : {}),
  };
  graphify(api);
  return {
    sent,
    trust(value: boolean) { trusted = value; },
    async emit(event: string, payload: Record<string, unknown> = {}) { 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(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "overflow guidance"}}) + " ".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));
  if (config.invalidFirst && n === 0) process.stdout.write("not json");
  else process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "guidance " + (n + 1)}}));
} 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 once; later calls return trusted passive context", 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" }, 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);
  // After the one-time deny, every qualifying call carries its own guidance.
  const second = await api.emit("tool_call", { ...event, toolCallId: "call-2" });
  expect(second?.additionalContext).toContain("graphify");
  await api.emit("before_agent_start");
  const third = await api.emit("tool_call", { ...event, toolCallId: "call-3" });
  expect(third?.additionalContext).toContain("graphify");
});

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;
    const result = await api.emit("tool_call", { toolName, input, toolCallId: `${toolName}-1` });
    expect(result?.additionalContext).toBe(`<system-reminder source="graphify">\n${expected}\n</system-reminder>`);
  }
});

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();
  const result = await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" });
  expect(result?.additionalContext).toContain("guidance 1");
  expect(result?.additionalContext).toContain("guidance 2");
});

test("malformed output from one glob target does not stop later targets", async () => {
  writeFileSync(control, JSON.stringify({ mode: "varying", invalidFirst: true }));
  const api = harness();
  const result = await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" });
  expect(result?.additionalContext).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"]) {
    expect(await api.emit("tool_call", { toolName: "read", input: { path }, toolCallId: "call-1" })).toBeUndefined();
    expect(existsSync(started)).toBe(false);
  }
  api.trust(false);
  expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" })).toBeUndefined();
  expect(existsSync(started)).toBe(false);
  api.trust(true);
  writeFileSync(join(cwd, "source.py:12"), "a real filename, not a selector");
  expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" }, toolCallId: "call-3" })).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 localPath
  // (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();
  const inProject = await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(cwd, "source.py")}` }, toolCallId: "call-1" });
  expect(inProject?.additionalContext).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.
  expect(await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(root, "outside.py")}` }, toolCallId: "call-2" })).toBeUndefined();
});

test("file:// read targets distinguish remote authorities from localhost", async () => {
  const api = harness();
  expect(await api.emit("tool_call", {
    toolName: "read",
    input: { path: `file://evil.com${join(cwd, "source.py")}` },
    toolCallId: "call-1",
  })).toBeUndefined();
  expect(existsSync(started)).toBe(false);

  const local = await api.emit("tool_call", {
    toolName: "read",
    input: { path: `file://localhost${join(cwd, "source.py")}` },
    toolCallId: "call-2",
  });
  expect(local?.additionalContext).toContain("graphify");
});

test("navigation cancels in-flight guidance promptly, for every reset event", 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" }, toolCallId: "call-1" });
    const deadline = Date.now() + 1500;
    while (!existsSync(started) && Date.now() < deadline) await Bun.sleep(5);
    expect(existsSync(started)).toBe(true);
    const navigated = performance.now();
    await api.emit(navigation);
    expect(await pending).toBeUndefined();
    // Without the abort the child would only be killed by the 2s timeout.
    expect(performance.now() - navigated).toBeLessThan(1000);
  }
}, 15000);

test("output that finishes after navigation is discarded", async () => {
  writeFileSync(control, JSON.stringify({ mode: "delayed" }));
  const api = harness("pi");
  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);
  // Release the child and block the event loop until it has exited, so its
  // output is ready before the session navigates.
  writeFileSync(join(root, "release"), "");
  Bun.sleepSync(300);
  await api.emit("session_start");
  expect(await pending).toBeUndefined();
  expect(api.sent).toEqual([]);
});

test("oversized input never spawns, invalid/oversized output fails open, and a hung child is killed", async () => {
  const api = harness();
  expect(await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) }, toolCallId: "call-1" })).toBeUndefined();
  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" }, toolCallId: "call-2" })).toBeUndefined();
    expect(performance.now() - start).toBeLessThan(3500);
  }
}, 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" }, 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" }, toolCallId: "call-2" });
  expect(existsSync(started)).toBe(false);
});

test("a project-local graphify earlier on PATH does not shadow an installed one", async () => {
  const local = join(cwd, ".venv", "bin");
  mkdirSync(local, { recursive: true });
  writeFileSync(join(local, "graphify"), `#!/bin/sh\ntouch ${JSON.stringify(join(root, "local-ran"))}\n`);
  chmodSync(join(local, "graphify"), 0o755);
  process.env.PATH = [local, dirname(fixture)].join(delimiter);
  const result = await harness().emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" });
  expect(result?.additionalContext).toContain("graphify");
  expect(existsSync(join(root, "local-ran"))).toBe(false);
});

test("the executable is spawned through its PATH entry, not the symlink's target", async () => {
  // A shim that dispatches on argv[0] must see the name it was installed under.
  const multicall = join(root, "multicall");
  writeFileSync(multicall, `#!/bin/sh\necho "$0" > ${JSON.stringify(join(root, "argv0"))}\nexec ${JSON.stringify(fixture)} "$@"\n`);
  chmodSync(multicall, 0o755);
  const shim = join(root, "shim");
  mkdirSync(shim);
  symlinkSync(multicall, join(shim, "graphify"));
  process.env.PATH = shim;
  const result = await harness().emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" });
  expect(result?.additionalContext).toContain("graphify");
  expect(readFileSync(join(root, "argv0"), "utf8").trim()).toBe(join(shim, "graphify"));
});

test("a relative PATH entry is never searched", async () => {
  process.env.PATH = relative(process.cwd(), dirname(fixture));
  await harness().emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" });
  expect(existsSync(started)).toBe(false);
});

test("tools the guard does not map never spawn it", async () => {
  process.env.GRAPHIFY_HOOK_STRICT = "1";
  const api = harness();
  for (const [toolName, input] of [
    ["edit", { path: "source.py" }],
    ["write", { path: "source.py", content: "" }],
    ["ls", { path: "source.py" }],
    // OMP's `find` is a semantic search, not Pi's pattern-based `find`.
    ["find", { query: "example", path: "source.py" }],
  ] as const) {
    expect(await api.emit("tool_call", { toolName, input, toolCallId: "call-1" })).toBeUndefined();
    expect(existsSync(started)).toBe(false);
  }
});

test("OMP glob targets outside the project stay silent; in-project ones still nudge", async () => {
  const api = harness();
  for (const path of [join(root, "outside", "**", "*.py"), "../outside/*.py"]) {
    expect(await api.emit("tool_call", { toolName: "glob", input: { path }, toolCallId: "call-1" })).toBeUndefined();
  }
  const inside = await api.emit("tool_call", { toolName: "glob", input: { path: "src/**/*.py" }, toolCallId: "call-2" });
  expect(inside?.additionalContext).toContain("graphify");
});

test("`@` and `~` prefixes are normalized like Pi's tools do", async () => {
  // ~/source.py is outside the project (and would match the manifest by basename
  // if it were mistaken for <cwd>/~/source.py); @source.py is the in-project file.
  const savedHome = process.env.HOME;
  process.env.HOME = join(root, "home");
  mkdirSync(process.env.HOME);
  writeFileSync(join(process.env.HOME, "source.py"), "def other(): pass\n");
  process.env.GRAPHIFY_HOOK_STRICT = "1";
  try {
    const api = harness();
    expect(await api.emit("tool_call", { toolName: "read", input: { path: "~/source.py" }, toolCallId: "call-1" })).toBeUndefined();
    const result = await api.emit("tool_call", { toolName: "read", input: { path: "@source.py" }, toolCallId: "call-2" });
    expect(result?.block).toBe(true);
  } finally {
    if (savedHome === undefined) delete process.env.HOME;
    else process.env.HOME = savedHome;
  }
});

test("on Pi, guidance goes out as one steering message and tool_call returns nothing", async () => {
  const api = harness("pi");
  const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", "search"], {
    cwd, input: JSON.stringify({ tool_input: { pattern: "example" } }), encoding: "utf8",
  })).hookSpecificOutput.additionalContext;
  expect(await api.emit("tool_call", { toolName: "grep", input: { pattern: "example", path: "." }, toolCallId: "call-1" })).toBeUndefined();
  expect(api.sent).toEqual([{
    message: { customType: "graphify", content: `<system-reminder source="graphify">\n${expected}\n</system-reminder>`, display: true },
    options: { deliverAs: "steer" },
  }]);
});

test("Pi's find tool reaches the read guard with its pattern and a resolved path", async () => {
  const api = harness("pi");
  await api.emit("tool_call", { toolName: "find", input: { pattern: "*.py", path: "." }, toolCallId: "call-1" });
  const payload = JSON.parse(readFileSync(started, "utf8"));
  expect(payload.tool_name).toBe("Glob");
  expect(payload.tool_input).toEqual({ pattern: "*.py", path: cwd });
  expect(api.sent).toHaveLength(1);
});

test("Pi strict denial still blocks through tool_call", async () => {
  process.env.GRAPHIFY_HOOK_STRICT = "1";
  const api = harness("pi");
  const result = await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" });
  expect(result?.block).toBe(true);
  expect(api.sent).toEqual([]);
});