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.
@@ -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 installAuthRelay(httpPort, setHttpRequestHandler) {
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&hellip;</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 &mdash; 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&hellip;</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&hellip;" 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&hellip;</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 (authRelay) {
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, cliConfigOverrides = {}, httpPort = 0, setHttpRequestHandler } = {}) {
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 { createWebRouter } from "./web/web-router.js";
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 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
-
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
- 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 });
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
- await toolProcessSupervisor.start();
139
- logger?.log("app", "starting Telegram bot");
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
+ }
@@ -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 = false, cliArgs = [] } = {}) {
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("--verbose");
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, webhookCallback } from "grammy";
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, webhookUrl, webRouter }) {
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
- if (webhookUrl && webRouter) {
740
- const webhookPath = `/telegram-${config.telegram.token.slice(-8)}`;
741
- const handleUpdate = webhookCallback(bot, "http", {
742
- timeoutMilliseconds: 60_000,
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
+ });
@@ -1,9 +0,0 @@
1
- export function parseProcessIds(commandOutput) {
2
- if (!commandOutput) return [];
3
-
4
- return commandOutput
5
- .split(/\s+/)
6
- .filter((token) => /^\d+$/.test(token))
7
- .map(Number)
8
- .filter((pid) => Number.isInteger(pid) && pid > 0);
9
- }