feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
+22
View File
@@ -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.
-87
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
{}
-2
View File
@@ -69,8 +69,6 @@
}
}
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -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.
+32
View File
@@ -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();
}
@@ -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.
@@ -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.
@@ -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. */
@@ -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.
+47
View File
@@ -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",
);
});
});
+122
View File
@@ -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([]);
});
});
+208
View File
@@ -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);
});
});
+94
View File
@@ -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();
});
});
+73
View File
@@ -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");
});
});
+10
View File
@@ -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"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
+22
View File
@@ -0,0 +1,22 @@
# Automations
Trigger-and-action automation engine: schedules, metric/status/docker-event triggers, and multi-step actions across managed hosts.
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.
-76
View File
@@ -1,76 +0,0 @@
/**
* Automations - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ai and fleets do (see src/backend/plugins/first-party.ts): its
* engine and scheduler write across hosts, snippets, fleets, and
* notification-channel repositories through the shared SSH pool via
* resolveHostById and withConnection, and its scheduler needs direct
* repository access plus event-bus subscription that the worker ctx's
* read-only ctx.hosts and single-postMessage ctx.http.route cannot support.
*
* Like fleets, this plugin does not own a live transport (no WebSocket
* server, no dedicated port), so it registers its Express router with the
* always-mounted /automations dispatcher in
* src/backend/database/routes/automation-dispatch.ts instead of getting its
* own port.
*
* routes.ts is what used to be src/backend/database/routes/automations.ts,
* moved here and rewritten from `export default router` into
* startAutomationsService()/stopAutomationsService(), called from
* activate()/deactivate() below. scheduler.ts owns the one setInterval the
* feature has always used for due schedules, dwell rechecks, docker-event
* polling and history pruning -- there is no ctx.schedule API for an
* in-process plugin, so it starts and stops the same way it always has. The
* automations/automationTriggerState/automationSchedules/automationRuns/
* automationRunSteps/automationChannels tables and their repository stay in
* core (src/backend/database/db/schema.ts,
* src/backend/database/repositories/automation-repository.ts) -- this plugin
* system has no owned-table migration mechanism of its own, and these tables
* carry FKs into core's hosts/users tables.
*/
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 routes.js/scheduler.js sits right next to this .mjs, and core
* modules resolve via ../../../src/backend/... from there. 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;
let schedulerModule = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
routesModule.startAutomationsService();
schedulerModule = await loadRelative("scheduler");
schedulerModule.startAutomationScheduler();
ctx.log.info(
"Automations router registered at /automations; scheduler started",
);
}
export async function deactivate() {
if (schedulerModule) {
schedulerModule.stopAutomationScheduler();
schedulerModule = null;
}
if (routesModule) {
routesModule.stopAutomationsService();
routesModule = null;
}
}
+1
View File
@@ -0,0 +1 @@
{}
-2
View File
@@ -25,8 +25,6 @@
"network:outbound",
"ui:surface"
],
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/automations",
"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": "*"
}
}
@@ -1,7 +1,7 @@
import type { HostSelector } from "../../../../src/types/automations.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { createCurrentFleetRepository } from "../../../../src/backend/database/repositories/factory.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
import type { HostSelector } from "../../../../../src/types/automations.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { createCurrentFleetRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
import type { StepExecutionContext } from "./types.js";
/** A host the caller is allowed to act on. `host` is always resolved. */
@@ -75,7 +75,7 @@ async function fleetHostIds(
async function allAccessibleHostIds(userId: string): Promise<number[]> {
try {
const { createCurrentHostRepository } =
await import("../../../../src/backend/database/repositories/factory.js");
await import("../../../../../src/backend/database/repositories/factory.js");
const hosts = await createCurrentHostRepository().listByUserId(userId);
const ids = hosts.map((host) => host.id);
const allowed =
@@ -1,22 +1,22 @@
import {
DEFAULT_STEP_TIMEOUT_MS,
type Step,
} from "../../../../src/types/automations.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/types/automations.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
import {
createCurrentNotificationChannelRepository,
createCurrentSnippetRepository,
} from "../../../../src/backend/database/repositories/factory.js";
} from "../../../../../src/backend/database/repositories/factory.js";
import { sendAutomationNotification } from "../notify.js";
import { automationFetch } from "../http.js";
import { renderRecord, renderTemplate } from "../template.js";
@@ -248,7 +248,7 @@ async function runTunnel(
try {
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await import("../../../../../src/backend/hosts/tunnel/manager.js");
const config = manager.tunnelConfigs?.get(name);
if (!config) return fail(`Tunnel "${name}" is not configured`);
@@ -284,7 +284,7 @@ async function runWol(
try {
const { sendWakeOnLan, isValidMac } =
await import("../../../../src/backend/utils/wake-on-lan.js");
await import("../../../../../src/backend/utils/wake-on-lan.js");
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
await sendWakeOnLan(mac);
return ok(`Sent magic packet to ${target.name}`);
@@ -1,4 +1,4 @@
import type { Step } from "../../../../src/types/automations.js";
import type { Step } from "../../../../../src/types/automations.js";
import type { TemplateContext } from "../template.js";
/** What every executor returns. `output` is what later steps can read. */
@@ -1,4 +1,7 @@
import type { MetricPath, Operator } from "../../../src/types/automations.js";
import type {
MetricPath,
Operator,
} from "../../../../src/types/automations.js";
/**
* Operator evaluation and metric extraction.
File renamed without changes.
@@ -1,14 +1,14 @@
import type { AutomationDefinition } from "../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js";
import type { AutomationDefinition } from "../../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.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";
import { statsLogger } from "../../../src/backend/utils/logger.js";
} from "../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { onDockerEvent } from "./triggers.js";
/**
@@ -2,16 +2,16 @@ import type {
AutomationDefinition,
RunStatus,
Step,
} from "../../../src/types/automations.js";
} from "../../../../src/types/automations.js";
import {
DEFAULT_MAX_RUN_SECONDS,
MAX_AUTOMATION_DEPTH,
MAX_STEP_OUTPUT_BYTES,
} from "../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js";
} from "../../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js";
import { executeStep } from "./actions/index.js";
import type { StepExecutionContext, StepResult } from "./actions/types.js";
import { compare } from "./conditions.js";
@@ -1,4 +1,4 @@
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { listAutomationWatchedHosts } from "./triggers.js";
/**
@@ -1,5 +1,5 @@
import { safeOutboundFetch } from "../../../src/backend/utils/safe-outbound-fetch.js";
import { readNotificationPrivateAllowlist } from "../../../src/backend/utils/notification-egress.js";
import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js";
import { readNotificationPrivateAllowlist } from "../../../../src/backend/utils/notification-egress.js";
/**
* Outbound HTTP for automation steps and notification channels.
+19
View File
@@ -0,0 +1,19 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startAutomationsService, stopAutomationsService } from "./routes.js";
import {
startAutomationScheduler,
stopAutomationScheduler,
} from "./scheduler.js";
export async function activate(ctx: PluginContext) {
startAutomationsService();
startAutomationScheduler();
ctx.log.info(
"Automations router registered at /automations; scheduler started",
);
}
export async function deactivate() {
stopAutomationScheduler();
stopAutomationsService();
}
@@ -1,4 +1,4 @@
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { automationFetch } from "./http.js";
import type { TemplateContext } from "./template.js";
@@ -1,28 +1,28 @@
import crypto from "node:crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import type {
AutomationDefinition,
Step,
Trigger,
} from "../../../src/types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../src/types/automations.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/types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../../src/types/automations.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";
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
import type { AutomationRow } from "../../../src/backend/database/repositories/automation-repository.js";
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import type { AutomationRow } from "../../../../src/backend/database/repositories/automation-repository.js";
import { AutomationEngine } from "./engine.js";
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
import {
registerAutomationsRouter,
unregisterAutomationsRouter,
} from "../../../src/backend/database/routes/automation-dispatch.js";
} from "../../../../src/backend/database/routes/automation-dispatch.js";
export const router = express.Router();
@@ -1,7 +1,7 @@
import type { AutomationDefinition } from "../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import type { AutomationDefinition } from "../../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { computeNextDueAt } from "./cron.js";
import { hasDwelled, isCoolingDown } from "./conditions.js";
import { pollDockerEvents } from "./docker-watcher.js";
@@ -2,10 +2,10 @@ import type {
AutomationDefinition,
HostSelector,
Trigger,
} from "../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
import type { AutomationEngineRow } from "../../../src/backend/database/repositories/automation-repository.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
} from "../../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import type { AutomationEngineRow } from "../../../../src/backend/database/repositories/automation-repository.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import {
compare,
extractMetricValue,
@@ -16,7 +16,10 @@ import {
type MetricsSnapshot,
} from "./conditions.js";
import { AutomationEngine } from "./engine.js";
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
/**
* Matches events against automation triggers and decides what fires.
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import {
compare,
extractMetricValue,
hasDwelled,
isCoolingDown,
metricStateKey,
severityForValue,
type MetricsSnapshot,
} from "../../src/backend/conditions.js";
const metrics: MetricsSnapshot = {
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
memory: { percent: 61, usedGiB: 7.5 },
disk: {
percent: 30,
filesystems: [
{ mount: "/", percent: 30, availableBytes: 100 },
{ mount: "/data", percent: 93.4, availableBytes: 25 },
],
},
network: {
interfaces: [
{ name: "eth0", rxBytes: "1000", txBytes: "2000" },
{ name: "eth1", rxBytes: "50", txBytes: "60" },
],
},
temperature: { highestCelsius: 71 },
uptime: { seconds: 86400 },
processes: { total: 210 },
};
describe("extractMetricValue", () => {
it("reads simple scalar paths", () => {
expect(extractMetricValue(metrics, { path: "cpu.percent" })).toBe(42.5);
expect(extractMetricValue(metrics, { path: "memory.percent" })).toBe(61);
expect(
extractMetricValue(metrics, { path: "temperature.highestCelsius" }),
).toBe(71);
expect(extractMetricValue(metrics, { path: "uptime.seconds" })).toBe(86400);
expect(extractMetricValue(metrics, { path: "processes.total" })).toBe(210);
});
it("reads load averages positionally", () => {
expect(extractMetricValue(metrics, { path: "cpu.load1" })).toBe(1.5);
expect(extractMetricValue(metrics, { path: "cpu.load15" })).toBe(0.9);
});
it("reads a specific mount rather than the aggregate", () => {
// The motivating case: /data is nearly full while / is fine.
expect(
extractMetricValue(metrics, { path: "disk.percent", mount: "/data" }),
).toBe(93.4);
expect(extractMetricValue(metrics, { path: "disk.percent" })).toBe(30);
});
it("returns null for a mount that is not present", () => {
expect(
extractMetricValue(metrics, { path: "disk.percent", mount: "/nope" }),
).toBeNull();
});
it("selects a named interface and coerces string counters", () => {
expect(
extractMetricValue(metrics, { path: "network.rxBytes", iface: "eth1" }),
).toBe(50);
expect(extractMetricValue(metrics, { path: "network.rxBytes" })).toBe(1000);
});
it("extracts per-interface network rates for bandwidth alerts", () => {
const rateMetrics = {
network: {
interfaces: [
{ name: "eth0", rxRateBps: 1024, txRateBps: 2048 },
{ name: "eth1", rxRateBps: 4096, txRateBps: 8192 },
],
},
};
expect(
extractMetricValue(rateMetrics, {
path: "network.rxRateBps",
iface: "eth1",
}),
).toBe(4096);
expect(
extractMetricValue(rateMetrics, {
path: "network.txRateBps",
iface: "eth0",
}),
).toBe(2048);
});
it("returns null for missing metrics rather than throwing", () => {
expect(extractMetricValue(null, { path: "cpu.percent" })).toBeNull();
expect(extractMetricValue({}, { path: "cpu.percent" })).toBeNull();
expect(
extractMetricValue({ cpu: { percent: null } }, { path: "cpu.percent" }),
).toBeNull();
});
});
describe("metricStateKey", () => {
it("scopes state per mount so dwell tracks one filesystem", () => {
expect(metricStateKey(7, { path: "disk.percent" })).toBe("7");
expect(metricStateKey(7, { path: "disk.percent", mount: "/data" })).toBe(
"7:/data",
);
expect(metricStateKey(7, { path: "network.rxBytes", iface: "eth1" })).toBe(
"7:eth1",
);
});
});
describe("compare", () => {
it("handles numeric operators", () => {
expect(compare(93, ">", 90)).toBe(true);
expect(compare(90, ">", 90)).toBe(false);
expect(compare(90, ">=", 90)).toBe(true);
expect(compare(10, "<", 90)).toBe(true);
expect(compare(90, "<=", 90)).toBe(true);
});
it("compares numeric strings numerically", () => {
expect(compare("93", ">", "90")).toBe(true);
// Lexically "9" > "10", so this would be wrong as a string compare.
expect(compare("9", "<", "10")).toBe(true);
});
it("falls back to string equality for non-numeric values", () => {
expect(compare("running", "==", "running")).toBe(true);
expect(compare("running", "!=", "exited")).toBe(true);
});
it("handles containment", () => {
expect(compare("disk full", "contains", "full")).toBe(true);
expect(compare("disk full", "not_contains", "full")).toBe(false);
expect(compare("all good", "not_contains", "error")).toBe(true);
});
it("treats changed as inequality of the rendered values", () => {
expect(compare("online", "changed", "offline")).toBe(true);
expect(compare("online", "changed", "online")).toBe(false);
});
it("is false when a numeric comparison has a non-numeric side", () => {
expect(compare("abc", ">", 5)).toBe(false);
});
});
describe("isCoolingDown", () => {
const now = Date.parse("2026-01-01T12:00:00.000Z");
it("is false when nothing has fired yet", () => {
expect(isCoolingDown(null, 15, now)).toBe(false);
});
it("is true inside the window and false outside it", () => {
expect(isCoolingDown("2026-01-01T11:50:00.000Z", 15, now)).toBe(true);
expect(isCoolingDown("2026-01-01T11:40:00.000Z", 15, now)).toBe(false);
});
it("treats a zero cooldown as always ready", () => {
expect(isCoolingDown("2026-01-01T11:59:59.000Z", 0, now)).toBe(false);
});
});
describe("hasDwelled", () => {
const now = Date.parse("2026-01-01T12:00:00.000Z");
it("fires immediately when no window is configured", () => {
expect(hasDwelled(null, undefined, now)).toBe(true);
expect(hasDwelled(null, 0, now)).toBe(true);
});
it("requires the window to have elapsed", () => {
expect(hasDwelled("2026-01-01T11:49:00.000Z", 600, now)).toBe(true);
expect(hasDwelled("2026-01-01T11:55:00.000Z", 600, now)).toBe(false);
});
it("is false when a window is set but no breach is open", () => {
expect(hasDwelled(null, 600, now)).toBe(false);
});
});
describe("severityForValue", () => {
it("escalates at 95 and honours an explicit override", () => {
expect(severityForValue(96)).toBe("critical");
expect(severityForValue(90)).toBe("warning");
expect(severityForValue(96, "info")).toBe("info");
});
});
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
computeNextDueAt,
isValidCron,
isValidTimezone,
nextCronRun,
parseCron,
} from "../../src/backend/cron.js";
describe("parseCron", () => {
it("rejects anything that is not five fields", () => {
expect(() => parseCron("* * * *")).toThrow(/five fields/);
expect(() => parseCron("* * * * * *")).toThrow(/five fields/);
});
it("expands wildcards, lists, ranges and steps", () => {
const fields = parseCron("0,30 9-17 * * 1-5");
expect([...fields.minutes]).toEqual([0, 30]);
expect([...fields.hours]).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
expect([...fields.daysOfWeek]).toEqual([1, 2, 3, 4, 5]);
expect(fields.dowRestricted).toBe(true);
expect(fields.domRestricted).toBe(false);
});
it("supports step syntax", () => {
expect([...parseCron("*/15 * * * *").minutes]).toEqual([0, 15, 30, 45]);
});
it("accepts month and day names", () => {
expect([...parseCron("0 0 1 jan *").months]).toEqual([1]);
expect([...parseCron("0 0 * * sun").daysOfWeek]).toEqual([0]);
});
it("treats day 7 as Sunday", () => {
expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]);
});
it("rejects out of range values", () => {
expect(() => parseCron("60 * * * *")).toThrow(/out of range/);
expect(() => parseCron("* 24 * * *")).toThrow(/out of range/);
expect(() => parseCron("* * 0 * *")).toThrow(/out of range/);
});
it("reports validity without throwing", () => {
expect(isValidCron("*/5 * * * *")).toBe(true);
expect(isValidCron("nonsense")).toBe(false);
});
});
describe("nextCronRun", () => {
it("finds the next matching minute", () => {
const from = new Date(2026, 0, 1, 10, 3, 30);
expect(nextCronRun("*/15 * * * *", from)).toEqual(
new Date(2026, 0, 1, 10, 15, 0, 0),
);
});
it("never returns the starting minute", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
expect(nextCronRun("0 * * * *", from)).toEqual(
new Date(2026, 0, 1, 11, 0, 0, 0),
);
});
it("rolls into the next day", () => {
const from = new Date(2026, 0, 1, 23, 45, 0);
expect(nextCronRun("0 2 * * *", from)).toEqual(
new Date(2026, 0, 2, 2, 0, 0, 0),
);
});
it("unions day-of-month and day-of-week when both are set", () => {
// The 15th, or any Monday.
const from = new Date(2026, 0, 1, 0, 0, 0);
const next = nextCronRun("0 0 15 * 1", from);
expect(next).not.toBeNull();
const isFifteenth = next!.getDate() === 15;
const isMonday = next!.getDay() === 1;
expect(isFifteenth || isMonday).toBe(true);
});
it("gives up on a date that can never match", () => {
expect(nextCronRun("0 0 30 2 *", new Date(2026, 0, 1))).toBeNull();
});
});
describe("computeNextDueAt", () => {
it("prefers an interval over a cron expression", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 300, cron: "0 0 * * *" }, from),
).toBe("2026-01-01T00:05:00.000Z");
});
it("falls back to cron", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const due = computeNextDueAt({ cron: "30 10 * * *" }, from);
expect(due).toBe(new Date(2026, 0, 1, 10, 30, 0, 0).toISOString());
});
it("returns null when nothing is scheduled", () => {
expect(computeNextDueAt({})).toBeNull();
});
it("passes the zone through to the cron evaluation", () => {
// 02:00 in Tokyo on 2026-06-02 is 17:00 UTC on 2026-06-01.
const from = new Date("2026-06-01T00:00:00.000Z");
expect(
computeNextDueAt({ cron: "0 2 * * *", timezone: "Asia/Tokyo" }, from),
).toBe("2026-06-01T17:00:00.000Z");
});
it("ignores the zone for interval schedules", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 600, timezone: "Asia/Tokyo" }, from),
).toBe("2026-01-01T00:10:00.000Z");
});
});
describe("time zone handling", () => {
it("resolves a daily cron against the given zone", () => {
const from = new Date("2026-06-01T00:00:00.000Z");
// 09:30 New York in June (UTC-4) is 13:30 UTC.
const next = nextCronRun("30 9 * * *", from, "America/New_York");
expect(next?.toISOString()).toBe("2026-06-01T13:30:00.000Z");
});
it("tracks daylight saving, so the UTC instant shifts by an hour", () => {
const summer = nextCronRun(
"0 12 * * *",
new Date("2026-07-01T00:00:00.000Z"),
"America/New_York",
);
const winter = nextCronRun(
"0 12 * * *",
new Date("2026-01-01T00:00:00.000Z"),
"America/New_York",
);
// Noon local both times, but UTC-4 in July and UTC-5 in January.
expect(summer?.toISOString()).toBe("2026-07-01T16:00:00.000Z");
expect(winter?.toISOString()).toBe("2026-01-01T17:00:00.000Z");
});
it("matches the day of week in the target zone, not the server's", () => {
// 23:00 UTC Sunday is already Monday in Tokyo.
const next = nextCronRun(
"0 8 * * mon",
new Date("2026-06-07T22:00:00.000Z"),
"Asia/Tokyo",
);
expect(next?.toISOString()).toBe("2026-06-07T23:00:00.000Z");
});
it("falls back to server time for an unknown zone instead of throwing", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const next = nextCronRun("30 10 * * *", from, "Not/AZone");
expect(next).toEqual(new Date(2026, 0, 1, 10, 30, 0, 0));
});
it("handles midnight, which some hour cycles format as 24", () => {
const next = nextCronRun(
"0 0 * * *",
new Date("2026-06-01T10:00:00.000Z"),
"UTC",
);
expect(next?.toISOString()).toBe("2026-06-02T00:00:00.000Z");
});
});
describe("isValidTimezone", () => {
it("accepts real zones", () => {
expect(isValidTimezone("UTC")).toBe(true);
expect(isValidTimezone("Europe/London")).toBe(true);
});
it("rejects made-up ones", () => {
expect(isValidTimezone("Middle/Earth")).toBe(false);
expect(isValidTimezone("")).toBe(false);
});
});
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
diffContainerStates,
parseContainerStates,
} from "../../src/backend/docker-watcher.js";
/**
* The polling side needs SSH, so what is tested here is the pure part: turning
* `docker ps` output into states, and turning two snapshots into events.
*/
describe("parseContainerStates", () => {
it("reads name, state and health out of the ps output", () => {
const output = [
'{"name":"web","state":"running","status":"Up 2 hours"}',
'{"name":"db","state":"exited","status":"Exited (0) 5 minutes ago"}',
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
].join("\n");
const states = parseContainerStates(output);
expect(states.get("web")).toEqual({ state: "running", unhealthy: false });
expect(states.get("db")).toEqual({ state: "exited", unhealthy: false });
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
});
it("skips blank and malformed lines rather than failing the poll", () => {
const output = [
'{"name":"web","state":"running","status":"Up"}',
"",
"not json at all",
'{"state":"running"}',
].join("\n");
const states = parseContainerStates(output);
expect([...states.keys()]).toEqual(["web"]);
});
it("returns nothing for empty output", () => {
expect(parseContainerStates("").size).toBe(0);
});
});
describe("diffContainerStates", () => {
const running = { state: "running", unhealthy: false };
const exited = { state: "exited", unhealthy: false };
it("reports a container that stopped", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", exited]]),
);
expect(events).toEqual([{ container: "web", event: "exited" }]);
});
it("reports a container that started", () => {
const events = diffContainerStates(
new Map([["web", exited]]),
new Map([["web", running]]),
);
expect(events).toEqual([{ container: "web", event: "started" }]);
});
it("reports a container that began restarting", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", { state: "restarting", unhealthy: false }]]),
);
expect(events).toEqual([{ container: "web", event: "restarting" }]);
});
it("reports a container that went unhealthy while still running", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", { state: "running", unhealthy: true }]]),
);
expect(events).toEqual([{ container: "web", event: "unhealthy" }]);
});
it("stays quiet when nothing changed", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", running]]),
);
expect(events).toEqual([]);
});
it("does not re-announce a container that is still unhealthy", () => {
const unhealthy = { state: "running", unhealthy: true };
const events = diffContainerStates(
new Map([["web", unhealthy]]),
new Map([["web", unhealthy]]),
);
expect(events).toEqual([]);
});
// A first sighting is a baseline, not an event: otherwise every container
// running at boot would report itself as freshly started.
it("treats a newly seen container as a baseline", () => {
const events = diffContainerStates(new Map(), new Map([["web", running]]));
expect(events).toEqual([]);
});
it("ignores a container that disappeared", () => {
const events = diffContainerStates(new Map([["web", running]]), new Map());
expect(events).toEqual([]);
});
});
@@ -0,0 +1,605 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
AutomationDefinition,
Step,
} from "../../../../src/types/automations.js";
/**
* The engine reaches the database through the repository factory and the
* outside world through the step executors, so both are mocked here. What is
* under test is the run loop itself: ordering, branching, error policy,
* concurrency, recursion and dry-run.
*/
interface FakeAutomation {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
const automations = new Map<number, FakeAutomation>();
const runs: Array<Record<string, unknown>> = [];
const runSteps: Array<Record<string, unknown>> = [];
let nextRunId = 1;
let nextStepRowId = 1;
const repository = {
findById: vi.fn(async (id: number) => automations.get(id) ?? null),
createRun: vi.fn(async (input: Record<string, unknown>) => {
const run = { id: nextRunId++, ...input };
runs.push(run);
return run;
}),
finishRun: vi.fn(async (runId: number, input: Record<string, unknown>) => {
const run = runs.find((entry) => entry.id === runId);
if (run) Object.assign(run, input);
}),
createRunStep: vi.fn(async (input: Record<string, unknown>) => {
const id = nextStepRowId++;
runSteps.push({ id, ...input });
return id;
}),
finishRunStep: vi.fn(async (id: number, input: Record<string, unknown>) => {
const step = runSteps.find((entry) => entry.id === id);
if (step) Object.assign(step, input);
}),
};
const resolveHostById = vi.fn();
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const executeStep = vi.fn();
vi.mock("../../src/backend/actions/index.js", () => ({
executeStep: (...args: unknown[]) => executeStep(...args),
}));
const { AutomationEngine } = await import("../../src/backend/engine.js");
function defineAutomation(
steps: Step[],
overrides: Partial<FakeAutomation> = {},
): FakeAutomation {
const definition: AutomationDefinition = {
version: 1,
trigger: { kind: "webhook", tokenHash: "x" },
steps,
};
const automation: FakeAutomation = {
id: overrides.id ?? 1,
userId: "user-1",
name: "Test",
enabled: true,
definition: JSON.stringify(definition),
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
...overrides,
};
automations.set(automation.id, automation);
return automation;
}
function step(partial: Partial<Step> & { id: string; type: string }): Step {
return partial as Step;
}
beforeEach(() => {
automations.clear();
runs.length = 0;
runSteps.length = 0;
nextRunId = 1;
nextStepRowId = 1;
vi.clearAllMocks();
resolveHostById.mockResolvedValue(null);
executeStep.mockResolvedValue({ success: true, output: "ok" });
// The singleton carries in-flight state between tests.
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
});
describe("AutomationEngine.run", () => {
it("adds the trigger host name to the template context", async () => {
defineAutomation([step({ id: "notify", type: "notify" })]);
resolveHostById.mockResolvedValue({
name: "Proxmox Node",
ip: "10.0.0.11",
username: "root",
port: 22,
});
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "metric_threshold",
triggerHostId: 11,
triggerContext: { hostId: 11, value: 97 },
});
const context = executeStep.mock.calls[0][1] as {
template: {
host: { id: number; name: string };
trigger: { hostName: string };
};
};
expect(context.template.host).toMatchObject({
id: 11,
name: "Proxmox Node",
});
expect(context.template.trigger.hostName).toBe("Proxmox Node");
});
it("runs steps in order and records each one", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(2);
expect(runSteps.map((s) => s.stepId)).toEqual(["a", "b"]);
expect(runSteps.map((s) => s.stepIndex)).toEqual([0, 1]);
});
it("fails the run and stops when a step fails under the default policy", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toBe("boom");
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("keeps going when a step is marked continue-on-error", async () => {
defineAutomation([
step({ id: "a", type: "run_command", onError: "continue" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(2);
});
it("skips disabled steps", async () => {
defineAutomation([
step({ id: "a", type: "run_command", enabled: false }),
step({ id: "b", type: "http" }),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(executeStep).toHaveBeenCalledTimes(1);
expect(runSteps.map((s) => s.stepId)).toEqual(["b"]);
});
it("passes earlier step output to later steps", async () => {
defineAutomation([
step({ id: "first", type: "run_command" }),
step({ id: "second", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const secondCallContext = executeStep.mock.calls[1][1] as {
template: { steps: Record<string, { stdout: string }> };
};
expect(secondCallContext.template.steps.first.stdout).toBe("hello");
});
it("merges variables set by a step into the template context", async () => {
defineAutomation([
step({ id: "setter", type: "set_var" }),
step({ id: "next", type: "http" }),
]);
executeStep.mockResolvedValueOnce({
success: true,
output: "x = 1",
vars: { x: "1" },
});
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const context = executeStep.mock.calls[1][1] as {
template: { vars: Record<string, string> };
};
expect(context.template.vars.x).toBe("1");
});
describe("if branching", () => {
it("runs the then branch when the condition matches", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "93", operator: ">", right: "90" },
then: [step({ id: "yes", type: "http" })],
else: [step({ id: "no", type: "http" })],
}),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(executeStep).toHaveBeenCalledTimes(1);
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
});
it("runs the else branch when it does not", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "10", operator: ">", right: "90" },
then: [step({ id: "yes", type: "http" })],
else: [step({ id: "no", type: "http" })],
}),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "no"]);
});
it("resolves templates on both sides of the condition", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: {
left: "{{trigger.value}}",
operator: ">=",
right: "90",
},
then: [step({ id: "yes", type: "http" })],
}),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "metric_threshold",
triggerContext: { value: 95 },
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
});
it("treats an empty else as a no-op", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "1", operator: "==", right: "2" },
then: [step({ id: "yes", type: "http" })],
}),
step({ id: "after", type: "http" }),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "after"]);
});
});
it("halts the run when a step returns a stop signal", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({
success: true,
halt: { status: "success" },
});
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("marks the run failed when a stop step asks for failure", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockResolvedValueOnce({
success: true,
halt: { status: "failed" },
});
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
});
describe("recursion protection", () => {
it("refuses to re-enter an automation already in the chain", async () => {
defineAutomation([
step({ id: "nested", type: "run_automation", automationId: 1 }),
]);
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
// The nested call is refused, and the refusal is recorded on the step.
const nestedStep = runSteps.find((s) => s.stepId === "nested");
expect(nestedStep?.status).toBe("failed");
expect(String(nestedStep?.error)).toMatch(
/already running in this chain/,
);
expect(outcome.status).toBe("failed");
});
it("refuses a mutual cycle between two automations", async () => {
defineAutomation(
[step({ id: "toB", type: "run_automation", automationId: 2 })],
{ id: 1 },
);
defineAutomation(
[step({ id: "toA", type: "run_automation", automationId: 1 })],
{ id: 2 },
);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const inner = runSteps.find((s) => s.stepId === "toA");
expect(inner?.status).toBe("failed");
expect(String(inner?.error)).toMatch(/already running in this chain/);
});
it("refuses to nest deeper than the maximum depth", async () => {
defineAutomation([step({ id: "a", type: "http" })]);
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
depth: 99,
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toMatch(/depth/);
expect(runs).toHaveLength(0);
});
});
describe("concurrency", () => {
it("records a skipped run rather than dropping it silently", async () => {
defineAutomation([step({ id: "slow", type: "run_command" })], {
concurrencyPolicy: "skip",
});
let release: () => void = () => {};
executeStep.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ success: true, output: "done" });
}),
);
const engine = AutomationEngine.getInstance();
const first = engine.run({ automationId: 1, triggerType: "manual" });
// Let the first run register itself as in flight.
await new Promise((resolve) => setTimeout(resolve, 10));
const second = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(second.status).toBe("skipped");
release();
await first;
const skipped = runs.find((run) => run.status === "skipped");
expect(skipped).toBeDefined();
expect(String(skipped?.error)).toMatch(/still in progress/);
});
it("skips the second of two triggers that arrive in the same tick", async () => {
defineAutomation([step({ id: "slow", type: "run_command" })], {
concurrencyPolicy: "skip",
});
executeStep.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ success: true, output: "done" }), 20),
),
);
// No gap between the two: the in-flight slot used to be claimed several
// awaits after it was checked, so both runs got through.
const engine = AutomationEngine.getInstance();
const [first, second] = await Promise.all([
engine.run({ automationId: 1, triggerType: "manual" }),
engine.run({ automationId: 1, triggerType: "manual" }),
]);
expect([first.status, second.status].sort()).toEqual([
"skipped",
"success",
]);
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("releases the in-flight slot when the run row cannot be created", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
repository.createRun.mockRejectedValueOnce(new Error("db down"));
const engine = AutomationEngine.getInstance();
const failed = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(failed.status).toBe("failed");
// A leaked slot would make every later run skip forever.
const after = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(after.status).toBe("success");
});
});
it("propagates the dry-run flag to executors", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
expect(context.dryRun).toBe(true);
});
it("lets a caller force a dry run on a live automation", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
dryRun: true,
});
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
expect(context.dryRun).toBe(true);
});
it("fails cleanly when the automation is missing", async () => {
const outcome = await AutomationEngine.getInstance().run({
automationId: 404,
triggerType: "manual",
});
expect(outcome).toMatchObject({ status: "failed", runId: null });
});
it("fails cleanly when the definition is not valid JSON", async () => {
automations.set(1, {
id: 1,
userId: "user-1",
name: "Broken",
enabled: true,
definition: "not json",
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
});
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toMatch(/not valid JSON/);
});
it("turns a thrown executor error into a failed step", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockRejectedValueOnce(new Error("connection reset"));
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(runSteps[0].status).toBe("failed");
expect(String(runSteps[0].error)).toMatch(/connection reset/);
});
it("truncates very large step output", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockResolvedValueOnce({
success: true,
output: "x".repeat(40_000),
});
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps[0].truncated).toBe(true);
expect(String(runSteps[0].output)).toMatch(/truncated/);
});
it("stops once the run deadline has passed", async () => {
defineAutomation(
[step({ id: "a", type: "run_command" }), step({ id: "b", type: "http" })],
{ maxRunSeconds: 1 },
);
// The first step consumes the whole budget, so the second must not start.
executeStep.mockImplementationOnce(async () => {
vi.setSystemTime(Date.now() + 5_000);
return { success: true, output: "slow" };
});
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("timeout");
expect(executeStep).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const listAutomationWatchedHosts = vi.fn();
vi.mock("../../src/backend/triggers.js", () => ({
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
}));
const {
automationSessionId,
reconcileHeadlessViewers,
releaseHeadlessViewers,
setViewerRegistry,
} = await import("../../src/backend/headless-viewer.js");
const registry = {
registerViewer: vi.fn(),
unregisterViewer: vi.fn(),
updateHeartbeat: vi.fn(() => true),
};
beforeEach(() => {
// Drop any viewers the previous test left behind before the mocks are
// cleared, so those unregister calls are not counted against this test.
releaseHeadlessViewers();
vi.clearAllMocks();
setViewerRegistry(registry);
listAutomationWatchedHosts.mockResolvedValue(new Map());
});
describe("reconcileHeadlessViewers", () => {
it("registers a synthetic viewer for each watched host", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[9, "user-2"],
]),
);
const result = await reconcileHeadlessViewers();
expect(result.added).toBe(2);
expect(registry.registerViewer).toHaveBeenCalledWith(
7,
"automation:7",
"user-1",
);
expect(registry.registerViewer).toHaveBeenCalledWith(
9,
"automation:9",
"user-2",
);
});
it("heartbeats instead of re-registering a host it already holds", async () => {
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
await reconcileHeadlessViewers();
registry.registerViewer.mockClear();
const second = await reconcileHeadlessViewers();
// The 120s reaper drops viewers with a stale heartbeat, so every tick has
// to refresh the ones it is keeping.
expect(registry.updateHeartbeat).toHaveBeenCalledWith("automation:7");
expect(registry.registerViewer).not.toHaveBeenCalled();
expect(second.added).toBe(0);
expect(second.active).toBe(1);
});
it("releases a viewer once no automation watches the host", async () => {
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
await reconcileHeadlessViewers();
listAutomationWatchedHosts.mockResolvedValue(new Map());
const result = await reconcileHeadlessViewers();
expect(result.removed).toBe(1);
expect(result.active).toBe(0);
expect(registry.unregisterViewer).toHaveBeenCalledWith(7, "automation:7");
});
it("does nothing when no registry has been wired up", async () => {
setViewerRegistry(null);
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
const result = await reconcileHeadlessViewers();
expect(result).toEqual({ added: 0, removed: 0, active: 0 });
expect(registry.registerViewer).not.toHaveBeenCalled();
});
it("keeps going when the watch list cannot be loaded", async () => {
listAutomationWatchedHosts.mockRejectedValue(new Error("db down"));
await expect(reconcileHeadlessViewers()).resolves.toMatchObject({
added: 0,
});
});
it("survives a registry that throws on register", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[8, "user-1"],
]),
);
registry.registerViewer.mockImplementationOnce(() => {
throw new Error("nope");
});
const result = await reconcileHeadlessViewers();
// One host failing must not stop the other from being registered.
expect(result.added).toBe(1);
});
});
describe("releaseHeadlessViewers", () => {
it("drops every viewer it is holding", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[8, "user-1"],
]),
);
await reconcileHeadlessViewers();
releaseHeadlessViewers();
expect(registry.unregisterViewer).toHaveBeenCalledTimes(2);
});
});
describe("automationSessionId", () => {
it("namespaces the session so it cannot collide with a real viewer", () => {
expect(automationSessionId(42)).toBe("automation:42");
});
});
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const automationFetch = vi.fn();
vi.mock("../../src/backend/http.js", () => ({
automationFetch: (...args: unknown[]) => automationFetch(...args),
}));
const { sendAutomationNotification } =
await import("../../src/backend/notify.js");
beforeEach(() => {
automationFetch.mockReset();
automationFetch.mockResolvedValue({ ok: true });
});
describe("sendAutomationNotification", () => {
it("keeps alert-compatible host and rule fields in webhook payloads", async () => {
await sendAutomationNotification(
{ id: 1, type: "webhook", config: '{"url":"https://example.com"}' },
{
title: "CPU warning",
body: "cpu.percent is at 97",
severity: "warning",
context: {
host: { id: 11, name: "Proxmox Node" },
trigger: { value: 97, threshold: 90 },
run: { automationId: 42 },
},
},
);
const options = automationFetch.mock.calls[0][1] as RequestInit;
expect(JSON.parse(options.body as string)).toMatchObject({
hostName: "Proxmox Node",
hostId: 11,
ruleName: "CPU warning",
ruleId: 42,
value: 97,
threshold: 90,
message: "cpu.percent is at 97",
});
});
});
@@ -0,0 +1,513 @@
import crypto from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, Response, Router } from "express";
import type { AutomationDefinition } from "../../../../src/types/automations.js";
/**
* Route-level behaviour: validation, ownership and webhook token handling. The repository and engine are mocked; what matters here is what
* the HTTP layer accepts, rejects and hands back.
*/
const state = vi.hoisted(() => ({
currentUserId: "user-1",
rows: [] as Array<Record<string, unknown>>,
nextId: 1,
}));
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
const repository = vi.hoisted(() => ({
list: vi.fn(),
findForUser: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
listAllEnabled: vi.fn(),
listRuns: vi.fn(),
findRunForUser: vi.fn(),
listRunSteps: vi.fn(),
upsertSchedule: vi.fn(),
deleteSchedule: vi.fn(),
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const run = vi.hoisted(() => vi.fn());
vi.mock("../../src/backend/engine.js", () => ({
AutomationEngine: { getInstance: () => ({ run }) },
}));
vi.mock(
"../../../../src/backend/database/routes/automation-dispatch.js",
() => ({
registerAutomationsRouter: vi.fn(),
unregisterAutomationsRouter: vi.fn(),
}),
);
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
requirePermission:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../../src/backend/utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => undefined),
getAuditUsername: vi.fn(async () => "alice"),
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const { router } = await import("../../src/backend/routes.js");
/** Runs the whole middleware chain for the route, not just its handler. */
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
query?: Record<string, unknown>;
} = {},
) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (...args: unknown[]) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
query: overrides.query ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
for (const entry of layer.route.stack) {
let advanced = false;
await entry.handle(req, res, () => {
advanced = true;
});
if (!advanced) break;
}
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
function definition(
overrides: Partial<AutomationDefinition> = {},
): AutomationDefinition {
return {
version: 1,
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
},
steps: [{ id: "a", type: "notify", channelIds: [1] }],
...overrides,
} as AutomationDefinition;
}
beforeEach(() => {
state.currentUserId = "user-1";
state.rows = [];
state.nextId = 1;
vi.clearAllMocks();
repository.list.mockImplementation(async (userId: string) =>
state.rows.filter((row) => row.user_id === userId),
);
repository.findForUser.mockImplementation(
async (id: number, userId: string) =>
state.rows.find((row) => row.id === id && row.user_id === userId) ?? null,
);
repository.create.mockImplementation(
async (input: Record<string, unknown>) => {
const row = {
id: state.nextId++,
user_id: input.userId,
name: input.name,
definition: input.definition,
enabled: input.enabled === false ? 0 : 1,
channels: input.channels ?? [],
};
state.rows.push(row);
return row;
},
);
repository.delete.mockImplementation(async (id: number, userId: string) => {
const index = state.rows.findIndex(
(row) => row.id === id && row.user_id === userId,
);
if (index === -1) return false;
state.rows.splice(index, 1);
return true;
});
repository.listAllEnabled.mockImplementation(async () =>
state.rows.map((row) => ({
id: row.id,
userId: row.user_id,
definition: row.definition,
enabled: true,
})),
);
repository.upsertSchedule.mockResolvedValue(undefined);
repository.deleteSchedule.mockResolvedValue(undefined);
run.mockResolvedValue({ runId: 1, status: "success" });
});
describe("POST /", () => {
it("creates an automation from a valid definition", async () => {
const res = await invoke("post", "/", {
body: { name: "Disk watch", definition: definition() },
});
expect(res.statusCode).toBe(201);
expect(res.jsonBody?.name).toBe("Disk watch");
});
it("requires a name", async () => {
const res = await invoke("post", "/", {
body: { definition: definition() },
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/name/i);
});
it("rejects an unknown trigger kind", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: { version: 1, trigger: { kind: "nope" }, steps: [] },
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/trigger/i);
});
it("rejects an unknown operator", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: "~=",
value: 1,
cooldownMinutes: 5,
},
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/operator/i);
});
it("rejects an unknown step type", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
steps: [{ id: "a", type: "launch_missiles" }],
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/step type/i);
});
it("rejects duplicate step ids, including inside a branch", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
steps: [
{ id: "dup", type: "wait", seconds: 1 },
{
id: "branch",
type: "if",
condition: { left: "1", operator: "==", right: "1" },
then: [{ id: "dup", type: "wait", seconds: 1 }],
},
],
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/duplicate/i);
});
it("rejects an invalid cron expression", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: { kind: "schedule", cron: "not a cron" },
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/cron/i);
});
it("rejects an interval under a minute", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: { kind: "schedule", intervalSeconds: 5 },
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/60 seconds/i);
});
it("registers a schedule for a schedule trigger", async () => {
await invoke("post", "/", {
body: {
name: "Nightly",
definition: definition({
trigger: { kind: "schedule", cron: "0 2 * * *" },
} as Partial<AutomationDefinition>),
},
});
expect(repository.upsertSchedule).toHaveBeenCalled();
});
it("returns a webhook token once and stores only its hash", async () => {
const res = await invoke("post", "/", {
body: {
name: "Hooked",
definition: definition({
trigger: { kind: "webhook", tokenHash: "" },
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(201);
const token = res.jsonBody?.webhookToken as string;
expect(token).toMatch(/^[a-f0-9]{64}$/);
const stored = JSON.parse(state.rows[0].definition as string);
expect(stored.trigger.tokenHash).not.toBe(token);
expect(stored.trigger.tokenHash).toBe(
crypto.createHash("sha256").update(token).digest("hex"),
);
// The hash is never echoed back to the client.
const body = res.jsonBody as { definition: AutomationDefinition };
expect((body.definition.trigger as { tokenHash: string }).tokenHash).toBe(
"",
);
});
it("stores the automation against the caller, not a supplied user id", async () => {
// Authorization here is ownership, the same as the other data routes:
// every read and write is scoped to req.userId, so a client cannot create
// an automation that belongs to somebody else.
await invoke("post", "/", {
body: {
name: "Mine",
definition: definition(),
userId: "user-2",
user_id: "user-2",
},
});
expect(state.rows).toHaveLength(1);
expect(state.rows[0].user_id).toBe("user-1");
});
});
describe("GET /", () => {
it("only returns the caller's automations", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("get", "/");
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toHaveLength(0);
});
});
describe("DELETE /:id", () => {
it("will not delete another user's automation", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("delete", "/:id", { params: { id: "1" } });
expect(res.statusCode).toBe(404);
expect(state.rows).toHaveLength(1);
});
});
describe("POST /:id/run", () => {
function seedOwned() {
state.rows.push({
id: 1,
user_id: "user-1",
name: "Mine",
definition: JSON.stringify(definition()),
channels: [],
});
}
it("runs an owned automation", async () => {
seedOwned();
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
expect(res.statusCode).toBe(200);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ automationId: 1, triggerType: "manual" }),
);
});
it("passes the dry-run flag through", async () => {
seedOwned();
await invoke("post", "/:id/run", {
params: { id: "1" },
body: { dryRun: true },
});
expect(run).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true }));
});
it("refuses to run someone else's automation", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
});
describe("POST /webhook/:token", () => {
function seedWebhook(token: string) {
state.rows.push({
id: 1,
user_id: "user-1",
name: "Hooked",
definition: JSON.stringify(
definition({
trigger: {
kind: "webhook",
tokenHash: crypto.createHash("sha256").update(token).digest("hex"),
},
} as Partial<AutomationDefinition>),
),
channels: [],
});
}
it("runs the automation matching the token", async () => {
const token = "a".repeat(64);
seedWebhook(token);
const res = await invoke("post", "/webhook/:token", {
params: { token },
body: { hello: "world" },
});
expect(res.statusCode).toBe(202);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ automationId: 1, triggerType: "webhook" }),
);
});
it("rejects a token that does not match", async () => {
seedWebhook("a".repeat(64));
const res = await invoke("post", "/webhook/:token", {
params: { token: "b".repeat(64) },
});
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
it("rejects a token too short to be real", async () => {
seedWebhook("a".repeat(64));
const res = await invoke("post", "/webhook/:token", {
params: { token: "short" },
});
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
hasUnresolvedTokens,
redactSecrets,
renderRecord,
renderTemplate,
} from "../../src/backend/template.js";
const context = {
host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 },
trigger: { value: 93.4, mount: "/data" },
steps: { check: { stdout: "ok\n", stderr: "", code: 0 } },
vars: { target: "/var/log" },
};
describe("renderTemplate", () => {
it("substitutes host, trigger, step and var tokens", () => {
expect(renderTemplate("{{host.name}} at {{trigger.value}}%", context)).toBe(
"Zeus at 93.4%",
);
expect(renderTemplate("{{steps.check.stdout}}", context)).toBe("ok\n");
expect(renderTemplate("{{vars.target}}", context)).toBe("/var/log");
});
it("tolerates whitespace inside the braces", () => {
expect(renderTemplate("{{ host.name }}", context)).toBe("Zeus");
});
it("leaves unknown tokens in place so typos are visible", () => {
expect(renderTemplate("{{host.nope}}", context)).toBe("{{host.nope}}");
expect(hasUnresolvedTokens(renderTemplate("{{host.nope}}", context))).toBe(
true,
);
});
it("returns the input untouched when there is nothing to render", () => {
expect(renderTemplate("plain text", context)).toBe("plain text");
expect(renderTemplate("", context)).toBe("");
});
it("does not escape or alter shell metacharacters", () => {
// Quoting is the caller's job; this keeps that boundary explicit.
const rendered = renderTemplate("{{vars.payload}}", {
vars: { payload: "; rm -rf /" },
});
expect(rendered).toBe("; rm -rf /");
});
it("does not recursively expand a value that looks like a token", () => {
const rendered = renderTemplate("{{vars.a}}", {
vars: { a: "{{vars.b}}", b: "gotcha" },
});
expect(rendered).toBe("{{vars.b}}");
});
it("serializes objects rather than printing [object Object]", () => {
expect(renderTemplate("{{trigger}}", { trigger: { a: 1 } })).toBe(
'{"a":1}',
);
});
it("renders missing branches as the literal token", () => {
expect(renderTemplate("{{steps.other.stdout}}", context)).toBe(
"{{steps.other.stdout}}",
);
});
});
describe("renderRecord", () => {
it("renders values and leaves keys alone", () => {
expect(renderRecord({ "X-Host": "{{host.name}}" }, context)).toEqual({
"X-Host": "Zeus",
});
});
it("passes undefined through", () => {
expect(renderRecord(undefined, context)).toBeUndefined();
});
});
describe("redactSecrets", () => {
it("masks credential-shaped keys", () => {
expect(
redactSecrets({
Authorization: "Bearer abc",
"X-Api-Key": "k",
token: "t",
password: "p",
Accept: "application/json",
}),
).toEqual({
Authorization: "***",
"X-Api-Key": "***",
token: "***",
password: "***",
Accept: "application/json",
});
});
});
@@ -0,0 +1,444 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
AutomationDefinition,
Trigger,
} from "../../../../src/types/automations.js";
/**
* Trigger matching, dwell windows and cooldowns. The repository and the engine
* are mocked so these tests only exercise the decision of whether to fire.
*/
interface FakeRow {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
const rows: FakeRow[] = [];
const triggerState = new Map<string, Record<string, unknown>>();
const repository = {
listEnabledForUser: vi.fn(async (userId: string) =>
rows.filter((row) => row.userId === userId && row.enabled),
),
listAllEnabled: vi.fn(async () => rows.filter((row) => row.enabled)),
getTriggerState: vi.fn(async (automationId: number, stateKey: string) => {
return triggerState.get(`${automationId}:${stateKey}`) ?? null;
}),
upsertTriggerState: vi.fn(async (input: Record<string, unknown>) => {
const key = `${input.automationId}:${input.stateKey}`;
triggerState.set(key, { ...(triggerState.get(key) ?? {}), ...input });
}),
clearBreach: vi.fn(async (automationId: number, stateKey: string) => {
const key = `${automationId}:${stateKey}`;
const existing = triggerState.get(key);
if (existing) existing.breachStartedAt = null;
}),
};
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
vi.mock("../../src/backend/engine.js", () => ({
AutomationEngine: { getInstance: () => ({ run }) },
}));
const triggers = await import("../../src/backend/triggers.js");
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
const row: FakeRow = {
id: overrides.id ?? rows.length + 1,
userId: overrides.userId ?? "user-1",
name: "Test",
enabled: overrides.enabled ?? true,
definition: JSON.stringify(definition),
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
};
rows.push(row);
return row;
}
const diskMetrics = {
disk: {
percent: 30,
filesystems: [
{ mount: "/", percent: 30 },
{ mount: "/data", percent: 93 },
],
},
};
beforeEach(() => {
rows.length = 0;
triggerState.clear();
vi.clearAllMocks();
});
describe("onMetrics", () => {
it("fires immediately when no dwell window is set", async () => {
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).toHaveBeenCalledTimes(1);
expect(run.mock.calls[0][0]).toMatchObject({
triggerType: "metric_threshold",
triggerHostId: 7,
});
});
it("watches the named mount rather than the aggregate", async () => {
// Root is at 30%, so a rule on / must not fire at a 90% threshold.
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
it("opens a dwell window instead of firing on the first sample", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
forSeconds: 600,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
expect(triggerState.get(`${row.id}:7:/data`)).toMatchObject({
breachStartedAt: expect.any(String),
});
});
it("fires once the dwell window has elapsed", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
forSeconds: 600,
cooldownMinutes: 15,
});
triggerState.set(`${row.id}:7:/data`, {
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).toHaveBeenCalledTimes(1);
});
it("clears the window as soon as the value recovers", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 95,
forSeconds: 600,
cooldownMinutes: 15,
});
triggerState.set(`${row.id}:7:/data`, {
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
expect(repository.clearBreach).toHaveBeenCalled();
});
it("stays quiet while the cooldown is open", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
triggerState.set(`${row.id}:7:/data`, {
lastFiredAt: new Date(Date.now() - 60_000).toISOString(),
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
it("ignores hosts outside the selector", async () => {
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 99 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
it("never evaluates another user's automations", async () => {
addAutomation(
{
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
},
{ userId: "user-2" },
);
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
});
describe("onStatus", () => {
it("treats the first observation as a baseline", async () => {
addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
await triggers.onStatus({
hostId: 7,
ownerUserId: "user-1",
online: false,
});
// Otherwise every host would announce itself after a restart.
expect(run).not.toHaveBeenCalled();
});
it("fires on a transition into the watched state", async () => {
const row = addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7`, { lastObservedState: "online" });
await triggers.onStatus({
hostId: 7,
ownerUserId: "user-1",
online: false,
});
expect(run).toHaveBeenCalledTimes(1);
});
it("does not fire on a transition in the other direction", async () => {
const row = addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7`, { lastObservedState: "offline" });
await triggers.onStatus({ hostId: 7, ownerUserId: "user-1", online: true });
expect(run).not.toHaveBeenCalled();
});
it("stays quiet while the state is unchanged", async () => {
const row = addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7`, { lastObservedState: "offline" });
await triggers.onStatus({
hostId: 7,
ownerUserId: "user-1",
online: false,
});
expect(run).not.toHaveBeenCalled();
});
});
describe("onHealthCheck", () => {
it("fires when a check transitions to failing", async () => {
const row = addAutomation({
kind: "health_check",
hostSelector: { kind: "host", hostId: 7 },
to: "failing",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7:web`, { lastObservedState: "recovered" });
await triggers.onHealthCheck({
hostId: 7,
userId: "user-1",
checkId: "web",
ok: false,
});
expect(run).toHaveBeenCalledTimes(1);
});
it("ignores a different check id", async () => {
const row = addAutomation({
kind: "health_check",
hostSelector: { kind: "host", hostId: 7 },
checkId: "db",
to: "failing",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7:web`, { lastObservedState: "recovered" });
await triggers.onHealthCheck({
hostId: 7,
userId: "user-1",
checkId: "web",
ok: false,
});
expect(run).not.toHaveBeenCalled();
});
});
describe("onDockerEvent", () => {
it("fires for a matching container and event", async () => {
addAutomation({
kind: "docker_event",
hostSelector: { kind: "host", hostId: 7 },
container: "api",
event: "exited",
cooldownMinutes: 0,
});
await triggers.onDockerEvent({
hostId: 7,
ownerUserId: "user-1",
container: "api",
event: "exited",
});
expect(run).toHaveBeenCalledTimes(1);
});
it("ignores a different container", async () => {
addAutomation({
kind: "docker_event",
hostSelector: { kind: "host", hostId: 7 },
container: "api",
event: "exited",
cooldownMinutes: 0,
});
await triggers.onDockerEvent({
hostId: 7,
ownerUserId: "user-1",
container: "worker",
event: "exited",
});
expect(run).not.toHaveBeenCalled();
});
});
describe("listAutomationWatchedHosts", () => {
it("collects hosts from metric triggers so they can be polled headlessly", async () => {
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "hosts", hostIds: [3, 4] },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
const watched = await triggers.listAutomationWatchedHosts();
expect([...watched.keys()].sort()).toEqual([3, 4]);
});
it("ignores triggers that do not need heavy collection", async () => {
addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 3 },
to: "offline",
cooldownMinutes: 0,
});
expect((await triggers.listAutomationWatchedHosts()).size).toBe(0);
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { defaultTrigger } from "../../src/frontend/automations/TriggerCard";
import { emptyDraft } from "../../src/frontend/automations/AutomationEditor";
import { newStepId } from "../../src/frontend/automations/editor-types";
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
describe("emptyDraft", () => {
it("starts on a metric threshold with no steps", () => {
const draft = emptyDraft();
expect(draft.name).toBe("");
expect(draft.enabled).toBe(true);
expect(draft.definition.version).toBe(AUTOMATION_DEFINITION_VERSION);
expect(draft.definition.trigger.kind).toBe("metric_threshold");
expect(draft.definition.steps).toEqual([]);
});
});
describe("defaultTrigger", () => {
it("gives every trigger kind a usable starting shape", () => {
expect(defaultTrigger("metric_threshold")).toMatchObject({
kind: "metric_threshold",
operator: ">",
cooldownMinutes: 15,
});
expect(defaultTrigger("host_status")).toMatchObject({
kind: "host_status",
to: "offline",
});
expect(defaultTrigger("health_check")).toMatchObject({
kind: "health_check",
to: "failing",
});
expect(defaultTrigger("schedule")).toMatchObject({ kind: "schedule" });
expect(defaultTrigger("docker_event")).toMatchObject({
kind: "docker_event",
event: "exited",
});
expect(defaultTrigger("webhook")).toMatchObject({ kind: "webhook" });
});
it("defaults a schedule to a valid cron expression", () => {
const trigger = defaultTrigger("schedule");
expect(trigger).toHaveProperty("cron");
// Five fields, which is what the backend parser requires.
expect(
String((trigger as { cron: string }).cron).split(/\s+/),
).toHaveLength(5);
});
});
describe("newStepId", () => {
it("produces distinct ids", () => {
const ids = new Set(Array.from({ length: 100 }, () => newStepId()));
expect(ids.size).toBe(100);
});
});
+10
View File
@@ -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"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
+22
View File
@@ -0,0 +1,22 @@
# Docker
Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.
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.
-143
View File
@@ -1,143 +0,0 @@
/**
* Docker - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ssh-terminal does (see src/backend/plugins/first-party.ts): its
* container-console WebSocket server holds long-lived ssh2.Client + PTY
* streams, its session-manager reuses a live ssh2.Client across many REST
* calls, and its SSH connect flow implements TOTP/Warpgate as a genuinely
* stateful multi-request handshake. None of that can cross the
* structured-clone postMessage boundary a worker plugin talks over.
*
* Unlike ssh-terminal, Docker's backend has no consumers outside itself, so
* the whole implementation moved here rather than staying in core with only
* a lifecycle wrapper. This file is what used to be
* src/backend/hosts/docker/index.ts, rewritten from a module-scope
* SIGINT/SIGTERM + listenOnServicePort() call into activate()/deactivate().
*
* The moved files (routes.ts, container-routes.ts, container-runtime.ts,
* session-manager.ts, console.ts) are real TypeScript, compiled by
* scripts/copy-bundled-plugins.cjs (see that script for why plugins/ needs
* its own compile step) rather than hand-written like ssh-terminal's .mjs
* wrapper. This file itself stays plain JS since it is a thin entry point
* with no types worth checking.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
/**
* 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 docker files (routes.js, session-manager.js, ...) sits
* right next to this .mjs, and core modules resolve via
* ../../../backend/backend/... from there (dist/plugins/docker/backend ->
* dist/backend/backend). Under vitest/dev nothing imports this plugin
* directly (src/backend/plugins/loader.ts 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 httpServer = null;
let consoleModule = null;
let authManagerInstance = null;
export async function activate(ctx) {
const [
express,
cookieParser,
{ createCorsMiddleware },
{ createCompressionMiddleware },
{ logger },
{ AuthManager },
{ registerDockerContainerRoutes },
sessionManager,
{ DOCKER_TIMESTAMP_RE, getRequestUserId, registerDockerSshRoutes },
] = await Promise.all([
import("express").then((m) => m.default),
import("cookie-parser").then((m) => m.default),
loadRelative("../../../backend/backend/utils/cors-config"),
loadRelative("../../../backend/backend/utils/compression-config"),
loadRelative("../../../backend/backend/utils/logger"),
loadRelative("../../../backend/backend/utils/auth-manager"),
loadRelative("container-routes"),
loadRelative("session-manager"),
loadRelative("routes"),
]);
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
authManagerInstance = AuthManager.getInstance();
app.use(authManagerInstance.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions: sessionManager.sshSessions,
pendingTOTPSessions: sessionManager.pendingTOTPSessions,
getRequestUserId,
executeDockerCommand: sessionManager.executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const port = 30007;
const server = createServer(app);
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
httpServer = server;
try {
await authManagerInstance.initialize();
} catch (err) {
logger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
// Importing console.js no longer binds anything: the server is started
// here so disabling the plugin frees port 30009 and re-enabling can take
// it again, even though the module stays in the ESM cache.
consoleModule = await loadRelative("console");
await consoleModule.startConsoleServer();
ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`);
}
export async function deactivate() {
if (consoleModule?.closeConsoleServer) {
await consoleModule.closeConsoleServer();
}
consoleModule = null;
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise((resolve) => server.close(() => resolve()));
}
authManagerInstance = null;
}
+1
View File
@@ -0,0 +1 @@
{}
-2
View File
@@ -37,8 +37,6 @@
"editorTab": "docker"
}
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/docker",
"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": "*"
}
}
@@ -1,35 +1,35 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js";
import { systemLogger } from "../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../src/types/index.js";
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
import { systemLogger } from "../../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../../src/types/index.js";
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import {
hostAddressMismatch,
HOST_ADDRESS_MISMATCH_MESSAGE,
HOST_NOT_ON_THIS_SERVER_MESSAGE,
} from "../../../src/backend/hosts/host-identity.js";
import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js";
} from "../../../../src/backend/hosts/host-identity.js";
import { extractWebSocketToken } from "../../../../src/backend/utils/ws-auth.js";
import {
asObject,
asString,
MAX_WS_MESSAGE_BYTES,
parseWsMessage,
toTerminalDimension,
} from "../../../src/backend/utils/ws-message.js";
} from "../../../../src/backend/utils/ws-message.js";
const sshLogger = systemLogger;
@@ -450,7 +450,7 @@ async function onConsoleConnection(
try {
// Resolve host with credentials server-side
const { resolveHostById, resolveHostBySyncId } =
await import("../../../src/backend/hosts/host-resolver.js");
await import("../../../../src/backend/hosts/host-resolver.js");
// syncId names the host on both sides of a sync pair; the numeric
// id only names it in the database the client is displaying.
const hostSyncId = hostConfig?.syncId;
@@ -1,9 +1,9 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import type express from "express";
import { logger } from "../../../src/backend/utils/logger.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
containerCommand,
type ContainerRuntime,
+88
View File
@@ -0,0 +1,88 @@
import { createServer, type Server } from "node:http";
import express from "express";
import cookieParser from "cookie-parser";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
import {
sshSessions,
pendingTOTPSessions,
executeDockerCommand,
} from "./session-manager.js";
import {
DOCKER_TIMESTAMP_RE,
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import { startConsoleServer, closeConsoleServer } from "./console.js";
const PORT = 30007;
let httpServer: Server | null = null;
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export async function activate(ctx: PluginContext) {
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
authManagerInstance = AuthManager.getInstance();
app.use(authManagerInstance.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions,
pendingTOTPSessions,
getRequestUserId,
executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const server = createServer(app);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(PORT, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
httpServer = server;
try {
await authManagerInstance.initialize();
} catch (err) {
logger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
// The console server is started here rather than on import so disabling the
// plugin frees port 30009 and re-enabling can take it again.
await startConsoleServer();
ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`);
}
export async function deactivate() {
await closeConsoleServer();
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
authManagerInstance = null;
}
@@ -1,45 +1,45 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../src/backend/utils/logger.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../../src/backend/utils/audit-logger.js";
import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import type {
AuthenticatedRequest,
ProxyNode,
SSHHost,
} from "../../../src/types/index.js";
} from "../../../../src/types/index.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../../src/backend/utils/socks5-helper.js";
} from "../../../../src/backend/utils/socks5-helper.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../src/types/connection-log.js";
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js";
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
} from "../../../../src/types/connection-log.js";
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js";
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
getRuntimeLabel,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import {
type SSHSession,
sshSessions,
@@ -307,7 +307,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try {
const { getOPKSSHToken } =
await import("../../../src/backend/hosts/opkssh-auth.js");
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
if (!token) {
@@ -327,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
const { setupOPKSSHCertAuth } =
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config as import("ssh2").ConnectConfig,
client,
@@ -1,8 +1,8 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../src/backend/utils/logger.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import type { ContainerRuntime } from "./container-runtime.js";
const sshLogger = logger;
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff. Show more