feat: finish the ai plugin (B18)

This commit is contained in:
LukeGus committed 2026-09-24 13:43:26 -05:00
1 parent 6b4d1432ab
commit 21937a6b02
193 files changed
+23429 -4244

No files matched your search

@@ -25,6 +25,8 @@ const h = vi.hoisted(() => ({
statusReports: [] as Array<Record<string, unknown>>,
statusPorts: [] as string[],
statusPortsRemoved: [] as string[],
userPermissions: new Set<string>(),
deleted: [] as Array<{ userId: string; hostId: number }>,
roles: [] as Array<{
id: number;
name: string;
@@ -52,6 +54,8 @@ vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async () => h.access,
hasPermission: async (_userId: string, permission: string) =>
h.userPermissions.has(permission),
}),
},
}));
@@ -147,6 +151,13 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
},
}));
vi.mock("../../hosts/delete-host.js", () => ({
deleteOwnedHost: async (userId: string, hostId: number) => {
h.deleted.push({ userId, hostId });
return hostId === 404 ? null : { id: hostId, name: "gone" };
},
}));
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
@@ -174,6 +185,8 @@ beforeEach(() => {
h.updateResult = null;
h.users = [];
h.roles = [];
h.userPermissions = new Set();
h.deleted = [];
});
describe("ctx.hosts", () => {
@@ -402,6 +415,45 @@ describe("ctx.hosts", () => {
});
});
describe("ctx.hosts.delete", () => {
it("refuses without hosts:write and audits it", async () => {
const audit = vi.fn(async () => {});
const hosts = createPluginHosts({ manifest: manifest([]), audit });
await expect(hosts.delete(1)).rejects.toBeInstanceOf(PluginCapabilityError);
expect(audit).toHaveBeenCalledWith(
"hosts_delete",
"host 1",
expect.objectContaining({ success: false }),
);
expect(h.deleted).toEqual([]);
});
it("refuses an acting user without hosts.delete", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(hosts.delete(1)).rejects.toThrow("may not delete hosts");
expect(h.deleted).toEqual([]);
});
it("deletes the actor's own host through core's delete path", async () => {
h.granted = new Set(["hosts:write"]);
h.userPermissions = new Set(["hosts.delete"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
expect(await hosts.delete(7)).toBe(true);
expect(await hosts.delete(404)).toBe(false);
expect(h.deleted).toEqual([
{ userId: "user-1", hostId: 7 },
{ userId: "user-1", hostId: 404 },
]);
});
});
describe("ctx.hosts session tracking and activity", () => {
it("counts a live session through core and releases it", () => {
h.sessions = [];
+14 -1
View File
@@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
delivered: [] as Array<{ channelId: number; title: string }>,
failChannel: null as number | null,
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
lastSignal: null as AbortSignal | null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -58,9 +59,10 @@ vi.mock("../../utils/notification-sender.js", () => ({
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: async (
url: string,
_init: RequestInit,
init: RequestInit,
allowlist: readonly string[],
) => {
state.lastSignal = init.signal ?? null;
state.fetchCalls.push({ url, allowlist });
return new Response("ok", { status: 200 });
},
@@ -181,4 +183,15 @@ describe("ctx.fetch", () => {
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
]);
});
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
const ctx = contextFor(["network:outbound"]);
const controller = new AbortController();
await ctx.fetch("https://example.com/stream", {
signal: controller.signal,
});
expect(state.lastSignal?.aborted).toBe(false);
controller.abort();
expect(state.lastSignal?.aborted).toBe(true);
});
});
@@ -0,0 +1,159 @@
/**
* ctx.secrets.get/set/delete: a plugin's own per-user secrets. Gated on
* secrets:own, scoped to the acting user, encrypted at rest, and the value
* never reaches the audit log.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
rows: new Map<string, { value: string | null; encrypted: boolean }>(),
}));
const rowKey = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => `${pluginId}|${scope}|${scopeId}|${key}`;
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => {
const row = state.rows.get(rowKey(pluginId, scope, scopeId, key));
return row ? { ...row } : null;
},
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.rows.set(rowKey(pluginId, scope, scopeId, key), {
value,
encrypted,
});
},
delete: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => state.rows.delete(rowKey(pluginId, scope, scopeId, key)),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
grants.set("demo", capabilities);
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
return createPluginContext(manifest, handle);
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.rows.clear();
});
describe("ctx.secrets store", () => {
it("refuses every call without secrets:own", async () => {
const ctx = contextFor([]);
await runAsActor("alice", "request", async () => {
await expect(ctx.secrets.get("k")).rejects.toThrow(/secrets:own/);
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/secrets:own/);
await expect(ctx.secrets.delete("k")).rejects.toThrow(/secrets:own/);
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_secret_delete",
success: false,
});
expect(state.rows.size).toBe(0);
});
it("refuses a call with no acting user", async () => {
const ctx = contextFor(["secrets:own"]);
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/acting user/);
});
it("stores encrypted, per user, and never audits the value", async () => {
const ctx = contextFor(["secrets:own"]);
await runAsActor("alice", "request", () =>
ctx.secrets.set("provider:1", "sk-alice"),
);
expect(state.rows.get("demo|secret|alice|provider:1")).toEqual({
value: JSON.stringify("sysenc:sk-alice"),
encrypted: true,
});
expect(
await runAsActor("alice", "request", () => ctx.secrets.get("provider:1")),
).toBe("sk-alice");
expect(
await runAsActor("bob", "request", () => ctx.secrets.get("provider:1")),
).toBeNull();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_secret_set",
success: true,
});
expect(JSON.stringify(auditEntries)).not.toContain("sk-alice");
});
it("clears on null and on delete", async () => {
const ctx = contextFor(["secrets:own"]);
await runAsActor("alice", "request", async () => {
await ctx.secrets.set("a", "1");
await ctx.secrets.set("b", "2");
await ctx.secrets.set("a", null);
await ctx.secrets.delete("b");
expect(await ctx.secrets.get("a")).toBeNull();
expect(await ctx.secrets.get("b")).toBeNull();
});
expect(state.rows.size).toBe(0);
});
});