feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
This commit is contained in:
LukeGus committed 2026-09-21 15:20:05 -05:00
1 parent c58ccbfc3f
commit 504710b065
43 files changed
+568 -262

No files matched your search

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildSystemPrompt } from "../../../../../plugins/ai/backend/context.js";
const BASE = { hostCount: 3, allowReadOnlyCommands: false };
describe("buildSystemPrompt", () => {
it("tells the assistant to stay inside what was asked", () => {
// Without these the assistant answered "what is running on this server"
// by also proposing a monitoring script and an alert rule nobody wanted.
const prompt = buildSystemPrompt(BASE);
expect(prompt).toContain("nothing beyond it");
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
expect(prompt).toMatch(/no monitoring, no alert rules, no scripts/);
expect(prompt).toMatch(/One request means one proposal at most/);
});
it("states that it proposes rather than applies", () => {
const prompt = buildSystemPrompt(BASE);
expect(prompt).toContain("cannot change anything directly");
expect(prompt).toContain("Never claim you have done something");
});
it("never claims credential access", () => {
const prompt = buildSystemPrompt(BASE);
expect(prompt).toMatch(/no access to passwords, SSH keys, API keys/);
});
it("mentions read-only commands only when the user opted in", () => {
expect(buildSystemPrompt(BASE)).not.toMatch(/read-only diagnostic/);
expect(buildSystemPrompt({ ...BASE, allowReadOnlyCommands: true })).toMatch(
/read-only diagnostic/,
);
});
it("pluralises the host count", () => {
expect(buildSystemPrompt({ ...BASE, hostCount: 1 })).toContain("1 host ");
expect(buildSystemPrompt({ ...BASE, hostCount: 2 })).toContain("2 hosts");
});
it("includes the active tab only when there is one", () => {
expect(buildSystemPrompt(BASE)).not.toContain("currently looking at");
expect(buildSystemPrompt({ ...BASE, activeTab: "terminal" })).toContain(
"currently looking at: terminal",
);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PRIVATE_ALLOWLIST,
evaluateEgress,
isPrivateDestination,
parseAllowlist,
} from "../../../../../plugins/ai/backend/egress.js";
/**
* The rule that lets a self-hosted Ollama work without turning the backend
* into an authenticated probe of its own network: private destinations are
* refused unless an admin named the host.
*/
describe("isPrivateDestination", () => {
it("recognises loopback and private ranges", () => {
for (const url of [
"http://localhost:11434",
"http://127.0.0.1:11434",
"http://10.0.0.5:11434",
"http://192.168.1.10:11434",
"http://172.16.4.4:11434",
"http://169.254.169.254/latest/meta-data",
"http://[::1]:11434",
]) {
expect(isPrivateDestination(url), url).toBe(true);
}
});
it("treats public hosts as public", () => {
for (const url of [
"https://api.openai.com/v1",
"https://api.anthropic.com",
"https://generativelanguage.googleapis.com",
"http://8.8.8.8",
]) {
expect(isPrivateDestination(url), url).toBe(false);
}
});
});
describe("evaluateEgress", () => {
it("allows public destinations without an allowlist entry", () => {
const decision = evaluateEgress("https://api.openai.com/v1", []);
expect(decision.allowed).toBe(true);
expect(decision.isPrivate).toBe(false);
});
it("refuses a private destination that is not allowlisted", () => {
const decision = evaluateEgress("http://192.168.1.50:11434", ["localhost"]);
expect(decision.allowed).toBe(false);
expect(decision.isPrivate).toBe(true);
expect(decision.reason).toContain("allowlist");
});
it("allows a private destination once its host is allowlisted", () => {
const decision = evaluateEgress("http://localhost:11434", [
"localhost",
"127.0.0.1",
]);
expect(decision.allowed).toBe(true);
expect(decision.isPrivate).toBe(true);
});
it.each(["llm.internal", "host.docker.internal"])(
"routes allowlisted private DNS name %s through the private path",
(host) => {
expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({
allowed: true,
isPrivate: true,
});
},
);
it("matches the allowlist case-insensitively", () => {
expect(
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
).toBe(true);
});
it("does not let one allowlisted host authorise another", () => {
// A cloud metadata endpoint is the classic target, so an allowlist for
// localhost must not open 169.254.169.254.
const decision = evaluateEgress("http://169.254.169.254/latest/meta-data", [
"localhost",
"127.0.0.1",
]);
expect(decision.allowed).toBe(false);
});
it("refuses non-http protocols and embedded credentials", () => {
expect(evaluateEgress("file:///etc/passwd", []).allowed).toBe(false);
expect(evaluateEgress("ftp://example.com", []).allowed).toBe(false);
expect(evaluateEgress("https://user:pass@api.openai.com", []).allowed).toBe(
false,
);
});
it("refuses a malformed url", () => {
expect(evaluateEgress("not a url", []).allowed).toBe(false);
});
});
describe("parseAllowlist", () => {
it("falls back to the defaults when unset or malformed", () => {
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
});
it("normalises stored entries", () => {
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).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([]);
});
});
+209
View File
@@ -0,0 +1,209 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatChunk } from "../../../../../plugins/ai/backend/providers/types.js";
const streamChat = vi.fn();
const handler = vi.fn();
vi.mock("../../../../../plugins/ai/backend/providers/registry.js", () => ({
getAdapter: () => ({ streamChat, listModels: async () => [] }),
}));
vi.mock("../../../../../plugins/ai/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("../../../../../plugins/ai/backend/engine.js");
function chunks(...values: ChatChunk[]) {
return (async function* () {
for (const value of values) yield value;
})();
}
const BASE = {
config: { providerType: "ollama" as const },
model: "test",
system: "system",
context: {
userId: "user-1",
conversationId: 1,
allowReadOnlyCommands: false,
},
};
async function collect(history: any[] = []) {
const events: any[] = [];
for await (const event of runAgent({ ...BASE, history })) {
events.push(event);
}
return events;
}
describe("runAgent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("streams text and finishes when no tools are called", async () => {
streamChat.mockReturnValueOnce(
chunks({ type: "text", text: "hello" }, { type: "done" }),
);
const events = await collect();
expect(events.filter((e) => e.type === "token")).toHaveLength(1);
expect(events.at(-1).type).toBe("done");
});
it("runs a known tool and feeds the result back", async () => {
handler.mockResolvedValue({ hosts: [{ id: 1, name: "web-1" }] });
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
expect(handler).toHaveBeenCalledOnce();
expect(events.some((e) => e.type === "tool_result")).toBe(true);
expect(streamChat).toHaveBeenCalledTimes(2);
});
it("refuses a tool that is not in the catalog", async () => {
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "read_credentials", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
// A model can emit any name it likes; only the catalog decides what runs.
expect(handler).not.toHaveBeenCalled();
const result = events.find((e) => e.type === "tool_result");
expect(JSON.stringify(result.result)).toContain("Unknown tool");
});
it("surfaces a handler failure without ending the run", async () => {
handler.mockRejectedValue(new Error("database is down"));
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
const result = events.find((e) => e.type === "tool_result");
expect(JSON.stringify(result.result)).toContain("database is down");
expect(events.at(-1).type).toBe("done");
});
it("closes the tool call when a tool returns a proposal", async () => {
// Without a matching tool_result the call rendered as permanently
// running, even though the work was done and awaiting the user.
handler.mockResolvedValue({
__proposal: true,
kind: "propose_create_host",
summary: "Add host web-1",
payload: {},
});
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
const callIndex = events.findIndex((e) => e.type === "tool_call");
const resultIndex = events.findIndex((e) => e.type === "tool_result");
const proposalIndex = events.findIndex((e) => e.type === "proposal");
expect(resultIndex).toBeGreaterThan(callIndex);
expect(proposalIndex).toBeGreaterThan(resultIndex);
expect(events[resultIndex]).toMatchObject({
name: "list_hosts",
result: { status: "awaiting_user_approval" },
});
});
it("reports a provider failure as an error and stops", async () => {
streamChat.mockImplementationOnce(() => {
throw new Error("provider unreachable");
});
const events = await collect();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "error",
message: "provider unreachable",
});
});
it("stops after too many tool turns", async () => {
handler.mockResolvedValue({ ok: true });
streamChat.mockImplementation(() =>
chunks(
{
type: "tool_call",
call: { id: "c", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
);
const events = await collect();
// A model that never stops calling tools must not spin forever.
expect(events.at(-1)).toMatchObject({ type: "error" });
expect(streamChat.mock.calls.length).toBeLessThanOrEqual(8);
});
});
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const settingsRepository = { getBoolean: vi.fn() };
const userPreferenceRepository = { findByUserId: vi.fn() };
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => settingsRepository,
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
}));
const { isAiGloballyEnabled, resolveAiAccess } =
await import("../../../../../plugins/ai/backend/gating.js");
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("blocks everyone when the admin global is off", async () => {
settingsRepository.getBoolean.mockResolvedValue(false);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: true,
});
const access = await resolveAiAccess("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);
});
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);
expect(access.allowReadOnlyCommands).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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("../../../../utils/proxy-agent.js", () => ({
fetchWithProxy,
getFetchDispatcher,
}));
vi.mock("../../../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch,
}));
vi.mock("../../../../../../plugins/ai/backend/egress.js", () => ({
readPrivateAllowlist,
evaluateEgress,
}));
const { providerFetch } =
await import("../../../../../../plugins/ai/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);
await expect(
providerFetch("http://192.168.1.50:11434/api/tags", init),
).resolves.toBe(response);
expect(fetchWithProxy).toHaveBeenCalledWith(
"http://192.168.1.50:11434/api/tags",
init,
);
expect(globalFetch).not.toHaveBeenCalled();
expect(safeOutboundFetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,308 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatChunk } from "../../../../../../plugins/ai/backend/providers/types.js";
const providerFetch = vi.fn();
vi.mock("../../../../../../plugins/ai/backend/providers/http.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../../../plugins/ai/backend/providers/http.js")
>("../../../../../../plugins/ai/backend/providers/http.js");
return { ...actual, providerFetch };
});
const { openAiAdapter } =
await import("../../../../../../plugins/ai/backend/providers/openai.js");
const { ollamaAdapter } =
await import("../../../../../../plugins/ai/backend/providers/ollama.js");
const { geminiAdapter } =
await import("../../../../../../plugins/ai/backend/providers/gemini.js");
/** Builds a Response whose body streams the given text chunks. */
function streamingResponse(lines: string[]): Response {
const encoder = new TextEncoder();
return {
ok: true,
status: 200,
body: {
getReader() {
let index = 0;
return {
async read() {
if (index >= lines.length) return { done: true, value: undefined };
return { done: false, value: encoder.encode(lines[index++]) };
},
releaseLock() {},
};
},
},
} as unknown as Response;
}
/** One SSE frame, built from an object so the JSON stays readable. */
function sseFrame(payload: unknown): string {
return `data: ${JSON.stringify(payload)}\n`;
}
async function collect(iterable: AsyncIterable<ChatChunk>) {
const chunks: ChatChunk[] = [];
for await (const chunk of iterable) chunks.push(chunk);
return chunks;
}
const REQUEST = {
model: "test-model",
system: "system",
messages: [{ role: "user" as const, content: "hi" }],
tools: [],
};
describe("openAiAdapter", () => {
beforeEach(() => vi.clearAllMocks());
it("normalises streamed text", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n',
'data: {"choices":[{"delta":{"content":"lo"}}]}\n',
"data: [DONE]\n",
]),
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
);
expect(chunks.filter((c) => c.type === "text")).toEqual([
{ type: "text", text: "Hel" },
{ type: "text", text: "lo" },
]);
expect(chunks.at(-1)?.type).toBe("done");
});
it("reassembles tool arguments split across deltas", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{\\"a"}}]}}]}\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":1}"}}]}}]}\n',
"data: [DONE]\n",
]),
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
);
const call = chunks.find((c) => c.type === "tool_call");
expect(call).toMatchObject({
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: { a: 1 } },
});
});
it("survives malformed tool arguments", async () => {
// A model that emits broken JSON gets an empty object; the tool's own
// validation then reports the problem back to it.
providerFetch.mockResolvedValue(
streamingResponse([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{not json"}}]}}]}\n',
"data: [DONE]\n",
]),
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
);
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
call: { arguments: {} },
});
});
it("needs a base url for an openai-compatible provider", async () => {
await expect(
collect(
openAiAdapter.streamChat(
{ providerType: "openai_compatible" },
REQUEST,
),
),
).rejects.toThrow("base URL");
});
});
describe("ollamaAdapter", () => {
beforeEach(() => vi.clearAllMocks());
it("reads newline-delimited json rather than sse", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'{"message":{"content":"Hel"}}\n',
'{"message":{"content":"lo"}}\n',
'{"done":true,"done_reason":"stop"}\n',
]),
);
const chunks = await collect(
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
);
expect(chunks.filter((c) => c.type === "text")).toHaveLength(2);
expect(chunks.at(-1)).toMatchObject({ type: "done", stopReason: "stop" });
});
it("accepts tool arguments as an object or a json string", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'{"message":{"tool_calls":[{"function":{"name":"list_hosts","arguments":{"a":1}}}]}}\n',
'{"message":{"tool_calls":[{"function":{"name":"get_host","arguments":"{\\"hostId\\":2}"}}]}}\n',
'{"done":true}\n',
]),
);
const chunks = await collect(
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
);
const calls = chunks.filter((c) => c.type === "tool_call") as any[];
expect(calls[0].call.arguments).toEqual({ a: 1 });
expect(calls[1].call.arguments).toEqual({ hostId: 2 });
});
});
describe("geminiAdapter", () => {
beforeEach(() => vi.clearAllMocks());
it("carries thoughtSignature off a function call", async () => {
// Gemini 2.5+ 400s a follow-up whose functionCall parts lost their
// signature, which broke every conversation on the second turn.
providerFetch.mockResolvedValue(
streamingResponse([
sseFrame({
candidates: [
{
content: {
parts: [
{
functionCall: { name: "list_hosts", args: {} },
thoughtSignature: "sig-abc",
},
],
},
},
],
}),
]),
);
const chunks = await collect(
geminiAdapter.streamChat(
{ providerType: "gemini", apiKey: "k" },
REQUEST,
),
);
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
call: { name: "list_hosts", providerSignature: "sig-abc" },
});
});
it("echoes the signature back on the next turn", async () => {
providerFetch.mockResolvedValue(streamingResponse([]));
await collect(
geminiAdapter.streamChat(
{ providerType: "gemini", apiKey: "k" },
{
...REQUEST,
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: "",
toolCalls: [
{
id: "c1",
name: "list_hosts",
arguments: {},
providerSignature: "sig-abc",
},
],
},
{
role: "tool",
content: "{}",
toolCallId: "c1",
toolName: "list_hosts",
},
],
},
),
);
const body = JSON.parse(providerFetch.mock.calls[0][1].body as string);
const modelTurn = body.contents.find((c: any) => c.role === "model");
expect(modelTurn.parts[0].thoughtSignature).toBe("sig-abc");
});
});
describe("assertOk error messages", () => {
beforeEach(() => vi.clearAllMocks());
function errorResponse(status: number, body: string): Response {
return {
ok: false,
status,
text: async () => body,
} as unknown as Response;
}
it("pulls the message out of a nested error body", async () => {
// Slicing the raw JSON used to cut the text off mid-sentence.
providerFetch.mockResolvedValue(
errorResponse(
400,
JSON.stringify({
error: {
code: 400,
message: "Function call is missing a signature.",
},
}),
),
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow("Function call is missing a signature.");
});
it("explains a rate limit instead of dumping the body", async () => {
providerFetch.mockResolvedValue(
errorResponse(
429,
JSON.stringify({ error: { message: "You exceeded your quota." } }),
),
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow(/rate limit reached/i);
});
it("explains a rejected key", async () => {
providerFetch.mockResolvedValue(
errorResponse(401, JSON.stringify({ error: { message: "Bad key" } })),
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow(/rejected the API key/i);
});
it("falls back to a trimmed snippet for a non-JSON body", async () => {
providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded"));
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow("upstream exploded");
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
REDACTED,
redact,
redactString,
} from "../../../../../plugins/ai/backend/redaction.js";
describe("redact", () => {
it("drops secret-named fields at any depth", () => {
const input = {
name: "web-1",
password: "hunter2",
nested: { privateKey: "abc", apiKey: "def", port: 22 },
list: [{ keyPassword: "xyz", label: "ok" }],
};
const output = redact(input) as any;
expect(output.name).toBe("web-1");
expect(output.password).toBe(REDACTED);
expect(output.nested.privateKey).toBe(REDACTED);
expect(output.nested.apiKey).toBe(REDACTED);
expect(output.nested.port).toBe(22);
expect(output.list[0].keyPassword).toBe(REDACTED);
expect(output.list[0].label).toBe("ok");
});
it("keeps a null secret null so absence stays distinguishable", () => {
const output = redact({ password: null }) as any;
expect(output.password).toBeNull();
});
it("leaves ordinary values untouched", () => {
const input = { id: 4, enabled: true, tags: ["a", "b"], note: null };
expect(redact(input)).toEqual(input);
});
it("does not recurse forever on a cyclic object", () => {
const cyclic: Record<string, unknown> = { name: "loop" };
cyclic.self = cyclic;
expect(() => redact(cyclic)).not.toThrow();
});
});
describe("redactString", () => {
it("masks private key blocks", () => {
const text =
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----";
expect(redactString(text)).toBe("[redacted private key]");
});
it("masks provider api keys", () => {
expect(redactString("key is sk-abcdefghijklmnopqrst here")).toContain(
"[redacted api key]",
);
expect(redactString("key is sk-ant-abcdefghijklmnopqrst here")).toContain(
"[redacted api key]",
);
});
it("masks bearer tokens and jwts", () => {
expect(
redactString("Authorization: Bearer abcdefghijklmnopqrst"),
).toContain("Bearer [redacted]");
expect(
redactString("token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefgh"),
).toContain("[redacted token]");
});
it("masks Termix api keys", () => {
expect(redactString("tmx_abcdefghijklmnopqrstuvwx")).toContain(
"[redacted token]",
);
});
it("leaves ordinary prose alone", () => {
const text = "The disk on web-1 is 82 percent full.";
expect(redactString(text)).toBe(text);
});
});
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { isReadOnlyCommand } from "../../../../../../plugins/ai/backend/tools/command-allowlist.js";
/**
* These commands can run without a per-command approval click, so the parser
* has to refuse anything that could become a second command. Substring
* matching would pass "df; rm -rf /", which is the whole reason this is
* argv-based.
*/
describe("isReadOnlyCommand", () => {
it("allows plain diagnostics", () => {
for (const command of [
"df -h",
"uptime",
"free -m",
"whoami",
"ps aux",
"lsblk",
"uname -a",
"/usr/bin/df -h",
]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(true);
}
});
it("rejects command chaining and redirection", () => {
for (const command of [
"df; rm -rf /",
"df && curl evil.example",
"df || reboot",
"df | sh",
"df > /etc/passwd",
"df >> /etc/passwd",
"cat < /etc/shadow",
"echo `whoami`",
"echo $(id)",
"df\nrm -rf /",
"df \\\n rm",
]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
}
});
it("rejects privilege escalation", () => {
for (const command of ["sudo df -h", "su root", "doas df", "env df"]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
}
});
it("rejects commands that are not on the list", () => {
for (const command of ["rm -rf /", "curl evil.example", "vi /etc/passwd"]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
}
});
it("limits systemctl and docker to read-only subcommands", () => {
expect(isReadOnlyCommand("systemctl status nginx").allowed).toBe(true);
expect(isReadOnlyCommand("systemctl restart nginx").allowed).toBe(false);
expect(isReadOnlyCommand("systemctl stop nginx").allowed).toBe(false);
expect(isReadOnlyCommand("docker ps").allowed).toBe(true);
expect(isReadOnlyCommand("docker stats --no-stream").allowed).toBe(true);
expect(isReadOnlyCommand("docker rm -f web").allowed).toBe(false);
expect(isReadOnlyCommand("docker exec -it web sh").allowed).toBe(false);
});
it("limits cat to safe paths", () => {
expect(isReadOnlyCommand("cat /proc/meminfo").allowed).toBe(true);
expect(isReadOnlyCommand("cat /etc/os-release").allowed).toBe(true);
expect(isReadOnlyCommand("cat /etc/shadow").allowed).toBe(false);
expect(isReadOnlyCommand("cat ~/.ssh/id_rsa").allowed).toBe(false);
expect(isReadOnlyCommand("cat").allowed).toBe(false);
});
it("rejects an empty command", () => {
expect(isReadOnlyCommand("").allowed).toBe(false);
expect(isReadOnlyCommand(" ").allowed).toBe(false);
});
});
@@ -0,0 +1,256 @@
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 fleetRepository = { create: vi.fn(), addMember: vi.fn() };
const alertRepository = { createAlertRule: vi.fn() };
const automationRepository = { create: vi.fn() };
vi.mock("../../../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => hostRepository,
createCurrentSnippetRepository: () => snippetRepository,
createCurrentFleetRepository: () => fleetRepository,
createCurrentAlertRepository: () => alertRepository,
createCurrentAutomationRepository: () => automationRepository,
}));
const resolveHostById = vi.fn();
vi.mock("../../../../hosts/host-resolver.js", () => ({
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
}));
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
vi.mock("../../../../hosts/ssh-client-factory.js", () => ({
createFleetSshFactory: () => () => ({}),
getFleetPoolKey: () => "pool",
}));
vi.mock("../../../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
_key: string,
_factory: unknown,
run: (client: unknown) => Promise<unknown>,
) => run({}),
}));
const { applyProposal } =
await import("../../../../../../plugins/ai/backend/tools/executor.js");
describe("applyProposal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
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"),
).rejects.toThrow("Unknown proposal kind");
});
it("validates an automation definition before creating it", async () => {
// Reuses the automations route's own validator, so a definition the model
// invented is held to the same standard as a hand-written one.
await expect(
applyProposal(
"propose_create_automation",
{
name: "bad",
definition: { trigger: { kind: "nonsense" }, steps: [] },
},
"user-1",
),
).rejects.toThrow();
expect(automationRepository.create).not.toHaveBeenCalled();
});
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
automationRepository.create.mockResolvedValue({ id: 5, name: "nightly" });
const result = await applyProposal(
"propose_create_automation",
{
name: "nightly",
definition: {
trigger: { kind: "schedule", intervalSeconds: 3600 },
steps: [{ id: "s1", type: "wait", seconds: 1 }],
},
},
"user-1",
);
expect(result.ok).toBe(true);
expect(automationRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", enabled: false }),
);
});
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" });
execCommand.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 });
const result = await applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
);
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 });
await applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
);
expect(resolveHostById).toHaveBeenCalledWith(9, "user-1");
expect(hostRepository.findByIdForUser).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",
),
).rejects.toThrow("code 1");
});
it("creates a host through the normal repository", async () => {
hostRepository.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",
);
expect(result.ok).toBe(true);
expect(hostRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
name: "web-1",
ip: "10.0.0.5",
}),
);
});
it("rejects a host payload missing required fields", async () => {
await expect(
applyProposal("propose_create_host", { name: "web-1" }, "user-1"),
).rejects.toThrow("ip is required");
expect(hostRepository.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);
await expect(
applyProposal(
"propose_update_host",
{ hostId: 999, changes: { name: "x" } },
"user-1",
),
).rejects.toThrow("Host not found");
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
});
it("rejects a non-numeric id rather than coercing it", async () => {
await expect(
applyProposal(
"propose_update_host",
{ hostId: "7; DROP TABLE hosts", changes: { name: "x" } },
"user-1",
),
).rejects.toThrow("hostId must be a positive integer");
});
it("only adds fleet members the approving user owns", async () => {
fleetRepository.create.mockResolvedValue({ id: 3, name: "prod" });
hostRepository.findByIdForUser.mockImplementation(
async (_userId: string, hostId: number) =>
hostId === 1 ? { id: 1 } : null,
);
const result = await applyProposal(
"propose_create_fleet",
{ name: "prod", hostIds: [1, 2] },
"user-1",
);
expect(fleetRepository.addMember).toHaveBeenCalledTimes(1);
expect(fleetRepository.addMember).toHaveBeenCalledWith(3, 1);
expect(result.summary).toContain("1 host");
});
it("reports nothing to change on an empty update", async () => {
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
const result = await applyProposal(
"propose_update_host",
{ hostId: 7, changes: {} },
"user-1",
);
expect(result.ok).toBe(false);
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import {
AI_TOOLS,
FORBIDDEN_DOMAINS,
getTool,
listToolNames,
toolDefinitions,
} from "../../../../../../plugins/ai/backend/tools/catalog.js";
/**
* The security regression test for the whole feature.
*
* Tools run in-process and never pass through the RBAC-gated routers, so "the
* assistant cannot reach credentials or user administration" is a property of
* this catalog, and nothing else. If a future change adds a tool that touches a
* forbidden domain, this test is what catches it.
*/
describe("AI tool catalog", () => {
it("exposes no tool naming a forbidden domain", () => {
for (const tool of AI_TOOLS) {
for (const domain of FORBIDDEN_DOMAINS) {
expect(
tool.name.includes(domain),
`${tool.name} references the forbidden domain "${domain}"`,
).toBe(false);
}
}
});
it("has no tool that could read a credential", () => {
const banned = [
"get_credential",
"list_credentials",
"get_password",
"get_private_key",
"get_api_key",
"create_user",
"delete_user",
"grant_permission",
"update_settings",
];
for (const name of banned) {
expect(getTool(name), `${name} must not exist`).toBeUndefined();
}
});
it("only allows read or propose categories", () => {
for (const tool of AI_TOOLS) {
expect(["read", "propose"]).toContain(tool.category);
}
});
it("names every tool by its category", () => {
// A propose tool that does not say "propose" would read as a direct action
// in the transcript, which is exactly the confusion this feature avoids.
for (const tool of AI_TOOLS) {
if (tool.category === "propose") {
expect(
tool.name.startsWith("propose_"),
`${tool.name} is a propose tool but is not named propose_*`,
).toBe(true);
} else {
expect(
tool.name.startsWith("propose_"),
`${tool.name} is a read tool but is named propose_*`,
).toBe(false);
}
}
});
it("has unique tool names", () => {
const names = listToolNames();
expect(new Set(names).size).toBe(names.length);
});
it("gives every tool a described object schema", () => {
for (const definition of toolDefinitions()) {
expect(definition.description.length, definition.name).toBeGreaterThan(
20,
);
expect(definition.parameters.type, definition.name).toBe("object");
// additionalProperties:false keeps a model from smuggling extra fields
// past the handler's explicit reads.
expect(definition.parameters.additionalProperties, definition.name).toBe(
false,
);
}
});
it("never takes a userId from the model", () => {
// Ownership is always derived from the verified JWT. A userId parameter
// would let the model ask for another account's data.
for (const tool of AI_TOOLS) {
const properties = (tool.parameters.properties ?? {}) as Record<
string,
unknown
>;
for (const key of Object.keys(properties)) {
expect(
/^user_?id$/i.test(key),
`${tool.name} accepts a model-supplied ${key}`,
).toBe(false);
}
}
});
it("still offers the read tools the assistant needs to be useful", () => {
for (const name of ["list_hosts", "list_snippets", "list_automations"]) {
expect(getTool(name), name).toBeDefined();
}
});
});
+18 -2
View File
@@ -39,18 +39,20 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker and host-metrics", () => {
it("contains only ssh-terminal, docker, host-metrics and ai", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"docker",
"host-metrics",
"ssh-terminal",
]);
});
it("recognises ssh-terminal, docker and host-metrics and nothing else", () => {
it("recognises ssh-terminal, docker, host-metrics and ai and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ai")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -66,12 +68,14 @@ describe("runsInProcess", () => {
expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -139,6 +143,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("host-metrics");
});
it("allows it for ai", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "ai",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("ai");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),