feat: Add Line plugin (#1630)

* feat: add LINE plugin (#1630) (thanks @plum-dawg)

* feat: complete LINE plugin (#1630) (thanks @plum-dawg)

* chore: drop line plugin node_modules (#1630) (thanks @plum-dawg)

* test: mock /context report in commands test (#1630) (thanks @plum-dawg)

* test: limit macOS CI workers to avoid OOM (#1630) (thanks @plum-dawg)

* test: reduce macOS CI vitest workers (#1630) (thanks @plum-dawg)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
plum-dawg
2026-01-25 07:22:36 -05:00
committed by GitHub
parent 101d0f451f
commit c96ffa7186
85 changed files with 11365 additions and 60 deletions

View File

@@ -230,8 +230,6 @@ export function createGatewayHttpServer(opts: {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (
await handleToolsInvokeHttpRequest(req, res, {
auth: resolvedAuth,
@@ -239,6 +237,8 @@ export function createGatewayHttpServer(opts: {
})
)
return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {

View File

@@ -18,6 +18,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics,

View File

@@ -40,6 +40,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -81,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -49,6 +49,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -78,6 +79,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -21,6 +21,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -47,6 +48,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -75,6 +75,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -10,6 +10,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
@@ -20,5 +21,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
...merged,
gatewayHandlers: merged.gatewayHandlers ?? {},
httpHandlers: merged.httpHandlers ?? [],
httpRoutes: merged.httpRoutes ?? [],
};
};

View File

@@ -56,6 +56,35 @@ describe("createGatewayPluginRequestHandler", () => {
expect(second).toHaveBeenCalledTimes(1);
});
it("handles registered http routes before generic handlers", async () => {
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;
});
const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/demo",
handler: routeHandler,
source: "route",
},
],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}),
log: { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"],
});
const { res } = makeResponse();
const handled = await handler({ url: "/demo" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(routeHandler).toHaveBeenCalledTimes(1);
expect(fallback).not.toHaveBeenCalled();
});
it("logs and responds with 500 when a handler throws", async () => {
const log = { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler

View File

@@ -16,8 +16,30 @@ export function createGatewayPluginRequestHandler(params: {
}): PluginHttpRequestHandler {
const { registry, log } = params;
return async (req, res) => {
if (registry.httpHandlers.length === 0) return false;
for (const entry of registry.httpHandlers) {
const routes = registry.httpRoutes ?? [];
const handlers = registry.httpHandlers ?? [];
if (routes.length === 0 && handlers.length === 0) return false;
if (routes.length > 0) {
const url = new URL(req.url ?? "/", "http://localhost");
const route = routes.find((entry) => entry.path === url.pathname);
if (route) {
try {
await route.handler(req, res);
return true;
} catch (err) {
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Internal Server Error");
}
return true;
}
}
}
for (const entry of handlers) {
try {
const handled = await entry.handler(req, res);
if (handled) return true;

View File

@@ -138,6 +138,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { testState } from "./test-helpers.mocks.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
installGatewayTestHooks({ scope: "suite" });
@@ -70,6 +72,58 @@ describe("POST /tools/invoke", () => {
await server.close();
});
it("routes tools invoke before plugin HTTP handlers", async () => {
const pluginHandler = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
res.statusCode = 418;
res.end("plugin");
return true;
});
const registry = createTestRegistry();
registry.httpHandlers = [
{
pluginId: "test-plugin",
source: "test",
handler: pluginHandler as unknown as (
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
) => Promise<boolean>,
},
];
setTestPluginRegistry(registry);
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
allow: ["sessions_list"],
},
},
],
} as any;
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
try {
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tool: "sessions_list",
action: "json",
args: {},
sessionKey: "main",
}),
});
expect(res.status).toBe(200);
expect(pluginHandler).not.toHaveBeenCalled();
} finally {
await server.close();
resetTestPluginRegistry();
}
});
it("rejects unauthorized when auth mode is token and header is missing", async () => {
testState.agentsConfig = {
list: [