arisa 4.0.8 → 4.0.12
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 +22 -0
- package/README.md +3 -1
- package/package.json +4 -3
- package/pnpm-workspace.yaml +3 -0
- package/src/core/agent/agent-manager.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/tools/tool-registry.js +19 -2
- package/src/index.js +126 -17
- package/src/runtime/bootstrap.js +1 -1
- package/src/runtime/create-app.js +59 -1
- package/src/runtime/web/route-validation.js +115 -0
- package/src/runtime/web/web-router.js +167 -0
- package/src/transport/telegram/bot.js +40 -33
- package/test/route-validation.test.js +88 -0
- package/test/web-router.test.js +267 -0
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
|
|
|
@@ -160,7 +162,7 @@ Notes:
|
|
|
160
162
|
|
|
161
163
|
- interactive bootstrap remains unchanged when no CLI overrides are provided
|
|
162
164
|
- `--bootstrap` can be combined with overrides to regenerate config non-interactively
|
|
163
|
-
-
|
|
165
|
+
- Arisa's HTTP server listens on `ARISA_HTTP_PORT` (default `91107`); bootstrap OAuth pages and tool-provided web routes use this server
|
|
164
166
|
- unknown `--pi.provider` or `--pi.model` values are ignored and replaced by safe defaults
|
|
165
167
|
|
|
166
168
|
Telegram bot tokens can be created with:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arisa",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.12",
|
|
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
|
-
"@
|
|
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
|
}
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -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 "@
|
|
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 "@
|
|
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();
|
|
@@ -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
|
-
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
4
5
|
import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
|
|
5
6
|
import { createApp } from "./runtime/create-app.js";
|
|
6
7
|
import { createLogger } from "./runtime/logger.js";
|
|
@@ -22,20 +23,126 @@ const logger = createLogger({ verbose });
|
|
|
22
23
|
let activeApp = null;
|
|
23
24
|
let shuttingDown = false;
|
|
24
25
|
|
|
25
|
-
const
|
|
26
|
+
const defaultHttpPort = 91107;
|
|
27
|
+
const httpPort = Number(process.env.ARISA_HTTP_PORT || defaultHttpPort);
|
|
28
|
+
const shouldStartHttpServer = Boolean(httpPort && !["stop", "status", "flush"].includes(command));
|
|
26
29
|
let httpRequestHandler = null;
|
|
30
|
+
let httpServerListening = false;
|
|
31
|
+
let httpServer = null;
|
|
27
32
|
|
|
28
33
|
function setHttpRequestHandler(handler) {
|
|
29
34
|
httpRequestHandler = handler;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const httpServerReady = shouldStartHttpServer
|
|
38
|
+
? startHttpServer()
|
|
39
|
+
: Promise.resolve(false);
|
|
40
|
+
|
|
41
|
+
async function runCommand(commandName, args = []) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
execFile(commandName, args, (error, stdout = "") => {
|
|
44
|
+
resolve(error ? "" : stdout.trim());
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getListeningPids(port) {
|
|
50
|
+
const lsofOutput = await runCommand("lsof", ["-tiTCP", `-sTCP:LISTEN`, `-iTCP:${port}`]);
|
|
51
|
+
if (lsofOutput) {
|
|
52
|
+
return lsofOutput.split(/\s+/).map(Number).filter(Number.isFinite);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fuserOutput = await runCommand("fuser", ["-n", "tcp", String(port)]);
|
|
56
|
+
return fuserOutput.split(/\s+/).map(Number).filter(Number.isFinite);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function getProcessCommand(pid) {
|
|
60
|
+
return runCommand("ps", ["-p", String(pid), "-o", "command="]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function looksLikeArisaProcess(commandText) {
|
|
64
|
+
return /\barisa\b/.test(commandText) || /\/arisa\/src\/index\.js\b/i.test(commandText);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isProcessRunning(pid) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function waitForProcessExit(pid, timeoutMs = 3000) {
|
|
77
|
+
const deadline = Date.now() + timeoutMs;
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
if (!isProcessRunning(pid)) return true;
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
81
|
+
}
|
|
82
|
+
return !isProcessRunning(pid);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function stopStaleArisaListeners(port) {
|
|
86
|
+
const pids = await getListeningPids(port);
|
|
87
|
+
for (const pid of pids) {
|
|
88
|
+
if (pid === process.pid) continue;
|
|
89
|
+
const commandText = await getProcessCommand(pid);
|
|
90
|
+
if (!looksLikeArisaProcess(commandText)) {
|
|
91
|
+
logger.error("http", `port ${port} is already used by pid ${pid}; not killing non-Arisa process`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
logger.log("http", `stopping stale Arisa listener on port ${port} (pid ${pid})`);
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, "SIGTERM");
|
|
98
|
+
if (!await waitForProcessExit(pid)) {
|
|
99
|
+
process.kill(pid, "SIGKILL");
|
|
100
|
+
await waitForProcessExit(pid, 1000);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
logger.error("http", `failed to stop stale Arisa listener pid ${pid}: ${message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function startHttpServer() {
|
|
110
|
+
await stopStaleArisaListeners(httpPort);
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
const server = createServer((req, res) => {
|
|
113
|
+
if (httpRequestHandler) return httpRequestHandler(req, res);
|
|
114
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
115
|
+
res.end("ok");
|
|
116
|
+
return undefined;
|
|
117
|
+
});
|
|
118
|
+
httpServer = server;
|
|
119
|
+
server.on("error", (error) => {
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
logger.error("http", `server failed on port ${httpPort}: ${message}`);
|
|
122
|
+
resolve(false);
|
|
123
|
+
});
|
|
124
|
+
server.listen(httpPort)
|
|
125
|
+
.on("listening", () => {
|
|
126
|
+
httpServerListening = true;
|
|
127
|
+
logger.log("http", `health server on port ${httpPort}`);
|
|
128
|
+
resolve(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function getHttpOptions() {
|
|
134
|
+
await httpServerReady;
|
|
135
|
+
return httpServerListening ? { httpPort, setHttpRequestHandler } : {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function stopHttpServer() {
|
|
139
|
+
if (!httpServerListening || !httpServer) return;
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
httpServer.close(() => resolve());
|
|
142
|
+
});
|
|
143
|
+
httpServer = null;
|
|
144
|
+
httpServerListening = false;
|
|
145
|
+
httpRequestHandler = null;
|
|
39
146
|
}
|
|
40
147
|
|
|
41
148
|
function parseCliArgs(rawArgs) {
|
|
@@ -97,15 +204,14 @@ function toServiceRunnerArgs(nestedFlags) {
|
|
|
97
204
|
return args;
|
|
98
205
|
}
|
|
99
206
|
|
|
100
|
-
const bootstrapHttpOptions = httpPort ? { httpPort, setHttpRequestHandler } : {};
|
|
101
207
|
const webhookUrl = bootstrapOverrides.webhook?.url || "";
|
|
102
|
-
const appHttpOptions = httpPort ? { webhookUrl, setHttpRequestHandler } : {};
|
|
103
208
|
|
|
104
209
|
async function shutdown(exitCode = 0) {
|
|
105
210
|
if (shuttingDown) return;
|
|
106
211
|
shuttingDown = true;
|
|
107
212
|
try {
|
|
108
213
|
await activeApp?.stop?.();
|
|
214
|
+
await stopHttpServer();
|
|
109
215
|
} catch (error) {
|
|
110
216
|
logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
111
217
|
exitCode = exitCode || 1;
|
|
@@ -124,8 +230,8 @@ process.once("SIGINT", () => {
|
|
|
124
230
|
shutdown(0);
|
|
125
231
|
});
|
|
126
232
|
|
|
127
|
-
async function startRuntimeApp() {
|
|
128
|
-
const app = await createApp({ logger, runtimeOverrides, ...
|
|
233
|
+
async function startRuntimeApp(httpOptions = {}) {
|
|
234
|
+
const app = await createApp({ logger, runtimeOverrides, webhookUrl, ...httpOptions });
|
|
129
235
|
activeApp = app;
|
|
130
236
|
await app.start();
|
|
131
237
|
}
|
|
@@ -137,9 +243,10 @@ async function runForeground() {
|
|
|
137
243
|
|| runtimeOverrides?.pi?.apiKey
|
|
138
244
|
);
|
|
139
245
|
logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
|
|
140
|
-
|
|
246
|
+
const httpOptions = await getHttpOptions();
|
|
247
|
+
await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
|
|
141
248
|
try {
|
|
142
|
-
await startRuntimeApp();
|
|
249
|
+
await startRuntimeApp(httpOptions);
|
|
143
250
|
} catch (error) {
|
|
144
251
|
const message = error instanceof Error ? error.message : String(error);
|
|
145
252
|
if (message.includes("No auth found")) {
|
|
@@ -152,8 +259,8 @@ async function runForeground() {
|
|
|
152
259
|
throw error;
|
|
153
260
|
}
|
|
154
261
|
console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
|
|
155
|
-
await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...
|
|
156
|
-
await startRuntimeApp();
|
|
262
|
+
await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
|
|
263
|
+
await startRuntimeApp(httpOptions);
|
|
157
264
|
return;
|
|
158
265
|
}
|
|
159
266
|
throw error;
|
|
@@ -168,7 +275,9 @@ async function main() {
|
|
|
168
275
|
}
|
|
169
276
|
|
|
170
277
|
if (command === "start") {
|
|
171
|
-
|
|
278
|
+
const httpOptions = await getHttpOptions();
|
|
279
|
+
await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
|
|
280
|
+
await stopHttpServer();
|
|
172
281
|
const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
|
|
173
282
|
if (!result.ok) {
|
|
174
283
|
console.log(`Arisa is already running in background (pid ${result.pid}).`);
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -254,7 +254,7 @@ export async function bootstrapIfNeeded({ force = false, cliConfigOverrides = {}
|
|
|
254
254
|
}
|
|
255
255
|
if (!httpPort || !setHttpRequestHandler) {
|
|
256
256
|
throw new Error(
|
|
257
|
-
`No auth found for ${resolvedPi.provider}.
|
|
257
|
+
`No auth found for ${resolvedPi.provider}. Arisa's HTTP server is unavailable; set ARISA_HTTP_PORT or free the configured port.`
|
|
258
258
|
);
|
|
259
259
|
}
|
|
260
260
|
const authRelay = installAuthRelay(httpPort, setHttpRequestHandler);
|
|
@@ -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
|
-
|
|
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,
|
|
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 &&
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
+
});
|