mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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
@@ -0,0 +1,73 @@
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("ai activate", () => {
|
||||
it.each(["db:own", "network:serve"])(
|
||||
"fails closed without %s",
|
||||
async (capability) => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot store a provider key without secrets:own", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await activate(mock.ctx);
|
||||
await expect(
|
||||
mock.ctx.secrets.set("provider:1", "k"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("offers the api key and the host import normalizer", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
await server.request("POST", "/providers", {
|
||||
body: { providerType: "openai", label: "Work", apiKey: "sk-shared" },
|
||||
});
|
||||
|
||||
const normalizer = server.mock.ctx.registry.consume<
|
||||
(raw: Record<string, unknown>) => Record<string, unknown> | null
|
||||
>("ai.hostImportNormalizer");
|
||||
expect(normalizer?.({ enableAiAssistant: true })).toEqual({
|
||||
enableAiAssistant: true,
|
||||
});
|
||||
expect(
|
||||
normalizer?.({ pluginSettings: { ai: { enableAiAssistant: false } } }),
|
||||
).toEqual({ enableAiAssistant: false });
|
||||
expect(normalizer?.({ name: "x" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listCommandHistory,
|
||||
setPluginServices,
|
||||
readService,
|
||||
requireService,
|
||||
serviceAvailable,
|
||||
SERVICE,
|
||||
} from "../../src/backend/services.js";
|
||||
|
||||
afterEach(() => setPluginServices(null));
|
||||
function services(provided: Record<string, object>) {
|
||||
return {
|
||||
providers: (service: string) => (service in provided ? [""] : []),
|
||||
get: vi.fn((service: string) => provided[service]),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("listCommandHistory", () => {
|
||||
describe("optional services", () => {
|
||||
it("reads the user's history through terminal.history", async () => {
|
||||
const list = vi.fn(async () => [
|
||||
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
const get = vi.fn(() => ({ list }));
|
||||
setPluginServices({ get, provide: vi.fn() } as never);
|
||||
const history = await readService(
|
||||
services({ [SERVICE.history]: { list } }),
|
||||
SERVICE.history,
|
||||
(terminal) => terminal.list(4, 25),
|
||||
);
|
||||
|
||||
const history = await listCommandHistory("user-1", 4, 25);
|
||||
|
||||
expect(get).toHaveBeenCalledWith("terminal.history", { userId: "user-1" });
|
||||
expect(list).toHaveBeenCalledWith(4, 25);
|
||||
expect(history).toEqual([
|
||||
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers null while the terminal is off or refuses the user", async () => {
|
||||
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
|
||||
it("answers null while the provider is off or refuses the user", async () => {
|
||||
expect(
|
||||
await readService(services({}), SERVICE.history, (terminal) =>
|
||||
terminal.list(4, 25),
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
setPluginServices({
|
||||
get: () => ({
|
||||
list: async () => {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
}),
|
||||
provide: vi.fn(),
|
||||
} as never);
|
||||
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
|
||||
const refusing = {
|
||||
list: async () => {
|
||||
throw new Error("Missing permission");
|
||||
},
|
||||
};
|
||||
expect(
|
||||
await readService(
|
||||
services({ [SERVICE.history]: refusing }),
|
||||
SERVICE.history,
|
||||
(terminal) => terminal.list(4, 25),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("says which plugin is missing when a write needs it", () => {
|
||||
expect(serviceAvailable(services({}), SERVICE.fleets)).toBe(false);
|
||||
expect(() => requireService(services({}), SERVICE.fleets)).toThrow(
|
||||
"The fleets plugin is not available",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -101,22 +101,22 @@ describe("evaluateEgress", () => {
|
||||
});
|
||||
|
||||
describe("parseAllowlist", () => {
|
||||
it("falls back to the defaults when unset or malformed", () => {
|
||||
it("falls back to the defaults when the setting is not text", () => {
|
||||
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
expect(parseAllowlist(undefined)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("normalises stored entries", () => {
|
||||
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([
|
||||
"localhost",
|
||||
"10.0.0.5",
|
||||
]);
|
||||
it("reads one host per line, normalised, skipping anything that is not a host", () => {
|
||||
expect(
|
||||
parseAllowlist(
|
||||
[" LocalHost ", "", "10.0.0.5", "http://x/y", "10.0.0.5"].join("\n"),
|
||||
),
|
||||
).toEqual(["localhost", "10.0.0.5"]);
|
||||
});
|
||||
|
||||
it("honours a deliberately empty allowlist", () => {
|
||||
// An admin clearing the list must actually block everything private,
|
||||
// not silently get the defaults back.
|
||||
expect(parseAllowlist("[]")).toEqual([]);
|
||||
expect(parseAllowlist("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,22 +8,6 @@ vi.mock("../../src/backend/providers/registry.js", () => ({
|
||||
getAdapter: () => ({ streamChat, listModels: async () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/backend/tools/catalog.js", () => ({
|
||||
getTool: (name: string) =>
|
||||
name === "list_hosts"
|
||||
? {
|
||||
name: "list_hosts",
|
||||
description: "List hosts",
|
||||
category: "read",
|
||||
parameters: { type: "object", properties: {} },
|
||||
handler,
|
||||
}
|
||||
: undefined,
|
||||
toolDefinitions: () => [
|
||||
{ name: "list_hosts", description: "List hosts", parameters: {} },
|
||||
],
|
||||
}));
|
||||
|
||||
const { runAgent } = await import("../../src/backend/engine.js");
|
||||
|
||||
function chunks(...values: ChatChunk[]) {
|
||||
@@ -33,14 +17,25 @@ function chunks(...values: ChatChunk[]) {
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
config: { providerType: "ollama" as const },
|
||||
config: { providerType: "ollama" as const, fetch: vi.fn() },
|
||||
model: "test",
|
||||
system: "system",
|
||||
context: {
|
||||
userId: "user-1",
|
||||
conversationId: 1,
|
||||
allowReadOnlyCommands: false,
|
||||
deps: {} as never,
|
||||
},
|
||||
// Only what was offered can run, so this is the whole catalog here.
|
||||
tools: [
|
||||
{
|
||||
name: "list_hosts",
|
||||
description: "List hosts",
|
||||
category: "read" as const,
|
||||
parameters: { type: "object", properties: {} },
|
||||
handler,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function collect(history: any[] = []) {
|
||||
|
||||
@@ -1,94 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAiGate,
|
||||
isAiGloballyEnabled,
|
||||
readPrivateAllowlist,
|
||||
resolveAiAccess,
|
||||
} from "../../src/backend/gating.js";
|
||||
import { DEFAULT_PRIVATE_ALLOWLIST } from "../../src/backend/egress.js";
|
||||
|
||||
const settingsRepository = { getBoolean: vi.fn() };
|
||||
const userPreferenceRepository = { findByUserId: vi.fn() };
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => settingsRepository,
|
||||
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
|
||||
}));
|
||||
|
||||
const { isAiGloballyEnabled, resolveAiAccess } =
|
||||
await import("../../src/backend/gating.js");
|
||||
/** The plugin's settings as ctx.settings reads them: admin, then per user. */
|
||||
function settings(
|
||||
admin: Record<string, unknown>,
|
||||
user: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
get: vi.fn(async (key: string) => admin[key]) as never,
|
||||
getUser: vi.fn(async (_userId: string, key: string) => user[key]) as never,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AI gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults to off so upgrading an install enables nothing", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(false);
|
||||
await isAiGloballyEnabled();
|
||||
expect(settingsRepository.getBoolean).toHaveBeenCalledWith(
|
||||
"ai_globally_enabled",
|
||||
false,
|
||||
it("is off until an admin turns it on, so an upgrade enables nothing", async () => {
|
||||
expect(await isAiGloballyEnabled(settings({}))).toBe(false);
|
||||
expect(await isAiGloballyEnabled(settings({ globallyEnabled: true }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks everyone when the admin global is off", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(false);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: true,
|
||||
});
|
||||
it("blocks everyone when the admin switch is off", async () => {
|
||||
const reader = settings(
|
||||
{ globallyEnabled: false },
|
||||
{ enabled: true, allowReadOnlyCommands: true },
|
||||
);
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
const access = await resolveAiAccess(reader, "user-1");
|
||||
|
||||
expect(access).toEqual({ enabled: false, allowReadOnlyCommands: false });
|
||||
// The kill switch short-circuits, so the user's choice is never read.
|
||||
expect(reader.getUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a user who never chose as not enabled", async () => {
|
||||
const access = await resolveAiAccess(
|
||||
settings({ globallyEnabled: true }),
|
||||
"user-1",
|
||||
);
|
||||
expect(access.enabled).toBe(false);
|
||||
expect(access.allowReadOnlyCommands).toBe(false);
|
||||
// The kill switch short-circuits, so the preference is never consulted.
|
||||
expect(userPreferenceRepository.findByUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a user who has not enabled it", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: false,
|
||||
});
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats never-asked as not enabled", async () => {
|
||||
// Null means the user was never shown the choice, which is not consent.
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: null,
|
||||
});
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing preference row as not enabled", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue(null);
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only when both gates are open", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: true,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(true);
|
||||
expect(access.allowReadOnlyCommands).toBe(true);
|
||||
const access = await resolveAiAccess(
|
||||
settings(
|
||||
{ globallyEnabled: true },
|
||||
{ enabled: true, allowReadOnlyCommands: true },
|
||||
),
|
||||
"user-1",
|
||||
);
|
||||
expect(access).toEqual({ enabled: true, allowReadOnlyCommands: true });
|
||||
});
|
||||
|
||||
it("keeps read-only commands off unless separately opted in", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: null,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(true);
|
||||
const access = await resolveAiAccess(
|
||||
settings({ globallyEnabled: true }, { enabled: true }),
|
||||
"user-1",
|
||||
);
|
||||
expect(access.allowReadOnlyCommands).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the private endpoint list, with defaults when unset", async () => {
|
||||
expect(await readPrivateAllowlist(settings({}))).toEqual(
|
||||
DEFAULT_PRIVATE_ALLOWLIST,
|
||||
);
|
||||
expect(
|
||||
await readPrivateAllowlist(
|
||||
settings({ privateEndpoints: "ollama.lan\n10.0.0.5" }),
|
||||
),
|
||||
).toEqual(["ollama.lan", "10.0.0.5"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAiGate", () => {
|
||||
function response() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return { json: (body: unknown) => (res.body = body) };
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
it("answers 401 without a user and 403 while the assistant is off", async () => {
|
||||
const next = vi.fn();
|
||||
const noUser = response();
|
||||
await createAiGate(settings({ globallyEnabled: true }), () => undefined)(
|
||||
{},
|
||||
noUser,
|
||||
next,
|
||||
);
|
||||
expect(noUser.statusCode).toBe(401);
|
||||
|
||||
const off = response();
|
||||
await createAiGate(settings({ globallyEnabled: false }), () => "user-1")(
|
||||
{},
|
||||
off,
|
||||
next,
|
||||
);
|
||||
expect(off.statusCode).toBe(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes an opted-in user through with their access attached", async () => {
|
||||
const next = vi.fn();
|
||||
const req: { aiAccess?: unknown } = {};
|
||||
await createAiGate(
|
||||
settings({ globallyEnabled: true }, { enabled: true }),
|
||||
() => "user-1",
|
||||
)(req, response(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.aiAccess).toEqual({
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
];
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
/** Turns the assistant on for the server and opts `userId` in. */
|
||||
enableFor: (userId: string) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
services?: Record<string, object>;
|
||||
fetch?: (url: string, init?: unknown) => Promise<Response>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ALL_PERMISSIONS,
|
||||
services: options.services,
|
||||
fetch: options.fetch as never,
|
||||
hosts: [
|
||||
{
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: "prod",
|
||||
folder: null,
|
||||
authType: "password",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return { status: response.status, body: parsed };
|
||||
},
|
||||
async enableFor(userId) {
|
||||
await mock.ctx.settings.set("globallyEnabled", true);
|
||||
await mock.ctx.settings.setUser(userId, "enabled", true);
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The four tables as core's SQLite bootstrap created them before 2.9.0, with
|
||||
// the indexes performance-indexes.ts added.
|
||||
export const LEGACY_DDL = `
|
||||
CREATE TABLE ai_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
base_url TEXT,
|
||||
api_key TEXT,
|
||||
api_key_prefix TEXT,
|
||||
default_model TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, label)
|
||||
);
|
||||
CREATE TABLE ai_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
provider_id INTEGER,
|
||||
model TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE ai_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_calls TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE ai_proposals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
applied_at TEXT,
|
||||
result_summary TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_ai_providers_user_label ON ai_providers (user_id, label);
|
||||
CREATE INDEX idx_ai_conversations_user ON ai_conversations (user_id, updated_at);
|
||||
CREATE INDEX idx_ai_messages_conversation ON ai_messages (conversation_id, created_at);
|
||||
CREATE INDEX idx_ai_proposals_user ON ai_proposals (user_id, status);
|
||||
CREATE INDEX idx_ai_proposals_conversation ON ai_proposals (conversation_id);
|
||||
`;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { LEGACY_DDL, pluginDir } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tables = (sqlite: TestDb["sqlite"]) =>
|
||||
(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%ai_%' ORDER BY name",
|
||||
)
|
||||
.all() as Array<{ name: string }>
|
||||
).map((row) => row.name);
|
||||
|
||||
const indexes = (sqlite: TestDb["sqlite"], table: string) =>
|
||||
(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name LIKE 'idx_%' ORDER BY name",
|
||||
)
|
||||
.all(table) as Array<{ name: string }>
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("ai adoption migration", () => {
|
||||
it("renames the legacy tables and keeps every row, index and cascade", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')")
|
||||
.run();
|
||||
sqlite.exec(`
|
||||
INSERT INTO ai_providers (id, user_id, provider_type, label, api_key, api_key_prefix)
|
||||
VALUES (3, 'u1', 'ollama', 'Local', 'enc-key', 'abc123');
|
||||
INSERT INTO ai_conversations (id, user_id, title) VALUES (5, 'u1', 'Disk');
|
||||
INSERT INTO ai_messages (conversation_id, role, content) VALUES (5, 'user', 'why full');
|
||||
INSERT INTO ai_proposals (conversation_id, user_id, kind) VALUES (5, 'u1', 'propose_run_command');
|
||||
`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(tables(db.sqlite)).toEqual([
|
||||
"p_ai_conversations",
|
||||
"p_ai_messages",
|
||||
"p_ai_proposals",
|
||||
"p_ai_providers",
|
||||
]);
|
||||
|
||||
const provider = db.sqlite
|
||||
.prepare("SELECT * FROM p_ai_providers WHERE id = 3")
|
||||
.get() as Record<string, unknown>;
|
||||
// The key stays until core's boot migration moves it into ctx.secrets.
|
||||
expect(provider).toMatchObject({ label: "Local", api_key: "enc-key" });
|
||||
|
||||
expect(indexes(db.sqlite, "p_ai_providers")).toEqual([
|
||||
"idx_ai_providers_user_label",
|
||||
]);
|
||||
expect(indexes(db.sqlite, "p_ai_proposals")).toEqual([
|
||||
"idx_ai_proposals_conversation",
|
||||
"idx_ai_proposals_user",
|
||||
]);
|
||||
|
||||
// The rename carried the foreign keys: a conversation still takes its
|
||||
// messages and proposals with it.
|
||||
db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 5").run();
|
||||
const count = (table: string) =>
|
||||
(
|
||||
db!.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
expect(count("p_ai_messages")).toBe(0);
|
||||
expect(count("p_ai_proposals")).toBe(0);
|
||||
});
|
||||
|
||||
it("creates fresh tables when there is nothing to adopt", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tables(db.sqlite)).toEqual([
|
||||
"p_ai_conversations",
|
||||
"p_ai_messages",
|
||||
"p_ai_proposals",
|
||||
"p_ai_providers",
|
||||
]);
|
||||
const columns = (
|
||||
db.sqlite.prepare("PRAGMA table_info(p_ai_providers)").all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((column) => column.name);
|
||||
expect(columns).not.toContain("api_key");
|
||||
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')")
|
||||
.run();
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO p_ai_conversations (id, user_id) VALUES (1, 'u1')")
|
||||
.run();
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_ai_messages (conversation_id, role) VALUES (1, 'user')",
|
||||
)
|
||||
.run();
|
||||
db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 1").run();
|
||||
expect(
|
||||
(
|
||||
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_ai_messages").get() as {
|
||||
n: number;
|
||||
}
|
||||
).n,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createProviderFetch } from "../../../src/backend/providers/http.js";
|
||||
import { PRIVATE_DESTINATION_MESSAGE } from "../../../src/backend/egress.js";
|
||||
|
||||
const fetchWithProxy = vi.fn();
|
||||
const getFetchDispatcher = vi.fn();
|
||||
const safeOutboundFetch = vi.fn();
|
||||
const readPrivateAllowlist = vi.fn();
|
||||
const evaluateEgress = vi.fn();
|
||||
const globalFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../../../src/backend/utils/proxy-agent.js", () => ({
|
||||
fetchWithProxy,
|
||||
getFetchDispatcher,
|
||||
}));
|
||||
vi.mock("../../../../../src/backend/utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch,
|
||||
}));
|
||||
vi.mock("../../../src/backend/egress.js", () => ({
|
||||
readPrivateAllowlist,
|
||||
evaluateEgress,
|
||||
}));
|
||||
|
||||
const { providerFetch } =
|
||||
await import("../../../src/backend/providers/http.js");
|
||||
|
||||
describe("providerFetch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", globalFetch);
|
||||
readPrivateAllowlist.mockResolvedValue(["192.168.1.50"]);
|
||||
});
|
||||
|
||||
it("uses the matching Undici fetch implementation for private providers", async () => {
|
||||
const response = { ok: true, status: 200 } as Response;
|
||||
const init = { method: "GET" };
|
||||
evaluateEgress.mockReturnValue({ allowed: true, isPrivate: true });
|
||||
fetchWithProxy.mockResolvedValue(response);
|
||||
describe("createProviderFetch", () => {
|
||||
it("sends provider requests through ctx.fetch with the admin allowlist", async () => {
|
||||
const response = new Response("{}");
|
||||
const fetch = vi.fn(async () => response);
|
||||
const controller = new AbortController();
|
||||
const providerFetch = createProviderFetch(fetch, ["192.168.1.50"]);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://192.168.1.50:11434/api/tags", init),
|
||||
providerFetch("http://192.168.1.50:11434/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).resolves.toBe(response);
|
||||
|
||||
expect(fetchWithProxy).toHaveBeenCalledWith(
|
||||
"http://192.168.1.50:11434/api/tags",
|
||||
init,
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://192.168.1.50:11434/api/chat",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
signal: controller.signal,
|
||||
allowPrivateHosts: ["192.168.1.50"],
|
||||
}),
|
||||
);
|
||||
expect(globalFetch).not.toHaveBeenCalled();
|
||||
expect(safeOutboundFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a private address the admin has not allowed, before any request", async () => {
|
||||
const fetch = vi.fn();
|
||||
const providerFetch = createProviderFetch(fetch, ["localhost"]);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://10.0.0.9:11434/api/tags", { method: "GET" }),
|
||||
).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains a hostname that resolved to a private address", async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
throw new Error("Private destinations are not allowed");
|
||||
});
|
||||
const providerFetch = createProviderFetch(fetch, []);
|
||||
|
||||
await expect(
|
||||
providerFetch("https://ollama.example.com/api/tags", { method: "GET" }),
|
||||
).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE);
|
||||
});
|
||||
|
||||
it("passes other failures through unchanged", async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
throw new Error("socket hang up");
|
||||
});
|
||||
const providerFetch = createProviderFetch(fetch, []);
|
||||
|
||||
await expect(
|
||||
providerFetch("https://api.openai.com/v1/models", { method: "GET" }),
|
||||
).rejects.toThrow("socket hang up");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../../src/backend/providers/types.js";
|
||||
|
||||
// Adapters get the egress-checked fetch through their config.
|
||||
const providerFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../src/backend/providers/http.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../src/backend/providers/http.js")
|
||||
>("../../../src/backend/providers/http.js");
|
||||
return { ...actual, providerFetch };
|
||||
});
|
||||
|
||||
const { openAiAdapter } =
|
||||
await import("../../../src/backend/providers/openai.js");
|
||||
const { ollamaAdapter } =
|
||||
@@ -69,7 +63,10 @@ describe("openAiAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toEqual([
|
||||
@@ -89,7 +86,10 @@ describe("openAiAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
const call = chunks.find((c) => c.type === "tool_call");
|
||||
@@ -110,7 +110,10 @@ describe("openAiAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
|
||||
@@ -122,7 +125,7 @@ describe("openAiAdapter", () => {
|
||||
await expect(
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ providerType: "openai_compatible" },
|
||||
{ fetch: providerFetch, providerType: "openai_compatible" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
@@ -143,7 +146,10 @@ describe("ollamaAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
ollamaAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "ollama" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toHaveLength(2);
|
||||
@@ -160,7 +166,10 @@ describe("ollamaAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
ollamaAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "ollama" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
const calls = chunks.filter((c) => c.type === "tool_call") as any[];
|
||||
@@ -196,7 +205,7 @@ describe("geminiAdapter", () => {
|
||||
|
||||
const chunks = await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
{ fetch: providerFetch, providerType: "gemini", apiKey: "k" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
@@ -211,7 +220,7 @@ describe("geminiAdapter", () => {
|
||||
|
||||
await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
{ fetch: providerFetch, providerType: "gemini", apiKey: "k" },
|
||||
{
|
||||
...REQUEST,
|
||||
messages: [
|
||||
@@ -271,7 +280,12 @@ describe("assertOk error messages", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("Function call is missing a signature.");
|
||||
});
|
||||
|
||||
@@ -284,7 +298,12 @@ describe("assertOk error messages", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/rate limit reached/i);
|
||||
});
|
||||
|
||||
@@ -294,7 +313,12 @@ describe("assertOk error messages", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/rejected the API key/i);
|
||||
});
|
||||
|
||||
@@ -302,7 +326,12 @@ describe("assertOk error messages", () => {
|
||||
providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded"));
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("upstream exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("ai routes", () => {
|
||||
it("reports status without the gate, and blocks everything else while off", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const status = await server.request("GET", "/status");
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body).toEqual({
|
||||
globallyEnabled: false,
|
||||
enabled: false,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
|
||||
expect((await server.request("GET", "/providers")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets a user opt in only while the server allows it", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const refused = await server.request("PUT", "/opt-in", {
|
||||
body: { enabled: true },
|
||||
});
|
||||
expect(refused.status).toBe(403);
|
||||
|
||||
await server.mock.ctx.settings.set("globallyEnabled", true);
|
||||
const accepted = await server.request("PUT", "/opt-in", {
|
||||
body: { enabled: true },
|
||||
});
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(await server.mock.ctx.settings.getUser("user-1", "enabled")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a provider's key in ctx.secrets, never in the row or the response", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
providerType: "openai",
|
||||
label: "Work",
|
||||
apiKey: "sk-secret-value",
|
||||
defaultModel: "gpt-5",
|
||||
},
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.provider.apiKeyPrefix).toBe("sk-sec");
|
||||
expect(JSON.stringify(created.body)).not.toContain("sk-secret-value");
|
||||
|
||||
const id = created.body.provider.id;
|
||||
expect(server.mock.secretStore.get(`user-1:provider:${id}`)).toBe(
|
||||
"sk-secret-value",
|
||||
);
|
||||
const row = server.db.sqlite
|
||||
.prepare("SELECT * FROM p_ai_providers WHERE id = ?")
|
||||
.get(id) as Record<string, unknown>;
|
||||
expect(JSON.stringify(row)).not.toContain("sk-secret-value");
|
||||
|
||||
const list = await server.request("GET", "/providers");
|
||||
expect(list.body.providers).toHaveLength(1);
|
||||
|
||||
// Another user sees none of it.
|
||||
await server.enableFor("user-2");
|
||||
const other = await server.request("GET", "/providers", { user: "user-2" });
|
||||
expect(other.body.providers).toEqual([]);
|
||||
|
||||
const removed = await server.request("DELETE", `/providers/${id}`);
|
||||
expect(removed.status).toBe(200);
|
||||
expect(server.mock.secretStore.has(`user-1:provider:${id}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("validates a new provider", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
|
||||
const unknown = await server.request("POST", "/providers", {
|
||||
body: { providerType: "nope", label: "x" },
|
||||
});
|
||||
expect(unknown.status).toBe(400);
|
||||
|
||||
const noKey = await server.request("POST", "/providers", {
|
||||
body: { providerType: "anthropic", label: "x" },
|
||||
});
|
||||
expect(noKey.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects and applies proposals only once, for their owner", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_ai_conversations (id, user_id, title) VALUES (1, 'user-1', 't')",
|
||||
)
|
||||
.run();
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_ai_proposals (id, conversation_id, user_id, kind, payload)
|
||||
VALUES (1, 1, 'user-1', 'propose_create_host', '{"name":"db","ip":"10.0.0.2"}'),
|
||||
(2, 1, 'user-1', 'propose_delete_host', '{"hostId":1}')`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Not theirs.
|
||||
await server.enableFor("user-2");
|
||||
expect(
|
||||
(await server.request("POST", "/proposals/1/reject", { user: "user-2" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
|
||||
const rejected = await server.request("POST", "/proposals/2/reject");
|
||||
expect(rejected.status).toBe(200);
|
||||
expect((await server.request("POST", "/proposals/2/reject")).status).toBe(
|
||||
404,
|
||||
);
|
||||
|
||||
const conversation = await server.request("GET", "/conversations/1");
|
||||
expect(conversation.status).toBe(200);
|
||||
expect(
|
||||
conversation.body.proposals.map((p: { id: number }) => p.id),
|
||||
).toEqual([2, 1]);
|
||||
|
||||
const deleted = await server.request("DELETE", "/conversations/1");
|
||||
expect(deleted.status).toBe(200);
|
||||
const left = server.db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_ai_proposals")
|
||||
.get() as { n: number };
|
||||
expect(left.n).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/providers", "ai.use"],
|
||||
["POST", "/providers", "ai.manage_providers"],
|
||||
["PATCH", "/providers/1", "ai.manage_providers"],
|
||||
["DELETE", "/providers/1", "ai.manage_providers"],
|
||||
["POST", "/probe-models", "ai.manage_providers"],
|
||||
["GET", "/providers/1/models", "ai.use"],
|
||||
["GET", "/conversations", "ai.use"],
|
||||
["GET", "/conversations/1", "ai.use"],
|
||||
["DELETE", "/conversations/1", "ai.use"],
|
||||
["POST", "/chat/stream", "ai.use"],
|
||||
["POST", "/proposals/1/apply", "ai.apply_proposals"],
|
||||
["POST", "/proposals/1/mark-run-in-terminal", "ai.apply_proposals"],
|
||||
["POST", "/proposals/1/reject", "ai.use"],
|
||||
["PUT", "/opt-in", "ai.use"],
|
||||
])("%s %s answers 403 without %s", async (method, path, permission) => {
|
||||
server = await startServer({
|
||||
permissions: [
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
].filter((entry) => entry !== permission),
|
||||
});
|
||||
await server.enableFor("user-1");
|
||||
const response = await server.request(method, path, { body: {} });
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat stream", () => {
|
||||
it("offers the model only the tools whose plugins are running", async () => {
|
||||
const bodies: Array<{ tools?: Array<{ function?: { name: string } }> }> =
|
||||
[];
|
||||
const encoder = new TextEncoder();
|
||||
server = await startServer({
|
||||
services: { "snippets.access": { list: async () => [] } },
|
||||
fetch: async (_url, init) => {
|
||||
bodies.push(JSON.parse((init as { body: string }).body));
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
},
|
||||
});
|
||||
await server.enableFor("user-1");
|
||||
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
providerType: "openai",
|
||||
label: "Work",
|
||||
apiKey: "sk-test",
|
||||
defaultModel: "gpt-5",
|
||||
},
|
||||
});
|
||||
|
||||
const reply = await server.request("POST", "/chat/stream", {
|
||||
body: { providerId: created.body.provider.id, message: "hello" },
|
||||
});
|
||||
expect(reply.status).toBe(200);
|
||||
expect(String(reply.body)).toContain('"type":"done"');
|
||||
|
||||
const offered = (bodies[0].tools ?? []).map((tool) => tool.function?.name);
|
||||
expect(offered).toContain("list_hosts");
|
||||
expect(offered).toContain("list_snippets");
|
||||
expect(offered).not.toContain("list_fleets");
|
||||
expect(offered).not.toContain("list_homepage_items");
|
||||
expect(offered).not.toContain("propose_create_automation");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hostRepository = {
|
||||
create: vi.fn(),
|
||||
findByIdForUser: vi.fn(),
|
||||
updateForUser: vi.fn(),
|
||||
deleteForUser: vi.fn(),
|
||||
};
|
||||
const snippetRepository = {
|
||||
createSnippet: vi.fn(),
|
||||
findOwnedById: vi.fn(),
|
||||
updateSnippet: vi.fn(),
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const automationsAccess = { create: vi.fn() };
|
||||
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
|
||||
|
||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("@termix/plugin-sdk/host-commands", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
vi.mock("../../../src/backend/ssh.js", () => ({
|
||||
withSshConnection: async (
|
||||
_host: unknown,
|
||||
_options: unknown,
|
||||
run: (client: unknown) => Promise<unknown>,
|
||||
) => run({}),
|
||||
}));
|
||||
|
||||
const { applyProposal } =
|
||||
await import("../../../src/backend/tools/executor.js");
|
||||
const { setPluginServices } = await import("../../../src/backend/services.js");
|
||||
|
||||
const hosts = {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
checkAccess: vi.fn(),
|
||||
};
|
||||
const automationsAccess = { create: vi.fn() };
|
||||
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
|
||||
const snippetsAccess = {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
const withConnection = vi.fn(
|
||||
async (
|
||||
_host: unknown,
|
||||
_options: unknown,
|
||||
run: (client: unknown) => unknown,
|
||||
) => run({}),
|
||||
);
|
||||
|
||||
let provided: Record<string, object> = {};
|
||||
let granted = new Set<string>();
|
||||
|
||||
/** What the executor gets from ctx: the approving user's view of core. */
|
||||
function deps() {
|
||||
return {
|
||||
hosts,
|
||||
services: {
|
||||
providers: (service: string) => (service in provided ? [""] : []),
|
||||
get: (service: string) => provided[service],
|
||||
},
|
||||
rbac: { has: async (permission: string) => granted.has(permission) },
|
||||
ssh: { withConnection },
|
||||
notify: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provided = {};
|
||||
granted = new Set(["hosts.create", "hosts.edit"]);
|
||||
});
|
||||
|
||||
describe("applyProposal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setPluginServices(null);
|
||||
});
|
||||
|
||||
it("refuses a kind that is not a real tool", async () => {
|
||||
// The stored payload is treated as untrusted even though the server wrote
|
||||
// it, because a proposal can outlive the release that created it.
|
||||
await expect(
|
||||
applyProposal("propose_delete_everything", {}, "user-1"),
|
||||
applyProposal("propose_delete_everything", {}, deps()),
|
||||
).rejects.toThrow("Unknown proposal kind");
|
||||
});
|
||||
|
||||
function withAutomations() {
|
||||
setPluginServices({
|
||||
get: (service: string) => {
|
||||
if (service !== "automations.access")
|
||||
throw new Error(`unexpected ${service}`);
|
||||
return automationsAccess as never;
|
||||
},
|
||||
provide: vi.fn(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("hands an automation to the automations plugin, which validates it", async () => {
|
||||
// The plugin runs its own route's validator, so a definition the model
|
||||
// invented is held to the same standard as a hand-written one.
|
||||
withAutomations();
|
||||
provided["automations.access"] = automationsAccess;
|
||||
automationsAccess.create.mockRejectedValue(
|
||||
new Error("Unknown or missing trigger kind"),
|
||||
);
|
||||
@@ -81,7 +75,7 @@ describe("applyProposal", () => {
|
||||
name: "bad",
|
||||
definition: { trigger: { kind: "nonsense" }, steps: [] },
|
||||
},
|
||||
"user-1",
|
||||
deps(),
|
||||
),
|
||||
).rejects.toThrow("Unknown or missing trigger kind");
|
||||
});
|
||||
@@ -91,13 +85,13 @@ describe("applyProposal", () => {
|
||||
applyProposal(
|
||||
"propose_create_automation",
|
||||
{ name: "x", definition: {} },
|
||||
"user-1",
|
||||
deps(),
|
||||
),
|
||||
).rejects.toThrow("The automations plugin is not available");
|
||||
});
|
||||
|
||||
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
|
||||
withAutomations();
|
||||
provided["automations.access"] = automationsAccess;
|
||||
automationsAccess.create.mockResolvedValue({ id: 5, name: "nightly" });
|
||||
|
||||
const result = await applyProposal(
|
||||
@@ -109,7 +103,7 @@ describe("applyProposal", () => {
|
||||
steps: [{ id: "s1", type: "wait", seconds: 1 }],
|
||||
},
|
||||
},
|
||||
"user-1",
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -118,119 +112,100 @@ describe("applyProposal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an approved command only on a host the user can reach", async () => {
|
||||
// resolveHostById returns null when the connect-level permission check
|
||||
// fails, so an unreachable host never gets as far as an SSH attempt.
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("Host not found");
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns command output on success", async () => {
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
it("runs an approved command through ctx.ssh on the host id", async () => {
|
||||
// ctx.ssh resolves the host for the approving user, with the connect
|
||||
// access check, so the executor never holds a host row itself.
|
||||
execCommand.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(withConnection).toHaveBeenCalledWith(
|
||||
9,
|
||||
{ pool: "ai", purpose: "fleet" },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.summary).toContain("up 3 days");
|
||||
});
|
||||
|
||||
it("resolves the host rather than using a raw repository row", async () => {
|
||||
// A raw row has no decrypted auth and an unresolved jumpHosts field, which
|
||||
// made the SSH factory fail with a jump host error on hosts that have none.
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "ok", stderr: "", code: 0 });
|
||||
it("fails when the user cannot reach the host", async () => {
|
||||
withConnection.mockRejectedValueOnce(new Error("Host not found"));
|
||||
|
||||
await applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(resolveHostById).toHaveBeenCalledWith(9, "user-1");
|
||||
expect(hostRepository.findByIdForUser).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
deps(),
|
||||
),
|
||||
).rejects.toThrow("Host not found");
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a non-zero exit rather than reporting success", async () => {
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "", stderr: "denied", code: 1 });
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "cat /etc/shadow" },
|
||||
"user-1",
|
||||
deps(),
|
||||
),
|
||||
).rejects.toThrow("code 1");
|
||||
});
|
||||
|
||||
it("creates a host through the normal repository", async () => {
|
||||
hostRepository.create.mockResolvedValue({ id: 7, name: "web-1" });
|
||||
it("creates a host through ctx.hosts, with tags as core stores them", async () => {
|
||||
hosts.create.mockResolvedValue({ id: 7, name: "web-1" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_host",
|
||||
{ name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod"] },
|
||||
"user-1",
|
||||
{ name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod", "eu"] },
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(hostRepository.create).toHaveBeenCalledWith(
|
||||
expect(hosts.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.5",
|
||||
tags: "prod,eu",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to create a host without hosts.create", async () => {
|
||||
granted = new Set();
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_create_host",
|
||||
{ name: "web-1", ip: "10.0.0.5" },
|
||||
deps(),
|
||||
),
|
||||
).rejects.toThrow("permission");
|
||||
expect(hosts.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a host payload missing required fields", async () => {
|
||||
await expect(
|
||||
applyProposal("propose_create_host", { name: "web-1" }, "user-1"),
|
||||
applyProposal("propose_create_host", { name: "web-1" }, deps()),
|
||||
).rejects.toThrow("ip is required");
|
||||
expect(hostRepository.create).not.toHaveBeenCalled();
|
||||
expect(hosts.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes an update to the approving user", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||
hostRepository.updateForUser.mockResolvedValue({ id: 7 });
|
||||
|
||||
await applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 7, changes: { name: "renamed" } },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(hostRepository.findByIdForUser).toHaveBeenCalledWith("user-1", 7);
|
||||
expect(hostRepository.updateForUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
7,
|
||||
expect.objectContaining({ name: "renamed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to update a host the user does not own", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue(null);
|
||||
it("updates through ctx.hosts, which refuses a host the user does not own", async () => {
|
||||
hosts.update.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 999, changes: { name: "x" } },
|
||||
"user-1",
|
||||
deps(),
|
||||
),
|
||||
).rejects.toThrow("Host not found");
|
||||
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
|
||||
expect(hosts.update).toHaveBeenCalledWith(999, { name: "x" });
|
||||
});
|
||||
|
||||
it("rejects a non-numeric id rather than coercing it", async () => {
|
||||
@@ -238,30 +213,46 @@ describe("applyProposal", () => {
|
||||
applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: "7; DROP TABLE hosts", changes: { name: "x" } },
|
||||
"user-1",
|
||||
deps(),
|
||||
),
|
||||
).rejects.toThrow("hostId must be a positive integer");
|
||||
});
|
||||
|
||||
it("only adds fleet members the approving user owns", async () => {
|
||||
setPluginServices({
|
||||
get: (service: string) => {
|
||||
if (service !== "fleets.access")
|
||||
throw new Error(`unexpected ${service}`);
|
||||
return fleetsAccess as never;
|
||||
},
|
||||
provide: vi.fn(),
|
||||
} as never);
|
||||
fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" });
|
||||
hostRepository.findByIdForUser.mockImplementation(
|
||||
async (_userId: string, hostId: number) =>
|
||||
hostId === 1 ? { id: 1 } : null,
|
||||
it("reports nothing to change on an empty update", async () => {
|
||||
const result = await applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 7, changes: {} },
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(hosts.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes through ctx.hosts", async () => {
|
||||
hosts.delete.mockResolvedValue(true);
|
||||
const result = await applyProposal(
|
||||
"propose_delete_host",
|
||||
{ hostId: 7, reason: "old" },
|
||||
deps(),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(hosts.delete).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("only adds fleet members the approving user owns", async () => {
|
||||
provided["fleets.access"] = fleetsAccess;
|
||||
fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" });
|
||||
hosts.checkAccess.mockImplementation(async (hostId: number) => ({
|
||||
hasAccess: true,
|
||||
isOwner: hostId === 1,
|
||||
isShared: hostId !== 1,
|
||||
}));
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_fleet",
|
||||
{ name: "prod", hostIds: [1, 2] },
|
||||
"user-1",
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(fleetsAccess.addMember).toHaveBeenCalledTimes(1);
|
||||
@@ -269,22 +260,22 @@ describe("applyProposal", () => {
|
||||
expect(result.summary).toContain("1 host");
|
||||
});
|
||||
|
||||
it("fails clearly when the fleets plugin is disabled", async () => {
|
||||
it("fails clearly when the fleets plugin is off", async () => {
|
||||
await expect(
|
||||
applyProposal("propose_create_fleet", { name: "prod" }, "user-1"),
|
||||
).rejects.toThrow("fleets plugin is disabled");
|
||||
applyProposal("propose_create_fleet", { name: "prod" }, deps()),
|
||||
).rejects.toThrow("The fleets plugin is not available");
|
||||
});
|
||||
|
||||
it("reports nothing to change on an empty update", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||
it("edits a snippet through the snippets plugin", async () => {
|
||||
provided["snippets.access"] = snippetsAccess;
|
||||
snippetsAccess.get.mockResolvedValue({ id: 4, name: "a" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 7, changes: {} },
|
||||
"user-1",
|
||||
await applyProposal(
|
||||
"propose_update_snippet",
|
||||
{ snippetId: 4, changes: { name: "b" } },
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
|
||||
expect(snippetsAccess.update).toHaveBeenCalledWith(4, { name: "b" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user