mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -0,0 +1,22 @@
|
||||
# AI Assistant
|
||||
|
||||
An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/frontend/ the UI it registers
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
|
||||
## Commands
|
||||
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* AI Assistant - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ssh-terminal, docker and host-metrics do (see
|
||||
* src/backend/plugins/first-party.ts): it writes directly across hosts,
|
||||
* snippets, fleets and automations through their repositories,
|
||||
* runs approved commands over the shared SSH pool via resolveHostById and
|
||||
* withConnection, and streams its chat replies as server-sent events. None
|
||||
* of that fits the worker ctx -- ctx.hosts is a 13-field read-only view with
|
||||
* no write methods and no snippets/fleets/automations surface at all,
|
||||
* and ctx.http.route replies with a single postMessage value rather than a
|
||||
* stream.
|
||||
*
|
||||
* Unlike ssh-terminal/docker/host-metrics, this plugin does not own a live
|
||||
* transport (no WebSocket server, no long-lived connection that must outlive
|
||||
* the main server's route table), so it does not get its own port. Instead
|
||||
* it registers its Express router with the always-mounted /ai dispatcher in
|
||||
* src/backend/database/routes/ai-dispatch.ts, the same "register a router,
|
||||
* a small dispatcher forwards to it" shape /plugin-api uses for worker
|
||||
* plugins, just scoped to this one path so /ai/* keeps its existing routes
|
||||
* instead of moving under /plugin-api/ai/.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/ai/index.ts, moved here with its
|
||||
* sibling modules (context.ts, egress.ts, engine.ts, gating.ts, redaction.ts,
|
||||
* providers/, tools/) and rewritten from `export default router` into
|
||||
* startAiService()/stopAiService(), called from activate()/deactivate()
|
||||
* below.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for the moved AI files (routes.js, ...) sits right next to this .mjs,
|
||||
* and core modules resolve via ../../../backend/backend/... from there
|
||||
* (dist/plugins/ai/backend -> dist/backend/backend). Under vitest/dev nothing
|
||||
* imports this plugin directly (the loader loads in-process plugins with a
|
||||
* plain dynamic import, no tsx loader involved), so the only layout that
|
||||
* needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
routesModule.startAiService();
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
||||
// under that user's data key, so a borrowed read returns the acting user's
|
||||
// own default key, never someone else's.
|
||||
ctx.secrets.offer("api-key", async (userId) => {
|
||||
const { createCurrentAiRepository } = await loadRelative(
|
||||
"../../../backend/backend/database/repositories/factory",
|
||||
);
|
||||
const repository = createCurrentAiRepository();
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
});
|
||||
|
||||
ctx.log.info("AI assistant router registered at /ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.stopAiService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -69,8 +69,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/ai",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
import { isIP } from "net";
|
||||
import { isBlockedAddress } from "../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Where the assistant is allowed to send requests.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { getAdapter } from "./providers/registry.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { NextFunction, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Three gates, all checked on the server.
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { startAiService, stopAiService } from "./routes.js";
|
||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startAiService();
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
||||
// under that user's data key, so a borrowed read returns the acting user's
|
||||
// own default key, never someone else's.
|
||||
ctx.secrets.offer("api-key", async (userId: string) => {
|
||||
const repository = createCurrentAiRepository();
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
});
|
||||
|
||||
ctx.log.info("AI assistant router registered at /ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopAiService();
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,25 +1,25 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import express from "express";
|
||||
import {
|
||||
registerAiRouter,
|
||||
unregisterAiRouter,
|
||||
} from "../../../src/backend/database/routes/ai-dispatch.js";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
||||
} from "../../../../src/backend/database/routes/ai-dispatch.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
} from "../../../../src/backend/utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
import type { UserRecord } from "../../../src/backend/database/repositories/user-repository.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { UserRecord } from "../../../../src/backend/database/repositories/user-repository.js";
|
||||
import { buildSystemPrompt } from "./context.js";
|
||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "./egress.js";
|
||||
import { runAgent } from "./engine.js";
|
||||
File renamed without changes.
File renamed without changes.
@@ -3,15 +3,15 @@ import {
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../automations/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
File renamed without changes.
@@ -8,7 +8,7 @@ import {
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSystemPrompt } from "../../src/backend/context.js";
|
||||
|
||||
const BASE = { hostCount: 3, allowReadOnlyCommands: false };
|
||||
|
||||
describe("buildSystemPrompt", () => {
|
||||
it("tells the assistant to stay inside what was asked", () => {
|
||||
// Without these the assistant answered "what is running on this server"
|
||||
// by also proposing a monitoring script and an automation nobody wanted.
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
|
||||
expect(prompt).toContain("nothing beyond it");
|
||||
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
|
||||
expect(prompt).toMatch(/no monitoring automations, no scripts/);
|
||||
expect(prompt).toMatch(/One request means one proposal at most/);
|
||||
});
|
||||
|
||||
it("states that it proposes rather than applies", () => {
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
expect(prompt).toContain("cannot change anything directly");
|
||||
expect(prompt).toContain("Never claim you have done something");
|
||||
});
|
||||
|
||||
it("never claims credential access", () => {
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
expect(prompt).toMatch(/no access to passwords, SSH keys, API keys/);
|
||||
});
|
||||
|
||||
it("mentions read-only commands only when the user opted in", () => {
|
||||
expect(buildSystemPrompt(BASE)).not.toMatch(/read-only diagnostic/);
|
||||
expect(buildSystemPrompt({ ...BASE, allowReadOnlyCommands: true })).toMatch(
|
||||
/read-only diagnostic/,
|
||||
);
|
||||
});
|
||||
|
||||
it("pluralises the host count", () => {
|
||||
expect(buildSystemPrompt({ ...BASE, hostCount: 1 })).toContain("1 host ");
|
||||
expect(buildSystemPrompt({ ...BASE, hostCount: 2 })).toContain("2 hosts");
|
||||
});
|
||||
|
||||
it("includes the active tab only when there is one", () => {
|
||||
expect(buildSystemPrompt(BASE)).not.toContain("currently looking at");
|
||||
expect(buildSystemPrompt({ ...BASE, activeTab: "terminal" })).toContain(
|
||||
"currently looking at: terminal",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PRIVATE_ALLOWLIST,
|
||||
evaluateEgress,
|
||||
isPrivateDestination,
|
||||
parseAllowlist,
|
||||
} from "../../src/backend/egress.js";
|
||||
|
||||
/**
|
||||
* The rule that lets a self-hosted Ollama work without turning the backend
|
||||
* into an authenticated probe of its own network: private destinations are
|
||||
* refused unless an admin named the host.
|
||||
*/
|
||||
describe("isPrivateDestination", () => {
|
||||
it("recognises loopback and private ranges", () => {
|
||||
for (const url of [
|
||||
"http://localhost:11434",
|
||||
"http://127.0.0.1:11434",
|
||||
"http://10.0.0.5:11434",
|
||||
"http://192.168.1.10:11434",
|
||||
"http://172.16.4.4:11434",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://[::1]:11434",
|
||||
]) {
|
||||
expect(isPrivateDestination(url), url).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats public hosts as public", () => {
|
||||
for (const url of [
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.anthropic.com",
|
||||
"https://generativelanguage.googleapis.com",
|
||||
"http://8.8.8.8",
|
||||
]) {
|
||||
expect(isPrivateDestination(url), url).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateEgress", () => {
|
||||
it("allows public destinations without an allowlist entry", () => {
|
||||
const decision = evaluateEgress("https://api.openai.com/v1", []);
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.isPrivate).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a private destination that is not allowlisted", () => {
|
||||
const decision = evaluateEgress("http://192.168.1.50:11434", ["localhost"]);
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.isPrivate).toBe(true);
|
||||
expect(decision.reason).toContain("allowlist");
|
||||
});
|
||||
|
||||
it("allows a private destination once its host is allowlisted", () => {
|
||||
const decision = evaluateEgress("http://localhost:11434", [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
]);
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["llm.internal", "host.docker.internal"])(
|
||||
"routes allowlisted private DNS name %s through the private path",
|
||||
(host) => {
|
||||
expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({
|
||||
allowed: true,
|
||||
isPrivate: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the allowlist case-insensitively", () => {
|
||||
expect(
|
||||
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let one allowlisted host authorise another", () => {
|
||||
// A cloud metadata endpoint is the classic target, so an allowlist for
|
||||
// localhost must not open 169.254.169.254.
|
||||
const decision = evaluateEgress("http://169.254.169.254/latest/meta-data", [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
]);
|
||||
expect(decision.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses non-http protocols and embedded credentials", () => {
|
||||
expect(evaluateEgress("file:///etc/passwd", []).allowed).toBe(false);
|
||||
expect(evaluateEgress("ftp://example.com", []).allowed).toBe(false);
|
||||
expect(evaluateEgress("https://user:pass@api.openai.com", []).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a malformed url", () => {
|
||||
expect(evaluateEgress("not a url", []).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAllowlist", () => {
|
||||
it("falls back to the defaults when unset or malformed", () => {
|
||||
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("normalises stored entries", () => {
|
||||
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([
|
||||
"localhost",
|
||||
"10.0.0.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("honours a deliberately empty allowlist", () => {
|
||||
// An admin clearing the list must actually block everything private,
|
||||
// not silently get the defaults back.
|
||||
expect(parseAllowlist("[]")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../src/backend/providers/types.js";
|
||||
|
||||
const streamChat = vi.fn();
|
||||
const handler = vi.fn();
|
||||
|
||||
vi.mock("../../src/backend/providers/registry.js", () => ({
|
||||
getAdapter: () => ({ streamChat, listModels: async () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/backend/tools/catalog.js", () => ({
|
||||
getTool: (name: string) =>
|
||||
name === "list_hosts"
|
||||
? {
|
||||
name: "list_hosts",
|
||||
description: "List hosts",
|
||||
category: "read",
|
||||
parameters: { type: "object", properties: {} },
|
||||
handler,
|
||||
}
|
||||
: undefined,
|
||||
toolDefinitions: () => [
|
||||
{ name: "list_hosts", description: "List hosts", parameters: {} },
|
||||
],
|
||||
}));
|
||||
|
||||
const { runAgent } = await import("../../src/backend/engine.js");
|
||||
|
||||
function chunks(...values: ChatChunk[]) {
|
||||
return (async function* () {
|
||||
for (const value of values) yield value;
|
||||
})();
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
config: { providerType: "ollama" as const },
|
||||
model: "test",
|
||||
system: "system",
|
||||
context: {
|
||||
userId: "user-1",
|
||||
conversationId: 1,
|
||||
allowReadOnlyCommands: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function collect(history: any[] = []) {
|
||||
const events: any[] = [];
|
||||
for await (const event of runAgent({ ...BASE, history })) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("runAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("streams text and finishes when no tools are called", async () => {
|
||||
streamChat.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "hello" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
expect(events.filter((e) => e.type === "token")).toHaveLength(1);
|
||||
expect(events.at(-1).type).toBe("done");
|
||||
});
|
||||
|
||||
it("runs a known tool and feeds the result back", async () => {
|
||||
handler.mockResolvedValue({ hosts: [{ id: 1, name: "web-1" }] });
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(events.some((e) => e.type === "tool_result")).toBe(true);
|
||||
expect(streamChat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refuses a tool that is not in the catalog", async () => {
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "read_credentials", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
// A model can emit any name it likes; only the catalog decides what runs.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const result = events.find((e) => e.type === "tool_result");
|
||||
expect(JSON.stringify(result.result)).toContain("Unknown tool");
|
||||
});
|
||||
|
||||
it("surfaces a handler failure without ending the run", async () => {
|
||||
handler.mockRejectedValue(new Error("database is down"));
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
const result = events.find((e) => e.type === "tool_result");
|
||||
expect(JSON.stringify(result.result)).toContain("database is down");
|
||||
expect(events.at(-1).type).toBe("done");
|
||||
});
|
||||
|
||||
it("closes the tool call when a tool returns a proposal", async () => {
|
||||
// Without a matching tool_result the call rendered as permanently
|
||||
// running, even though the work was done and awaiting the user.
|
||||
handler.mockResolvedValue({
|
||||
__proposal: true,
|
||||
kind: "propose_create_host",
|
||||
summary: "Add host web-1",
|
||||
payload: {},
|
||||
});
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
const callIndex = events.findIndex((e) => e.type === "tool_call");
|
||||
const resultIndex = events.findIndex((e) => e.type === "tool_result");
|
||||
const proposalIndex = events.findIndex((e) => e.type === "proposal");
|
||||
|
||||
expect(resultIndex).toBeGreaterThan(callIndex);
|
||||
expect(proposalIndex).toBeGreaterThan(resultIndex);
|
||||
expect(events[resultIndex]).toMatchObject({
|
||||
name: "list_hosts",
|
||||
result: { status: "awaiting_user_approval" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a provider failure as an error and stops", async () => {
|
||||
streamChat.mockImplementationOnce(() => {
|
||||
throw new Error("provider unreachable");
|
||||
});
|
||||
|
||||
const events = await collect();
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "error",
|
||||
message: "provider unreachable",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops after too many tool turns", async () => {
|
||||
handler.mockResolvedValue({ ok: true });
|
||||
streamChat.mockImplementation(() =>
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
// A model that never stops calling tools must not spin forever.
|
||||
expect(events.at(-1)).toMatchObject({ type: "error" });
|
||||
expect(streamChat.mock.calls.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const settingsRepository = { getBoolean: vi.fn() };
|
||||
const userPreferenceRepository = { findByUserId: vi.fn() };
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => settingsRepository,
|
||||
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
|
||||
}));
|
||||
|
||||
const { isAiGloballyEnabled, resolveAiAccess } =
|
||||
await import("../../src/backend/gating.js");
|
||||
|
||||
describe("AI gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults to off so upgrading an install enables nothing", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(false);
|
||||
await isAiGloballyEnabled();
|
||||
expect(settingsRepository.getBoolean).toHaveBeenCalledWith(
|
||||
"ai_globally_enabled",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks everyone when the admin global is off", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(false);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: true,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(false);
|
||||
expect(access.allowReadOnlyCommands).toBe(false);
|
||||
// The kill switch short-circuits, so the preference is never consulted.
|
||||
expect(userPreferenceRepository.findByUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a user who has not enabled it", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: false,
|
||||
});
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats never-asked as not enabled", async () => {
|
||||
// Null means the user was never shown the choice, which is not consent.
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: null,
|
||||
});
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing preference row as not enabled", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue(null);
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only when both gates are open", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: true,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(true);
|
||||
expect(access.allowReadOnlyCommands).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps read-only commands off unless separately opted in", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: null,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(true);
|
||||
expect(access.allowReadOnlyCommands).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWithProxy = vi.fn();
|
||||
const getFetchDispatcher = vi.fn();
|
||||
const safeOutboundFetch = vi.fn();
|
||||
const readPrivateAllowlist = vi.fn();
|
||||
const evaluateEgress = vi.fn();
|
||||
const globalFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../../../src/backend/utils/proxy-agent.js", () => ({
|
||||
fetchWithProxy,
|
||||
getFetchDispatcher,
|
||||
}));
|
||||
vi.mock("../../../../../src/backend/utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch,
|
||||
}));
|
||||
vi.mock("../../../src/backend/egress.js", () => ({
|
||||
readPrivateAllowlist,
|
||||
evaluateEgress,
|
||||
}));
|
||||
|
||||
const { providerFetch } =
|
||||
await import("../../../src/backend/providers/http.js");
|
||||
|
||||
describe("providerFetch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", globalFetch);
|
||||
readPrivateAllowlist.mockResolvedValue(["192.168.1.50"]);
|
||||
});
|
||||
|
||||
it("uses the matching Undici fetch implementation for private providers", async () => {
|
||||
const response = { ok: true, status: 200 } as Response;
|
||||
const init = { method: "GET" };
|
||||
evaluateEgress.mockReturnValue({ allowed: true, isPrivate: true });
|
||||
fetchWithProxy.mockResolvedValue(response);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://192.168.1.50:11434/api/tags", init),
|
||||
).resolves.toBe(response);
|
||||
|
||||
expect(fetchWithProxy).toHaveBeenCalledWith(
|
||||
"http://192.168.1.50:11434/api/tags",
|
||||
init,
|
||||
);
|
||||
expect(globalFetch).not.toHaveBeenCalled();
|
||||
expect(safeOutboundFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../../src/backend/providers/types.js";
|
||||
|
||||
const providerFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../src/backend/providers/http.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../src/backend/providers/http.js")
|
||||
>("../../../src/backend/providers/http.js");
|
||||
return { ...actual, providerFetch };
|
||||
});
|
||||
|
||||
const { openAiAdapter } =
|
||||
await import("../../../src/backend/providers/openai.js");
|
||||
const { ollamaAdapter } =
|
||||
await import("../../../src/backend/providers/ollama.js");
|
||||
const { geminiAdapter } =
|
||||
await import("../../../src/backend/providers/gemini.js");
|
||||
|
||||
/** Builds a Response whose body streams the given text chunks. */
|
||||
function streamingResponse(lines: string[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader() {
|
||||
let index = 0;
|
||||
return {
|
||||
async read() {
|
||||
if (index >= lines.length) return { done: true, value: undefined };
|
||||
return { done: false, value: encoder.encode(lines[index++]) };
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** One SSE frame, built from an object so the JSON stays readable. */
|
||||
function sseFrame(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n`;
|
||||
}
|
||||
|
||||
async function collect(iterable: AsyncIterable<ChatChunk>) {
|
||||
const chunks: ChatChunk[] = [];
|
||||
for await (const chunk of iterable) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const REQUEST = {
|
||||
model: "test-model",
|
||||
system: "system",
|
||||
messages: [{ role: "user" as const, content: "hi" }],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
describe("openAiAdapter", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("normalises streamed text", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n',
|
||||
'data: {"choices":[{"delta":{"content":"lo"}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toEqual([
|
||||
{ type: "text", text: "Hel" },
|
||||
{ type: "text", text: "lo" },
|
||||
]);
|
||||
expect(chunks.at(-1)?.type).toBe("done");
|
||||
});
|
||||
|
||||
it("reassembles tool arguments split across deltas", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{\\"a"}}]}}]}\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":1}"}}]}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
);
|
||||
|
||||
const call = chunks.find((c) => c.type === "tool_call");
|
||||
expect(call).toMatchObject({
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: { a: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("survives malformed tool arguments", async () => {
|
||||
// A model that emits broken JSON gets an empty object; the tool's own
|
||||
// validation then reports the problem back to it.
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{not json"}}]}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
);
|
||||
|
||||
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
|
||||
call: { arguments: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("needs a base url for an openai-compatible provider", async () => {
|
||||
await expect(
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ providerType: "openai_compatible" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("base URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ollamaAdapter", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("reads newline-delimited json rather than sse", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'{"message":{"content":"Hel"}}\n',
|
||||
'{"message":{"content":"lo"}}\n',
|
||||
'{"done":true,"done_reason":"stop"}\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toHaveLength(2);
|
||||
expect(chunks.at(-1)).toMatchObject({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
|
||||
it("accepts tool arguments as an object or a json string", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'{"message":{"tool_calls":[{"function":{"name":"list_hosts","arguments":{"a":1}}}]}}\n',
|
||||
'{"message":{"tool_calls":[{"function":{"name":"get_host","arguments":"{\\"hostId\\":2}"}}]}}\n',
|
||||
'{"done":true}\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
);
|
||||
|
||||
const calls = chunks.filter((c) => c.type === "tool_call") as any[];
|
||||
expect(calls[0].call.arguments).toEqual({ a: 1 });
|
||||
expect(calls[1].call.arguments).toEqual({ hostId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("geminiAdapter", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("carries thoughtSignature off a function call", async () => {
|
||||
// Gemini 2.5+ 400s a follow-up whose functionCall parts lost their
|
||||
// signature, which broke every conversation on the second turn.
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
sseFrame({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: { name: "list_hosts", args: {} },
|
||||
thoughtSignature: "sig-abc",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
|
||||
call: { name: "list_hosts", providerSignature: "sig-abc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("echoes the signature back on the next turn", async () => {
|
||||
providerFetch.mockResolvedValue(streamingResponse([]));
|
||||
|
||||
await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
{
|
||||
...REQUEST,
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "c1",
|
||||
name: "list_hosts",
|
||||
arguments: {},
|
||||
providerSignature: "sig-abc",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: "{}",
|
||||
toolCallId: "c1",
|
||||
toolName: "list_hosts",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const body = JSON.parse(providerFetch.mock.calls[0][1].body as string);
|
||||
const modelTurn = body.contents.find((c: any) => c.role === "model");
|
||||
expect(modelTurn.parts[0].thoughtSignature).toBe("sig-abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertOk error messages", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function errorResponse(status: number, body: string): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
text: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
it("pulls the message out of a nested error body", async () => {
|
||||
// Slicing the raw JSON used to cut the text off mid-sentence.
|
||||
providerFetch.mockResolvedValue(
|
||||
errorResponse(
|
||||
400,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 400,
|
||||
message: "Function call is missing a signature.",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow("Function call is missing a signature.");
|
||||
});
|
||||
|
||||
it("explains a rate limit instead of dumping the body", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
errorResponse(
|
||||
429,
|
||||
JSON.stringify({ error: { message: "You exceeded your quota." } }),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow(/rate limit reached/i);
|
||||
});
|
||||
|
||||
it("explains a rejected key", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
errorResponse(401, JSON.stringify({ error: { message: "Bad key" } })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow(/rejected the API key/i);
|
||||
});
|
||||
|
||||
it("falls back to a trimmed snippet for a non-JSON body", async () => {
|
||||
providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded"));
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow("upstream exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { REDACTED, redact, redactString } from "../../src/backend/redaction.js";
|
||||
|
||||
describe("redact", () => {
|
||||
it("drops secret-named fields at any depth", () => {
|
||||
const input = {
|
||||
name: "web-1",
|
||||
password: "hunter2",
|
||||
nested: { privateKey: "abc", apiKey: "def", port: 22 },
|
||||
list: [{ keyPassword: "xyz", label: "ok" }],
|
||||
};
|
||||
|
||||
const output = redact(input) as any;
|
||||
|
||||
expect(output.name).toBe("web-1");
|
||||
expect(output.password).toBe(REDACTED);
|
||||
expect(output.nested.privateKey).toBe(REDACTED);
|
||||
expect(output.nested.apiKey).toBe(REDACTED);
|
||||
expect(output.nested.port).toBe(22);
|
||||
expect(output.list[0].keyPassword).toBe(REDACTED);
|
||||
expect(output.list[0].label).toBe("ok");
|
||||
});
|
||||
|
||||
it("keeps a null secret null so absence stays distinguishable", () => {
|
||||
const output = redact({ password: null }) as any;
|
||||
expect(output.password).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves ordinary values untouched", () => {
|
||||
const input = { id: 4, enabled: true, tags: ["a", "b"], note: null };
|
||||
expect(redact(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("does not recurse forever on a cyclic object", () => {
|
||||
const cyclic: Record<string, unknown> = { name: "loop" };
|
||||
cyclic.self = cyclic;
|
||||
expect(() => redact(cyclic)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactString", () => {
|
||||
it("masks private key blocks", () => {
|
||||
const text =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----";
|
||||
expect(redactString(text)).toBe("[redacted private key]");
|
||||
});
|
||||
|
||||
it("masks provider api keys", () => {
|
||||
expect(redactString("key is sk-abcdefghijklmnopqrst here")).toContain(
|
||||
"[redacted api key]",
|
||||
);
|
||||
expect(redactString("key is sk-ant-abcdefghijklmnopqrst here")).toContain(
|
||||
"[redacted api key]",
|
||||
);
|
||||
});
|
||||
|
||||
it("masks bearer tokens and jwts", () => {
|
||||
expect(
|
||||
redactString("Authorization: Bearer abcdefghijklmnopqrst"),
|
||||
).toContain("Bearer [redacted]");
|
||||
expect(
|
||||
redactString("token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefgh"),
|
||||
).toContain("[redacted token]");
|
||||
});
|
||||
|
||||
it("masks Termix api keys", () => {
|
||||
expect(redactString("tmx_abcdefghijklmnopqrstuvwx")).toContain(
|
||||
"[redacted token]",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves ordinary prose alone", () => {
|
||||
const text = "The disk on web-1 is 82 percent full.";
|
||||
expect(redactString(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isReadOnlyCommand } from "../../../src/backend/tools/command-allowlist.js";
|
||||
|
||||
/**
|
||||
* These commands can run without a per-command approval click, so the parser
|
||||
* has to refuse anything that could become a second command. Substring
|
||||
* matching would pass "df; rm -rf /", which is the whole reason this is
|
||||
* argv-based.
|
||||
*/
|
||||
describe("isReadOnlyCommand", () => {
|
||||
it("allows plain diagnostics", () => {
|
||||
for (const command of [
|
||||
"df -h",
|
||||
"uptime",
|
||||
"free -m",
|
||||
"whoami",
|
||||
"ps aux",
|
||||
"lsblk",
|
||||
"uname -a",
|
||||
"/usr/bin/df -h",
|
||||
]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects command chaining and redirection", () => {
|
||||
for (const command of [
|
||||
"df; rm -rf /",
|
||||
"df && curl evil.example",
|
||||
"df || reboot",
|
||||
"df | sh",
|
||||
"df > /etc/passwd",
|
||||
"df >> /etc/passwd",
|
||||
"cat < /etc/shadow",
|
||||
"echo `whoami`",
|
||||
"echo $(id)",
|
||||
"df\nrm -rf /",
|
||||
"df \\\n rm",
|
||||
]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects privilege escalation", () => {
|
||||
for (const command of ["sudo df -h", "su root", "doas df", "env df"]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects commands that are not on the list", () => {
|
||||
for (const command of ["rm -rf /", "curl evil.example", "vi /etc/passwd"]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("limits systemctl and docker to read-only subcommands", () => {
|
||||
expect(isReadOnlyCommand("systemctl status nginx").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("systemctl restart nginx").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("systemctl stop nginx").allowed).toBe(false);
|
||||
|
||||
expect(isReadOnlyCommand("docker ps").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("docker stats --no-stream").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("docker rm -f web").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("docker exec -it web sh").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("limits cat to safe paths", () => {
|
||||
expect(isReadOnlyCommand("cat /proc/meminfo").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("cat /etc/os-release").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("cat /etc/shadow").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("cat ~/.ssh/id_rsa").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("cat").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty command", () => {
|
||||
expect(isReadOnlyCommand("").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand(" ").allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hostRepository = {
|
||||
create: vi.fn(),
|
||||
findByIdForUser: vi.fn(),
|
||||
updateForUser: vi.fn(),
|
||||
deleteForUser: vi.fn(),
|
||||
};
|
||||
const snippetRepository = {
|
||||
createSnippet: vi.fn(),
|
||||
findOwnedById: vi.fn(),
|
||||
updateSnippet: vi.fn(),
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
||||
const automationRepository = { create: vi.fn() };
|
||||
|
||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock(
|
||||
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
|
||||
() => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}),
|
||||
);
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-client-factory.js", () => ({
|
||||
createFleetSshFactory: () => () => ({}),
|
||||
getFleetPoolKey: () => "pool",
|
||||
}));
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
run: (client: unknown) => Promise<unknown>,
|
||||
) => run({}),
|
||||
}));
|
||||
|
||||
const { applyProposal } =
|
||||
await import("../../../src/backend/tools/executor.js");
|
||||
|
||||
describe("applyProposal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("refuses a kind that is not a real tool", async () => {
|
||||
// The stored payload is treated as untrusted even though the server wrote
|
||||
// it, because a proposal can outlive the release that created it.
|
||||
await expect(
|
||||
applyProposal("propose_delete_everything", {}, "user-1"),
|
||||
).rejects.toThrow("Unknown proposal kind");
|
||||
});
|
||||
|
||||
it("validates an automation definition before creating it", async () => {
|
||||
// Reuses the automations route's own validator, so a definition the model
|
||||
// invented is held to the same standard as a hand-written one.
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_create_automation",
|
||||
{
|
||||
name: "bad",
|
||||
definition: { trigger: { kind: "nonsense" }, steps: [] },
|
||||
},
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(automationRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
|
||||
automationRepository.create.mockResolvedValue({ id: 5, name: "nightly" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_automation",
|
||||
{
|
||||
name: "nightly",
|
||||
definition: {
|
||||
trigger: { kind: "schedule", intervalSeconds: 3600 },
|
||||
steps: [{ id: "s1", type: "wait", seconds: 1 }],
|
||||
},
|
||||
},
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(automationRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an approved command only on a host the user can reach", async () => {
|
||||
// resolveHostById returns null when the connect-level permission check
|
||||
// fails, so an unreachable host never gets as far as an SSH attempt.
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("Host not found");
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns command output on success", async () => {
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.summary).toContain("up 3 days");
|
||||
});
|
||||
|
||||
it("resolves the host rather than using a raw repository row", async () => {
|
||||
// A raw row has no decrypted auth and an unresolved jumpHosts field, which
|
||||
// made the SSH factory fail with a jump host error on hosts that have none.
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "ok", stderr: "", code: 0 });
|
||||
|
||||
await applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(resolveHostById).toHaveBeenCalledWith(9, "user-1");
|
||||
expect(hostRepository.findByIdForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a non-zero exit rather than reporting success", async () => {
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "", stderr: "denied", code: 1 });
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "cat /etc/shadow" },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("code 1");
|
||||
});
|
||||
|
||||
it("creates a host through the normal repository", async () => {
|
||||
hostRepository.create.mockResolvedValue({ id: 7, name: "web-1" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_host",
|
||||
{ name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod"] },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(hostRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a host payload missing required fields", async () => {
|
||||
await expect(
|
||||
applyProposal("propose_create_host", { name: "web-1" }, "user-1"),
|
||||
).rejects.toThrow("ip is required");
|
||||
expect(hostRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes an update to the approving user", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||
hostRepository.updateForUser.mockResolvedValue({ id: 7 });
|
||||
|
||||
await applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 7, changes: { name: "renamed" } },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(hostRepository.findByIdForUser).toHaveBeenCalledWith("user-1", 7);
|
||||
expect(hostRepository.updateForUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
7,
|
||||
expect.objectContaining({ name: "renamed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to update a host the user does not own", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 999, changes: { name: "x" } },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("Host not found");
|
||||
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-numeric id rather than coercing it", async () => {
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: "7; DROP TABLE hosts", changes: { name: "x" } },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("hostId must be a positive integer");
|
||||
});
|
||||
|
||||
it("only adds fleet members the approving user owns", async () => {
|
||||
fleetRepository.create.mockResolvedValue({ id: 3, name: "prod" });
|
||||
hostRepository.findByIdForUser.mockImplementation(
|
||||
async (_userId: string, hostId: number) =>
|
||||
hostId === 1 ? { id: 1 } : null,
|
||||
);
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_fleet",
|
||||
{ name: "prod", hostIds: [1, 2] },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(fleetRepository.addMember).toHaveBeenCalledTimes(1);
|
||||
expect(fleetRepository.addMember).toHaveBeenCalledWith(3, 1);
|
||||
expect(result.summary).toContain("1 host");
|
||||
});
|
||||
|
||||
it("reports nothing to change on an empty update", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 7, changes: {} },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AI_TOOLS,
|
||||
FORBIDDEN_DOMAINS,
|
||||
getTool,
|
||||
listToolNames,
|
||||
toolDefinitions,
|
||||
} from "../../../src/backend/tools/catalog.js";
|
||||
|
||||
/**
|
||||
* The security regression test for the whole feature.
|
||||
*
|
||||
* Tools run in-process and never pass through the RBAC-gated routers, so "the
|
||||
* assistant cannot reach credentials or user administration" is a property of
|
||||
* this catalog, and nothing else. If a future change adds a tool that touches a
|
||||
* forbidden domain, this test is what catches it.
|
||||
*/
|
||||
describe("AI tool catalog", () => {
|
||||
it("exposes no tool naming a forbidden domain", () => {
|
||||
for (const tool of AI_TOOLS) {
|
||||
for (const domain of FORBIDDEN_DOMAINS) {
|
||||
expect(
|
||||
tool.name.includes(domain),
|
||||
`${tool.name} references the forbidden domain "${domain}"`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no tool that could read a credential", () => {
|
||||
const banned = [
|
||||
"get_credential",
|
||||
"list_credentials",
|
||||
"get_password",
|
||||
"get_private_key",
|
||||
"get_api_key",
|
||||
"create_user",
|
||||
"delete_user",
|
||||
"grant_permission",
|
||||
"update_settings",
|
||||
];
|
||||
for (const name of banned) {
|
||||
expect(getTool(name), `${name} must not exist`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("only allows read or propose categories", () => {
|
||||
for (const tool of AI_TOOLS) {
|
||||
expect(["read", "propose"]).toContain(tool.category);
|
||||
}
|
||||
});
|
||||
|
||||
it("names every tool by its category", () => {
|
||||
// A propose tool that does not say "propose" would read as a direct action
|
||||
// in the transcript, which is exactly the confusion this feature avoids.
|
||||
for (const tool of AI_TOOLS) {
|
||||
if (tool.category === "propose") {
|
||||
expect(
|
||||
tool.name.startsWith("propose_"),
|
||||
`${tool.name} is a propose tool but is not named propose_*`,
|
||||
).toBe(true);
|
||||
} else {
|
||||
expect(
|
||||
tool.name.startsWith("propose_"),
|
||||
`${tool.name} is a read tool but is named propose_*`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has unique tool names", () => {
|
||||
const names = listToolNames();
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it("gives every tool a described object schema", () => {
|
||||
for (const definition of toolDefinitions()) {
|
||||
expect(definition.description.length, definition.name).toBeGreaterThan(
|
||||
20,
|
||||
);
|
||||
expect(definition.parameters.type, definition.name).toBe("object");
|
||||
// additionalProperties:false keeps a model from smuggling extra fields
|
||||
// past the handler's explicit reads.
|
||||
expect(definition.parameters.additionalProperties, definition.name).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("never takes a userId from the model", () => {
|
||||
// Ownership is always derived from the verified JWT. A userId parameter
|
||||
// would let the model ask for another account's data.
|
||||
for (const tool of AI_TOOLS) {
|
||||
const properties = (tool.parameters.properties ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const key of Object.keys(properties)) {
|
||||
expect(
|
||||
/^user_?id$/i.test(key),
|
||||
`${tool.name} accepts a model-supplied ${key}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("still offers the read tools the assistant needs to be useful", () => {
|
||||
for (const name of ["list_hosts", "list_snippets", "list_automations"]) {
|
||||
expect(getTool(name), name).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { AiProvider } from "@/api/ai-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
createAiProvider: vi.fn(),
|
||||
deleteAiProvider: vi.fn(),
|
||||
getAiProviderModels: vi.fn(),
|
||||
probeAiModels: vi.fn(),
|
||||
updateAiProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/ai-api", () => api);
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { AiProviderSettings } from "@/features/ai/AiProviderSettings";
|
||||
|
||||
const provider: AiProvider = {
|
||||
id: 7,
|
||||
providerType: "ollama",
|
||||
label: "Local Ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
apiKeyPrefix: null,
|
||||
defaultModel: "llama3.1",
|
||||
enabled: true,
|
||||
createdAt: "2026-08-21T00:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
api.getAiProviderModels.mockReset();
|
||||
api.getAiProviderModels.mockResolvedValue([]);
|
||||
api.updateAiProvider.mockReset();
|
||||
api.updateAiProvider.mockResolvedValue(provider);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AiProviderSettings", () => {
|
||||
it("edits an existing provider name and configured model", async () => {
|
||||
const onChanged = vi.fn();
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={onChanged} />);
|
||||
|
||||
expect(screen.getByText(/llama3\.1/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
const label = screen.getByLabelText("ai.providerLabel");
|
||||
const model = screen.getByLabelText("ai.defaultModel");
|
||||
fireEvent.change(label, { target: { value: "Production Ollama" } });
|
||||
fireEvent.change(model, { target: { value: "qwen3:32b" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateAiProvider).toHaveBeenCalledWith(7, {
|
||||
label: "Production Ollama",
|
||||
defaultModel: "qwen3:32b",
|
||||
});
|
||||
});
|
||||
expect(onChanged).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("loads the saved provider model list when editing starts", async () => {
|
||||
api.getAiProviderModels.mockResolvedValue(["llama3.1", "qwen3:32b"]);
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.getAiProviderModels).toHaveBeenCalledWith(7);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "ai.defaultModel" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the provider error when refreshing models fails", async () => {
|
||||
api.getAiProviderModels.mockRejectedValue(
|
||||
new Error("Add llm.internal to the AI endpoint allowlist"),
|
||||
);
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fieldLabel,
|
||||
humanize,
|
||||
mentionLabel,
|
||||
toolLabel,
|
||||
} from "@/features/ai/labels";
|
||||
import { AI_TOOLS } from "../../src/backend/tools/catalog.js";
|
||||
|
||||
describe("humanize", () => {
|
||||
it("splits camelCase into words", () => {
|
||||
// The case that shipped as "thresholddurationseconds".
|
||||
expect(humanize("thresholdDurationSeconds")).toBe(
|
||||
"Threshold duration seconds",
|
||||
);
|
||||
});
|
||||
|
||||
it("splits snake_case into words", () => {
|
||||
expect(humanize("propose_create_automation")).toBe(
|
||||
"Propose create automation",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a single word alone apart from casing", () => {
|
||||
expect(humanize("port")).toBe("Port");
|
||||
});
|
||||
|
||||
it("returns the input when there is nothing to split", () => {
|
||||
expect(humanize("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldLabel", () => {
|
||||
it("uses the mapped name where there is one", () => {
|
||||
expect(fieldLabel("thresholdDurationSeconds")).toBe("Duration (seconds)");
|
||||
expect(fieldLabel("cooldownMinutes")).toBe("Cooldown (minutes)");
|
||||
expect(fieldLabel("ip")).toBe("Address");
|
||||
});
|
||||
|
||||
it("falls back to humanizing an unmapped key", () => {
|
||||
expect(fieldLabel("someFutureField")).toBe("Some future field");
|
||||
});
|
||||
|
||||
it("never returns a run-on identifier", () => {
|
||||
for (const key of ["thresholdDurationSeconds", "hostIds", "triggerType"]) {
|
||||
expect(fieldLabel(key)).not.toBe(key.toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolLabel", () => {
|
||||
it("phrases tools as actions rather than function names", () => {
|
||||
expect(toolLabel("list_hosts")).toBe("Reading hosts");
|
||||
expect(toolLabel("propose_create_fleet")).toBe("Create fleet");
|
||||
});
|
||||
|
||||
it("covers every tool in the catalog", () => {
|
||||
// A new tool without a label would surface its raw snake_case name.
|
||||
for (const tool of AI_TOOLS) {
|
||||
const label = toolLabel(tool.name);
|
||||
expect(label, tool.name).not.toContain("_");
|
||||
expect(label, tool.name).not.toBe(tool.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mentionLabel", () => {
|
||||
it("capitalises the mention kinds", () => {
|
||||
expect(mentionLabel("host")).toBe("Host");
|
||||
expect(mentionLabel("snippet")).toBe("Snippet");
|
||||
expect(mentionLabel("automation")).toBe("Automation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -25,8 +25,6 @@
|
||||
"network:outbound",
|
||||
"ui:surface"
|
||||
],
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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 =
|
||||
+9
-9
@@ -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
-1
@@ -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. */
|
||||
+4
-1
@@ -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.
+8
-8
@@ -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
-1
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
+4
-4
@@ -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";
|
||||
File renamed without changes.
+8
-5
@@ -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.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -37,8 +37,6 @@
|
||||
"editorTab": "docker"
|
||||
}
|
||||
},
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -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;
|
||||
+4
-4
@@ -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,
|
||||
File renamed without changes.
@@ -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,
|
||||
+3
-3
@@ -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;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user