feat(FN-3426): add plugin AI security scan gate and GraphTaskNode wrapper t

Adds a plugin AI security scan gate that enforces security scanning before plugin installation, including new `plugin-security-scan.ts` infrastructure, CLI integration (`fn plugin install`), plugin loader hooks, and a changeset. Implements branch filter persistence across sessions in the dashboard (

Fusion-Task-Id: FN-3426
This commit is contained in:
Fusion
2026-05-07 02:42:08 -07:00
committed by gsxdsm
parent 773a69031a
commit 16d3f18d8d
5 changed files with 147 additions and 3 deletions

View File

@@ -18,6 +18,7 @@ Features:
- Drag-and-drop between lifecycle columns
- Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
- Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation
- Column visibility controls
- Inline quick entry creation
- PR/issue badges with live updates

View File

@@ -116,6 +116,8 @@ function prefetchLazyViews() {
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
@@ -209,6 +211,21 @@ function AppInner() {
const [searchQuery, setSearchQuery] = useState("");
const [branchFilter, setBranchFilter] = useState("");
const [baseBranchFilter, setBaseBranchFilter] = useState("");
useEffect(() => {
setBranchFilter(getScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? "");
setBaseBranchFilter(getScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? "");
}, [currentProject?.id]);
const handleBranchFilterChange = useCallback((value: string) => {
setBranchFilter(value);
setScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id);
}, [currentProject?.id]);
const handleBaseBranchFilterChange = useCallback((value: string) => {
setBaseBranchFilter(value);
setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id);
}, [currentProject?.id]);
// Remote node data and events when in remote mode (pass searchQuery for server-side filtering)
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined });
@@ -1316,8 +1333,8 @@ function AppInner() {
baseBranchFilter={baseBranchFilter}
branchOptions={branchOptions}
baseBranchOptions={baseBranchOptions}
onBranchFilterChange={setBranchFilter}
onBaseBranchFilterChange={setBaseBranchFilter}
onBranchFilterChange={handleBranchFilterChange}
onBaseBranchFilterChange={handleBaseBranchFilterChange}
projects={effectiveProjects}
currentProject={currentProject}
onSelectProject={handleSelectProject}

View File

@@ -505,6 +505,7 @@ async function waitForAppShell(): Promise<void> {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockSubscribeSse.mockReset();
mockSubscribeSse.mockReturnValue(vi.fn());
mockCreateTask.mockReset();
@@ -3260,6 +3261,13 @@ describe("FN-3290: modal keyboard isolation for mobile dashboard layout", () =>
});
describe("App board branch filters", () => {
const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
function scopedProjectKey(baseKey: string, projectId: string) {
return `kb:${projectId}:${baseKey}`;
}
function makeTask(id: string, title: string, branch?: string, baseBranch?: string) {
return {
id,
@@ -3390,6 +3398,109 @@ describe("App board branch filters", () => {
remoteSpy.mockRestore();
});
it("restores saved branch filter selections per project", async () => {
const projectId = "project-restore";
mockCurrentProjectState.currentProject = {
id: projectId,
name: "Restore Project",
path: "/restore",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
localStorage.setItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectId), "feature/a");
localStorage.setItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectId), "__fusion:no-branch__");
mockUseTasks.mockImplementation(() => ({
tasks: [
makeTask("FN-1", "Restore Candidate", "feature/a"),
makeTask("FN-2", "Filtered Out", "feature/b", "main"),
],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect((screen.getByTestId("working-branch-filter") as HTMLSelectElement).value).toBe("feature/a");
expect((screen.getByTestId("target-branch-filter") as HTMLSelectElement).value).toBe("__fusion:no-branch__");
await waitFor(() => {
expect(screen.getByText("Restore Candidate")).toBeTruthy();
expect(screen.queryByText("Filtered Out")).toBeNull();
});
});
it("writes updated filter values to project-scoped storage and isolates between projects", async () => {
const projectOneId = "project-one";
const projectTwoId = "project-two";
mockCurrentProjectState.currentProject = {
id: projectOneId,
name: "Project One",
path: "/one",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
mockUseTasks.mockImplementation(() => ({
tasks: [makeTask("FN-1", "Alpha Search", "feature/a", "main")],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
const { rerender } = render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } });
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "main" } });
expect(localStorage.getItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectOneId))).toBe("feature/a");
expect(localStorage.getItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectOneId))).toBe("main");
mockCurrentProjectState.currentProject = {
id: projectTwoId,
name: "Project Two",
path: "/two",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
rerender(<App />);
await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect((screen.getByTestId("working-branch-filter") as HTMLSelectElement).value).toBe("");
expect((screen.getByTestId("target-branch-filter") as HTMLSelectElement).value).toBe("");
expect(localStorage.getItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull();
expect(localStorage.getItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull();
});
it("composes with search and does not affect list view tasks", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [

View File

@@ -95,11 +95,24 @@ describe("projectStorage", () => {
"kb-usage-modal-size",
"kb-usage-provider-order",
"kb-chat-active-session",
"kb-dashboard-working-branch-filter",
"kb-dashboard-base-branch-filter",
"kb-files-line-numbers",
"fusion-plugin-dependency-graph:positions",
]),
);
expect(PROJECT_STORAGE_KEYS).toHaveLength(21);
expect(PROJECT_STORAGE_KEYS).toHaveLength(23);
});
it("stores branch filter values as scoped strings per project", () => {
setScopedItem("kb-dashboard-working-branch-filter", "feature/a", "proj-1");
setScopedItem("kb-dashboard-base-branch-filter", "__fusion:no-branch__", "proj-1");
setScopedItem("kb-dashboard-working-branch-filter", "feature/b", "proj-2");
expect(getScopedItem("kb-dashboard-working-branch-filter", "proj-1")).toBe("feature/a");
expect(getScopedItem("kb-dashboard-base-branch-filter", "proj-1")).toBe("__fusion:no-branch__");
expect(getScopedItem("kb-dashboard-working-branch-filter", "proj-2")).toBe("feature/b");
expect(getScopedItem("kb-dashboard-working-branch-filter", "proj-3")).toBeNull();
});
it("has no overlap between global and project-scoped keys", () => {

View File

@@ -28,6 +28,8 @@ export const PROJECT_STORAGE_KEYS: string[] = [
"kb-usage-modal-size",
"kb-usage-provider-order",
"kb-chat-active-session",
"kb-dashboard-working-branch-filter",
"kb-dashboard-base-branch-filter",
"kb-files-line-numbers",
"fusion-plugin-dependency-graph:positions",
];