arisa 4.0.14 → 4.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +19 -22
- package/README.md +1 -1
- package/package.json +7 -8
- package/src/core/tools/ipc-client.js +94 -0
- package/src/core/tools/tool-registry.js +4 -21
- package/src/index.js +3 -2
- package/src/runtime/arisa-capabilities.js +123 -0
- package/src/runtime/create-app.js +15 -62
- package/src/runtime/ipc/ipc-server.js +109 -0
- package/src/runtime/paths.js +1 -0
- package/src/runtime/process-ids.js +9 -0
- package/test/ipc-server.test.js +154 -0
- package/test/process-ids.test.js +14 -0
- package/src/runtime/web/route-validation.js +0 -115
- package/src/runtime/web/web-router.js +0 -167
- package/test/route-validation.test.js +0 -88
- package/test/web-router.test.js +0 -267
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { createArisaClient } from "../src/core/tools/ipc-client.js";
|
|
8
|
+
import { createArisaCapabilities } from "../src/runtime/arisa-capabilities.js";
|
|
9
|
+
import { createIpcServer } from "../src/runtime/ipc/ipc-server.js";
|
|
10
|
+
|
|
11
|
+
function createFakeArtifactStore() {
|
|
12
|
+
const stores = new Map();
|
|
13
|
+
return {
|
|
14
|
+
forChat(chatId) {
|
|
15
|
+
const key = String(chatId);
|
|
16
|
+
if (!stores.has(key)) {
|
|
17
|
+
const items = [];
|
|
18
|
+
stores.set(key, {
|
|
19
|
+
createText: async ({ text, source, metadata }) => {
|
|
20
|
+
const artifact = { id: `artifact-${items.length + 1}`, chatId: key, text, source, metadata };
|
|
21
|
+
items.push(artifact);
|
|
22
|
+
return artifact;
|
|
23
|
+
},
|
|
24
|
+
listRecent: async () => [...items].reverse(),
|
|
25
|
+
get: async (artifactId) => items.find((item) => item.id === artifactId) || null
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return stores.get(key);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createFakeTaskStore() {
|
|
34
|
+
const tasks = [];
|
|
35
|
+
return {
|
|
36
|
+
add: async (task, defaults = {}) => {
|
|
37
|
+
const created = {
|
|
38
|
+
id: `task-${tasks.length + 1}`,
|
|
39
|
+
...task,
|
|
40
|
+
payload: { ...(defaults.payload || {}), ...(task.payload || {}) },
|
|
41
|
+
source: { ...(defaults.source || {}), ...(task.source || {}) }
|
|
42
|
+
};
|
|
43
|
+
tasks.push(created);
|
|
44
|
+
return created;
|
|
45
|
+
},
|
|
46
|
+
list: async (filter = {}) => tasks.filter((task) => {
|
|
47
|
+
if (filter.chatId && String(task.payload?.chatId) !== String(filter.chatId)) return false;
|
|
48
|
+
if (filter.kind && task.kind !== filter.kind) return false;
|
|
49
|
+
if (filter.status && task.status !== filter.status) return false;
|
|
50
|
+
return true;
|
|
51
|
+
}),
|
|
52
|
+
get: async (taskId) => tasks.find((task) => task.id === taskId) || null,
|
|
53
|
+
cancel: async (taskId) => {
|
|
54
|
+
const index = tasks.findIndex((task) => task.id === taskId);
|
|
55
|
+
if (index === -1) return null;
|
|
56
|
+
const [removed] = tasks.splice(index, 1);
|
|
57
|
+
return removed;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createCapabilities() {
|
|
63
|
+
return createArisaCapabilities({
|
|
64
|
+
artifactStore: createFakeArtifactStore(),
|
|
65
|
+
taskStore: createFakeTaskStore()
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function createTempSocketPath() {
|
|
70
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "arisa-ipc-"));
|
|
71
|
+
return path.join(root, "arisa.sock");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function sendRaw(socketPath, request) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const socket = net.createConnection(socketPath);
|
|
77
|
+
let buffer = "";
|
|
78
|
+
socket.setEncoding("utf8");
|
|
79
|
+
socket.once("connect", () => {
|
|
80
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
81
|
+
});
|
|
82
|
+
socket.on("data", (chunk) => {
|
|
83
|
+
buffer += chunk;
|
|
84
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
85
|
+
if (newlineIndex === -1) return;
|
|
86
|
+
socket.end();
|
|
87
|
+
resolve(JSON.parse(buffer.slice(0, newlineIndex)));
|
|
88
|
+
});
|
|
89
|
+
socket.once("error", reject);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("dispatches explicit capabilities over local IPC", async () => {
|
|
94
|
+
const socketPath = await createTempSocketPath();
|
|
95
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
96
|
+
await ipcServer.start();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const client = createArisaClient({ toolName: "ipc-tool", chatId: 123, socketPath });
|
|
100
|
+
const artifact = await client.artifacts.createText({ text: "hello" });
|
|
101
|
+
assert.equal(artifact.text, "hello");
|
|
102
|
+
assert.equal(artifact.source.toolName, "ipc-tool");
|
|
103
|
+
assert.equal(artifact.source.chatId, 123);
|
|
104
|
+
} finally {
|
|
105
|
+
await ipcServer.stop();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects unknown IPC methods", async () => {
|
|
110
|
+
const socketPath = await createTempSocketPath();
|
|
111
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
112
|
+
await ipcServer.start();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const response = await sendRaw(socketPath, {
|
|
116
|
+
id: "one",
|
|
117
|
+
method: "unknown.method",
|
|
118
|
+
toolName: "ipc-tool",
|
|
119
|
+
params: {}
|
|
120
|
+
});
|
|
121
|
+
assert.equal(response.ok, false);
|
|
122
|
+
assert.match(response.error, /unknown IPC method/);
|
|
123
|
+
} finally {
|
|
124
|
+
await ipcServer.stop();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("requires chatId for chat-scoped capabilities", async () => {
|
|
129
|
+
const socketPath = await createTempSocketPath();
|
|
130
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
131
|
+
await ipcServer.start();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const client = createArisaClient({ toolName: "ipc-tool", socketPath });
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
() => client.artifacts.listRecent(),
|
|
137
|
+
/artifacts\.listRecent requires chatId/
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
await ipcServer.stop();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("listens on a local socket, not a TCP port", async () => {
|
|
145
|
+
const socketPath = await createTempSocketPath();
|
|
146
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
147
|
+
await ipcServer.start();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
assert.equal(ipcServer.address(), socketPath);
|
|
151
|
+
} finally {
|
|
152
|
+
await ipcServer.stop();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parseProcessIds } from "../src/runtime/process-ids.js";
|
|
4
|
+
|
|
5
|
+
test("ignores empty PID command output", () => {
|
|
6
|
+
assert.deepEqual(parseProcessIds(""), []);
|
|
7
|
+
assert.deepEqual(parseProcessIds(" \n\t"), []);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("parses only positive integer PID tokens", () => {
|
|
11
|
+
assert.deepEqual(parseProcessIds("123\n456"), [123, 456]);
|
|
12
|
+
assert.deepEqual(parseProcessIds("11970/tcp: 1234"), [1234]);
|
|
13
|
+
assert.deepEqual(parseProcessIds("0 nope -12 42"), [42]);
|
|
14
|
+
});
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
|
|
3
|
-
export const RESERVED_EXACT = ["/"];
|
|
4
|
-
export const RESERVED_PATHS = ["/health"];
|
|
5
|
-
export const RESERVED_PREFIXES = ["/telegram-", "/api", "/auth"];
|
|
6
|
-
|
|
7
|
-
const DEFAULT_METHODS = ["GET"];
|
|
8
|
-
|
|
9
|
-
function isObject(value) {
|
|
10
|
-
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function normalizeMethods(methods, isPublic) {
|
|
14
|
-
const requested = methods == null ? DEFAULT_METHODS : methods;
|
|
15
|
-
if (!Array.isArray(requested) || requested.length === 0) {
|
|
16
|
-
throw new Error("methods must be a non-empty array");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const normalized = requested.map((method) => {
|
|
20
|
-
if (typeof method !== "string" || !method.trim()) {
|
|
21
|
-
throw new Error("methods must contain only non-empty strings");
|
|
22
|
-
}
|
|
23
|
-
return method.trim().toUpperCase();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
if (!isPublic && methods == null) return normalized;
|
|
27
|
-
return [...new Set(normalized)];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function isReservedPath(routePath) {
|
|
31
|
-
return RESERVED_EXACT.includes(routePath)
|
|
32
|
-
|| RESERVED_PATHS.includes(routePath)
|
|
33
|
-
|| RESERVED_PREFIXES.some((prefix) => routePath === prefix || routePath.startsWith(prefix));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function validateRoutePath(routePath) {
|
|
37
|
-
if (typeof routePath !== "string" || !routePath.startsWith("/")) {
|
|
38
|
-
throw new Error("path must be a string that starts with /");
|
|
39
|
-
}
|
|
40
|
-
if (routePath.includes("..")) {
|
|
41
|
-
throw new Error("path must not contain ..");
|
|
42
|
-
}
|
|
43
|
-
if (isReservedPath(routePath)) {
|
|
44
|
-
throw new Error(`path is reserved: ${routePath}`);
|
|
45
|
-
}
|
|
46
|
-
return routePath;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function resolveHandlerWithinTool(toolDir, handlerRel) {
|
|
50
|
-
if (typeof handlerRel !== "string" || !handlerRel.trim()) {
|
|
51
|
-
throw new Error("handler must be a non-empty string");
|
|
52
|
-
}
|
|
53
|
-
if (path.isAbsolute(handlerRel)) {
|
|
54
|
-
throw new Error("handler must be relative to the tool directory");
|
|
55
|
-
}
|
|
56
|
-
if (handlerRel.includes("..")) {
|
|
57
|
-
throw new Error("handler must not contain ..");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const root = path.resolve(toolDir);
|
|
61
|
-
const handlerPath = path.resolve(root, handlerRel);
|
|
62
|
-
const relative = path.relative(root, handlerPath);
|
|
63
|
-
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
64
|
-
throw new Error("handler must resolve inside the tool directory");
|
|
65
|
-
}
|
|
66
|
-
return handlerPath;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function validateToolWebRoutes(manifest, toolDir, claimedPaths = new Map()) {
|
|
70
|
-
const routes = [];
|
|
71
|
-
const errors = [];
|
|
72
|
-
const toolName = manifest?.name;
|
|
73
|
-
const manifestRoutes = manifest?.web?.routes;
|
|
74
|
-
|
|
75
|
-
if (manifestRoutes == null) return { routes, errors };
|
|
76
|
-
if (!Array.isArray(manifestRoutes)) {
|
|
77
|
-
return { routes, errors: [{ toolName, error: "web.routes must be an array" }] };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const [index, route] of manifestRoutes.entries()) {
|
|
81
|
-
try {
|
|
82
|
-
if (!isObject(route)) {
|
|
83
|
-
throw new Error("route must be an object");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const routePath = validateRoutePath(route.path);
|
|
87
|
-
const previousTool = claimedPaths.get(routePath);
|
|
88
|
-
if (previousTool && previousTool !== toolName) {
|
|
89
|
-
throw new Error(`path collides with tool ${previousTool}: ${routePath}`);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const isPublic = route.public === true;
|
|
93
|
-
const methods = normalizeMethods(route.methods, isPublic);
|
|
94
|
-
const handlerPath = resolveHandlerWithinTool(toolDir, route.handler);
|
|
95
|
-
|
|
96
|
-
claimedPaths.set(routePath, toolName);
|
|
97
|
-
routes.push({
|
|
98
|
-
toolName,
|
|
99
|
-
path: routePath,
|
|
100
|
-
methods,
|
|
101
|
-
public: isPublic,
|
|
102
|
-
handlerPath
|
|
103
|
-
});
|
|
104
|
-
} catch (error) {
|
|
105
|
-
errors.push({
|
|
106
|
-
toolName,
|
|
107
|
-
index,
|
|
108
|
-
path: route?.path,
|
|
109
|
-
error: error instanceof Error ? error.message : String(error)
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return { routes, errors };
|
|
115
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
|
|
4
|
-
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
|
-
|
|
7
|
-
function sendText(res, statusCode, text, headers = {}) {
|
|
8
|
-
if (res.headersSent || res.writableEnded) return;
|
|
9
|
-
res.writeHead(statusCode, {
|
|
10
|
-
"Content-Type": "text/plain; charset=utf-8",
|
|
11
|
-
...headers
|
|
12
|
-
});
|
|
13
|
-
res.end(text);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function safePathname(req) {
|
|
17
|
-
try {
|
|
18
|
-
return new URL(req.url || "/", "http://localhost").pathname;
|
|
19
|
-
} catch {
|
|
20
|
-
return "/";
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function methodKey(method, routePath) {
|
|
25
|
-
return `${String(method || "GET").toUpperCase()} ${routePath}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function extractBearerToken(req, parsedUrl) {
|
|
29
|
-
const authorization = req.headers.authorization || "";
|
|
30
|
-
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
|
31
|
-
if (match) return match[1].trim();
|
|
32
|
-
return parsedUrl.searchParams.get("token") || "";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function timingSafeEqualString(actual, expected) {
|
|
36
|
-
if (!actual || !expected) return false;
|
|
37
|
-
const actualBuffer = Buffer.from(actual);
|
|
38
|
-
const expectedBuffer = Buffer.from(expected);
|
|
39
|
-
if (actualBuffer.length !== expectedBuffer.length) return false;
|
|
40
|
-
return crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function installBodyLimit(req, res, limitBytes) {
|
|
44
|
-
const contentLength = Number(req.headers["content-length"] || 0);
|
|
45
|
-
if (Number.isFinite(contentLength) && contentLength > limitBytes) {
|
|
46
|
-
sendText(res, 413, "request body too large\n");
|
|
47
|
-
req.destroy();
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
let total = 0;
|
|
52
|
-
const originalEmit = req.emit;
|
|
53
|
-
req.emit = function emitWithBodyLimit(eventName, ...args) {
|
|
54
|
-
if (eventName === "data" && !res.writableEnded) {
|
|
55
|
-
const chunk = args[0];
|
|
56
|
-
total += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk || ""));
|
|
57
|
-
if (total > limitBytes) {
|
|
58
|
-
sendText(res, 413, "request body too large\n");
|
|
59
|
-
req.destroy();
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return originalEmit.call(this, eventName, ...args);
|
|
64
|
-
};
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function parseChatId(parsedUrl) {
|
|
69
|
-
const raw = parsedUrl.searchParams.get("chatId");
|
|
70
|
-
if (raw == null || raw.trim() === "") return null;
|
|
71
|
-
const numberValue = Number(raw);
|
|
72
|
-
return Number.isSafeInteger(numberValue) ? numberValue : raw;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function createWebRouter({
|
|
76
|
-
getRoutes,
|
|
77
|
-
buildContext,
|
|
78
|
-
getToken,
|
|
79
|
-
logger,
|
|
80
|
-
limits = {}
|
|
81
|
-
} = {}) {
|
|
82
|
-
const coreRoutes = new Map();
|
|
83
|
-
const handlerCache = new Map();
|
|
84
|
-
const bodyLimitBytes = limits.bodyLimitBytes || DEFAULT_BODY_LIMIT_BYTES;
|
|
85
|
-
const timeoutMs = limits.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
86
|
-
|
|
87
|
-
async function loadHandler(route) {
|
|
88
|
-
if (!handlerCache.has(route.handlerPath)) {
|
|
89
|
-
handlerCache.set(route.handlerPath, import(pathToFileURL(route.handlerPath).href));
|
|
90
|
-
}
|
|
91
|
-
const module = await handlerCache.get(route.handlerPath);
|
|
92
|
-
if (typeof module.handleWebRequest !== "function") {
|
|
93
|
-
throw new Error(`handler does not export handleWebRequest: ${route.handlerPath}`);
|
|
94
|
-
}
|
|
95
|
-
return module.handleWebRequest;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function registerCoreRoute({ method, path, handler }) {
|
|
99
|
-
if (typeof method !== "string" || typeof path !== "string" || typeof handler !== "function") {
|
|
100
|
-
throw new Error("core route requires method, path, and handler");
|
|
101
|
-
}
|
|
102
|
-
coreRoutes.set(methodKey(method, path), handler);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function dispatch(req, res) {
|
|
106
|
-
const parsedUrl = new URL(req.url || "/", "http://localhost");
|
|
107
|
-
const pathname = parsedUrl.pathname;
|
|
108
|
-
const method = String(req.method || "GET").toUpperCase();
|
|
109
|
-
const coreHandler = coreRoutes.get(methodKey(method, pathname));
|
|
110
|
-
if (coreHandler) {
|
|
111
|
-
return coreHandler(req, res);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const route = (getRoutes?.() || []).find((item) => item.path === pathname);
|
|
115
|
-
if (!route) {
|
|
116
|
-
sendText(res, 200, "ok");
|
|
117
|
-
return undefined;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const timeout = setTimeout(() => {
|
|
121
|
-
sendText(res, 504, "request timed out\n");
|
|
122
|
-
req.destroy();
|
|
123
|
-
}, timeoutMs);
|
|
124
|
-
timeout.unref?.();
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
if (!installBodyLimit(req, res, bodyLimitBytes)) return undefined;
|
|
128
|
-
|
|
129
|
-
const token = getToken?.() || "";
|
|
130
|
-
if (!route.public && !timingSafeEqualString(extractBearerToken(req, parsedUrl), token)) {
|
|
131
|
-
sendText(res, 401, "unauthorized\n", { "WWW-Authenticate": "Bearer" });
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const methods = route.methods || ["GET"];
|
|
136
|
-
if (!methods.includes(method)) {
|
|
137
|
-
sendText(res, 405, "method not allowed\n", { Allow: methods.join(", ") });
|
|
138
|
-
return undefined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
logger?.log?.("web", `${route.toolName} ${method} ${pathname}`);
|
|
142
|
-
const handleWebRequest = await loadHandler(route);
|
|
143
|
-
const context = buildContext?.({
|
|
144
|
-
toolName: route.toolName,
|
|
145
|
-
route,
|
|
146
|
-
req,
|
|
147
|
-
res,
|
|
148
|
-
parsedUrl,
|
|
149
|
-
chatId: parseChatId(parsedUrl)
|
|
150
|
-
}) || { toolName: route.toolName, chatId: parseChatId(parsedUrl) };
|
|
151
|
-
await handleWebRequest(req, res, context);
|
|
152
|
-
return undefined;
|
|
153
|
-
} catch (error) {
|
|
154
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
-
logger?.error?.("web", `${route.toolName} ${safePathname(req)}: ${message}`);
|
|
156
|
-
sendText(res, 500, "internal server error\n");
|
|
157
|
-
return undefined;
|
|
158
|
-
} finally {
|
|
159
|
-
clearTimeout(timeout);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
dispatch,
|
|
165
|
-
registerCoreRoute
|
|
166
|
-
};
|
|
167
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtemp } from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import test from "node:test";
|
|
6
|
-
import {
|
|
7
|
-
isReservedPath,
|
|
8
|
-
validateToolWebRoutes
|
|
9
|
-
} from "../src/runtime/web/route-validation.js";
|
|
10
|
-
|
|
11
|
-
async function tempToolDir(name) {
|
|
12
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "arisa-route-validation-"));
|
|
13
|
-
return path.join(root, name);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
test("loads valid tool web routes", async () => {
|
|
17
|
-
const toolDir = await tempToolDir("alpha");
|
|
18
|
-
const claimedPaths = new Map();
|
|
19
|
-
const manifest = {
|
|
20
|
-
name: "alpha",
|
|
21
|
-
web: {
|
|
22
|
-
routes: [
|
|
23
|
-
{ path: "/alpha", handler: "web/index.js" }
|
|
24
|
-
]
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const { routes, errors } = validateToolWebRoutes(manifest, toolDir, claimedPaths);
|
|
29
|
-
|
|
30
|
-
assert.deepEqual(errors, []);
|
|
31
|
-
assert.equal(routes.length, 1);
|
|
32
|
-
assert.equal(routes[0].toolName, "alpha");
|
|
33
|
-
assert.equal(routes[0].path, "/alpha");
|
|
34
|
-
assert.deepEqual(routes[0].methods, ["GET"]);
|
|
35
|
-
assert.equal(routes[0].public, false);
|
|
36
|
-
assert.equal(routes[0].handlerPath, path.join(toolDir, "web", "index.js"));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("rejects invalid and reserved route paths", async () => {
|
|
40
|
-
const toolDir = await tempToolDir("bad");
|
|
41
|
-
const invalidPaths = ["/", "/health", "/api", "/api/tools", "/telegram-secret", "/has/../dotdot", "relative"];
|
|
42
|
-
|
|
43
|
-
for (const routePath of invalidPaths) {
|
|
44
|
-
const { routes, errors } = validateToolWebRoutes({
|
|
45
|
-
name: "bad",
|
|
46
|
-
web: { routes: [{ path: routePath, handler: "web/index.js" }] }
|
|
47
|
-
}, toolDir, new Map());
|
|
48
|
-
|
|
49
|
-
assert.equal(routes.length, 0, routePath);
|
|
50
|
-
assert.equal(errors.length, 1, routePath);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
assert.equal(isReservedPath("/telegram-token"), true);
|
|
54
|
-
assert.equal(isReservedPath("/custom"), false);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("rejects route collisions between tools", async () => {
|
|
58
|
-
const firstToolDir = await tempToolDir("first");
|
|
59
|
-
const secondToolDir = await tempToolDir("second");
|
|
60
|
-
const claimedPaths = new Map();
|
|
61
|
-
|
|
62
|
-
const first = validateToolWebRoutes({
|
|
63
|
-
name: "first",
|
|
64
|
-
web: { routes: [{ path: "/shared", handler: "web/index.js" }] }
|
|
65
|
-
}, firstToolDir, claimedPaths);
|
|
66
|
-
|
|
67
|
-
const second = validateToolWebRoutes({
|
|
68
|
-
name: "second",
|
|
69
|
-
web: { routes: [{ path: "/shared", handler: "web/index.js" }] }
|
|
70
|
-
}, secondToolDir, claimedPaths);
|
|
71
|
-
|
|
72
|
-
assert.equal(first.routes.length, 1);
|
|
73
|
-
assert.equal(second.routes.length, 0);
|
|
74
|
-
assert.match(second.errors[0].error, /collides with tool first/);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("rejects handlers outside the tool directory", async () => {
|
|
78
|
-
const toolDir = await tempToolDir("escape");
|
|
79
|
-
|
|
80
|
-
const { routes, errors } = validateToolWebRoutes({
|
|
81
|
-
name: "escape",
|
|
82
|
-
web: { routes: [{ path: "/escape", handler: "../outside.js" }] }
|
|
83
|
-
}, toolDir, new Map());
|
|
84
|
-
|
|
85
|
-
assert.equal(routes.length, 0);
|
|
86
|
-
assert.equal(errors.length, 1);
|
|
87
|
-
assert.match(errors[0].error, /handler must not contain \.\./);
|
|
88
|
-
});
|