mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Module mocks (hoisted) ─────────────────────────────────────────────────
|
||||
|
||||
const { mockGetActiveAgentName, mockGetActiveAgentNameFromSystemPrompt } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetActiveAgentName: vi.fn<(ctx: ExtensionContext) => string | null>(),
|
||||
mockGetActiveAgentNameFromSystemPrompt:
|
||||
vi.fn<(systemPrompt?: string) => string | null>(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/active-agent", () => ({
|
||||
getActiveAgentName: mockGetActiveAgentName,
|
||||
getActiveAgentNameFromSystemPrompt: mockGetActiveAgentNameFromSystemPrompt,
|
||||
}));
|
||||
|
||||
// ── Test helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
import type { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
||||
import { win32PathFlavor } from "#src/path/path-flavor";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
||||
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
||||
import { makeCtx } from "#test/helpers/handler-fixtures";
|
||||
import {
|
||||
makeConfigStore,
|
||||
makeFakePermissionManager,
|
||||
makeRealSession,
|
||||
} from "#test/helpers/session-fixtures";
|
||||
|
||||
// Alias so the existing tests read naturally.
|
||||
const createSession = makeRealSession;
|
||||
const makePermissionManager = makeFakePermissionManager;
|
||||
|
||||
function makeSkillEntry(
|
||||
name: string,
|
||||
overrides: Partial<SkillPromptEntry> = {},
|
||||
): SkillPromptEntry {
|
||||
return {
|
||||
name,
|
||||
description: `${name} skill`,
|
||||
location: `/${name}/SKILL.md`,
|
||||
state: "allow",
|
||||
normalizedLocation: `/${name}/SKILL.md`,
|
||||
normalizedBaseDir: `/${name}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetActiveAgentName.mockReset();
|
||||
mockGetActiveAgentNameFromSystemPrompt.mockReset();
|
||||
mockGetActiveAgentName.mockReturnValue(null);
|
||||
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("PermissionSession", () => {
|
||||
describe("activate and deactivate", () => {
|
||||
it("stores the context on activate", () => {
|
||||
const { session, forwarding } = createSession();
|
||||
const ctx = makeCtx();
|
||||
|
||||
session.activate(ctx);
|
||||
|
||||
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("clears context on deactivate", () => {
|
||||
const { session, forwarding } = createSession();
|
||||
session.activate(makeCtx());
|
||||
session.deactivate();
|
||||
|
||||
expect(forwarding.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards activate to the authorizer selection", () => {
|
||||
const { session, authorizerSelection } = createSession();
|
||||
const ctx = makeCtx();
|
||||
|
||||
session.activate(ctx);
|
||||
|
||||
expect(authorizerSelection.activate).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("forwards deactivate to the authorizer selection", () => {
|
||||
const { session, authorizerSelection } = createSession();
|
||||
session.activate(makeCtx());
|
||||
session.deactivate();
|
||||
|
||||
expect(authorizerSelection.deactivate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetForNewSession", () => {
|
||||
it("configures the injected PermissionManager for the context cwd when trusted", () => {
|
||||
const pm = makePermissionManager();
|
||||
const { session } = createSession({ permissionManager: pm });
|
||||
const ctx = makeCtx({ cwd: "/new/project" });
|
||||
|
||||
session.resetForNewSession(ctx, true);
|
||||
|
||||
expect(pm.configureForCwd).toHaveBeenCalledWith("/new/project");
|
||||
});
|
||||
|
||||
it("withholds the project cwd (global-only) when the project is untrusted", () => {
|
||||
const pm = makePermissionManager();
|
||||
const { session } = createSession({ permissionManager: pm });
|
||||
const ctx = makeCtx({ cwd: "/new/project" });
|
||||
|
||||
session.resetForNewSession(ctx, false);
|
||||
|
||||
expect(pm.configureForCwd).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("clears skill entries", () => {
|
||||
const { session } = createSession();
|
||||
session.setActiveSkillEntries([makeSkillEntry("test")]);
|
||||
expect(session.getActiveSkillEntries()).toHaveLength(1);
|
||||
|
||||
session.resetForNewSession(makeCtx(), true);
|
||||
|
||||
expect(session.getActiveSkillEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("starts forwarding with the new context", () => {
|
||||
const { session, forwarding } = createSession();
|
||||
const ctx = makeCtx();
|
||||
|
||||
session.resetForNewSession(ctx, true);
|
||||
|
||||
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("activates the new context", () => {
|
||||
const { session } = createSession();
|
||||
const ctx = makeCtx();
|
||||
|
||||
session.resetForNewSession(ctx, true);
|
||||
|
||||
// Verify context is stored by calling resolveAgentName which needs it
|
||||
mockGetActiveAgentName.mockReturnValue("test-agent");
|
||||
const name = session.resolveAgentName(ctx);
|
||||
expect(name).toBe("test-agent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPathNormalizer", () => {
|
||||
it("returns a normalizer bound to the reset session cwd", () => {
|
||||
const { session } = createSession();
|
||||
session.resetForNewSession(makeCtx({ cwd: "/projects/app" }), true);
|
||||
|
||||
const ap = session.getPathNormalizer().forPath("src/foo.ts");
|
||||
|
||||
expect(ap.value()).toBe("/projects/app/src/foo.ts");
|
||||
});
|
||||
|
||||
it("rebinds the normalizer cwd on a subsequent reset", () => {
|
||||
const { session } = createSession();
|
||||
session.resetForNewSession(makeCtx({ cwd: "/projects/app" }), true);
|
||||
session.resetForNewSession(makeCtx({ cwd: "/projects/other" }), true);
|
||||
|
||||
expect(session.getPathNormalizer().forPath("a.ts").value()).toBe(
|
||||
"/projects/other/a.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("binds the normalizer on activate, before any reset (no fail-open)", () => {
|
||||
const { session } = createSession();
|
||||
// A tool call can activate the session before session_start resets it;
|
||||
// the normalizer must still track the active ctx cwd.
|
||||
session.activate(makeCtx({ cwd: "/projects/activated" }));
|
||||
|
||||
expect(session.getPathNormalizer().forPath("a.ts").value()).toBe(
|
||||
"/projects/activated/a.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a win32 normalizer when constructed with the win32 flavor", () => {
|
||||
const { session } = createSession({ flavor: win32PathFlavor });
|
||||
session.resetForNewSession(makeCtx({ cwd: "C:\\Projects\\App" }), true);
|
||||
|
||||
expect(session.getPathNormalizer().forPath("src\\foo.ts").value()).toBe(
|
||||
"c:\\projects\\app\\src\\foo.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shutdown", () => {
|
||||
it("clears session rules", () => {
|
||||
const { session, sessionRules } = createSession();
|
||||
sessionRules.recordSessionApproval(SessionApproval.single("bash", "*"));
|
||||
expect(sessionRules.getRuleset()).toHaveLength(1);
|
||||
|
||||
session.shutdown();
|
||||
|
||||
expect(sessionRules.getRuleset()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears skill entries", () => {
|
||||
const { session } = createSession();
|
||||
session.setActiveSkillEntries([makeSkillEntry("s")]);
|
||||
|
||||
session.shutdown();
|
||||
|
||||
expect(session.getActiveSkillEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("stops forwarding and deactivates context", () => {
|
||||
const { session, forwarding } = createSession();
|
||||
session.activate(makeCtx());
|
||||
|
||||
session.shutdown();
|
||||
|
||||
expect(forwarding.stop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill entries", () => {
|
||||
it("get/set skill entries", () => {
|
||||
const { session } = createSession();
|
||||
const entries = [makeSkillEntry("a"), makeSkillEntry("b")];
|
||||
session.setActiveSkillEntries(entries);
|
||||
expect(session.getActiveSkillEntries()).toEqual(entries);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentName", () => {
|
||||
it("returns name from session context", () => {
|
||||
mockGetActiveAgentName.mockReturnValue("ctx-agent");
|
||||
const { session } = createSession();
|
||||
const ctx = makeCtx();
|
||||
|
||||
expect(session.resolveAgentName(ctx)).toBe("ctx-agent");
|
||||
});
|
||||
|
||||
it("falls back to system prompt", () => {
|
||||
mockGetActiveAgentName.mockReturnValue(null);
|
||||
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue("prompt-agent");
|
||||
const { session } = createSession();
|
||||
const ctx = makeCtx();
|
||||
|
||||
expect(session.resolveAgentName(ctx, "system prompt")).toBe(
|
||||
"prompt-agent",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to last known name", () => {
|
||||
const { session } = createSession();
|
||||
const ctx = makeCtx();
|
||||
|
||||
// First call sets name
|
||||
mockGetActiveAgentName.mockReturnValue("first-agent");
|
||||
session.resolveAgentName(ctx);
|
||||
|
||||
// Second call with no name resolves to last known
|
||||
mockGetActiveAgentName.mockReturnValue(null);
|
||||
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
|
||||
expect(session.resolveAgentName(ctx)).toBe("first-agent");
|
||||
});
|
||||
|
||||
it("exposes lastKnownActiveAgentName", () => {
|
||||
const { session } = createSession();
|
||||
expect(session.lastKnownActiveAgentName).toBeNull();
|
||||
|
||||
mockGetActiveAgentName.mockReturnValue("named");
|
||||
session.resolveAgentName(makeCtx());
|
||||
expect(session.lastKnownActiveAgentName).toBe("named");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infrastructure paths", () => {
|
||||
it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
|
||||
const configStore = makeConfigStore({
|
||||
current: vi.fn().mockReturnValue({
|
||||
piInfrastructureReadPaths: ["/extra/path"],
|
||||
}),
|
||||
});
|
||||
const { session } = createSession({ configStore });
|
||||
expect(session.getInfrastructureReadDirs()).toEqual([
|
||||
"/test/agent",
|
||||
"/test/agent/git",
|
||||
"/extra/path",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
|
||||
const { session } = createSession();
|
||||
expect(session.getInfrastructureReadDirs()).toEqual([
|
||||
"/test/agent",
|
||||
"/test/agent/git",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config delegation", () => {
|
||||
it("refreshConfig delegates to configStore.refresh with the trust flag", () => {
|
||||
const { session, configStore } = createSession();
|
||||
const ctx = makeCtx();
|
||||
session.refreshConfig(ctx, true);
|
||||
expect(configStore.refresh).toHaveBeenCalledWith(ctx, true);
|
||||
});
|
||||
|
||||
it("refreshConfig forwards projectTrusted=false when untrusted", () => {
|
||||
const { session, configStore } = createSession();
|
||||
const ctx = makeCtx();
|
||||
session.refreshConfig(ctx, false);
|
||||
expect(configStore.refresh).toHaveBeenCalledWith(ctx, false);
|
||||
});
|
||||
|
||||
it("logResolvedConfigPaths delegates to configStore.logResolvedPaths", () => {
|
||||
const { session, configStore } = createSession();
|
||||
session.logResolvedConfigPaths();
|
||||
expect(configStore.logResolvedPaths).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("config getter delegates to configStore.current()", () => {
|
||||
const fakeConfig = { debugLog: true } as typeof DEFAULT_EXTENSION_CONFIG;
|
||||
const configStore = makeConfigStore({
|
||||
current: vi.fn().mockReturnValue(fakeConfig),
|
||||
});
|
||||
const { session } = createSession({ configStore });
|
||||
expect(session.config).toBe(fakeConfig);
|
||||
});
|
||||
|
||||
it("getToolPreviewLimits returns the built-in limits regardless of config", () => {
|
||||
const configStore = makeConfigStore({
|
||||
current: vi.fn().mockReturnValue({
|
||||
toolInputPreviewMaxLength: 400,
|
||||
toolTextSummaryMaxLength: 120,
|
||||
}),
|
||||
});
|
||||
const { session } = createSession({ configStore });
|
||||
expect(session.getToolPreviewLimits()).toEqual(
|
||||
resolveToolPreviewLimits(),
|
||||
);
|
||||
});
|
||||
|
||||
it("getToolPreviewLimits returns positive built-in defaults", () => {
|
||||
const { session } = createSession();
|
||||
const limits = session.getToolPreviewLimits();
|
||||
expect(limits.toolInputPreviewMaxLength).toBeGreaterThan(0);
|
||||
expect(limits.toolTextSummaryMaxLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reload", () => {
|
||||
it("configures PermissionManager for current context cwd when trusted", () => {
|
||||
const pm = makePermissionManager();
|
||||
const { session } = createSession({ permissionManager: pm });
|
||||
const ctx = makeCtx({ cwd: "/project" });
|
||||
session.activate(ctx);
|
||||
|
||||
session.reload(true);
|
||||
|
||||
expect(pm.configureForCwd).toHaveBeenCalledWith("/project");
|
||||
});
|
||||
|
||||
it("withholds the project cwd (global-only) when the project is untrusted", () => {
|
||||
const pm = makePermissionManager();
|
||||
const { session } = createSession({ permissionManager: pm });
|
||||
const ctx = makeCtx({ cwd: "/project" });
|
||||
session.activate(ctx);
|
||||
|
||||
session.reload(false);
|
||||
|
||||
expect(pm.configureForCwd).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("clears skill entries", () => {
|
||||
const { session } = createSession();
|
||||
session.setActiveSkillEntries([makeSkillEntry("s")]);
|
||||
|
||||
session.reload(true);
|
||||
|
||||
expect(session.getActiveSkillEntries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeContext", () => {
|
||||
it("returns null before activation", () => {
|
||||
const { session } = createSession();
|
||||
expect(session.getRuntimeContext()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns context after activation", () => {
|
||||
const { session } = createSession();
|
||||
const ctx = makeCtx();
|
||||
session.activate(ctx);
|
||||
expect(session.getRuntimeContext()).toBe(ctx);
|
||||
});
|
||||
|
||||
it("returns null after deactivation", () => {
|
||||
const { session } = createSession();
|
||||
session.activate(makeCtx());
|
||||
session.deactivate();
|
||||
expect(session.getRuntimeContext()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify", () => {
|
||||
it("forwards the message to ctx.ui.notify with 'warning' severity after activation", () => {
|
||||
const { session } = createSession();
|
||||
const ctx = makeCtx();
|
||||
session.activate(ctx);
|
||||
|
||||
session.notify("something went wrong");
|
||||
|
||||
expect(ctx.ui.notify).toHaveBeenCalledOnce();
|
||||
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
||||
"something went wrong",
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op and does not throw before activation", () => {
|
||||
const { session } = createSession();
|
||||
|
||||
expect(() => session.notify("msg")).not.toThrow();
|
||||
});
|
||||
|
||||
it("is a no-op and does not throw after deactivation", () => {
|
||||
const { session } = createSession();
|
||||
const ctx = makeCtx();
|
||||
session.activate(ctx);
|
||||
session.deactivate();
|
||||
|
||||
expect(() => session.notify("msg")).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user