arisa 4.0.16 → 4.0.20

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.
@@ -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,14 +0,0 @@
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,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
- });
@@ -1,267 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { createServer } from "node:http";
3
- import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import test from "node:test";
7
- import { createWebRouter } from "../src/runtime/web/web-router.js";
8
-
9
- async function createHandlerFiles() {
10
- const root = await mkdtemp(path.join(os.tmpdir(), "arisa-web-router-"));
11
- await mkdir(root, { recursive: true });
12
- const okHandler = path.join(root, "ok.js");
13
- const errorHandler = path.join(root, "error.js");
14
-
15
- await writeFile(okHandler, `export async function handleWebRequest(_req, res, context) {
16
- res.writeHead(200, { "Content-Type": "application/json" });
17
- res.end(JSON.stringify({ toolName: context.toolName, chatId: context.chatId }));
18
- }
19
- `, "utf8");
20
-
21
- await writeFile(errorHandler, `export async function handleWebRequest() {
22
- throw new Error("secret stack marker");
23
- }
24
- `, "utf8");
25
-
26
- return { okHandler, errorHandler };
27
- }
28
-
29
- function buildRouter({ routes, token = "secret-token", limits, logger } = {}) {
30
- return createWebRouter({
31
- getRoutes: () => routes,
32
- getToken: () => token,
33
- logger,
34
- limits,
35
- buildContext: ({ toolName, chatId }) => ({ toolName, chatId })
36
- });
37
- }
38
-
39
- async function startRouterServer(router) {
40
- const server = createServer((req, res) => {
41
- router.dispatch(req, res);
42
- });
43
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
44
- const address = server.address();
45
- const baseUrl = `http://127.0.0.1:${address.port}`;
46
- return {
47
- baseUrl,
48
- close: () => new Promise((resolve, reject) => {
49
- server.close((error) => error ? reject(error) : resolve());
50
- })
51
- };
52
- }
53
-
54
- test("protected routes require the shared bearer token", async () => {
55
- const { okHandler } = await createHandlerFiles();
56
- const routes = [
57
- { toolName: "private-tool", path: "/private", methods: ["GET"], public: false, handlerPath: okHandler }
58
- ];
59
- const router = createWebRouter({
60
- getRoutes: () => routes,
61
- getToken: () => "secret-token",
62
- buildContext: ({ toolName, chatId }) => ({ toolName, chatId })
63
- });
64
- const server = await startRouterServer(router);
65
-
66
- try {
67
- const rejected = await fetch(`${server.baseUrl}/private`);
68
- assert.equal(rejected.status, 401);
69
-
70
- const accepted = await fetch(`${server.baseUrl}/private?chatId=123`, {
71
- headers: { Authorization: "Bearer secret-token" }
72
- });
73
- assert.equal(accepted.status, 200);
74
- assert.deepEqual(await accepted.json(), { toolName: "private-tool", chatId: 123 });
75
- } finally {
76
- await server.close();
77
- }
78
- });
79
-
80
- test("public routes do not require a token", async () => {
81
- const { okHandler } = await createHandlerFiles();
82
- const routes = [
83
- { toolName: "public-tool", path: "/public", methods: ["GET"], public: true, handlerPath: okHandler }
84
- ];
85
- const router = createWebRouter({
86
- getRoutes: () => routes,
87
- getToken: () => "secret-token",
88
- buildContext: ({ toolName, chatId }) => ({ toolName, chatId })
89
- });
90
- const server = await startRouterServer(router);
91
-
92
- try {
93
- const response = await fetch(`${server.baseUrl}/public`);
94
- assert.equal(response.status, 200);
95
- assert.deepEqual(await response.json(), { toolName: "public-tool", chatId: null });
96
- } finally {
97
- await server.close();
98
- }
99
- });
100
-
101
- test("disallows undeclared HTTP methods", async () => {
102
- const { okHandler } = await createHandlerFiles();
103
- const routes = [
104
- { toolName: "public-tool", path: "/public", methods: ["GET"], public: true, handlerPath: okHandler }
105
- ];
106
- const router = createWebRouter({
107
- getRoutes: () => routes,
108
- getToken: () => "secret-token",
109
- buildContext: ({ toolName, chatId }) => ({ toolName, chatId })
110
- });
111
- const server = await startRouterServer(router);
112
-
113
- try {
114
- const response = await fetch(`${server.baseUrl}/public`, { method: "POST" });
115
- assert.equal(response.status, 405);
116
- assert.equal(response.headers.get("allow"), "GET");
117
- } finally {
118
- await server.close();
119
- }
120
- });
121
-
122
- test("handler errors do not expose stack traces to clients", async () => {
123
- const { errorHandler } = await createHandlerFiles();
124
- const logs = [];
125
- const routes = [
126
- { toolName: "error-tool", path: "/error", methods: ["GET"], public: true, handlerPath: errorHandler }
127
- ];
128
- const router = createWebRouter({
129
- getRoutes: () => routes,
130
- getToken: () => "secret-token",
131
- logger: { error: (scope, message) => logs.push({ scope, message }) },
132
- buildContext: ({ toolName, chatId }) => ({ toolName, chatId })
133
- });
134
- const server = await startRouterServer(router);
135
-
136
- try {
137
- const response = await fetch(`${server.baseUrl}/error`);
138
- const body = await response.text();
139
- assert.equal(response.status, 500);
140
- assert.equal(body.includes("secret stack marker"), false);
141
- assert.equal(body.includes("Error:"), false);
142
- assert.equal(logs[0].scope, "web");
143
- assert.match(logs[0].message, /secret stack marker/);
144
- } finally {
145
- await server.close();
146
- }
147
- });
148
-
149
- test("core routes take precedence over tool routes", async () => {
150
- const { okHandler } = await createHandlerFiles();
151
- const router = buildRouter({
152
- routes: [
153
- { toolName: "tool", path: "/health", methods: ["GET"], public: true, handlerPath: okHandler }
154
- ]
155
- });
156
- router.registerCoreRoute({
157
- method: "GET",
158
- path: "/health",
159
- handler: (_req, res) => {
160
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
161
- res.end("core-health");
162
- }
163
- });
164
- const server = await startRouterServer(router);
165
-
166
- try {
167
- const response = await fetch(`${server.baseUrl}/health`);
168
- assert.equal(response.status, 200);
169
- assert.equal(await response.text(), "core-health");
170
- } finally {
171
- await server.close();
172
- }
173
- });
174
-
175
- test("unknown paths return a plain ok response", async () => {
176
- const router = buildRouter({ routes: [] });
177
- const server = await startRouterServer(router);
178
-
179
- try {
180
- const response = await fetch(`${server.baseUrl}/does-not-exist`);
181
- assert.equal(response.status, 200);
182
- assert.equal(await response.text(), "ok");
183
- } finally {
184
- await server.close();
185
- }
186
- });
187
-
188
- test("rejects request bodies over the configured limit", async () => {
189
- const { okHandler } = await createHandlerFiles();
190
- const router = buildRouter({
191
- routes: [
192
- { toolName: "upload-tool", path: "/upload", methods: ["POST"], public: true, handlerPath: okHandler }
193
- ],
194
- limits: { bodyLimitBytes: 8 }
195
- });
196
- const server = await startRouterServer(router);
197
-
198
- try {
199
- const response = await fetch(`${server.baseUrl}/upload`, {
200
- method: "POST",
201
- body: "x".repeat(64)
202
- });
203
- assert.equal(response.status, 413);
204
- } finally {
205
- await server.close();
206
- }
207
- });
208
-
209
- test("accepts the shared token from the query string", async () => {
210
- const { okHandler } = await createHandlerFiles();
211
- const router = buildRouter({
212
- routes: [
213
- { toolName: "private-tool", path: "/private", methods: ["GET"], public: false, handlerPath: okHandler }
214
- ]
215
- });
216
- const server = await startRouterServer(router);
217
-
218
- try {
219
- const response = await fetch(`${server.baseUrl}/private?token=secret-token`);
220
- assert.equal(response.status, 200);
221
- } finally {
222
- await server.close();
223
- }
224
- });
225
-
226
- test("rejects wrong tokens and locks down protected routes without a configured token", async () => {
227
- const { okHandler } = await createHandlerFiles();
228
- const protectedRoute = { toolName: "private-tool", path: "/private", methods: ["GET"], public: false, handlerPath: okHandler };
229
-
230
- const wrongTokenServer = await startRouterServer(buildRouter({ routes: [protectedRoute] }));
231
- try {
232
- const response = await fetch(`${wrongTokenServer.baseUrl}/private`, {
233
- headers: { Authorization: "Bearer wrong-token" }
234
- });
235
- assert.equal(response.status, 401);
236
- } finally {
237
- await wrongTokenServer.close();
238
- }
239
-
240
- const noTokenServer = await startRouterServer(buildRouter({ routes: [protectedRoute], token: "" }));
241
- try {
242
- const response = await fetch(`${noTokenServer.baseUrl}/private`, {
243
- headers: { Authorization: "Bearer anything" }
244
- });
245
- assert.equal(response.status, 401);
246
- } finally {
247
- await noTokenServer.close();
248
- }
249
- });
250
-
251
- test("passes a non-numeric chatId through to the handler context", async () => {
252
- const { okHandler } = await createHandlerFiles();
253
- const router = buildRouter({
254
- routes: [
255
- { toolName: "public-tool", path: "/public", methods: ["GET"], public: true, handlerPath: okHandler }
256
- ]
257
- });
258
- const server = await startRouterServer(router);
259
-
260
- try {
261
- const response = await fetch(`${server.baseUrl}/public?chatId=team-7`);
262
- assert.equal(response.status, 200);
263
- assert.deepEqual(await response.json(), { toolName: "public-tool", chatId: "team-7" });
264
- } finally {
265
- await server.close();
266
- }
267
- });