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.
- package/AGENTS.md +19 -22
- package/README.md +2 -26
- package/package.json +7 -8
- package/src/core/tools/ipc-client.js +94 -0
- package/src/core/tools/tool-registry.js +4 -21
- package/src/index.js +10 -146
- package/src/runtime/arisa-capabilities.js +123 -0
- package/src/runtime/bootstrap.js +3 -165
- package/src/runtime/create-app.js +16 -63
- package/src/runtime/ipc/ipc-server.js +109 -0
- package/src/runtime/paths.js +1 -0
- package/src/runtime/service-manager.js +2 -2
- package/src/transport/telegram/bot.js +6 -21
- package/test/ipc-server.test.js +154 -0
- package/src/runtime/process-ids.js +0 -9
- package/src/runtime/web/route-validation.js +0 -115
- package/src/runtime/web/web-router.js +0 -167
- package/test/process-ids.test.js +0 -14
- package/test/route-validation.test.js +0 -88
- package/test/web-router.test.js +0 -267
package/src/runtime/bootstrap.js
CHANGED
|
@@ -24,17 +24,6 @@ async function exists(file) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function normalizeString(value) {
|
|
28
|
-
if (typeof value !== "string") return "";
|
|
29
|
-
return value.trim();
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function parseMaxChatIds(value, fallback = 1) {
|
|
33
|
-
const parsed = Number(value);
|
|
34
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
35
|
-
return parsed;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
27
|
function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piApiKey }) {
|
|
39
28
|
return {
|
|
40
29
|
telegram: {
|
|
@@ -52,37 +41,6 @@ function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piAp
|
|
|
52
41
|
};
|
|
53
42
|
}
|
|
54
43
|
|
|
55
|
-
function resolvePiDefaults(runtime, { provider: preferredProvider = "", model: preferredModel = "" } = {}) {
|
|
56
|
-
const providers = sortBootstrapProviders(listPiProviders(runtime));
|
|
57
|
-
if (!providers.length) {
|
|
58
|
-
throw new Error("No Pi providers are available for bootstrap.");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const preferredProviderValue = normalizeString(preferredProvider);
|
|
62
|
-
const providerExists = providers.some((item) => item.provider === preferredProviderValue);
|
|
63
|
-
if (preferredProviderValue && !providerExists) {
|
|
64
|
-
console.log(`Ignoring unknown Pi provider override: ${preferredProviderValue}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const selectedProvider = providerExists
|
|
68
|
-
? preferredProviderValue
|
|
69
|
-
: providers[0].provider;
|
|
70
|
-
|
|
71
|
-
const models = sortBootstrapModels(selectedProvider, listProviderModels(selectedProvider, runtime));
|
|
72
|
-
if (!models.length) {
|
|
73
|
-
throw new Error(`No Pi models are available for provider ${selectedProvider}.`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const preferredModelValue = normalizeString(preferredModel);
|
|
77
|
-
const modelExists = models.some((item) => item.id === preferredModelValue);
|
|
78
|
-
if (preferredModelValue && !modelExists) {
|
|
79
|
-
console.log(`Ignoring unknown Pi model override for ${selectedProvider}: ${preferredModelValue}`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const selectedModel = modelExists ? preferredModelValue : models[0].id;
|
|
83
|
-
return { provider: selectedProvider, model: selectedModel };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
44
|
function sortBootstrapProviders(providers) {
|
|
87
45
|
const preferredOrder = ["openai-codex"];
|
|
88
46
|
const positions = new Map(providers.map((provider, index) => [provider.provider, index]));
|
|
@@ -131,89 +89,13 @@ async function maybeOpenExternal(url) {
|
|
|
131
89
|
});
|
|
132
90
|
}
|
|
133
91
|
|
|
134
|
-
function
|
|
135
|
-
let authUrl = "";
|
|
136
|
-
let resolveRedirectUrl;
|
|
137
|
-
const redirectUrlPromise = new Promise((resolve) => {
|
|
138
|
-
resolveRedirectUrl = resolve;
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
const page = (body) => [
|
|
142
|
-
"<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width'>",
|
|
143
|
-
"<title>Arisa Auth</title>",
|
|
144
|
-
"<style>body{font-family:system-ui,sans-serif;max-width:600px;margin:40px auto;padding:0 20px;line-height:1.6}",
|
|
145
|
-
"input[type=text]{width:100%;padding:8px;box-sizing:border-box;margin:8px 0}",
|
|
146
|
-
"button{padding:8px 24px;cursor:pointer}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style>",
|
|
147
|
-
"</head><body>",
|
|
148
|
-
body,
|
|
149
|
-
"</body></html>"
|
|
150
|
-
].join("");
|
|
151
|
-
|
|
152
|
-
setHttpRequestHandler((req, res) => {
|
|
153
|
-
const parsed = new URL(req.url, `http://localhost:${httpPort}`);
|
|
154
|
-
|
|
155
|
-
if (req.method === "GET" && parsed.pathname === "/auth/callback" && parsed.searchParams.has("code")) {
|
|
156
|
-
const callbackUrl = `http://localhost:1455${parsed.pathname}${parsed.search}`;
|
|
157
|
-
resolveRedirectUrl(callbackUrl);
|
|
158
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
159
|
-
res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting…</p>"));
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (req.method === "GET" && parsed.pathname === "/") {
|
|
164
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
165
|
-
res.end(page([
|
|
166
|
-
"<h2>Arisa — Pi Authentication</h2>",
|
|
167
|
-
authUrl
|
|
168
|
-
? `<p><strong>1.</strong> <a href="${authUrl}" target="_blank">Click here to log in with Pi</a></p>`
|
|
169
|
-
: "<p>Waiting for authentication URL…</p>",
|
|
170
|
-
"<p><strong>2.</strong> After login your browser will redirect to a <code>localhost</code> URL that won't load. That's expected.</p>",
|
|
171
|
-
"<p><strong>3.</strong> In your browser's address bar, replace <code>localhost:1455</code> with your server's domain and press Enter.</p>",
|
|
172
|
-
"<hr>",
|
|
173
|
-
"<p><em>Or paste the full redirect URL here:</em></p>",
|
|
174
|
-
'<form method="POST" action="/auth/relay">',
|
|
175
|
-
'<input type="text" name="url" placeholder="Paste the localhost redirect URL here…" required />',
|
|
176
|
-
"<button type='submit'>Submit</button>",
|
|
177
|
-
"</form>"
|
|
178
|
-
].join("\n")));
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (req.method === "POST" && parsed.pathname === "/auth/relay") {
|
|
183
|
-
let body = "";
|
|
184
|
-
req.on("data", (chunk) => { body += chunk; });
|
|
185
|
-
req.on("end", () => {
|
|
186
|
-
const url = (new URLSearchParams(body).get("url") || "").trim();
|
|
187
|
-
if (url) resolveRedirectUrl(url);
|
|
188
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
189
|
-
res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting…</p>"));
|
|
190
|
-
});
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
195
|
-
res.end("ok");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
setAuthUrl(url) { authUrl = url; },
|
|
200
|
-
waitForRedirectUrl() { return redirectUrlPromise; },
|
|
201
|
-
uninstall() { setHttpRequestHandler(null); }
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async function runInternalPiLogin(provider, { rl = null, authRelay = null } = {}) {
|
|
92
|
+
async function runInternalPiLogin(provider, { rl = null } = {}) {
|
|
206
93
|
const login = createPiOAuthLogin({
|
|
207
94
|
provider,
|
|
208
95
|
onAuth: async ({ url, instructions, controller }) => {
|
|
209
96
|
console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
|
|
210
97
|
await maybeOpenExternal(url);
|
|
211
|
-
if (
|
|
212
|
-
authRelay.setAuthUrl(url);
|
|
213
|
-
console.log("Waiting for authentication via the web relay...");
|
|
214
|
-
const redirectUrl = await authRelay.waitForRedirectUrl();
|
|
215
|
-
if (redirectUrl) controller.submitManualCode(redirectUrl);
|
|
216
|
-
} else if (controller.oauthProvider.usesCallbackServer && rl) {
|
|
98
|
+
if (controller.oauthProvider.usesCallbackServer && rl) {
|
|
217
99
|
const pasted = (await rl.question("Paste the redirect URL here if the browser does not return automatically, or press Enter to keep waiting: ")).trim();
|
|
218
100
|
if (pasted) controller.submitManualCode(pasted);
|
|
219
101
|
}
|
|
@@ -236,54 +118,10 @@ async function runInternalPiLogin(provider, { rl = null, authRelay = null } = {}
|
|
|
236
118
|
await login.promise;
|
|
237
119
|
}
|
|
238
120
|
|
|
239
|
-
export async function bootstrapIfNeeded({ force = false
|
|
121
|
+
export async function bootstrapIfNeeded({ force = false } = {}) {
|
|
240
122
|
await ensureArisaHome();
|
|
241
123
|
if (!force && await exists(configFile)) return;
|
|
242
124
|
|
|
243
|
-
const telegramApiKeyFromCli = normalizeString(cliConfigOverrides?.telegram?.token);
|
|
244
|
-
if (telegramApiKeyFromCli) {
|
|
245
|
-
const runtime = createPiRuntime();
|
|
246
|
-
const resolvedPi = resolvePiDefaults(runtime, cliConfigOverrides?.pi || {});
|
|
247
|
-
const telegramMaxChatIds = parseMaxChatIds(cliConfigOverrides?.telegram?.maxChatIds, 1);
|
|
248
|
-
const piApiKey = normalizeString(cliConfigOverrides?.pi?.apiKey);
|
|
249
|
-
if (!piApiKey && !hasProviderAuth(resolvedPi.provider, runtime)) {
|
|
250
|
-
if (!supportsProviderOAuth(resolvedPi.provider, runtime)) {
|
|
251
|
-
throw new Error(
|
|
252
|
-
`No auth found for ${resolvedPi.provider}. Provide --pi.apiKey for non-interactive bootstrap, or use a provider that supports OAuth.`
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
if (!httpPort || !setHttpRequestHandler) {
|
|
256
|
-
throw new Error(
|
|
257
|
-
`No auth found for ${resolvedPi.provider}. Arisa's HTTP server is unavailable; set ARISA_HTTP_PORT or free the configured port.`
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
const authRelay = installAuthRelay(httpPort, setHttpRequestHandler);
|
|
261
|
-
console.log(`No existing Pi auth found for ${resolvedPi.provider}. Auth relay active on port ${httpPort}.`);
|
|
262
|
-
console.log(`Open your server URL in a browser to complete Pi authentication.\n`);
|
|
263
|
-
try {
|
|
264
|
-
await runInternalPiLogin(resolvedPi.provider, { authRelay });
|
|
265
|
-
} finally {
|
|
266
|
-
authRelay.uninstall();
|
|
267
|
-
}
|
|
268
|
-
if (!hasProviderAuth(resolvedPi.provider, createPiRuntime())) {
|
|
269
|
-
throw new Error(
|
|
270
|
-
`Pi login did not complete for ${resolvedPi.provider}. Retry or provide --pi.apiKey.`
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
console.log(`Detected Pi auth for ${resolvedPi.provider}. Continuing bootstrap.`);
|
|
274
|
-
}
|
|
275
|
-
const config = buildConfig({
|
|
276
|
-
telegramApiKey: telegramApiKeyFromCli,
|
|
277
|
-
telegramMaxChatIds,
|
|
278
|
-
provider: resolvedPi.provider,
|
|
279
|
-
model: resolvedPi.model,
|
|
280
|
-
piApiKey
|
|
281
|
-
});
|
|
282
|
-
await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
283
|
-
console.log(`\nConfig saved to ${configFile} (non-interactive bootstrap)\n`);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
125
|
const rl = readline.createInterface({ input, output });
|
|
288
126
|
const ask = async (label, fallback = "") => {
|
|
289
127
|
const suffix = fallback ? ` (${fallback})` : "";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
1
|
import { loadConfig, saveConfig, updateConfig } from "../core/config/config-store.js";
|
|
3
2
|
import { ArtifactStore } from "../core/artifacts/artifact-store.js";
|
|
4
3
|
import { ToolRegistry } from "../core/tools/tool-registry.js";
|
|
@@ -7,14 +6,8 @@ import { AgentManager } from "../core/agent/agent-manager.js";
|
|
|
7
6
|
import { getErrorMessage, getPiAuthIssue } from "../core/agent/auth-flow.js";
|
|
8
7
|
import { createTelegramBot } from "../transport/telegram/bot.js";
|
|
9
8
|
import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
getChatArtifactsDir,
|
|
13
|
-
getChatToolStateDir,
|
|
14
|
-
getChatToolTmpDir,
|
|
15
|
-
getToolStateDir,
|
|
16
|
-
getToolTmpDir
|
|
17
|
-
} from "./paths.js";
|
|
9
|
+
import { createArisaCapabilities } from "./arisa-capabilities.js";
|
|
10
|
+
import { createIpcServer } from "./ipc/ipc-server.js";
|
|
18
11
|
|
|
19
12
|
function normalizeString(value) {
|
|
20
13
|
const text = String(value ?? "").trim();
|
|
@@ -53,30 +46,7 @@ function applyRuntimeOverrides(config, runtimeOverrides) {
|
|
|
53
46
|
};
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
async function
|
|
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
|
-
|
|
79
|
-
export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpRequestHandler } = {}) {
|
|
49
|
+
export async function createApp({ logger, runtimeOverrides } = {}) {
|
|
80
50
|
logger?.log("app", "loading config");
|
|
81
51
|
const persistedConfig = await loadConfig();
|
|
82
52
|
const config = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
|
|
@@ -92,33 +62,9 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
|
|
|
92
62
|
logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
|
|
93
63
|
|
|
94
64
|
const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
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 });
|
|
65
|
+
const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore });
|
|
66
|
+
const ipcServer = createIpcServer({ capabilities: arisaCapabilities, logger });
|
|
67
|
+
const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger });
|
|
122
68
|
|
|
123
69
|
return {
|
|
124
70
|
async start() {
|
|
@@ -135,12 +81,18 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
|
|
|
135
81
|
logger?.error("app", `Pi auth validation failed; starting Telegram in auth recovery mode: ${getErrorMessage(error)}`);
|
|
136
82
|
await bot.notifyPiAuthIssue?.(error);
|
|
137
83
|
}
|
|
138
|
-
|
|
139
|
-
|
|
84
|
+
let ipcStarted = false;
|
|
85
|
+
let supervisorStarted = false;
|
|
140
86
|
try {
|
|
87
|
+
await ipcServer.start();
|
|
88
|
+
ipcStarted = true;
|
|
89
|
+
await toolProcessSupervisor.start();
|
|
90
|
+
supervisorStarted = true;
|
|
91
|
+
logger?.log("app", "starting Telegram bot");
|
|
141
92
|
await bot.start({ skipAgentStartupPrompts });
|
|
142
93
|
} catch (error) {
|
|
143
|
-
await toolProcessSupervisor.stop();
|
|
94
|
+
if (supervisorStarted) await toolProcessSupervisor.stop();
|
|
95
|
+
if (ipcStarted) await ipcServer.stop();
|
|
144
96
|
throw error;
|
|
145
97
|
}
|
|
146
98
|
},
|
|
@@ -148,6 +100,7 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
|
|
|
148
100
|
async stop() {
|
|
149
101
|
await bot.stop?.();
|
|
150
102
|
await toolProcessSupervisor.stop();
|
|
103
|
+
await ipcServer.stop();
|
|
151
104
|
}
|
|
152
105
|
};
|
|
153
106
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { chmod, mkdir, unlink } from "node:fs/promises";
|
|
4
|
+
import { arisaIpcSocketFile } from "../paths.js";
|
|
5
|
+
|
|
6
|
+
function writeResponse(socket, response) {
|
|
7
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isStaleSocketError(error) {
|
|
11
|
+
return ["ENOENT", "ECONNREFUSED"].includes(error?.code);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function hasLiveSocket(socketPath) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const client = net.createConnection(socketPath);
|
|
17
|
+
client.once("connect", () => {
|
|
18
|
+
client.end();
|
|
19
|
+
resolve(true);
|
|
20
|
+
});
|
|
21
|
+
client.once("error", (error) => {
|
|
22
|
+
if (isStaleSocketError(error)) {
|
|
23
|
+
resolve(false);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
reject(error);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createIpcServer({ capabilities, socketPath = arisaIpcSocketFile, logger } = {}) {
|
|
32
|
+
let server = null;
|
|
33
|
+
|
|
34
|
+
async function handleLine(socket, line) {
|
|
35
|
+
let request;
|
|
36
|
+
try {
|
|
37
|
+
request = JSON.parse(line);
|
|
38
|
+
} catch {
|
|
39
|
+
writeResponse(socket, { id: null, ok: false, error: "invalid JSON request" });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await capabilities.dispatch(request);
|
|
45
|
+
writeResponse(socket, { id: request.id ?? null, ok: true, result });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
logger?.log?.("ipc", `request failed: ${message}`);
|
|
49
|
+
writeResponse(socket, { id: request.id ?? null, ok: false, error: message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function start() {
|
|
54
|
+
if (server) return { socketPath };
|
|
55
|
+
await mkdir(path.dirname(socketPath), { recursive: true });
|
|
56
|
+
|
|
57
|
+
if (await hasLiveSocket(socketPath)) {
|
|
58
|
+
throw new Error(`Arisa IPC socket already in use: ${socketPath}`);
|
|
59
|
+
}
|
|
60
|
+
await unlink(socketPath).catch((error) => {
|
|
61
|
+
if (error?.code !== "ENOENT") throw error;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
server = net.createServer((socket) => {
|
|
65
|
+
socket.setEncoding("utf8");
|
|
66
|
+
let buffer = "";
|
|
67
|
+
socket.on("data", (chunk) => {
|
|
68
|
+
buffer += chunk;
|
|
69
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
70
|
+
while (newlineIndex !== -1) {
|
|
71
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
72
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
73
|
+
if (line) handleLine(socket, line);
|
|
74
|
+
newlineIndex = buffer.indexOf("\n");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
socket.on("error", (error) => {
|
|
78
|
+
logger?.log?.("ipc", `client socket error: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
server.once("error", reject);
|
|
84
|
+
server.listen(socketPath, resolve);
|
|
85
|
+
});
|
|
86
|
+
await chmod(socketPath, 0o600).catch(() => {});
|
|
87
|
+
logger?.log?.("ipc", `listening on ${socketPath}`);
|
|
88
|
+
return { socketPath };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function stop() {
|
|
92
|
+
if (!server) return;
|
|
93
|
+
const closingServer = server;
|
|
94
|
+
server = null;
|
|
95
|
+
await new Promise((resolve) => closingServer.close(resolve));
|
|
96
|
+
await unlink(socketPath).catch((error) => {
|
|
97
|
+
if (error?.code !== "ENOENT") throw error;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
start,
|
|
103
|
+
stop,
|
|
104
|
+
address: () => server?.address() || null,
|
|
105
|
+
get socketPath() {
|
|
106
|
+
return socketPath;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
package/src/runtime/paths.js
CHANGED
|
@@ -9,6 +9,7 @@ export const stateDir = path.join(arisaHomeDir, "state");
|
|
|
9
9
|
export const configFile = path.join(stateDir, "config.json");
|
|
10
10
|
export const servicePidFile = path.join(stateDir, "arisa.pid");
|
|
11
11
|
export const serviceLogFile = path.join(stateDir, "arisa.log");
|
|
12
|
+
export const arisaIpcSocketFile = path.join(stateDir, "arisa.sock");
|
|
12
13
|
export const tasksFile = path.join(stateDir, "tasks.json");
|
|
13
14
|
export const toolsDir = path.join(arisaHomeDir, "tools");
|
|
14
15
|
export const chatsDir = path.join(arisaHomeDir, "chats");
|
|
@@ -36,7 +36,7 @@ export async function getServiceStatus() {
|
|
|
36
36
|
return { running: true, pid };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function startService({ verbose =
|
|
39
|
+
export async function startService({ verbose = true, cliArgs = [] } = {}) {
|
|
40
40
|
await ensureArisaHome();
|
|
41
41
|
const status = await getServiceStatus();
|
|
42
42
|
if (status.running) {
|
|
@@ -45,7 +45,7 @@ export async function startService({ verbose = false, cliArgs = [] } = {}) {
|
|
|
45
45
|
|
|
46
46
|
const logHandle = await open(serviceLogFile, "a");
|
|
47
47
|
const args = [entryFile, "--service-runner", ...cliArgs];
|
|
48
|
-
if (verbose) args.push("--
|
|
48
|
+
if (!verbose) args.push("--silent");
|
|
49
49
|
|
|
50
50
|
const child = spawn(process.execPath, args, {
|
|
51
51
|
detached: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Bot, InputFile
|
|
1
|
+
import { Bot, InputFile } from "grammy";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { authorizeChat } from "./auth.js";
|
|
4
4
|
import { captureIncomingArtifact } from "./media.js";
|
|
@@ -251,7 +251,7 @@ async function withTyping(ctx, work) {
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger
|
|
254
|
+
export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger }) {
|
|
255
255
|
const bot = new Bot(config.telegram.token);
|
|
256
256
|
const perChatState = new Map();
|
|
257
257
|
const notifiedPromptErrors = new WeakSet();
|
|
@@ -736,25 +736,10 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
736
736
|
}, 1000);
|
|
737
737
|
taskTimer.unref();
|
|
738
738
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
onTimeout: "return",
|
|
744
|
-
});
|
|
745
|
-
webRouter.registerCoreRoute({
|
|
746
|
-
method: "POST",
|
|
747
|
-
path: webhookPath,
|
|
748
|
-
handler: handleUpdate
|
|
749
|
-
});
|
|
750
|
-
await bot.api.setWebhook(`${webhookUrl}${webhookPath}`);
|
|
751
|
-
logger?.log("telegram", `webhook mode: ${webhookUrl}${webhookPath}`);
|
|
752
|
-
scheduleStartupMessages({ skipAgentStartupPrompts });
|
|
753
|
-
} else {
|
|
754
|
-
logger?.log("telegram", "bot polling started");
|
|
755
|
-
scheduleStartupMessages({ skipAgentStartupPrompts });
|
|
756
|
-
await bot.start({ drop_pending_updates: true });
|
|
757
|
-
}
|
|
739
|
+
await bot.api.deleteWebhook({ drop_pending_updates: true });
|
|
740
|
+
logger?.log("telegram", "bot polling started");
|
|
741
|
+
scheduleStartupMessages({ skipAgentStartupPrompts });
|
|
742
|
+
await bot.start();
|
|
758
743
|
},
|
|
759
744
|
|
|
760
745
|
async stop() {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { createArisaClient } from "../src/core/tools/ipc-client.js";
|
|
8
|
+
import { createArisaCapabilities } from "../src/runtime/arisa-capabilities.js";
|
|
9
|
+
import { createIpcServer } from "../src/runtime/ipc/ipc-server.js";
|
|
10
|
+
|
|
11
|
+
function createFakeArtifactStore() {
|
|
12
|
+
const stores = new Map();
|
|
13
|
+
return {
|
|
14
|
+
forChat(chatId) {
|
|
15
|
+
const key = String(chatId);
|
|
16
|
+
if (!stores.has(key)) {
|
|
17
|
+
const items = [];
|
|
18
|
+
stores.set(key, {
|
|
19
|
+
createText: async ({ text, source, metadata }) => {
|
|
20
|
+
const artifact = { id: `artifact-${items.length + 1}`, chatId: key, text, source, metadata };
|
|
21
|
+
items.push(artifact);
|
|
22
|
+
return artifact;
|
|
23
|
+
},
|
|
24
|
+
listRecent: async () => [...items].reverse(),
|
|
25
|
+
get: async (artifactId) => items.find((item) => item.id === artifactId) || null
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return stores.get(key);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createFakeTaskStore() {
|
|
34
|
+
const tasks = [];
|
|
35
|
+
return {
|
|
36
|
+
add: async (task, defaults = {}) => {
|
|
37
|
+
const created = {
|
|
38
|
+
id: `task-${tasks.length + 1}`,
|
|
39
|
+
...task,
|
|
40
|
+
payload: { ...(defaults.payload || {}), ...(task.payload || {}) },
|
|
41
|
+
source: { ...(defaults.source || {}), ...(task.source || {}) }
|
|
42
|
+
};
|
|
43
|
+
tasks.push(created);
|
|
44
|
+
return created;
|
|
45
|
+
},
|
|
46
|
+
list: async (filter = {}) => tasks.filter((task) => {
|
|
47
|
+
if (filter.chatId && String(task.payload?.chatId) !== String(filter.chatId)) return false;
|
|
48
|
+
if (filter.kind && task.kind !== filter.kind) return false;
|
|
49
|
+
if (filter.status && task.status !== filter.status) return false;
|
|
50
|
+
return true;
|
|
51
|
+
}),
|
|
52
|
+
get: async (taskId) => tasks.find((task) => task.id === taskId) || null,
|
|
53
|
+
cancel: async (taskId) => {
|
|
54
|
+
const index = tasks.findIndex((task) => task.id === taskId);
|
|
55
|
+
if (index === -1) return null;
|
|
56
|
+
const [removed] = tasks.splice(index, 1);
|
|
57
|
+
return removed;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createCapabilities() {
|
|
63
|
+
return createArisaCapabilities({
|
|
64
|
+
artifactStore: createFakeArtifactStore(),
|
|
65
|
+
taskStore: createFakeTaskStore()
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function createTempSocketPath() {
|
|
70
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "arisa-ipc-"));
|
|
71
|
+
return path.join(root, "arisa.sock");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function sendRaw(socketPath, request) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const socket = net.createConnection(socketPath);
|
|
77
|
+
let buffer = "";
|
|
78
|
+
socket.setEncoding("utf8");
|
|
79
|
+
socket.once("connect", () => {
|
|
80
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
81
|
+
});
|
|
82
|
+
socket.on("data", (chunk) => {
|
|
83
|
+
buffer += chunk;
|
|
84
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
85
|
+
if (newlineIndex === -1) return;
|
|
86
|
+
socket.end();
|
|
87
|
+
resolve(JSON.parse(buffer.slice(0, newlineIndex)));
|
|
88
|
+
});
|
|
89
|
+
socket.once("error", reject);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("dispatches explicit capabilities over local IPC", async () => {
|
|
94
|
+
const socketPath = await createTempSocketPath();
|
|
95
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
96
|
+
await ipcServer.start();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const client = createArisaClient({ toolName: "ipc-tool", chatId: 123, socketPath });
|
|
100
|
+
const artifact = await client.artifacts.createText({ text: "hello" });
|
|
101
|
+
assert.equal(artifact.text, "hello");
|
|
102
|
+
assert.equal(artifact.source.toolName, "ipc-tool");
|
|
103
|
+
assert.equal(artifact.source.chatId, 123);
|
|
104
|
+
} finally {
|
|
105
|
+
await ipcServer.stop();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects unknown IPC methods", async () => {
|
|
110
|
+
const socketPath = await createTempSocketPath();
|
|
111
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
112
|
+
await ipcServer.start();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const response = await sendRaw(socketPath, {
|
|
116
|
+
id: "one",
|
|
117
|
+
method: "unknown.method",
|
|
118
|
+
toolName: "ipc-tool",
|
|
119
|
+
params: {}
|
|
120
|
+
});
|
|
121
|
+
assert.equal(response.ok, false);
|
|
122
|
+
assert.match(response.error, /unknown IPC method/);
|
|
123
|
+
} finally {
|
|
124
|
+
await ipcServer.stop();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("requires chatId for chat-scoped capabilities", async () => {
|
|
129
|
+
const socketPath = await createTempSocketPath();
|
|
130
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
131
|
+
await ipcServer.start();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const client = createArisaClient({ toolName: "ipc-tool", socketPath });
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
() => client.artifacts.listRecent(),
|
|
137
|
+
/artifacts\.listRecent requires chatId/
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
await ipcServer.stop();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("listens on a local socket, not a TCP port", async () => {
|
|
145
|
+
const socketPath = await createTempSocketPath();
|
|
146
|
+
const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
|
|
147
|
+
await ipcServer.start();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
assert.equal(ipcServer.address(), socketPath);
|
|
151
|
+
} finally {
|
|
152
|
+
await ipcServer.stop();
|
|
153
|
+
}
|
|
154
|
+
});
|