mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -0,0 +1,22 @@
|
||||
# AI Assistant
|
||||
|
||||
An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/frontend/ the UI it registers
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
|
||||
## Commands
|
||||
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* AI Assistant - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ssh-terminal, docker and host-metrics do (see
|
||||
* src/backend/plugins/first-party.ts): it writes directly across hosts,
|
||||
* snippets, fleets and automations through their repositories,
|
||||
* runs approved commands over the shared SSH pool via resolveHostById and
|
||||
* withConnection, and streams its chat replies as server-sent events. None
|
||||
* of that fits the worker ctx -- ctx.hosts is a 13-field read-only view with
|
||||
* no write methods and no snippets/fleets/automations surface at all,
|
||||
* and ctx.http.route replies with a single postMessage value rather than a
|
||||
* stream.
|
||||
*
|
||||
* Unlike ssh-terminal/docker/host-metrics, this plugin does not own a live
|
||||
* transport (no WebSocket server, no long-lived connection that must outlive
|
||||
* the main server's route table), so it does not get its own port. Instead
|
||||
* it registers its Express router with the always-mounted /ai dispatcher in
|
||||
* src/backend/database/routes/ai-dispatch.ts, the same "register a router,
|
||||
* a small dispatcher forwards to it" shape /plugin-api uses for worker
|
||||
* plugins, just scoped to this one path so /ai/* keeps its existing routes
|
||||
* instead of moving under /plugin-api/ai/.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/ai/index.ts, moved here with its
|
||||
* sibling modules (context.ts, egress.ts, engine.ts, gating.ts, redaction.ts,
|
||||
* providers/, tools/) and rewritten from `export default router` into
|
||||
* startAiService()/stopAiService(), called from activate()/deactivate()
|
||||
* below.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for the moved AI files (routes.js, ...) sits right next to this .mjs,
|
||||
* and core modules resolve via ../../../backend/backend/... from there
|
||||
* (dist/plugins/ai/backend -> dist/backend/backend). Under vitest/dev nothing
|
||||
* imports this plugin directly (the loader loads in-process plugins with a
|
||||
* plain dynamic import, no tsx loader involved), so the only layout that
|
||||
* needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
routesModule.startAiService();
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
||||
// under that user's data key, so a borrowed read returns the acting user's
|
||||
// own default key, never someone else's.
|
||||
ctx.secrets.offer("api-key", async (userId) => {
|
||||
const { createCurrentAiRepository } = await loadRelative(
|
||||
"../../../backend/backend/database/repositories/factory",
|
||||
);
|
||||
const repository = createCurrentAiRepository();
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
});
|
||||
|
||||
ctx.log.info("AI assistant router registered at /ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.stopAiService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -69,8 +69,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/ai",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
import { isIP } from "net";
|
||||
import { isBlockedAddress } from "../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Where the assistant is allowed to send requests.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { getAdapter } from "./providers/registry.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { NextFunction, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Three gates, all checked on the server.
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { startAiService, stopAiService } from "./routes.js";
|
||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startAiService();
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
||||
// under that user's data key, so a borrowed read returns the acting user's
|
||||
// own default key, never someone else's.
|
||||
ctx.secrets.offer("api-key", async (userId: string) => {
|
||||
const repository = createCurrentAiRepository();
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
});
|
||||
|
||||
ctx.log.info("AI assistant router registered at /ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopAiService();
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,25 +1,25 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import express from "express";
|
||||
import {
|
||||
registerAiRouter,
|
||||
unregisterAiRouter,
|
||||
} from "../../../src/backend/database/routes/ai-dispatch.js";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
||||
} from "../../../../src/backend/database/routes/ai-dispatch.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
} from "../../../../src/backend/utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
import type { UserRecord } from "../../../src/backend/database/repositories/user-repository.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { UserRecord } from "../../../../src/backend/database/repositories/user-repository.js";
|
||||
import { buildSystemPrompt } from "./context.js";
|
||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "./egress.js";
|
||||
import { runAgent } from "./engine.js";
|
||||
File renamed without changes.
File renamed without changes.
@@ -3,15 +3,15 @@ import {
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../automations/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
File renamed without changes.
@@ -8,7 +8,7 @@ import {
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSystemPrompt } from "../../src/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 automation 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 automations, 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PRIVATE_ALLOWLIST,
|
||||
evaluateEgress,
|
||||
isPrivateDestination,
|
||||
parseAllowlist,
|
||||
} from "../../src/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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../src/backend/providers/types.js";
|
||||
|
||||
const streamChat = vi.fn();
|
||||
const handler = vi.fn();
|
||||
|
||||
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[]) {
|
||||
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("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => settingsRepository,
|
||||
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
|
||||
}));
|
||||
|
||||
const { isAiGloballyEnabled, resolveAiAccess } =
|
||||
await import("../../src/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("../../../../../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);
|
||||
|
||||
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 "../../../src/backend/providers/types.js";
|
||||
|
||||
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 } =
|
||||
await import("../../../src/backend/providers/ollama.js");
|
||||
const { geminiAdapter } =
|
||||
await import("../../../src/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,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { REDACTED, redact, redactString } from "../../src/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 "../../../src/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,257 @@
|
||||
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 automationRepository = { create: vi.fn() };
|
||||
|
||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock(
|
||||
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
|
||||
() => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}),
|
||||
);
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-client-factory.js", () => ({
|
||||
createFleetSshFactory: () => () => ({}),
|
||||
getFleetPoolKey: () => "pool",
|
||||
}));
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
run: (client: unknown) => Promise<unknown>,
|
||||
) => run({}),
|
||||
}));
|
||||
|
||||
const { applyProposal } =
|
||||
await import("../../../src/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 "../../../src/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { AiProvider } from "@/api/ai-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
createAiProvider: vi.fn(),
|
||||
deleteAiProvider: vi.fn(),
|
||||
getAiProviderModels: vi.fn(),
|
||||
probeAiModels: vi.fn(),
|
||||
updateAiProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/ai-api", () => api);
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { AiProviderSettings } from "@/features/ai/AiProviderSettings";
|
||||
|
||||
const provider: AiProvider = {
|
||||
id: 7,
|
||||
providerType: "ollama",
|
||||
label: "Local Ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
apiKeyPrefix: null,
|
||||
defaultModel: "llama3.1",
|
||||
enabled: true,
|
||||
createdAt: "2026-08-21T00:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
api.getAiProviderModels.mockReset();
|
||||
api.getAiProviderModels.mockResolvedValue([]);
|
||||
api.updateAiProvider.mockReset();
|
||||
api.updateAiProvider.mockResolvedValue(provider);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AiProviderSettings", () => {
|
||||
it("edits an existing provider name and configured model", async () => {
|
||||
const onChanged = vi.fn();
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={onChanged} />);
|
||||
|
||||
expect(screen.getByText(/llama3\.1/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
const label = screen.getByLabelText("ai.providerLabel");
|
||||
const model = screen.getByLabelText("ai.defaultModel");
|
||||
fireEvent.change(label, { target: { value: "Production Ollama" } });
|
||||
fireEvent.change(model, { target: { value: "qwen3:32b" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateAiProvider).toHaveBeenCalledWith(7, {
|
||||
label: "Production Ollama",
|
||||
defaultModel: "qwen3:32b",
|
||||
});
|
||||
});
|
||||
expect(onChanged).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("loads the saved provider model list when editing starts", async () => {
|
||||
api.getAiProviderModels.mockResolvedValue(["llama3.1", "qwen3:32b"]);
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.getAiProviderModels).toHaveBeenCalledWith(7);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "ai.defaultModel" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the provider error when refreshing models fails", async () => {
|
||||
api.getAiProviderModels.mockRejectedValue(
|
||||
new Error("Add llm.internal to the AI endpoint allowlist"),
|
||||
);
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fieldLabel,
|
||||
humanize,
|
||||
mentionLabel,
|
||||
toolLabel,
|
||||
} from "@/features/ai/labels";
|
||||
import { AI_TOOLS } from "../../src/backend/tools/catalog.js";
|
||||
|
||||
describe("humanize", () => {
|
||||
it("splits camelCase into words", () => {
|
||||
// The case that shipped as "thresholddurationseconds".
|
||||
expect(humanize("thresholdDurationSeconds")).toBe(
|
||||
"Threshold duration seconds",
|
||||
);
|
||||
});
|
||||
|
||||
it("splits snake_case into words", () => {
|
||||
expect(humanize("propose_create_automation")).toBe(
|
||||
"Propose create automation",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a single word alone apart from casing", () => {
|
||||
expect(humanize("port")).toBe("Port");
|
||||
});
|
||||
|
||||
it("returns the input when there is nothing to split", () => {
|
||||
expect(humanize("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldLabel", () => {
|
||||
it("uses the mapped name where there is one", () => {
|
||||
expect(fieldLabel("thresholdDurationSeconds")).toBe("Duration (seconds)");
|
||||
expect(fieldLabel("cooldownMinutes")).toBe("Cooldown (minutes)");
|
||||
expect(fieldLabel("ip")).toBe("Address");
|
||||
});
|
||||
|
||||
it("falls back to humanizing an unmapped key", () => {
|
||||
expect(fieldLabel("someFutureField")).toBe("Some future field");
|
||||
});
|
||||
|
||||
it("never returns a run-on identifier", () => {
|
||||
for (const key of ["thresholdDurationSeconds", "hostIds", "triggerType"]) {
|
||||
expect(fieldLabel(key)).not.toBe(key.toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolLabel", () => {
|
||||
it("phrases tools as actions rather than function names", () => {
|
||||
expect(toolLabel("list_hosts")).toBe("Reading hosts");
|
||||
expect(toolLabel("propose_create_fleet")).toBe("Create fleet");
|
||||
});
|
||||
|
||||
it("covers every tool in the catalog", () => {
|
||||
// A new tool without a label would surface its raw snake_case name.
|
||||
for (const tool of AI_TOOLS) {
|
||||
const label = toolLabel(tool.name);
|
||||
expect(label, tool.name).not.toContain("_");
|
||||
expect(label, tool.name).not.toBe(tool.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mentionLabel", () => {
|
||||
it("capitalises the mention kinds", () => {
|
||||
expect(mentionLabel("host")).toBe("Host");
|
||||
expect(mentionLabel("snippet")).toBe("Snippet");
|
||||
expect(mentionLabel("automation")).toBe("Automation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user