mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a small /ai dispatcher instead of its own port. Moves the ai-enabled and ai-private-endpoints settings routes out of user-settings-routes.ts into the plugin too, so it has no leftover core dependents.
This commit is contained in:
1 parent
c58ccbfc3f
commit
504710b065
43 files changed
+568
-262
No files matched your search
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The system prompt.
|
||||
*
|
||||
* Deliberately small: the assistant starts with almost no context and must call
|
||||
* a read tool to learn anything. That keeps token cost predictable, makes every
|
||||
* data access visible in the transcript, and means nothing is sent to a
|
||||
* third-party provider that the user did not implicitly ask for.
|
||||
*/
|
||||
export function buildSystemPrompt(options: {
|
||||
hostCount: number;
|
||||
activeTab?: string | null;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [
|
||||
"You are the assistant built into Termix, a self-hosted server management app.",
|
||||
"You help the user manage their servers, snippets, automations, fleets and alerts.",
|
||||
"",
|
||||
"How you work:",
|
||||
"- You start with no knowledge of this user's setup. Call a read tool to find out anything you need.",
|
||||
"- You cannot change anything directly. To make a change, call a propose_* tool; the user sees a card and approves or rejects it.",
|
||||
"- You have no access to passwords, SSH keys, API keys or any other credential, and you never ask the user to paste one into the chat.",
|
||||
"",
|
||||
"Scope:",
|
||||
"- Do exactly what was asked, and nothing beyond it. A question is a request for an answer, not for changes.",
|
||||
"- Questions like 'what is running on this server', 'check this host' or 'why is this slow' are answered with information. Read, then report. Do not propose anything.",
|
||||
"- Only propose a change when the user asked for one, in words like 'add', 'create', 'set up', 'fix' or 'change'.",
|
||||
"- Do not propose follow-up work you thought of yourself: no monitoring, no alert rules, no scripts, no snippets, no cleanup, unless that is what was requested.",
|
||||
"- If you think something is worth doing, say so in one sentence and stop. Let the user ask.",
|
||||
"- One request means one proposal at most. Do not bundle extras alongside it.",
|
||||
"",
|
||||
"How to answer:",
|
||||
"- Lead with the answer. Keep responses short and concrete.",
|
||||
"- When you propose something, say in one line what it does and why.",
|
||||
"- If a request is ambiguous in a way that changes what you would propose, ask before proposing.",
|
||||
"- Never claim you have done something. You propose; the user applies.",
|
||||
];
|
||||
|
||||
if (options.allowReadOnlyCommands) {
|
||||
lines.push(
|
||||
"- The user has allowed you to run read-only diagnostic commands directly. Anything that changes state still has to be proposed.",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Current context:",
|
||||
`- The user has ${options.hostCount} host${options.hostCount === 1 ? "" : "s"} configured.`,
|
||||
);
|
||||
|
||||
if (options.activeTab) {
|
||||
lines.push(`- They are currently looking at: ${options.activeTab}.`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { isIP } from "net";
|
||||
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.
|
||||
*
|
||||
* Cloud providers are reached through the SSRF-guarded path, which refuses to
|
||||
* resolve to a private address. That guard is exactly what a self-hosted Ollama
|
||||
* on localhost trips over, so private destinations are permitted only when an
|
||||
* admin has named the host. Without that split, any logged-in user could point
|
||||
* a "provider" at an internal service and use the backend as an authenticated
|
||||
* probe of the server's own network.
|
||||
*/
|
||||
|
||||
export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist";
|
||||
|
||||
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
|
||||
export const DEFAULT_PRIVATE_ALLOWLIST = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"host.docker.internal",
|
||||
];
|
||||
|
||||
export function parseAllowlist(raw: string | null): string[] {
|
||||
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
return parsed
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
AI_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseAllowlist(raw);
|
||||
}
|
||||
|
||||
function normalizeHost(hostname: string): string {
|
||||
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the URL names a destination the SSRF guard would refuse. A bare
|
||||
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
||||
* private only if it is "localhost" -- anything else needs DNS resolution.
|
||||
*/
|
||||
export function isPrivateDestination(rawUrl: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = normalizeHost(url.hostname);
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
if (isIP(host)) return isBlockedAddress(host);
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface EgressDecision {
|
||||
allowed: boolean;
|
||||
/** True when the destination needs the allowlisted-private path. */
|
||||
isPrivate: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function evaluateEgress(
|
||||
rawUrl: string,
|
||||
allowlist: string[],
|
||||
): EgressDecision {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, reason: "Invalid URL" };
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return { allowed: false, isPrivate: false, reason: "Unsupported protocol" };
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: false,
|
||||
reason: "Credentials in URL are not allowed",
|
||||
};
|
||||
}
|
||||
|
||||
const host = normalizeHost(url.hostname);
|
||||
const isPrivate = isPrivateDestination(rawUrl);
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
|
||||
// An explicitly allowlisted hostname may resolve to a private address. It
|
||||
// must use the private fetch path; sending it through safeOutboundFetch
|
||||
// would reject it after DNS resolution and make hostname allowlist entries
|
||||
// ineffective. Only administrators can write this list.
|
||||
if (normalized.includes(host)) {
|
||||
return { allowed: true, isPrivate: true };
|
||||
}
|
||||
|
||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
reason:
|
||||
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getAdapter } from "./providers/registry.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ProviderConfig,
|
||||
ToolCall,
|
||||
} from "./providers/types.js";
|
||||
import { redact, redactToJson } from "./redaction.js";
|
||||
import { getTool, toolDefinitions } from "./tools/catalog.js";
|
||||
import {
|
||||
isProposalDraft,
|
||||
type ProposalDraft,
|
||||
type ToolContext,
|
||||
} from "./tools/types.js";
|
||||
|
||||
/**
|
||||
* The agent loop: stream a turn, run any tools the model asked for, feed the
|
||||
* results back, repeat. Bounded so a model that keeps calling tools cannot spin
|
||||
* forever.
|
||||
*/
|
||||
|
||||
const MAX_TURNS = 8;
|
||||
|
||||
export type EngineEvent =
|
||||
| { type: "token"; text: string }
|
||||
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
|
||||
| { type: "tool_result"; name: string; result: unknown }
|
||||
| { type: "proposal"; draft: ProposalDraft }
|
||||
| { type: "assistant_message"; content: string; toolCalls: ToolCall[] }
|
||||
| { type: "done" }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export interface EngineOptions {
|
||||
config: ProviderConfig;
|
||||
model: string;
|
||||
system: string;
|
||||
history: ChatMessage[];
|
||||
context: ToolContext;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function* runAgent(
|
||||
options: EngineOptions,
|
||||
): AsyncGenerator<EngineEvent> {
|
||||
const adapter = getAdapter(options.config.providerType);
|
||||
const tools = toolDefinitions();
|
||||
const messages: ChatMessage[] = [...options.history];
|
||||
|
||||
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
|
||||
let text = "";
|
||||
const calls: ToolCall[] = [];
|
||||
let failed = false;
|
||||
|
||||
try {
|
||||
for await (const chunk of adapter.streamChat(options.config, {
|
||||
model: options.model,
|
||||
system: options.system,
|
||||
messages,
|
||||
tools,
|
||||
signal: options.signal,
|
||||
})) {
|
||||
if (chunk.type === "text") {
|
||||
text += chunk.text;
|
||||
yield { type: "token", text: chunk.text };
|
||||
} else if (chunk.type === "tool_call") {
|
||||
calls.push(chunk.call);
|
||||
} else if (chunk.type === "error") {
|
||||
failed = true;
|
||||
yield { type: "error", message: chunk.message };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "The provider request failed");
|
||||
yield { type: "error", message };
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed) return;
|
||||
|
||||
yield { type: "assistant_message", content: text, toolCalls: calls };
|
||||
|
||||
if (!calls.length) {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push({ role: "assistant", content: text, toolCalls: calls });
|
||||
|
||||
for (const call of calls) {
|
||||
yield { type: "tool_call", name: call.name, arguments: call.arguments };
|
||||
|
||||
const result = await runTool(call, options.context);
|
||||
|
||||
if (isProposalDraft(result)) {
|
||||
// Closes the tool call before the proposal card is emitted. Without
|
||||
// this the call has no matching result and renders as permanently
|
||||
// running, even though the work is done and awaiting the user.
|
||||
yield {
|
||||
type: "tool_result",
|
||||
name: call.name,
|
||||
result: { status: "awaiting_user_approval" },
|
||||
};
|
||||
yield { type: "proposal", draft: result };
|
||||
// The model is told the proposal is awaiting the user rather than done,
|
||||
// so it does not go on to describe the change as applied.
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
status: "awaiting_user_approval",
|
||||
summary: result.summary,
|
||||
}),
|
||||
toolCallId: call.id,
|
||||
toolName: call.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
yield { type: "tool_result", name: call.name, result: redact(result) };
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: redactToJson(result),
|
||||
toolCallId: call.id,
|
||||
toolName: call.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ran out of turns with the model still calling tools.
|
||||
yield {
|
||||
type: "error",
|
||||
message: "The assistant used too many steps without finishing.",
|
||||
};
|
||||
}
|
||||
|
||||
async function runTool(call: ToolCall, context: ToolContext): Promise<unknown> {
|
||||
const tool = getTool(call.name);
|
||||
|
||||
// A model can emit any name it likes; only the catalog decides what runs.
|
||||
if (!tool) {
|
||||
return { error: `Unknown tool: ${call.name}` };
|
||||
}
|
||||
|
||||
try {
|
||||
return await tool.handler(call.arguments ?? {}, context);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: getErrorMessage(error, "The tool failed"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { NextFunction, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Three gates, all checked on the server.
|
||||
*
|
||||
* The admin global is a hard kill switch: when it is off the feature does not
|
||||
* exist for anyone, regardless of what any user has enabled. It defaults to
|
||||
* false so upgrading an existing install turns nothing on by surprise.
|
||||
*
|
||||
* Mirrors the shape of isSharingEnabledForHost in the session-sharing routes,
|
||||
* where the global also wins over the per-entity setting.
|
||||
*/
|
||||
|
||||
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
|
||||
|
||||
export async function isAiGloballyEnabled(): Promise<boolean> {
|
||||
return createCurrentSettingsRepository().getBoolean(
|
||||
AI_GLOBAL_ENABLED_KEY,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
export interface AiAccess {
|
||||
enabled: boolean;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
|
||||
const globalEnabled = await isAiGloballyEnabled();
|
||||
if (!globalEnabled) {
|
||||
return { enabled: false, allowReadOnlyCommands: false };
|
||||
}
|
||||
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
|
||||
return {
|
||||
// Null means the user was never asked, which is not consent.
|
||||
enabled: preferences?.aiAssistantEnabled === true,
|
||||
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rejects any AI request unless both gates are open. */
|
||||
export function createAiGate() {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
if (!req.userId) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await resolveAiAccess(req.userId);
|
||||
if (!access.enabled) {
|
||||
res.status(403).json({ error: "The AI assistant is not enabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access;
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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, alert rules 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/alerts/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();
|
||||
|
||||
ctx.log.info("AI assistant router registered at /ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.stopAiService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { providerFetch } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
} from "./types.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Model ids offered in the picker. Users can type any other id; this is a
|
||||
* convenience list, not a restriction.
|
||||
*/
|
||||
export const ANTHROPIC_MODELS = [
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-haiku-4-5",
|
||||
];
|
||||
|
||||
function createClient(config: ProviderConfig): Anthropic {
|
||||
if (!config.apiKey) {
|
||||
throw new AiProviderError("This provider needs an API key");
|
||||
}
|
||||
return new Anthropic({
|
||||
apiKey: config.apiKey,
|
||||
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
|
||||
// Routes the SDK's HTTP through the shared egress guard.
|
||||
fetch: providerFetch as unknown as typeof fetch,
|
||||
});
|
||||
}
|
||||
|
||||
function toAnthropicMessages(request: ChatRequest): Anthropic.MessageParam[] {
|
||||
const messages: Anthropic.MessageParam[] = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: message.toolCallId ?? "",
|
||||
content: message.content,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
const content: Anthropic.ContentBlockParam[] = [];
|
||||
if (message.content)
|
||||
content.push({ type: "text", text: message.content });
|
||||
for (const call of message.toolCalls) {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input: call.arguments,
|
||||
});
|
||||
}
|
||||
messages.push({ role: "assistant", content });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "system") continue;
|
||||
messages.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK throws its own typed errors rather than going through assertOk, so
|
||||
* they are translated here to match what every other provider reports.
|
||||
*/
|
||||
function translateSdkError(error: unknown): never {
|
||||
const status =
|
||||
typeof (error as { status?: unknown })?.status === "number"
|
||||
? (error as { status: number }).status
|
||||
: undefined;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (status === 429) {
|
||||
throw new AiProviderError(
|
||||
`Anthropic rate limit reached. Wait a moment and try again, or check your plan's quota. (${detail})`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
throw new AiProviderError(
|
||||
`Anthropic rejected the API key. Check that it is correct and still active. (${detail})`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
throw new AiProviderError(
|
||||
status ? `Anthropic request failed (${status}): ${detail}` : detail,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
export const anthropicAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const client = createClient(config);
|
||||
|
||||
const stream = client.messages.stream({
|
||||
model: request.model,
|
||||
max_tokens: 16000,
|
||||
system: request.system,
|
||||
messages: toAnthropicMessages(request),
|
||||
thinking: { type: "adaptive" },
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.parameters as Anthropic.Tool.InputSchema,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(request.signal ? { signal: request.signal } : {}),
|
||||
});
|
||||
|
||||
let final: Anthropic.Message;
|
||||
try {
|
||||
for await (const event of stream) {
|
||||
if (
|
||||
event.type === "content_block_delta" &&
|
||||
event.delta.type === "text_delta"
|
||||
) {
|
||||
yield { type: "text", text: event.delta.text };
|
||||
}
|
||||
}
|
||||
final = await stream.finalMessage();
|
||||
} catch (error) {
|
||||
translateSdkError(error);
|
||||
}
|
||||
|
||||
for (const block of final.content) {
|
||||
if (block.type === "tool_use") {
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
arguments: (block.input ?? {}) as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason: final.stop_reason ?? undefined };
|
||||
},
|
||||
|
||||
async listModels(): Promise<string[]> {
|
||||
return [...ANTHROPIC_MODELS];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { assertOk, providerFetch, readSseLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
} from "./types.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
const GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
function baseFor(config: ProviderConfig): string {
|
||||
return config.baseUrl?.trim() || GEMINI_DEFAULT_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini has no tool role: a tool result is a user-side functionResponse part,
|
||||
* and an assistant tool request is a model-side functionCall part.
|
||||
*/
|
||||
function toGeminiContents(request: ChatRequest): unknown[] {
|
||||
const contents: unknown[] = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: message.toolName ?? "tool",
|
||||
response: { result: message.content },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
const parts: unknown[] = [];
|
||||
if (message.content) parts.push({ text: message.content });
|
||||
for (const call of message.toolCalls) {
|
||||
// thoughtSignature must be returned exactly as received or Gemini
|
||||
// 2.5+ rejects the turn with a 400.
|
||||
parts.push({
|
||||
functionCall: { name: call.name, args: call.arguments },
|
||||
...(call.providerSignature
|
||||
? { thoughtSignature: call.providerSignature }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
contents.push({ role: "model", parts });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "system") continue;
|
||||
|
||||
contents.push({
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: message.content }],
|
||||
});
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini rejects the JSON Schema keywords it does not implement, so the tool
|
||||
* schemas are trimmed to the subset it accepts.
|
||||
*/
|
||||
function toGeminiSchema(schema: unknown): unknown {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
if (Array.isArray(schema)) return schema.map(toGeminiSchema);
|
||||
|
||||
const source = schema as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key === "additionalProperties" || key === "$schema") continue;
|
||||
if (key === "properties" && value && typeof value === "object") {
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [name, child] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
properties[name] = toGeminiSchema(child);
|
||||
}
|
||||
output[key] = properties;
|
||||
continue;
|
||||
}
|
||||
output[key] = toGeminiSchema(value);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export const geminiAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
if (!config.apiKey) {
|
||||
throw new AiProviderError("This provider needs an API key");
|
||||
}
|
||||
|
||||
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
|
||||
|
||||
const response = await providerFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": config.apiKey,
|
||||
},
|
||||
signal: request.signal,
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: request.system }] },
|
||||
contents: toGeminiContents(request),
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: request.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: toGeminiSchema(tool.parameters),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
await assertOk(response, "Gemini");
|
||||
|
||||
let index = 0;
|
||||
let stopReason: string | undefined;
|
||||
|
||||
for await (const data of readSseLines(response)) {
|
||||
let frame: any;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = frame.candidates?.[0];
|
||||
if (!candidate) continue;
|
||||
if (candidate.finishReason) stopReason = candidate.finishReason;
|
||||
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (typeof part.text === "string" && part.text) {
|
||||
yield { type: "text", text: part.text };
|
||||
}
|
||||
if (part.functionCall?.name) {
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: {
|
||||
id: `call_${part.functionCall.name}_${index++}`,
|
||||
name: part.functionCall.name,
|
||||
arguments: (part.functionCall.args ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
// Carried so the next turn can echo it back; without it Gemini
|
||||
// 400s as soon as a tool has been used once.
|
||||
providerSignature: part.thoughtSignature,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason };
|
||||
},
|
||||
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
if (!config.apiKey) return [];
|
||||
|
||||
const response = await providerFetch(
|
||||
`${baseFor(config).replace(/\/+$/, "")}/models`,
|
||||
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
|
||||
);
|
||||
await assertOk(response, "Gemini");
|
||||
|
||||
const body: any = await response.json();
|
||||
return (body.models ?? [])
|
||||
.map((entry: any) => String(entry.name ?? "").replace(/^models\//, ""))
|
||||
.filter((name: string) => name.length > 0)
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Every outbound provider request goes through here so the egress rules cannot
|
||||
* be bypassed by an adapter calling fetch directly.
|
||||
*
|
||||
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
||||
* connect time. Allowlisted private hosts cannot use it (its whole job is to
|
||||
* refuse them), so they use the installed Undici fetch implementation with
|
||||
* its matching proxy dispatcher -- still respecting proxy configuration.
|
||||
*/
|
||||
export async function providerFetch(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const allowlist = await readPrivateAllowlist();
|
||||
const decision = evaluateEgress(url, allowlist);
|
||||
|
||||
if (!decision.allowed) {
|
||||
throw new AiProviderError(decision.reason ?? "Destination not allowed");
|
||||
}
|
||||
|
||||
if (decision.isPrivate) {
|
||||
return fetchWithProxy(url, init);
|
||||
}
|
||||
|
||||
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
||||
}
|
||||
|
||||
export function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields the data payload of each SSE frame. Providers differ in what they put
|
||||
* inside, so parsing the JSON is left to the caller.
|
||||
*/
|
||||
export async function* readSseLines(
|
||||
response: Response,
|
||||
): AsyncGenerator<string> {
|
||||
const body = response.body;
|
||||
if (!body) return;
|
||||
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line.startsWith("data:")) {
|
||||
yield line.slice(5).trim();
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Yields one parsed JSON object per line, for newline-delimited streams. */
|
||||
export async function* readJsonLines(
|
||||
response: Response,
|
||||
): AsyncGenerator<unknown> {
|
||||
const body = response.body;
|
||||
if (!body) return;
|
||||
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line) {
|
||||
try {
|
||||
yield JSON.parse(line);
|
||||
} catch {
|
||||
// A partial or malformed frame is skipped rather than failing the
|
||||
// whole stream.
|
||||
}
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Digs the human-readable message out of an error body.
|
||||
*
|
||||
* Every provider nests it differently, and dumping the raw JSON produced
|
||||
* something that got cut off mid-sentence. Falls back to a trimmed snippet
|
||||
* when the shape is unfamiliar.
|
||||
*/
|
||||
function extractProviderMessage(body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
const message =
|
||||
parsed?.error?.message ??
|
||||
parsed?.error?.["message"] ??
|
||||
parsed?.message ??
|
||||
(typeof parsed?.error === "string" ? parsed.error : null);
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message.trim();
|
||||
}
|
||||
} catch {
|
||||
// Not JSON; fall through to the snippet.
|
||||
}
|
||||
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
|
||||
}
|
||||
|
||||
export async function assertOk(
|
||||
response: Response,
|
||||
provider: string,
|
||||
): Promise<void> {
|
||||
if (response.ok) return;
|
||||
|
||||
let body = "";
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch {
|
||||
body = "";
|
||||
}
|
||||
|
||||
const detail = extractProviderMessage(body);
|
||||
|
||||
// Rate limits and auth failures are the two the user can actually act on,
|
||||
// so they say what to do instead of reading like an internal failure.
|
||||
if (response.status === 429) {
|
||||
throw new AiProviderError(
|
||||
`${provider} rate limit reached. Wait a moment and try again, or check your plan's quota.${
|
||||
detail ? ` (${detail})` : ""
|
||||
}`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AiProviderError(
|
||||
`${provider} rejected the API key. Check that it is correct and still active.${
|
||||
detail ? ` (${detail})` : ""
|
||||
}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
throw new AiProviderError(
|
||||
`${provider} request failed (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
} from "./types.js";
|
||||
|
||||
export const OLLAMA_DEFAULT_BASE = "http://localhost:11434";
|
||||
|
||||
function baseFor(config: ProviderConfig): string {
|
||||
return config.baseUrl?.trim() || OLLAMA_DEFAULT_BASE;
|
||||
}
|
||||
|
||||
function toOllamaMessages(request: ChatRequest): unknown[] {
|
||||
const messages: unknown[] = [{ role: "system", content: request.system }];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: message.content,
|
||||
...(message.toolName ? { tool_name: message.toolName } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message.content,
|
||||
tool_calls: message.toolCalls.map((call) => ({
|
||||
function: { name: call.name, arguments: call.arguments },
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export const ollamaAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: request.signal,
|
||||
body: JSON.stringify({
|
||||
model: request.model,
|
||||
messages: toOllamaMessages(request),
|
||||
stream: true,
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
await assertOk(response, "Ollama");
|
||||
|
||||
let index = 0;
|
||||
let stopReason: string | undefined;
|
||||
|
||||
// Ollama streams newline-delimited JSON rather than SSE.
|
||||
for await (const frame of readJsonLines(response)) {
|
||||
const payload = frame as any;
|
||||
|
||||
if (
|
||||
typeof payload.message?.content === "string" &&
|
||||
payload.message.content
|
||||
) {
|
||||
yield { type: "text", text: payload.message.content };
|
||||
}
|
||||
|
||||
for (const call of payload.message?.tool_calls ?? []) {
|
||||
const name = call.function?.name;
|
||||
if (!name) continue;
|
||||
const rawArgs = call.function?.arguments;
|
||||
// Ollama sends an object, but some builds send a JSON string.
|
||||
let args: Record<string, unknown> = {};
|
||||
if (rawArgs && typeof rawArgs === "object") {
|
||||
args = rawArgs as Record<string, unknown>;
|
||||
} else if (typeof rawArgs === "string" && rawArgs.trim()) {
|
||||
try {
|
||||
args = JSON.parse(rawArgs);
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: { id: `call_${name}_${index++}`, name, arguments: args },
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.done) {
|
||||
stopReason = payload.done_reason ?? "stop";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason };
|
||||
},
|
||||
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), {
|
||||
method: "GET",
|
||||
});
|
||||
await assertOk(response, "Ollama");
|
||||
|
||||
const body: any = await response.json();
|
||||
return (body.models ?? [])
|
||||
.map((entry: any) => entry.name)
|
||||
.filter((name: unknown): name is string => typeof name === "string")
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
ToolCall,
|
||||
} from "./types.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
const OPENAI_DEFAULT_BASE = "https://api.openai.com/v1";
|
||||
|
||||
function baseFor(config: ProviderConfig): string {
|
||||
if (config.baseUrl?.trim()) return config.baseUrl.trim();
|
||||
if (config.providerType === "openai") return OPENAI_DEFAULT_BASE;
|
||||
throw new AiProviderError("This provider needs a base URL");
|
||||
}
|
||||
|
||||
function toOpenAiMessages(request: ChatRequest): unknown[] {
|
||||
const messages: unknown[] = [{ role: "system", content: request.system }];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: message.toolCallId,
|
||||
content: message.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message.content || null,
|
||||
tool_calls: message.toolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
},
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call arguments arrive as JSON fragments spread across many deltas, so
|
||||
* they are accumulated per index and only parsed once the stream ends.
|
||||
*/
|
||||
interface PartialCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
export const openAiAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const url = joinUrl(baseFor(config), "chat/completions");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
|
||||
const response = await providerFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: request.signal,
|
||||
body: JSON.stringify({
|
||||
model: request.model,
|
||||
messages: toOpenAiMessages(request),
|
||||
stream: true,
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
await assertOk(response, "OpenAI");
|
||||
|
||||
const partial = new Map<number, PartialCall>();
|
||||
let stopReason: string | undefined;
|
||||
|
||||
for await (const data of readSseLines(response)) {
|
||||
if (data === "[DONE]") break;
|
||||
|
||||
let frame: any;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const choice = frame.choices?.[0];
|
||||
if (!choice) continue;
|
||||
|
||||
if (choice.finish_reason) stopReason = choice.finish_reason;
|
||||
|
||||
const delta = choice.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
if (typeof delta.content === "string" && delta.content) {
|
||||
yield { type: "text", text: delta.content };
|
||||
}
|
||||
|
||||
for (const call of delta.tool_calls ?? []) {
|
||||
const index = call.index ?? 0;
|
||||
const existing = partial.get(index) ?? { id: "", name: "", args: "" };
|
||||
if (call.id) existing.id = call.id;
|
||||
if (call.function?.name) existing.name = call.function.name;
|
||||
if (call.function?.arguments) existing.args += call.function.arguments;
|
||||
partial.set(index, existing);
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of partial.values()) {
|
||||
if (!call.name) continue;
|
||||
yield { type: "tool_call", call: finalizeCall(call) };
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason };
|
||||
},
|
||||
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "models"), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
await assertOk(response, "OpenAI");
|
||||
|
||||
const body: any = await response.json();
|
||||
return (body.data ?? [])
|
||||
.map((entry: any) => entry.id)
|
||||
.filter((id: unknown): id is string => typeof id === "string")
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
|
||||
export function finalizeCall(call: PartialCall): ToolCall {
|
||||
let args: Record<string, unknown> = {};
|
||||
if (call.args.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(call.args);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
args = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// A model that emitted malformed arguments gets an empty object; the
|
||||
// tool's own schema validation reports the problem back to it.
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
return {
|
||||
id:
|
||||
call.id || `call_${call.name}_${Math.random().toString(36).slice(2, 10)}`,
|
||||
name: call.name,
|
||||
arguments: args,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { anthropicAdapter } from "./anthropic.js";
|
||||
import { geminiAdapter } from "./gemini.js";
|
||||
import { ollamaAdapter } from "./ollama.js";
|
||||
import { openAiAdapter } from "./openai.js";
|
||||
import {
|
||||
AiProviderError,
|
||||
type AiProviderType,
|
||||
type ProviderAdapter,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* openai_compatible reuses the OpenAI adapter: OpenRouter, Groq, Mistral,
|
||||
* DeepSeek, together.ai, LM Studio and vLLM all speak the same wire format,
|
||||
* they differ only in base URL.
|
||||
*/
|
||||
const ADAPTERS: Record<AiProviderType, ProviderAdapter> = {
|
||||
ollama: ollamaAdapter,
|
||||
anthropic: anthropicAdapter,
|
||||
openai: openAiAdapter,
|
||||
gemini: geminiAdapter,
|
||||
openai_compatible: openAiAdapter,
|
||||
};
|
||||
|
||||
export function getAdapter(providerType: string): ProviderAdapter {
|
||||
const adapter = ADAPTERS[providerType as AiProviderType];
|
||||
if (!adapter) {
|
||||
throw new AiProviderError(`Unknown provider type: ${providerType}`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/** Provider types that cannot work without a base URL. */
|
||||
export const REQUIRES_BASE_URL: AiProviderType[] = [
|
||||
"ollama",
|
||||
"openai_compatible",
|
||||
];
|
||||
|
||||
/** Provider types that cannot work without an API key. */
|
||||
export const REQUIRES_API_KEY: AiProviderType[] = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"gemini",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shown when a provider's model list cannot be fetched: no key entered yet,
|
||||
* the endpoint is unreachable, or the vendor has no list endpoint. Users can
|
||||
* always type a model id the list does not contain, so this is a starting
|
||||
* point rather than a restriction.
|
||||
*/
|
||||
export const FALLBACK_MODELS: Record<AiProviderType, string[]> = {
|
||||
ollama: [
|
||||
"llama3.2",
|
||||
"llama3.1",
|
||||
"qwen2.5-coder",
|
||||
"mistral",
|
||||
"phi4",
|
||||
"gemma2",
|
||||
],
|
||||
anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
|
||||
openai: ["gpt-5", "gpt-5-mini", "o4-mini", "gpt-4.1"],
|
||||
gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
|
||||
openai_compatible: [],
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The shape every provider adapter normalizes to. Adding a provider means
|
||||
* translating its wire format into these events; nothing downstream (the
|
||||
* engine, the tool dispatcher, the SSE route) knows which vendor is in use.
|
||||
*/
|
||||
|
||||
export type AiProviderType =
|
||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||
|
||||
export const AI_PROVIDER_TYPES: AiProviderType[] = [
|
||||
"ollama",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"gemini",
|
||||
"openai_compatible",
|
||||
];
|
||||
|
||||
export function isAiProviderType(value: unknown): value is AiProviderType {
|
||||
return (
|
||||
typeof value === "string" && (AI_PROVIDER_TYPES as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
/** Set on assistant turns that requested tools. */
|
||||
toolCalls?: ToolCall[];
|
||||
/** Set on tool turns, matching the id of the call being answered. */
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
/**
|
||||
* Opaque provider state that has to be echoed back verbatim on the next
|
||||
* turn. Gemini 2.5+ rejects a follow-up whose functionCall parts have lost
|
||||
* their thoughtSignature, so this rides along rather than being dropped.
|
||||
*/
|
||||
providerSignature?: string;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
model: string;
|
||||
system: string;
|
||||
messages: ChatMessage[];
|
||||
tools: ToolDefinition[];
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type ChatChunk =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_call"; call: ToolCall }
|
||||
| { type: "done"; stopReason?: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export interface ProviderConfig {
|
||||
providerType: AiProviderType;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderAdapter {
|
||||
/** Streams a single assistant turn. Tool execution happens in the engine. */
|
||||
streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk>;
|
||||
/** Model ids to offer in the picker, best effort. */
|
||||
listModels(config: ProviderConfig): Promise<string[]>;
|
||||
}
|
||||
|
||||
export class AiProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AiProviderError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Defense in depth for anything about to leave the server.
|
||||
*
|
||||
* Read tools already select explicit field allowlists rather than spreading
|
||||
* rows, so nothing secret should reach here. This exists because "should" is
|
||||
* not a guarantee: a future tool that forgets to project its fields would
|
||||
* otherwise ship credentials to a third-party model provider.
|
||||
*/
|
||||
|
||||
const SECRET_KEY_PATTERN =
|
||||
/^(password|passwd|pass|secret|token|api_?key|apikey|private_?key|privatekey|key_?password|keypassword|passphrase|client_?secret|authorization|auth_?token|access_?token|refresh_?token|totp_?secret|backup_?codes|session_?token|cookie|credential|ssh_?cert|data_?key|dek)$/i;
|
||||
|
||||
/** Substring markers for keys that are not exact matches but still sensitive. */
|
||||
const SECRET_KEY_SUBSTRINGS = [
|
||||
"password",
|
||||
"secret",
|
||||
"privatekey",
|
||||
"private_key",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"passphrase",
|
||||
];
|
||||
|
||||
const VALUE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
||||
{
|
||||
pattern:
|
||||
/-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
|
||||
label: "[redacted private key]",
|
||||
},
|
||||
{ pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
|
||||
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
|
||||
{ pattern: /\bghp_[A-Za-z0-9]{20,}\b/g, label: "[redacted token]" },
|
||||
{ pattern: /\btmx_[A-Za-z0-9_-]{16,}\b/g, label: "[redacted token]" },
|
||||
{
|
||||
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/g,
|
||||
label: "[redacted token]",
|
||||
},
|
||||
{
|
||||
pattern: /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
|
||||
label: "Bearer [redacted]",
|
||||
},
|
||||
];
|
||||
|
||||
export const REDACTED = "[redacted]";
|
||||
|
||||
function isSecretKey(key: string): boolean {
|
||||
if (SECRET_KEY_PATTERN.test(key)) return true;
|
||||
const lower = key.toLowerCase();
|
||||
return SECRET_KEY_SUBSTRINGS.some((marker) => lower.includes(marker));
|
||||
}
|
||||
|
||||
export function redactString(value: string): string {
|
||||
let output = value;
|
||||
for (const { pattern, label } of VALUE_PATTERNS) {
|
||||
output = output.replace(pattern, label);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively drops secret-named fields and masks secret-shaped values.
|
||||
* Depth is bounded so a cyclic or pathological structure cannot hang the loop.
|
||||
*/
|
||||
export function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 12) return REDACTED;
|
||||
|
||||
if (typeof value === "string") return redactString(value);
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redact(entry, depth + 1));
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (isSecretKey(key)) {
|
||||
// Preserve the shape so the model can still reason about presence,
|
||||
// without ever seeing the value.
|
||||
output[key] = entry === null || entry === undefined ? null : REDACTED;
|
||||
continue;
|
||||
}
|
||||
output[key] = redact(entry, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Convenience wrapper for serializing a tool result. */
|
||||
export function redactToJson(value: unknown): string {
|
||||
return JSON.stringify(redact(value));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
||||
import { proposeTools } from "./propose-tools.js";
|
||||
import { readTools } from "./read-tools.js";
|
||||
import type { AiTool, ToolDefinitionShape } from "./types.js";
|
||||
|
||||
/**
|
||||
* The allowlist, and the security boundary for the whole feature.
|
||||
*
|
||||
* A model can only ever invoke what appears here. Route-level RBAC gates the
|
||||
* HTTP API, but tools run in-process with the calling user's identity and never
|
||||
* pass through a router, so "the assistant cannot reach credentials or user
|
||||
* administration" is a property of this list, not of the permission system.
|
||||
*
|
||||
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
||||
* SSO or instance settings is deliberately absent and must stay absent.
|
||||
*/
|
||||
export const AI_TOOLS: AiTool[] = [...readTools, ...proposeTools];
|
||||
|
||||
const BY_NAME = new Map(AI_TOOLS.map((tool) => [tool.name, tool]));
|
||||
|
||||
export function getTool(name: string): AiTool | undefined {
|
||||
return BY_NAME.get(name);
|
||||
}
|
||||
|
||||
export function listToolNames(): string[] {
|
||||
return AI_TOOLS.map((tool) => tool.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains the assistant must never be able to reach, in any tool, ever.
|
||||
* The catalog test asserts no tool name references these.
|
||||
*/
|
||||
export const FORBIDDEN_DOMAINS = [
|
||||
"credential",
|
||||
"vault",
|
||||
"rbac",
|
||||
"role",
|
||||
"permission",
|
||||
"user_admin",
|
||||
"password",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
"api_key",
|
||||
"session",
|
||||
"oidc",
|
||||
"sso",
|
||||
"ldap",
|
||||
"termix_id",
|
||||
"identity",
|
||||
"certificate",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"step_ca",
|
||||
"acme",
|
||||
"ssl",
|
||||
"audit",
|
||||
"sync",
|
||||
"settings",
|
||||
];
|
||||
|
||||
/** Tool definitions in the shape the provider adapters expect. */
|
||||
export function toolDefinitions(): ToolDefinitionShape[] {
|
||||
return AI_TOOLS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Which commands the assistant may run without a per-command approval click,
|
||||
* for users who opted into read-only execution.
|
||||
*
|
||||
* The check parses the command into arguments and matches the resolved binary
|
||||
* against the allowlist. Substring matching would be trivially defeated
|
||||
* ("df; rm -rf /" contains "df"), so any shell metacharacter that could chain,
|
||||
* redirect or substitute a second command rejects the whole string outright.
|
||||
*/
|
||||
|
||||
export const READ_ONLY_COMMANDS = new Set([
|
||||
"df",
|
||||
"du",
|
||||
"free",
|
||||
"uptime",
|
||||
"uname",
|
||||
"whoami",
|
||||
"hostname",
|
||||
"id",
|
||||
"ps",
|
||||
"top",
|
||||
"systemctl",
|
||||
"journalctl",
|
||||
"docker",
|
||||
"ip",
|
||||
"ss",
|
||||
"netstat",
|
||||
"lsblk",
|
||||
"cat",
|
||||
"ls",
|
||||
"stat",
|
||||
"which",
|
||||
"date",
|
||||
"lscpu",
|
||||
"vmstat",
|
||||
"iostat",
|
||||
]);
|
||||
|
||||
/** Characters that let one command become several. */
|
||||
const SHELL_METACHARACTERS = /[;&|`$><\n\r\\]/;
|
||||
|
||||
/** Subcommands that are safe for otherwise-powerful binaries. */
|
||||
const SUBCOMMAND_ALLOWLIST: Record<string, Set<string>> = {
|
||||
systemctl: new Set([
|
||||
"status",
|
||||
"list-units",
|
||||
"list-unit-files",
|
||||
"is-active",
|
||||
"is-enabled",
|
||||
"show",
|
||||
]),
|
||||
docker: new Set([
|
||||
"ps",
|
||||
"stats",
|
||||
"images",
|
||||
"logs",
|
||||
"inspect",
|
||||
"version",
|
||||
"info",
|
||||
]),
|
||||
ip: new Set(["a", "addr", "link", "route", "neigh"]),
|
||||
};
|
||||
|
||||
/** Paths `cat` may read. Anything else could disclose credentials. */
|
||||
const CAT_ALLOWED_PREFIXES = ["/proc/", "/sys/", "/etc/os-release"];
|
||||
|
||||
export interface CommandCheck {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function isReadOnlyCommand(raw: string): CommandCheck {
|
||||
const command = raw.trim();
|
||||
if (!command) return { allowed: false, reason: "Empty command" };
|
||||
|
||||
if (SHELL_METACHARACTERS.test(command)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "Command chaining, redirection and substitution are not allowed",
|
||||
};
|
||||
}
|
||||
|
||||
const parts = command.split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return { allowed: false, reason: "Empty command" };
|
||||
|
||||
// Reject env-prefixed and privilege-escalating forms outright.
|
||||
const head = parts[0];
|
||||
if (head === "sudo" || head === "su" || head === "doas" || head === "env") {
|
||||
return { allowed: false, reason: `${head} is not allowed` };
|
||||
}
|
||||
|
||||
// A path like /usr/bin/df resolves to its basename.
|
||||
const binary = head.includes("/")
|
||||
? head.slice(head.lastIndexOf("/") + 1)
|
||||
: head;
|
||||
|
||||
if (!READ_ONLY_COMMANDS.has(binary)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${binary} is not on the read-only allowlist`,
|
||||
};
|
||||
}
|
||||
|
||||
const allowedSubcommands = SUBCOMMAND_ALLOWLIST[binary];
|
||||
if (allowedSubcommands) {
|
||||
const subcommand = parts.slice(1).find((part) => !part.startsWith("-"));
|
||||
if (!subcommand || !allowedSubcommands.has(subcommand)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
`${binary} ${subcommand ?? ""}`.trim() +
|
||||
" is not on the read-only allowlist",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (binary === "cat") {
|
||||
const targets = parts.slice(1).filter((part) => !part.startsWith("-"));
|
||||
if (!targets.length) {
|
||||
return { allowed: false, reason: "cat needs a file path" };
|
||||
}
|
||||
for (const target of targets) {
|
||||
const permitted = CAT_ALLOWED_PREFIXES.some((prefix) =>
|
||||
prefix.endsWith("/") ? target.startsWith(prefix) : target === prefix,
|
||||
);
|
||||
if (!permitted) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `cat is limited to ${CAT_ALLOWED_PREFIXES.join(", ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
createCurrentAlertRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../src/backend/database/routes/automations.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";
|
||||
import { getTool } from "./catalog.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Applies an approved proposal.
|
||||
*
|
||||
* The stored payload is treated as untrusted input even though the server wrote
|
||||
* it: the proposal could have sat in the table across a release, and defending
|
||||
* the apply path rather than the create path means one place to get right.
|
||||
* Everything goes through the same repositories a human action uses, scoped to
|
||||
* the approving user.
|
||||
*/
|
||||
|
||||
export interface ApplyResult {
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
function requireNumber(value: unknown, field: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${field} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function applyProposal(
|
||||
kind: string,
|
||||
payload: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Promise<ApplyResult> {
|
||||
// A payload whose tool no longer exists is refused rather than guessed at.
|
||||
if (!getTool(kind)) {
|
||||
throw new Error(`Unknown proposal kind: ${kind}`);
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "propose_create_host": {
|
||||
const created = await createCurrentHostRepository().create({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
ip: requireString(payload.ip, "ip"),
|
||||
port: Number(payload.port) || 22,
|
||||
username: optionalString(payload.username) ?? "",
|
||||
folder: optionalString(payload.folder) ?? "",
|
||||
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
|
||||
} as any);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created host ${(created as any).name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_update_host": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!existing) throw new Error("Host not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (changes.name !== undefined)
|
||||
updates.name = requireString(changes.name, "name");
|
||||
if (changes.ip !== undefined)
|
||||
updates.ip = requireString(changes.ip, "ip");
|
||||
if (changes.port !== undefined) updates.port = Number(changes.port);
|
||||
if (changes.username !== undefined)
|
||||
updates.username = optionalString(changes.username) ?? "";
|
||||
if (changes.folder !== undefined)
|
||||
updates.folder = optionalString(changes.folder) ?? "";
|
||||
if (changes.tags !== undefined) {
|
||||
updates.tags = JSON.stringify(
|
||||
Array.isArray(changes.tags) ? changes.tags : [],
|
||||
);
|
||||
}
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentHostRepository().updateForUser(
|
||||
userId,
|
||||
hostId,
|
||||
updates as any,
|
||||
);
|
||||
return { ok: true, summary: `Updated host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_host": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const deleted = await createCurrentHostRepository().deleteForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!deleted) throw new Error("Host not found");
|
||||
return { ok: true, summary: `Deleted host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_create_snippet": {
|
||||
const created = await createCurrentSnippetRepository().createSnippet(
|
||||
userId,
|
||||
{
|
||||
name: requireString(payload.name, "name"),
|
||||
content: requireString(payload.content, "content"),
|
||||
description: optionalString(payload.description),
|
||||
folder: optionalString(payload.folder),
|
||||
} as any,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_update_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentSnippetRepository().findOwnedById(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
if (!existing) throw new Error("Snippet not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (changes.name !== undefined)
|
||||
updates.name = requireString(changes.name, "name");
|
||||
if (changes.content !== undefined)
|
||||
updates.content = requireString(changes.content, "content");
|
||||
if (changes.description !== undefined)
|
||||
updates.description = optionalString(changes.description);
|
||||
if (changes.folder !== undefined)
|
||||
updates.folder = optionalString(changes.folder);
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentSnippetRepository().updateSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
updates as any,
|
||||
);
|
||||
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const deleted = await createCurrentSnippetRepository().deleteSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
if (!deleted) throw new Error("Snippet not found");
|
||||
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_create_fleet": {
|
||||
const fleet = await createCurrentFleetRepository().create(userId, {
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
} as any);
|
||||
|
||||
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
||||
let added = 0;
|
||||
for (const raw of hostIds) {
|
||||
const hostId = Number(raw);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) continue;
|
||||
// Only hosts the approving user owns can join their fleet.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) continue;
|
||||
await createCurrentFleetRepository().addMember(
|
||||
(fleet as any).id,
|
||||
hostId,
|
||||
);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_create_alert_rule": {
|
||||
const created = await createCurrentAlertRepository().createAlertRule({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
hostId:
|
||||
payload.hostId === null || payload.hostId === undefined
|
||||
? null
|
||||
: requireNumber(payload.hostId, "hostId"),
|
||||
enabled: true,
|
||||
triggerType: requireString(payload.triggerType, "triggerType"),
|
||||
thresholdValue:
|
||||
payload.thresholdValue === null ||
|
||||
payload.thresholdValue === undefined
|
||||
? null
|
||||
: Number(payload.thresholdValue),
|
||||
thresholdDurationSeconds:
|
||||
payload.thresholdDurationSeconds === null ||
|
||||
payload.thresholdDurationSeconds === undefined
|
||||
? null
|
||||
: Number(payload.thresholdDurationSeconds),
|
||||
cooldownMinutes: Number(payload.cooldownMinutes) || 15,
|
||||
channelIds: [],
|
||||
} as any);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created alert rule ${(created as any)?.name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_create_automation": {
|
||||
// Reuses the same validator the automations route runs, so an
|
||||
// LLM-authored definition is held to exactly the human standard.
|
||||
const validation = validateDefinition(payload.definition);
|
||||
if (!validation.ok || !validation.definition) {
|
||||
throw new Error(
|
||||
validation.error ?? "The automation definition is invalid",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createCurrentAutomationRepository().create({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
definition: JSON.stringify(validation.definition),
|
||||
// Starts disabled: an automation the user has not watched run once
|
||||
// should not begin firing against their servers on approval.
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_run_command": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const command = requireString(payload.command, "command");
|
||||
|
||||
// resolveHostById, not the raw repository row: it runs the connect-level
|
||||
// permission check, decrypts auth under the owner's key, and resolves the
|
||||
// jump host chain. A plain row has none of that, so the SSH factory saw
|
||||
// an unresolved jumpHosts field and failed the connection.
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) throw new Error("Host not found");
|
||||
|
||||
const result = await runCommandOnHost(host, command);
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
summary: result.output?.slice(0, 2000) ?? "(no output)",
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Proposal kind ${kind} cannot be applied automatically`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one approved command over the shared SSH pool, mirroring how an
|
||||
* automation run_command step executes.
|
||||
*/
|
||||
async function runCommandOnHost(
|
||||
host: Record<string, any>,
|
||||
command: string,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
try {
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(host as any),
|
||||
createFleetSshFactory(host as any),
|
||||
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.code === 0 || result.code === null) {
|
||||
return { output: output || "(no output)" };
|
||||
}
|
||||
return { error: `Exited with code ${result.code}: ${output}`.trim() };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kinds the route handles itself rather than through applyProposal.
|
||||
* Empty: everything the assistant can propose can now be applied.
|
||||
*/
|
||||
export const ROUTE_APPLIED_KINDS = new Set<string>();
|
||||
@@ -0,0 +1,279 @@
|
||||
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Propose tools never mutate anything. They return a draft that is stored as a
|
||||
* pending proposal and rendered as a card; the change happens only when the
|
||||
* user approves it, and the payload is re-validated at that point.
|
||||
*/
|
||||
|
||||
export const proposeTools: AiTool[] = [
|
||||
{
|
||||
name: "propose_create_host",
|
||||
description:
|
||||
"Propose adding a new SSH host. Never include credentials: the user attaches those themselves after approving.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Display name for the host"),
|
||||
ip: str("Hostname or IP address"),
|
||||
port: num("SSH port (defaults to 22)"),
|
||||
username: str("SSH username"),
|
||||
folder: str("Folder to file the host under"),
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags to apply",
|
||||
},
|
||||
},
|
||||
["name", "ip"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_create_host", `Add host ${String(args.name)}`, {
|
||||
name: args.name,
|
||||
ip: args.ip,
|
||||
port: args.port ?? 22,
|
||||
username: args.username ?? null,
|
||||
folder: args.folder ?? null,
|
||||
tags: args.tags ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_update_host",
|
||||
description:
|
||||
"Propose changing an existing host's non-secret settings. Only include the fields that should change.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to update"),
|
||||
name: str("New display name"),
|
||||
ip: str("New hostname or IP address"),
|
||||
port: num("New SSH port"),
|
||||
username: str("New SSH username"),
|
||||
folder: str("New folder"),
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Replacement tag list",
|
||||
},
|
||||
},
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const field of [
|
||||
"name",
|
||||
"ip",
|
||||
"port",
|
||||
"username",
|
||||
"folder",
|
||||
"tags",
|
||||
]) {
|
||||
if (args[field] !== undefined) changes[field] = args[field];
|
||||
}
|
||||
return proposal(
|
||||
"propose_update_host",
|
||||
`Update host ${String(args.hostId)}`,
|
||||
{ hostId: args.hostId, changes },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propose_delete_host",
|
||||
description:
|
||||
"Propose removing a host. Use sparingly and explain why in the reason.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to delete"),
|
||||
reason: str("Why this host should be removed"),
|
||||
},
|
||||
["hostId", "reason"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_delete_host", `Delete host ${String(args.hostId)}`, {
|
||||
hostId: args.hostId,
|
||||
reason: args.reason,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_create_snippet",
|
||||
description:
|
||||
"Propose saving a new command snippet the user can run against their hosts.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Snippet name"),
|
||||
content: str("The command text"),
|
||||
description: str("What the snippet does"),
|
||||
folder: str("Folder to file it under"),
|
||||
},
|
||||
["name", "content"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_snippet",
|
||||
`Create snippet ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
content: args.content,
|
||||
description: args.description ?? null,
|
||||
folder: args.folder ?? null,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_update_snippet",
|
||||
description: "Propose editing an existing snippet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to update"),
|
||||
name: str("New name"),
|
||||
content: str("New command text"),
|
||||
description: str("New description"),
|
||||
folder: str("New folder"),
|
||||
},
|
||||
["snippetId"],
|
||||
),
|
||||
handler: async (args) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const field of ["name", "content", "description", "folder"]) {
|
||||
if (args[field] !== undefined) changes[field] = args[field];
|
||||
}
|
||||
return proposal(
|
||||
"propose_update_snippet",
|
||||
`Update snippet ${String(args.snippetId)}`,
|
||||
{ snippetId: args.snippetId, changes },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propose_delete_snippet",
|
||||
description: "Propose deleting a snippet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to delete"),
|
||||
reason: str("Why this snippet should be removed"),
|
||||
},
|
||||
["snippetId", "reason"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_delete_snippet",
|
||||
`Delete snippet ${String(args.snippetId)}`,
|
||||
{ snippetId: args.snippetId, reason: args.reason },
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_create_automation",
|
||||
description:
|
||||
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Automation name"),
|
||||
description: str("What the automation does"),
|
||||
definition: {
|
||||
type: "object",
|
||||
description:
|
||||
"The AutomationDefinition: { trigger: {...}, steps: [...] }",
|
||||
},
|
||||
},
|
||||
["name", "definition"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_automation",
|
||||
`Create automation ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
definition: args.definition,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_create_fleet",
|
||||
description: "Propose grouping hosts into a new fleet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Fleet name"),
|
||||
description: str("What this fleet is for"),
|
||||
hostIds: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
description: "Host ids to add as members",
|
||||
},
|
||||
},
|
||||
["name"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_create_fleet", `Create fleet ${String(args.name)}`, {
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
hostIds: args.hostIds ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_create_alert_rule",
|
||||
description:
|
||||
"Propose a new alert rule that fires when a host metric crosses a threshold.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Rule name"),
|
||||
hostId: num("Host id to watch, or omit to watch all hosts"),
|
||||
triggerType: str(
|
||||
"What to watch, for example cpu, memory, disk or host_status",
|
||||
),
|
||||
thresholdValue: num("Threshold to compare against"),
|
||||
thresholdDurationSeconds: num(
|
||||
"How long the breach must persist before firing",
|
||||
),
|
||||
cooldownMinutes: num("Minimum minutes between repeat firings"),
|
||||
},
|
||||
["name", "triggerType"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_alert_rule",
|
||||
`Create alert rule ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
hostId: args.hostId ?? null,
|
||||
triggerType: args.triggerType,
|
||||
thresholdValue: args.thresholdValue ?? null,
|
||||
thresholdDurationSeconds: args.thresholdDurationSeconds ?? null,
|
||||
cooldownMinutes: args.cooldownMinutes ?? 15,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_run_command",
|
||||
description:
|
||||
"Propose running a command on a host. The user reviews and approves it before it runs. Use this for anything that changes state; read-only diagnostics may run directly if the user has enabled that.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to run on"),
|
||||
command: str("The exact command to run"),
|
||||
explanation: str("What the command does and why it is needed"),
|
||||
},
|
||||
["hostId", "command", "explanation"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_run_command",
|
||||
// Deliberately short: the card renders the command in its own block,
|
||||
// so repeating it here made every card twice as tall as it needed.
|
||||
`Run a command on host ${String(args.hostId)}`,
|
||||
{
|
||||
hostId: args.hostId,
|
||||
command: args.command,
|
||||
explanation: args.explanation,
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
createCurrentAlertRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
* afterwards as a second line of defense, but the projection here is the
|
||||
* primary control: a field that is never selected cannot leak.
|
||||
*/
|
||||
|
||||
interface HostSummary {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string | null;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
folder: string | null;
|
||||
tags: unknown;
|
||||
protocol: string | null;
|
||||
enableTerminal: boolean | null;
|
||||
enableFileManager: boolean | null;
|
||||
enableTunnel: boolean | null;
|
||||
enableDocker: boolean | null;
|
||||
}
|
||||
|
||||
function toHostSummary(host: Record<string, any>): HostSummary {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name ?? null,
|
||||
ip: host.ip ?? null,
|
||||
port: host.port ?? null,
|
||||
username: host.username ?? null,
|
||||
folder: host.folder ?? null,
|
||||
tags: host.tags ?? null,
|
||||
protocol: host.protocol ?? null,
|
||||
enableTerminal: host.enableTerminal ?? null,
|
||||
enableFileManager: host.enableFileManager ?? null,
|
||||
enableTunnel: host.enableTunnel ?? null,
|
||||
enableDocker: host.enableDocker ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const readTools: AiTool[] = [
|
||||
{
|
||||
name: "list_hosts",
|
||||
description:
|
||||
"List the user's SSH hosts with their names, addresses, folders and tags. Never returns passwords or keys. Call this before proposing anything that references a host.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const hosts = await createCurrentHostRepository().listByUserId(
|
||||
context.userId,
|
||||
);
|
||||
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_host",
|
||||
description:
|
||||
"Get one host's non-secret configuration by id. Use it to inspect settings before proposing an update.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{ hostId: num("The host id, as returned by list_hosts") },
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const hostId = Number(args.hostId);
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) return { error: "Host not found" };
|
||||
return { host: toHostSummary(host as any) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_fleets",
|
||||
description:
|
||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const fleets = await createCurrentFleetRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
fleets: fleets.map((fleet: any) => ({
|
||||
id: fleet.id,
|
||||
name: fleet.name,
|
||||
description: fleet.description ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_snippets",
|
||||
description:
|
||||
"List the user's saved command snippets, including their folder and the command text.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
snippets: snippets.map((snippet: any) => ({
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
folder: snippet.folder ?? null,
|
||||
description: snippet.description ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_automations",
|
||||
description:
|
||||
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const automations = await createCurrentAutomationRepository().list(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
automations: automations.map((automation: any) => ({
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? null,
|
||||
enabled: automation.enabled,
|
||||
lastRunAt: automation.lastRunAt ?? null,
|
||||
lastRunStatus: automation.lastRunStatus ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_automation",
|
||||
description:
|
||||
"Get one automation's full definition (trigger and steps) by id.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
automationId: num("The automation id, as returned by list_automations"),
|
||||
},
|
||||
["automationId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const automation = await createCurrentAutomationRepository().findForUser(
|
||||
Number(args.automationId),
|
||||
context.userId,
|
||||
);
|
||||
if (!automation) return { error: "Automation not found" };
|
||||
return {
|
||||
automation: {
|
||||
id: (automation as any).id,
|
||||
name: (automation as any).name,
|
||||
enabled: (automation as any).enabled,
|
||||
definition: (automation as any).definition,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_workspaces",
|
||||
description:
|
||||
"List the user's saved workspace layouts (named sets of open tabs and splits).",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const workspaces = await createCurrentWorkspaceRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
workspaces: workspaces.map((workspace: any) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isDefault: workspace.isDefault ?? false,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_alert_rules",
|
||||
description:
|
||||
"List the user's alert rules with their thresholds and enabled state.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const rules = await createCurrentAlertRepository().listAlertRules(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
rules: rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
hostId: rule.host_id,
|
||||
enabled: rule.enabled === 1,
|
||||
triggerType: rule.trigger_type,
|
||||
thresholdValue: rule.threshold_value,
|
||||
thresholdDurationSeconds: rule.threshold_duration_seconds,
|
||||
cooldownMinutes: rule.cooldown_minutes,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_notification_channels",
|
||||
description:
|
||||
"List the user's notification channels by id, name and type. Channel configuration is never returned because it holds tokens.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const channels =
|
||||
await createCurrentAlertRepository().listNotificationChannels(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
channels: channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
enabled: channel.enabled === 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_alert_firings",
|
||||
description:
|
||||
"Recent alert firings, newest first. Use this to answer questions about what has been alerting.",
|
||||
category: "read",
|
||||
parameters: objectSchema({
|
||||
limit: num("How many firings to return (default 25, max 100)"),
|
||||
}),
|
||||
handler: async (args, context) => {
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||
const result = await createCurrentAlertRepository().listAlertFirings({
|
||||
userId: context.userId,
|
||||
limit,
|
||||
offset: 0,
|
||||
});
|
||||
return {
|
||||
firings: (result.firings ?? []).map((firing) => ({
|
||||
id: firing.id,
|
||||
ruleName: firing.rule_name,
|
||||
hostName: firing.host_name,
|
||||
firedAt: firing.fired_at,
|
||||
resolvedAt: firing.resolved_at,
|
||||
severity: firing.severity,
|
||||
message: firing.message,
|
||||
acknowledged: firing.acknowledged === 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_homepage_items",
|
||||
description: "List the user's homepage service-link tiles.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const items = await createCurrentHomepageItemRepository().listByUserId(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
items: (items as any[]).map((item) => ({
|
||||
id: item.id,
|
||||
typeId: item.typeId,
|
||||
title: item.title ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_command_history",
|
||||
description:
|
||||
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id, as returned by list_hosts"),
|
||||
limit: num("How many entries to return (default 25, max 100)"),
|
||||
},
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||
const hostId = Number(args.hostId);
|
||||
|
||||
// Ownership is enforced here rather than trusted from the model.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) return { error: "Host not found" };
|
||||
|
||||
const commands =
|
||||
await createCurrentCommandHistoryRepository().listCommandsForHost(
|
||||
context.userId,
|
||||
hostId,
|
||||
limit,
|
||||
);
|
||||
return { commands };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_network_topology",
|
||||
description: "The user's saved network topology graph, if they have one.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const topology =
|
||||
await createCurrentNetworkTopologyRepository().findByUserId(
|
||||
context.userId,
|
||||
);
|
||||
if (!topology) return { topology: null };
|
||||
return { topology: (topology as any).data ?? null };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,72 @@
|
||||
export type ToolCategory = "read" | "propose";
|
||||
|
||||
export interface ToolContext {
|
||||
/** Always taken from the verified JWT, never from model input. */
|
||||
userId: string;
|
||||
conversationId: number;
|
||||
/** Per-user opt-in for running allowlisted read-only commands. */
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export interface AiTool {
|
||||
name: string;
|
||||
description: string;
|
||||
category: ToolCategory;
|
||||
/** JSON Schema for the arguments, sent to the provider verbatim. */
|
||||
parameters: Record<string, unknown>;
|
||||
/**
|
||||
* Read tools return data to feed back to the model. Propose tools return a
|
||||
* ProposalDraft and must not mutate anything.
|
||||
*/
|
||||
handler: (
|
||||
args: Record<string, unknown>,
|
||||
context: ToolContext,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What a provider adapter needs to describe a tool to its model. */
|
||||
export interface ToolDefinitionShape {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProposalDraft {
|
||||
__proposal: true;
|
||||
kind: string;
|
||||
summary: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isProposalDraft(value: unknown): value is ProposalDraft {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as ProposalDraft).__proposal === true
|
||||
);
|
||||
}
|
||||
|
||||
export function proposal(
|
||||
kind: string,
|
||||
summary: string,
|
||||
payload: Record<string, unknown>,
|
||||
): ProposalDraft {
|
||||
return { __proposal: true, kind, summary, payload };
|
||||
}
|
||||
|
||||
/** Small helper so tool schemas stay readable. */
|
||||
export function objectSchema(
|
||||
properties: Record<string, unknown>,
|
||||
required: string[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
additionalProperties: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const str = (description: string) => ({ type: "string", description });
|
||||
export const num = (description: string) => ({ type: "number", description });
|
||||
export const bool = (description: string) => ({ type: "boolean", description });
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Frontend half of the ai plugin.
|
||||
*
|
||||
* AiPanel and the rest of src/ui/features/ai/ stay in core rather than
|
||||
* moving here: TerminalAiPanel.tsx and TerminalProposalCard.tsx in
|
||||
* src/ui/features/terminal/ (also core) import AiMessage, AiToolCall,
|
||||
* useAiStream and ProposalCard directly, for the terminal's own docked AI
|
||||
* panel. That is a real external dependency, the same reason session-manager
|
||||
* stays in core for ssh-terminal rather than being copied into that plugin.
|
||||
*
|
||||
* The "ai" surface itself is not registered through registerTabComponent:
|
||||
* it is a rail-view/right-dock panel baked into AppShell.tsx and
|
||||
* tabUtils.tsx (railView === "ai", case "ai"), the same way the terminal's
|
||||
* tab is baked into Terminal.tsx rather than going through the generic tab
|
||||
* registry. What gates it is isTabTypeAvailable("ai") in
|
||||
* src/ui/shell/pluginLoader.ts's BUILT_IN_TABS_BY_PLUGIN map, which is
|
||||
* consulted directly by AppShell.tsx, CommandPalette.tsx and HostItem.tsx --
|
||||
* the same live mechanism ssh-terminal's "terminal" entry already uses.
|
||||
*
|
||||
* register()/unregister() exist for parity with the other first-party
|
||||
* plugins' frontend halves. Nothing invokes them yet: there is no frontend
|
||||
* plugin loader (see src/ui/tests/sidebar/plugin-extension-seam.test.tsx),
|
||||
* and the "ai" surface does not need registerRailItem/registerTabComponent
|
||||
* in the first place, since it is gated through BUILT_IN_TABS_BY_PLUGIN
|
||||
* instead.
|
||||
*/
|
||||
|
||||
export const id = "ai";
|
||||
export const tabId = "ai";
|
||||
|
||||
export async function register() {
|
||||
// Nothing to register: "ai" is a built-in rail-view/tab gated through
|
||||
// isTabTypeAvailable, not a runtime-registered surface. See the header.
|
||||
}
|
||||
|
||||
export async function unregister() {
|
||||
// Nothing to unregister; see register().
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "ai",
|
||||
"name": "AI Assistant",
|
||||
"version": "1.0.0",
|
||||
"description": "An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve.",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Productivity",
|
||||
"icon": "Sparkles",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "ai",
|
||||
"titleKey": "nav.ai",
|
||||
"icon": "Sparkles",
|
||||
"openFrom": ["rail", "palette"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
Reference in new issue
Block a user