arisa 4.0.8 → 4.0.10

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 CHANGED
@@ -31,6 +31,7 @@ Each tool declares in `tool.manifest.json`:
31
31
  - `output`: produced output types
32
32
  - `configSchema`: required config fields
33
33
  - `skillHints`: optional skills to apply when using or editing the tool
34
+ - `web.routes`: optional HTTP routes exposed through Arisa's main server
34
35
 
35
36
  ## Conceptual pipe model
36
37
  There are two different moments where pipes can happen:
@@ -71,6 +72,27 @@ Every CLI must support (the entrypoint comes from `manifest.entry`, currently al
71
72
  - `node index.js --help`
72
73
  - `node index.js run --request-file <json>`
73
74
 
75
+ ## Tool-provided web routes
76
+ Tools may expose small web pages or HTTP endpoints through Arisa's main HTTP server by declaring `web.routes` in `tool.manifest.json`. See `tools/README.md` and `tools/web-hello` for the full contract and minimal example.
77
+
78
+ ```json
79
+ {
80
+ "name": "example-tool",
81
+ "web": {
82
+ "routes": [
83
+ {
84
+ "path": "/example",
85
+ "handler": "web/index.js",
86
+ "methods": ["GET"],
87
+ "public": false
88
+ }
89
+ ]
90
+ }
91
+ }
92
+ ```
93
+
94
+ Handlers run in-process and export `handleWebRequest(req, res, context)`. Routes are protected by default with `config.web.token`; use `public: true` only for intentional public GET endpoints. Keep handlers inside the installed tool directory, avoid reserved paths (`/`, `/health`, `/api*`, `/auth*`, `/telegram-*`), and persist data only through `context.paths` runtime helpers such as `getChatToolStateDir(chatId, toolName)`.
95
+
74
96
  ### Tools that need daemons
75
97
  A tool may need a persistent process, for example to keep a browser session alive or a local model warm. The shared daemon runtime exists for this (the `whispermix-transcribe` catalog tool uses it).
76
98
  When such a tool is built, implement it with the shared daemon runtime instead of custom ad hoc process management:
package/README.md CHANGED
@@ -71,6 +71,8 @@ Each tool folder contains:
71
71
  - `tool.manifest.json`
72
72
  - `index.js`
73
73
 
74
+ `tool.manifest.json` may also include optional `skillHints`; Arisa resolves installed skills and passes them into `run_tool` requests as `skills` guidance, not runtime dependencies.
75
+
74
76
  Each tool is isolated from the root project and from other tools.
75
77
  That isolation is part of the architecture:
76
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.0.8",
3
+ "version": "4.0.10",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -37,12 +37,13 @@
37
37
  },
38
38
  "homepage": "https://github.com/clasen/Arisa#readme",
39
39
  "dependencies": {
40
- "@mariozechner/pi-coding-agent": "^0.73.1",
40
+ "@earendil-works/pi-coding-agent": "^0.79.9",
41
41
  "@sinclair/typebox": "^0.34.41",
42
42
  "grammy": "^1.42.0"
43
43
  },
44
44
  "scripts": {
45
45
  "start": "node src/index.js",
46
- "bootstrap": "node src/index.js --bootstrap"
46
+ "bootstrap": "node src/index.js --bootstrap",
47
+ "test": "node --test"
47
48
  }
48
49
  }
@@ -1,3 +1,6 @@
1
+ allowBuilds:
2
+ '@google/genai': false
3
+
1
4
  overrides:
2
5
  '@protobufjs/utf8@<=1.1.0': '>=1.1.1'
3
6
  basic-ftp@<=5.2.1: '>=5.2.2'
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { unlink } from "node:fs/promises";
3
- import { createAgentSession, SessionManager, defineTool } from "@mariozechner/pi-coding-agent";
3
+ import { createAgentSession, SessionManager, defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
6
  import { arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
@@ -1,4 +1,4 @@
1
- import { AuthStorage } from "@mariozechner/pi-coding-agent";
1
+ import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, onProgress, onSelect } = {}) {
4
4
  const authStorage = AuthStorage.create();
@@ -1,4 +1,4 @@
1
- import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
1
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  function compareText(a, b) {
4
4
  return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from "node:fs/prom
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
+ import { validateToolWebRoutes } from "../../runtime/web/route-validation.js";
5
6
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
6
7
  import { normalizeToolResult } from "./tool-result.js";
7
8
  import { SkillRegistry } from "../skills/skill-registry.js";
@@ -25,11 +26,14 @@ export class ToolRegistry {
25
26
  constructor({ logger } = {}) {
26
27
  this.logger = logger;
27
28
  this.tools = new Map();
29
+ this.webRoutes = [];
28
30
  this.skillRegistry = new SkillRegistry();
29
31
  }
30
32
 
31
33
  async load() {
32
34
  this.tools.clear();
35
+ this.webRoutes = [];
36
+ const claimedWebPaths = new Map();
33
37
 
34
38
  let entries = [];
35
39
  try {
@@ -50,7 +54,7 @@ export class ToolRegistry {
50
54
  const defaults = parseConfigModule(configSource);
51
55
  const config = await loadToolConfig(manifest.name, defaults);
52
56
  const skillHints = this.skillRegistry.normalizeHints(manifest);
53
- this.tools.set(manifest.name, {
57
+ const tool = {
54
58
  ...manifest,
55
59
  skillHints,
56
60
  dir: toolDir,
@@ -59,7 +63,13 @@ export class ToolRegistry {
59
63
  configPath: getToolConfigPath(manifest.name),
60
64
  defaults,
61
65
  config
62
- });
66
+ };
67
+ this.tools.set(manifest.name, tool);
68
+ const { routes, errors } = validateToolWebRoutes(manifest, toolDir, claimedWebPaths);
69
+ this.webRoutes.push(...routes);
70
+ for (const error of errors) {
71
+ this.logger?.log("web", `skipping route for ${manifest.name}: ${error.error}`);
72
+ }
63
73
  } catch {
64
74
  // ignore invalid tool dirs in v1
65
75
  }
@@ -79,6 +89,13 @@ export class ToolRegistry {
79
89
  }));
80
90
  }
81
91
 
92
+ listWebRoutes() {
93
+ return this.webRoutes.map((route) => ({
94
+ ...route,
95
+ methods: [...route.methods]
96
+ }));
97
+ }
98
+
82
99
  get(name) {
83
100
  return this.tools.get(name) || null;
84
101
  }
package/src/index.js CHANGED
@@ -29,7 +29,7 @@ function setHttpRequestHandler(handler) {
29
29
  httpRequestHandler = handler;
30
30
  }
31
31
 
32
- if (httpPort && bootstrapOverrides.telegram) {
32
+ if (httpPort) {
33
33
  createServer((req, res) => {
34
34
  if (httpRequestHandler) return httpRequestHandler(req, res);
35
35
  res.writeHead(200, { "Content-Type": "text/plain" });
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import { loadConfig, saveConfig, updateConfig } from "../core/config/config-store.js";
2
3
  import { ArtifactStore } from "../core/artifacts/artifact-store.js";
3
4
  import { ToolRegistry } from "../core/tools/tool-registry.js";
@@ -6,6 +7,14 @@ import { AgentManager } from "../core/agent/agent-manager.js";
6
7
  import { getErrorMessage, getPiAuthIssue } from "../core/agent/auth-flow.js";
7
8
  import { createTelegramBot } from "../transport/telegram/bot.js";
8
9
  import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
10
+ import { createWebRouter } from "./web/web-router.js";
11
+ import {
12
+ getChatArtifactsDir,
13
+ getChatToolStateDir,
14
+ getChatToolTmpDir,
15
+ getToolStateDir,
16
+ getToolTmpDir
17
+ } from "./paths.js";
9
18
 
10
19
  function normalizeString(value) {
11
20
  const text = String(value ?? "").trim();
@@ -44,6 +53,29 @@ function applyRuntimeOverrides(config, runtimeOverrides) {
44
53
  };
45
54
  }
46
55
 
56
+ async function ensureWebToken(config, logger) {
57
+ if (config.web?.token) return config.web.token;
58
+
59
+ const generatedToken = crypto.randomBytes(24).toString("hex");
60
+ const persisted = await updateConfig((storedConfig) => {
61
+ storedConfig.web ||= {};
62
+ storedConfig.web.token ||= generatedToken;
63
+ });
64
+ config.web = { ...(config.web || {}), token: persisted.web.token };
65
+ logger?.log("web", "generated shared web route token");
66
+ return config.web.token;
67
+ }
68
+
69
+ function createToolWebPaths() {
70
+ return {
71
+ getChatArtifactsDir,
72
+ getChatToolStateDir,
73
+ getChatToolTmpDir,
74
+ getToolStateDir,
75
+ getToolTmpDir
76
+ };
77
+ }
78
+
47
79
  export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpRequestHandler } = {}) {
48
80
  logger?.log("app", "loading config");
49
81
  const persistedConfig = await loadConfig();
@@ -60,7 +92,33 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
60
92
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
61
93
 
62
94
  const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
63
- const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler });
95
+ await ensureWebToken(config, logger);
96
+ const webRouter = createWebRouter({
97
+ getRoutes: () => toolRegistry.listWebRoutes(),
98
+ getToken: () => config.web?.token || "",
99
+ logger,
100
+ buildContext: ({ toolName, chatId }) => ({
101
+ toolName,
102
+ chatId,
103
+ logger,
104
+ toolRegistry,
105
+ artifactStore,
106
+ taskStore,
107
+ agentManager,
108
+ paths: createToolWebPaths()
109
+ })
110
+ });
111
+ webRouter.registerCoreRoute({
112
+ method: "GET",
113
+ path: "/health",
114
+ handler: (_req, res) => {
115
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
116
+ res.end("ok\n");
117
+ }
118
+ });
119
+ setHttpRequestHandler?.(webRouter.dispatch);
120
+
121
+ const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, webRouter: setHttpRequestHandler ? webRouter : null });
64
122
 
65
123
  return {
66
124
  async start() {
@@ -0,0 +1,115 @@
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
+ }
@@ -0,0 +1,167 @@
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
+ }
@@ -185,6 +185,13 @@ function sessionEventLogMessage(event) {
185
185
  return "";
186
186
  }
187
187
 
188
+ function buildStartupMessage(chatMeta = {}) {
189
+ const languageCode = String(chatMeta.languageCode || "").toLowerCase();
190
+ if (languageCode.startsWith("es")) return "Arisa esta en linea de nuevo.";
191
+ if (languageCode.startsWith("pt")) return "Arisa esta online de novo.";
192
+ return "Arisa is back online.";
193
+ }
194
+
188
195
  async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {}) {
189
196
  let text = "";
190
197
  let assistantErrorMessage = "";
@@ -244,7 +251,7 @@ async function withTyping(ctx, work) {
244
251
  }
245
252
  }
246
253
 
247
- export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler }) {
254
+ export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, webRouter }) {
248
255
  const bot = new Bot(config.telegram.token);
249
256
  const perChatState = new Map();
250
257
  const notifiedPromptErrors = new WeakSet();
@@ -522,6 +529,31 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
522
529
  });
523
530
  }
524
531
 
532
+ async function sendStartupMessages() {
533
+ for (const chatId of config.telegram.authorizedChatIds || []) {
534
+ try {
535
+ logger?.log("telegram", `sending startup message for chat ${chatId}`);
536
+ const chatMeta = config.telegram.chatMeta[chatId] || {};
537
+ await bot.api.sendMessage(chatId, buildStartupMessage(chatMeta));
538
+ } catch (error) {
539
+ logger?.log("telegram", `startup message failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
540
+ }
541
+ }
542
+ }
543
+
544
+ function scheduleStartupMessages({ skipAgentStartupPrompts = false } = {}) {
545
+ if (skipAgentStartupPrompts) {
546
+ logger?.log("telegram", "skipping startup messages because Pi auth needs attention");
547
+ return;
548
+ }
549
+ const timer = setTimeout(() => {
550
+ sendStartupMessages().catch((error) => {
551
+ logger?.log("telegram", `startup messages failed: ${error instanceof Error ? error.message : String(error)}`);
552
+ });
553
+ }, 0);
554
+ timer.unref?.();
555
+ }
556
+
525
557
  async function dispatchTask(task) {
526
558
  const chatId = task.payload?.chatId;
527
559
  if (!chatId) {
@@ -692,30 +724,6 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
692
724
  return {
693
725
  async start({ skipAgentStartupPrompts = false } = {}) {
694
726
  config.telegram.chatMeta ||= {};
695
- if (skipAgentStartupPrompts) {
696
- logger?.log("telegram", "skipping agent startup messages because Pi auth needs attention");
697
- } else {
698
- for (const chatId of config.telegram.authorizedChatIds || []) {
699
- try {
700
- logger?.log("telegram", `generating startup message for chat ${chatId}`);
701
- const chatMeta = config.telegram.chatMeta[chatId] || {};
702
- const welcomePrompt = [
703
- "System event: Arisa has just started.",
704
- `chatId: ${chatId}`,
705
- `preferredTelegramLanguageCode: ${chatMeta.languageCode || "unknown"}`,
706
- chatMeta.username ? `username: ${chatMeta.username}` : null,
707
- chatMeta.firstName ? `firstName: ${chatMeta.firstName}` : null,
708
- "Send a short welcome-back message for Telegram.",
709
- "Keep it brief, warm, and natural.",
710
- "Use the user's Telegram language when possible.",
711
- "Do not mention internal implementation details."
712
- ].filter(Boolean).join("\n");
713
- await enqueuePrompt({ chatId, prompt: welcomePrompt, label: "startup message" });
714
- } catch (error) {
715
- logger?.log("telegram", `startup message failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
716
- }
717
- }
718
- }
719
727
  await bot.api.setMyCommands([
720
728
  { command: "new", description: "Start a new chat context" },
721
729
  { command: "auth", description: "Show Pi authentication status" }
@@ -728,24 +736,23 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
728
736
  }, 1000);
729
737
  taskTimer.unref();
730
738
  }
731
- if (webhookUrl && setHttpRequestHandler) {
739
+ if (webhookUrl && webRouter) {
732
740
  const webhookPath = `/telegram-${config.telegram.token.slice(-8)}`;
733
741
  const handleUpdate = webhookCallback(bot, "http", {
734
742
  timeoutMilliseconds: 60_000,
735
743
  onTimeout: "return",
736
744
  });
737
- setHttpRequestHandler((req, res) => {
738
- const parsed = new URL(req.url, "http://localhost");
739
- if (req.method === "POST" && parsed.pathname === webhookPath) {
740
- return handleUpdate(req, res);
741
- }
742
- res.writeHead(200, { "Content-Type": "text/plain" });
743
- res.end("ok");
745
+ webRouter.registerCoreRoute({
746
+ method: "POST",
747
+ path: webhookPath,
748
+ handler: handleUpdate
744
749
  });
745
750
  await bot.api.setWebhook(`${webhookUrl}${webhookPath}`);
746
751
  logger?.log("telegram", `webhook mode: ${webhookUrl}${webhookPath}`);
752
+ scheduleStartupMessages({ skipAgentStartupPrompts });
747
753
  } else {
748
754
  logger?.log("telegram", "bot polling started");
755
+ scheduleStartupMessages({ skipAgentStartupPrompts });
749
756
  await bot.start({ drop_pending_updates: true });
750
757
  }
751
758
  },
@@ -0,0 +1,88 @@
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
+ });
@@ -0,0 +1,267 @@
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
+ });