mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the ai plugin (B18)
This commit is contained in:
1 parent
6b4d1432ab
commit
21937a6b02
193 files changed
+23429
-4244
No files matched your search
@@ -92,6 +92,16 @@ describe("user deletion", () => {
|
||||
expect(await remaining()).toEqual([{ scope: "user", scopeId: "user-2" }]);
|
||||
});
|
||||
|
||||
it("removes the user's ctx.secrets rows too", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("secret", "user-1");
|
||||
await addSetting("secret", "user-2");
|
||||
|
||||
await new UserRepository(context).delete("user-1");
|
||||
|
||||
expect(await remaining()).toEqual([{ scope: "secret", scopeId: "user-2" }]);
|
||||
});
|
||||
|
||||
it("leaves admin-scope settings alone", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("admin", null);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* The AI settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: the admin switch, the private endpoint list,
|
||||
* every user's opt-in, every host's switch and every provider key have to
|
||||
* reach the ai plugin, keys encrypted and gone from the table. Running it
|
||||
* twice must not duplicate or clobber anything.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginRows: [] as Row[],
|
||||
coreSettings: new Map<string, string>(),
|
||||
preferences: [] as Array<Record<string, unknown>>,
|
||||
hosts: [] as Array<Record<string, unknown>>,
|
||||
providers: new Map<string, Array<Record<string, unknown>>>(),
|
||||
pluginInstalled: true,
|
||||
deks: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "ai" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
state.pluginRows.find(
|
||||
(row) =>
|
||||
row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.pluginRows.push({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted,
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
/** The SQL text of a drizzle sql`` query, identifiers included. */
|
||||
function text(query: { queryChunks: unknown[] }): string {
|
||||
return query.queryChunks
|
||||
.map((chunk) => {
|
||||
if (typeof chunk === "string") return chunk;
|
||||
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
|
||||
if (Array.isArray(value.value)) return value.value.join("");
|
||||
// sql.identifier() is a Name chunk carrying the table name.
|
||||
if (typeof value.value === "string") return value.value;
|
||||
if (typeof value.name === "string") return value.name;
|
||||
return String(chunk);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: { queryChunks: unknown[] }) => {
|
||||
const sqlText = text(query);
|
||||
if (sqlText.includes("FROM user_preferences")) return state.preferences;
|
||||
if (sqlText.includes("FROM ssh_data")) return state.hosts;
|
||||
for (const [table, rows] of state.providers) {
|
||||
if (new RegExp(`FROM ${table} `).test(sqlText)) {
|
||||
return rows.filter((row) => row.api_key);
|
||||
}
|
||||
}
|
||||
throw new Error(`no such table in: ${sqlText}`);
|
||||
},
|
||||
runStatement: async (query: { queryChunks: unknown[] }) => {
|
||||
const sqlText = text(query);
|
||||
for (const [table, rows] of state.providers) {
|
||||
if (!sqlText.includes(`UPDATE ${table} `)) continue;
|
||||
const id = query.queryChunks.find((chunk) => typeof chunk === "number");
|
||||
for (const row of rows) if (row.id === id) row.api_key = null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: (userId: string) =>
|
||||
state.deks.has(userId) ? Buffer.from(userId) : null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/lazy-field-encryption.js", () => ({
|
||||
LazyFieldEncryption: {
|
||||
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
|
||||
// legacy plaintext value.
|
||||
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
allowlistToText,
|
||||
runAiSettingsMigration,
|
||||
} from "../../utils/crypto-migration/ai-settings-migration.js";
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = state.pluginRows.find(
|
||||
(entry) =>
|
||||
entry.scope === scope && entry.scopeId === scopeId && entry.key === key,
|
||||
);
|
||||
return row ? JSON.parse(row.value ?? "null") : undefined;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.pluginRows = [];
|
||||
state.coreSettings = new Map([
|
||||
["ai_globally_enabled", "true"],
|
||||
["ai_private_endpoint_allowlist", '["localhost", "ollama.lan"]'],
|
||||
]);
|
||||
state.preferences = [
|
||||
{ user_id: "u1", ai_assistant_enabled: 1, ai_read_only_commands: 0 },
|
||||
{ user_id: "u2", ai_assistant_enabled: null, ai_read_only_commands: null },
|
||||
];
|
||||
state.hosts = [
|
||||
{ id: 5, enable_ai_assistant: 1 },
|
||||
{ id: 6, enable_ai_assistant: 0 },
|
||||
];
|
||||
state.providers = new Map([
|
||||
[
|
||||
"ai_providers",
|
||||
[
|
||||
{ id: 3, user_id: "u1", api_key: "enc:sk-one" },
|
||||
{ id: 4, user_id: "u2", api_key: "sk-plain" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
state.pluginInstalled = true;
|
||||
state.deks = new Set(["u1", "u2"]);
|
||||
});
|
||||
|
||||
describe("allowlistToText", () => {
|
||||
it("turns the stored JSON array into one host per line", () => {
|
||||
expect(allowlistToText('[" a ", "", "b"]')).toBe("a\nb");
|
||||
expect(allowlistToText("[]")).toBe("");
|
||||
expect(allowlistToText("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAiSettingsMigration", () => {
|
||||
it("moves admin, user and host settings", async () => {
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("admin", null, "globallyEnabled")).toBe(true);
|
||||
expect(value("admin", null, "privateEndpoints")).toBe(
|
||||
"localhost\nollama.lan",
|
||||
);
|
||||
expect(value("user", "u1", "enabled")).toBe(true);
|
||||
expect(value("user", "u1", "allowReadOnlyCommands")).toBe(false);
|
||||
// Never asked stays unset, which the plugin reads as not enabled.
|
||||
expect(value("user", "u2", "enabled")).toBeUndefined();
|
||||
expect(value("host", "5", "enableAiAssistant")).toBe(true);
|
||||
expect(value("host", "6", "enableAiAssistant")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves each provider key into its owner's secrets, encrypted, and clears the column", async () => {
|
||||
await runAiSettingsMigration();
|
||||
|
||||
const secret = state.pluginRows.find(
|
||||
(row) => row.scope === "secret" && row.key === "provider:3",
|
||||
);
|
||||
expect(secret).toMatchObject({
|
||||
scopeId: "u1",
|
||||
encrypted: true,
|
||||
value: JSON.stringify("sysenc:sk-one"),
|
||||
});
|
||||
expect(value("secret", "u2", "provider:4")).toBe("sysenc:sk-plain");
|
||||
expect(
|
||||
state.providers.get("ai_providers")!.every((row) => row.api_key === null),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a key alone while its owner's data key is unavailable", async () => {
|
||||
state.deks = new Set(["u1"]);
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("secret", "u2", "provider:4")).toBeUndefined();
|
||||
const row = state.providers.get("ai_providers")!.find((r) => r.id === 4);
|
||||
expect(row?.api_key).toBe("sk-plain");
|
||||
});
|
||||
|
||||
it("reads the adopted table once the plugin has renamed it", async () => {
|
||||
state.providers = new Map([
|
||||
["p_ai_providers", [{ id: 8, user_id: "u1", api_key: "enc:sk-late" }]],
|
||||
]);
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("secret", "u1", "provider:8")).toBe("sysenc:sk-late");
|
||||
});
|
||||
|
||||
it("changes nothing on a second run", async () => {
|
||||
await runAiSettingsMigration();
|
||||
const first = JSON.stringify(state.pluginRows);
|
||||
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(first);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already has", async () => {
|
||||
state.pluginRows.push({
|
||||
pluginId: "ai",
|
||||
scope: "admin",
|
||||
scopeId: null,
|
||||
key: "globallyEnabled",
|
||||
value: "false",
|
||||
encrypted: false,
|
||||
});
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("admin", null, "globallyEnabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
await runAiSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user