arisa 5.1.2 → 5.1.8

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.
Files changed (39) hide show
  1. package/AGENTS.md +12 -4
  2. package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
  3. package/README.md +25 -0
  4. package/package.json +1 -1
  5. package/src/core/agent/agent-manager.js +55 -12
  6. package/src/core/config/config-defaults.js +3 -1
  7. package/src/core/tools/daemon-processes.js +48 -6
  8. package/src/core/tools/daemon-runtime.js +370 -138
  9. package/src/core/tools/ipc-client.js +3 -0
  10. package/src/core/tools/official-tool-catalog.js +32 -0
  11. package/src/core/tools/official-tool-installer.js +183 -0
  12. package/src/core/tools/tool-registry.js +203 -18
  13. package/src/core/tools/tool-resource-note-store.js +78 -0
  14. package/src/index.js +25 -2
  15. package/src/official-tools.lock.json +40 -0
  16. package/src/runtime/arisa-capabilities.js +43 -3
  17. package/src/runtime/create-app.js +9 -0
  18. package/src/runtime/create-headless-app.js +77 -0
  19. package/src/runtime/doctor.js +27 -2
  20. package/src/runtime/headless-tool-executor.js +45 -0
  21. package/src/runtime/paths.js +16 -4
  22. package/src/runtime/secure-request-file.js +21 -0
  23. package/src/runtime/slave-bootstrap-url.js +51 -0
  24. package/src/runtime/slave-cli.js +267 -0
  25. package/src/runtime/slave-service.js +225 -0
  26. package/src/runtime/tool-usage-report.js +11 -3
  27. package/src/transport/telegram/bot.js +37 -7
  28. package/test/capabilities-security.test.js +29 -0
  29. package/test/daemon-catalog-conformance.test.js +3 -1
  30. package/test/daemon-runtime.test.js +58 -2
  31. package/test/official-tool-installer.test.js +107 -0
  32. package/test/paths.test.js +6 -12
  33. package/test/slave-cli.test.js +282 -0
  34. package/test/telegram-text-artifact.test.js +24 -1
  35. package/test/tool-capability-search.test.js +55 -0
  36. package/test/tool-registry-run.test.js +70 -1
  37. package/test/tool-resource-note.test.js +50 -0
  38. package/test/tool-usage.test.js +10 -5
  39. package/test-fixtures/fake-daemon.js +12 -1
@@ -0,0 +1,225 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const slaveServiceName = "arisa-slave.service";
7
+
8
+ function exists(target) {
9
+ return access(target).then(() => true, () => false);
10
+ }
11
+
12
+ function requireAccountName(value) {
13
+ const name = String(value || "").trim();
14
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(name)) throw new Error(`Invalid service account: ${name || "empty"}`);
15
+ return name;
16
+ }
17
+
18
+ export function resolveSlaveHome({ environment = process.env, euid = process.geteuid?.(), homedir = os.homedir() } = {}) {
19
+ if (environment.ARISA_SLAVE_HOME) return path.resolve(environment.ARISA_SLAVE_HOME);
20
+ return euid === 0 ? "/var/lib/arisa-slave" : path.join(homedir, ".arisa-slave");
21
+ }
22
+
23
+ export function getSlavePaths(slaveHome) {
24
+ const home = path.resolve(slaveHome);
25
+ const state = path.join(home, "state");
26
+ return {
27
+ home,
28
+ state,
29
+ configFile: path.join(state, "config.json"),
30
+ descriptorFile: path.join(state, "service.json"),
31
+ pidFile: path.join(state, "arisa-slave.pid"),
32
+ logFile: path.join(state, "arisa-slave.log"),
33
+ ipcSocket: process.platform === "win32" ? null : path.join(state, "arisa.sock"),
34
+ toolsDir: path.join(home, "tools"),
35
+ tmpDir: path.join(state, "tmp")
36
+ };
37
+ }
38
+
39
+ export async function selectSlaveServiceAccount({
40
+ euid = process.geteuid?.(),
41
+ currentUser = os.userInfo().username,
42
+ ask
43
+ } = {}) {
44
+ if (euid !== 0) {
45
+ return { scope: "user", user: requireAccountName(currentUser), root: false, dedicated: false };
46
+ }
47
+ if (typeof ask !== "function") throw new Error("Running Arisa Slave as UID 0 requires an explicit account selection");
48
+ const choice = String(await ask([
49
+ "Run Arisa Slave as:",
50
+ "1. dedicated user arisa-slave (recommended)",
51
+ "2. another existing user",
52
+ "3. root",
53
+ "Selection"
54
+ ].join("\n"))).trim();
55
+ if (choice === "1") return { scope: "system", user: "arisa-slave", root: false, dedicated: true };
56
+ if (choice === "2") {
57
+ return { scope: "system", user: requireAccountName(await ask("Existing service user")), root: false, dedicated: false };
58
+ }
59
+ if (choice === "3") {
60
+ const confirmation = String(await ask("Type RUN AS ROOT to confirm full root authority")).trim();
61
+ if (confirmation !== "RUN AS ROOT") throw new Error("Root execution was not confirmed");
62
+ return { scope: "system", user: "root", root: true, dedicated: false };
63
+ }
64
+ throw new Error("Invalid Arisa Slave service account selection");
65
+ }
66
+
67
+ function quoteSystemd(value) {
68
+ return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
69
+ }
70
+
71
+ export function buildSlaveSystemdUnit({ account, slaveHome, entryFile, nodePath = process.execPath }) {
72
+ if (!account?.scope || !account?.user) throw new Error("Slave systemd unit requires a service account");
73
+ if (typeof entryFile !== "string" || !path.isAbsolute(entryFile)) throw new Error("Slave systemd unit requires an absolute Arisa entry file");
74
+ const paths = getSlavePaths(slaveHome);
75
+ const userDirective = account.scope === "system" ? `User=${account.user}\n` : "";
76
+ return [
77
+ "[Unit]",
78
+ "Description=Arisa Slave headless host",
79
+ "After=network-online.target",
80
+ "Wants=network-online.target",
81
+ "",
82
+ "[Service]",
83
+ "Type=simple",
84
+ userDirective.trimEnd(),
85
+ `Environment=${quoteSystemd(`ARISA_HOME=${paths.home}`)}`,
86
+ `Environment=${quoteSystemd(`ARISA_SLAVE_HOME=${paths.home}`)}`,
87
+ `WorkingDirectory=${quoteSystemd(paths.home)}`,
88
+ `ExecStart=${quoteSystemd(nodePath)} ${quoteSystemd(entryFile)} slave --service-runner`,
89
+ `StandardOutput=append:${paths.logFile}`,
90
+ `StandardError=append:${paths.logFile}`,
91
+ "Restart=on-failure",
92
+ "RestartSec=2",
93
+ "",
94
+ "[Install]",
95
+ account.scope === "system" ? "WantedBy=multi-user.target" : "WantedBy=default.target",
96
+ ""
97
+ ].filter((line, index, lines) => line || lines[index - 1] !== "").join("\n");
98
+ }
99
+
100
+ export function runCommand(command, args, { cwd, env = process.env } = {}) {
101
+ return new Promise((resolve, reject) => {
102
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
103
+ let stdout = "";
104
+ let stderr = "";
105
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
106
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
107
+ child.once("error", reject);
108
+ child.once("close", (code, signal) => {
109
+ if (code === 0) resolve({ stdout, stderr });
110
+ else reject(new Error(`${command} failed (${signal || code}): ${(stderr || stdout).trim().slice(-1000)}`));
111
+ });
112
+ });
113
+ }
114
+
115
+ async function ensureSystemAccount(account, execute) {
116
+ if (account.scope !== "system" || account.user === "root") return "root";
117
+ try {
118
+ await execute("id", ["-u", account.user]);
119
+ } catch (error) {
120
+ if (!account.dedicated) throw new Error(`Service user does not exist: ${account.user}`, { cause: error });
121
+ await execute("useradd", ["--system", "--home-dir", "/var/lib/arisa-slave", "--create-home", "--shell", "/usr/sbin/nologin", account.user]);
122
+ }
123
+ return (await execute("id", ["-gn", account.user])).stdout.trim();
124
+ }
125
+
126
+ export async function installSlaveSystemdService({
127
+ account,
128
+ slaveHome,
129
+ entryFile,
130
+ execute = runCommand,
131
+ environment = process.env,
132
+ platform = process.platform,
133
+ systemUnitDir = "/etc/systemd/system",
134
+ userUnitDir = path.join(os.homedir(), ".config", "systemd", "user")
135
+ }) {
136
+ if (platform !== "linux") throw new Error("Arisa Slave service installation currently requires Linux with systemd");
137
+ const accountGroup = await ensureSystemAccount(account, execute);
138
+ const paths = getSlavePaths(slaveHome);
139
+ if (account.scope === "system") {
140
+ await execute("install", ["-d", "-m", "0700", "-o", account.user, "-g", accountGroup, paths.home]);
141
+ } else {
142
+ await mkdir(paths.state, { recursive: true, mode: 0o700 });
143
+ }
144
+ const unitDir = account.scope === "system"
145
+ ? systemUnitDir
146
+ : userUnitDir;
147
+ await mkdir(unitDir, { recursive: true });
148
+ const unitFile = path.join(unitDir, slaveServiceName);
149
+ await writeFile(unitFile, buildSlaveSystemdUnit({ account, slaveHome: paths.home, entryFile }), { mode: 0o644 });
150
+ if (account.scope === "system") {
151
+ await execute("chown", ["-R", `${account.user}:${accountGroup}`, paths.home]);
152
+ }
153
+ const systemctlArgs = account.scope === "user" ? ["--user"] : [];
154
+ await execute("systemctl", [...systemctlArgs, "daemon-reload"], { env: environment });
155
+ await execute("systemctl", [...systemctlArgs, "enable", "--now", slaveServiceName], { env: environment });
156
+ return { unitFile, serviceName: slaveServiceName, account, paths };
157
+ }
158
+
159
+ export async function writeSlaveServiceDescriptor(paths, descriptor) {
160
+ await mkdir(paths.state, { recursive: true, mode: 0o700 });
161
+ await writeFile(paths.descriptorFile, `${JSON.stringify(descriptor, null, 2)}\n`, { mode: 0o600 });
162
+ }
163
+
164
+ export async function readSlaveServiceDescriptor(paths) {
165
+ try {
166
+ return JSON.parse(await readFile(paths.descriptorFile, "utf8"));
167
+ } catch (error) {
168
+ if (error?.code === "ENOENT") throw new Error("Arisa Slave service is not installed");
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ export async function controlSlaveService(paths, operation, { execute = runCommand } = {}) {
174
+ const descriptor = await readSlaveServiceDescriptor(paths);
175
+ const prefix = descriptor.account?.scope === "user" ? ["--user"] : [];
176
+ if (operation === "status") {
177
+ try {
178
+ const result = await execute("systemctl", [...prefix, "is-active", slaveServiceName]);
179
+ return { running: result.stdout.trim() === "active", status: result.stdout.trim() };
180
+ } catch {
181
+ return { running: false, status: "inactive" };
182
+ }
183
+ }
184
+ if (!["start", "stop", "restart"].includes(operation)) throw new Error(`Unsupported Slave service operation: ${operation}`);
185
+ await execute("systemctl", [...prefix, operation, slaveServiceName]);
186
+ return { ok: true, operation };
187
+ }
188
+
189
+ export async function registerSlaveServiceProcess(paths) {
190
+ await mkdir(paths.state, { recursive: true, mode: 0o700 });
191
+ let registeredPid = null;
192
+ try {
193
+ registeredPid = Number.parseInt((await readFile(paths.pidFile, "utf8")).trim(), 10);
194
+ } catch {}
195
+ if (Number.isSafeInteger(registeredPid) && registeredPid > 0 && registeredPid !== process.pid) {
196
+ try {
197
+ process.kill(registeredPid, 0);
198
+ throw new Error(`Arisa Slave is already running (pid ${registeredPid})`);
199
+ } catch (error) {
200
+ if (error?.code !== "ESRCH") throw error;
201
+ }
202
+ }
203
+ const handle = await open(paths.pidFile, "w", 0o600);
204
+ try {
205
+ await handle.writeFile(`${process.pid}\n`, "utf8");
206
+ } finally {
207
+ await handle.close();
208
+ }
209
+ }
210
+
211
+ export async function unregisterSlaveServiceProcess(paths) {
212
+ try {
213
+ const registeredPid = Number.parseInt((await readFile(paths.pidFile, "utf8")).trim(), 10);
214
+ if (registeredPid !== process.pid) return false;
215
+ await rm(paths.pidFile, { force: true });
216
+ return true;
217
+ } catch (error) {
218
+ if (error?.code === "ENOENT") return false;
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ export async function isSlaveToolInstalled(paths, toolName = "master-slave") {
224
+ return exists(path.join(paths.toolsDir, toolName, "tool.manifest.json"));
225
+ }
@@ -1,10 +1,18 @@
1
- import { renderTextReport, reportRow } from "./report-format.js";
1
+ import { renderTextReport } from "./report-format.js";
2
2
 
3
3
  export function formatToolUsageReport(tools) {
4
4
  const lines = ["Arisa tools", "===========", "Usage count"];
5
5
  if (!tools.length) lines.push(" (none installed)");
6
- for (const tool of tools) {
7
- lines.push(...reportRow(tool.name, tool.count, { labelWidth: 24 }));
6
+
7
+ const sortedTools = [...tools].sort((left, right) =>
8
+ Number(right.count) - Number(left.count) || String(left.name).localeCompare(String(right.name))
9
+ );
10
+ const nameWidth = Math.max(0, ...sortedTools.map((tool) => String(tool.name).length));
11
+ const countWidth = Math.max(1, ...sortedTools.map((tool) => String(tool.count).length));
12
+ for (const tool of sortedTools) {
13
+ const name = String(tool.name).padEnd(nameWidth);
14
+ const count = String(tool.count).padStart(countWidth);
15
+ lines.push(`- ${name} ${count}`);
8
16
  }
9
17
  return renderTextReport(lines);
10
18
  }
@@ -15,6 +15,7 @@ import { formatPortableSessionHistory } from "../../core/agent/agent-manager.js"
15
15
  import { ConversationHistoryStore } from "../../core/conversation/conversation-history-store.js";
16
16
  import { formatDoctorReport } from "../../runtime/doctor.js";
17
17
  import { formatToolUsageReport } from "../../runtime/tool-usage-report.js";
18
+ import { ToolResourceNoteStore } from "../../core/tools/tool-resource-note-store.js";
18
19
 
19
20
  const slowPromptNoticeMs = 300_000;
20
21
 
@@ -220,12 +221,31 @@ function buildNewSessionPrompt(ctx) {
220
221
  ].join("\n");
221
222
  }
222
223
 
223
- async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger }) {
224
+ export function isScheduledTaskPrompt(prompt) {
225
+ return String(prompt || "").startsWith("Scheduled task fired.\n");
226
+ }
227
+
228
+ export async function withPromptSpeed({ speedController, speed, restoreSpeed }, work) {
229
+ if (!speedController || speed === undefined) return work();
230
+ speedController.setSpeed(speed);
231
+ try {
232
+ return await work();
233
+ } finally {
234
+ speedController.setSpeed(restoreSpeed());
235
+ }
236
+ }
237
+
238
+ export async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }) {
224
239
  const taskText = task.payload.prompt || "";
240
+ const resourceId = String(task.source?.resourceId || task.payload?.resourceId || "").trim();
241
+ const resourceNote = resourceId && task.source?.toolName
242
+ ? await resourceNotes.get(task.payload.chatId, task.source.toolName, resourceId)
243
+ : "";
225
244
  const parts = [
226
245
  "Scheduled task fired.",
227
246
  `taskId: ${task.id}`,
228
247
  `chatId: ${task.payload.chatId}`,
248
+ resourceNote ? `resourceNote: ${resourceNote}` : null,
229
249
  taskText ? `text: ${taskText}` : null
230
250
  ];
231
251
 
@@ -268,11 +288,16 @@ async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger
268
288
  return parts.filter(Boolean).join("\n");
269
289
  }
270
290
 
271
- function buildAsyncEventPrompt(task) {
291
+ async function buildAsyncEventPrompt(task, resourceNotes) {
292
+ const resourceId = String(task.source?.resourceId || task.payload?.resourceId || "").trim();
293
+ const resourceNote = resourceId && task.source?.toolName
294
+ ? await resourceNotes.get(task.payload.chatId, task.source.toolName, resourceId)
295
+ : "";
272
296
  return [
273
297
  "External event arrived.",
274
298
  `taskId: ${task.id}`,
275
299
  `chatId: ${task.payload.chatId}`,
300
+ resourceNote ? `resourceNote: ${resourceNote}` : null,
276
301
  task.payload.prompt ? `event: ${task.payload.prompt}` : null,
277
302
  "A polling checker detected this external event. Evaluate it and decide the next action.",
278
303
  "If it warrants no action, you may stay silent.",
@@ -544,6 +569,7 @@ export async function closeModelPicker(ctx, { messageText, callbackText }) {
544
569
  }
545
570
 
546
571
  export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, doctor, checkUpdates, requestRestart, logger }) {
572
+ const resourceNotes = new ToolResourceNoteStore();
547
573
  const bot = new Bot(config.telegram.token);
548
574
  const perChatState = createChatStateStore();
549
575
  const conversationHistory = new ConversationHistoryStore();
@@ -916,7 +942,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
916
942
 
917
943
  async function processPromptForChat({ chatId, prompt, ctx = null }) {
918
944
  const work = async () => {
919
- const { session } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
945
+ const { session, speedController } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
920
946
  const historyRevision = getChatState(chatId).historyRevision;
921
947
  await conversationHistory.ensureSeed(chatId, {
922
948
  runtime: "pi",
@@ -928,14 +954,18 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
928
954
  chatState.activeSession = session;
929
955
  chatState.activeSteers = [];
930
956
  try {
931
- text = await collectText(session, prompt, {
957
+ text = await withPromptSpeed({
958
+ speedController,
959
+ speed: isScheduledTaskPrompt(prompt) ? 1 : undefined,
960
+ restoreSpeed: () => clampModelSpeed(session.model, resolveChatSpeed(config, chatId))
961
+ }, () => collectText(session, prompt, {
932
962
  logger,
933
963
  chatId,
934
964
  onSlowPrompt: () => bot.api.sendMessage(
935
965
  chatId,
936
966
  "This is taking longer than 5 minutes, so I will keep the current session running instead of starting over. Send /new if you want to abandon it and start fresh."
937
967
  )
938
- });
968
+ }));
939
969
  } catch (error) {
940
970
  agentManager.resetSession(chatId);
941
971
  throw error;
@@ -1082,7 +1112,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1082
1112
  logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
1083
1113
  await enqueuePrompt({
1084
1114
  chatId,
1085
- prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger }),
1115
+ prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
1086
1116
  label: `scheduled task ${task.id}`
1087
1117
  });
1088
1118
  await taskStore.complete(task.id);
@@ -1093,7 +1123,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1093
1123
  logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
1094
1124
  await enqueuePrompt({
1095
1125
  chatId,
1096
- prompt: buildAsyncEventPrompt(task),
1126
+ prompt: await buildAsyncEventPrompt(task, resourceNotes),
1097
1127
  label: `agent event ${task.id}`
1098
1128
  });
1099
1129
  await taskStore.complete(task.id);
@@ -238,6 +238,35 @@ test("rejects missing artifact input before running a tool", async () => {
238
238
  assert.equal(calls.length, 0);
239
239
  });
240
240
 
241
+ test("requires exact confirmation before installing a bundled official tool", async () => {
242
+ const calls = [];
243
+ const toolRegistry = { load: async () => calls.push("reload") };
244
+ const capabilities = createArisaCapabilities({
245
+ artifactStore: createFakeArtifactStore(),
246
+ taskStore: createFakeTaskStore(),
247
+ toolRegistry,
248
+ installOfficialTool: async (name) => {
249
+ calls.push(name);
250
+ return { toolName: name, installed: true };
251
+ }
252
+ });
253
+
254
+ await assert.rejects(() => capabilities.dispatch({
255
+ method: "tools.installOfficial",
256
+ toolName: "master-slave",
257
+ params: { name: "fixture", confirmName: "other" }
258
+ }), /confirmName equal to name/);
259
+ assert.deepEqual(calls, []);
260
+
261
+ const result = await capabilities.dispatch({
262
+ method: "tools.installOfficial",
263
+ toolName: "master-slave",
264
+ params: { name: "fixture", confirmName: "fixture" }
265
+ });
266
+ assert.deepEqual(result, { toolName: "fixture", installed: true });
267
+ assert.deepEqual(calls, ["fixture", "reload"]);
268
+ });
269
+
241
270
  test("normalizes list limits for artifact reads", async () => {
242
271
  const observedLimits = [];
243
272
  const artifactStore = {
@@ -10,7 +10,8 @@ const expected = {
10
10
  "whatsapp-web": { scope: "chat", autoStart: false },
11
11
  "roster-sites": { scope: "global", autoStart: true },
12
12
  "turn-server": { scope: "global", autoStart: true },
13
- "signaling-server": { scope: "global", autoStart: true }
13
+ "signaling-server": { scope: "global", autoStart: true },
14
+ "master-slave": { scope: "global", autoStart: true, protocol: "arisa-daemon-v1" }
14
15
  };
15
16
 
16
17
  for (const [toolName, daemon] of Object.entries(expected)) {
@@ -22,6 +23,7 @@ for (const [toolName, daemon] of Object.entries(expected)) {
22
23
  assert.equal(manifest.daemon.scope, daemon.scope);
23
24
  assert.equal(manifest.daemon.autoStart, daemon.autoStart);
24
25
  assert.equal(manifest.daemon.health, "internal");
26
+ if (daemon.protocol) assert.equal(manifest.daemon.protocol, daemon.protocol);
25
27
  assert.match(source, /createDaemonRuntime/);
26
28
  assert.match(source, /healthCheck/);
27
29
  });
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -38,7 +38,8 @@ const {
38
38
  readJson,
39
39
  stopManagedDaemon,
40
40
  unregisterManagedDaemon,
41
- writeDaemonStatus
41
+ writeDaemonStatus,
42
+ writeJson
42
43
  } = await import("../src/core/tools/daemon-processes.js");
43
44
  const {
44
45
  createDaemonRuntime,
@@ -101,6 +102,61 @@ test("runs health through the queue before accepting jobs", async () => {
101
102
  await runtime.stop();
102
103
  });
103
104
 
105
+ test("streams ordered daemon events and persists the terminal result", async () => {
106
+ const runtime = runtimeFor({ type: "global" });
107
+ const events = [];
108
+ const output = await runtime.submit({ action: "stream", value: "done" }, {
109
+ timeoutMs: 1_000,
110
+ onEvent: (event) => events.push(event)
111
+ });
112
+
113
+ assert.deepEqual(output, { echo: "done" });
114
+ assert.deepEqual(events.map((event) => event.type), ["accepted", "progress", "chunk", "completed"]);
115
+ assert.deepEqual(events.map((event) => event.sequence), [1, 2, 3, 4]);
116
+ assert.ok((await readdir(runtime.paths.commandsDir)).some((file) => file.endsWith(".result.json")));
117
+ await runtime.stop();
118
+ });
119
+
120
+ test("deduplicates repeated notifications for one durable job id", async () => {
121
+ const runtime = runtimeFor({ type: "global" });
122
+ const jobId = "job-deduplicated";
123
+ const [first, second] = await Promise.all([
124
+ runtime.submit({ action: "count" }, { timeoutMs: 1_000, jobId }),
125
+ runtime.submit({ action: "count" }, { timeoutMs: 1_000, jobId })
126
+ ]);
127
+
128
+ assert.deepEqual(first, { count: 1 });
129
+ assert.deepEqual(second, { count: 1 });
130
+ assert.deepEqual(await readJson(path.join(runtime.paths.root, "effects.json"), {}), { count: 1 });
131
+ await runtime.stop();
132
+ });
133
+
134
+ test("recovers queued and accepted journal records after daemon start", async () => {
135
+ const runtime = runtimeFor({ type: "global" });
136
+ await runtime.ensure();
137
+ await writeJson(path.join(runtime.paths.commandsDir, "job-recovered.request.json"), {
138
+ id: "job-recovered",
139
+ status: "queued",
140
+ queuedAt: new Date().toISOString(),
141
+ payload: { value: "queued" }
142
+ });
143
+ await writeJson(path.join(runtime.paths.commandsDir, "job-accepted.processing.json"), {
144
+ id: "job-accepted",
145
+ status: "accepted",
146
+ queuedAt: new Date().toISOString(),
147
+ acceptedAt: new Date().toISOString(),
148
+ payload: { value: "accepted" }
149
+ });
150
+
151
+ const [queued, accepted] = await Promise.all([
152
+ runtime.submit({ value: "ignored" }, { timeoutMs: 1_000, jobId: "job-recovered" }),
153
+ runtime.submit({ value: "ignored" }, { timeoutMs: 1_000, jobId: "job-accepted" })
154
+ ]);
155
+ assert.deepEqual(queued, { echo: "queued" });
156
+ assert.deepEqual(accepted, { echo: "accepted" });
157
+ await runtime.stop();
158
+ });
159
+
104
160
  test("isolates daemon process files and context by chat scope", async () => {
105
161
  const first = runtimeFor({ type: "chat", chatId: "101" });
106
162
  const second = runtimeFor({ type: "chat", chatId: "202" });
@@ -0,0 +1,107 @@
1
+ import assert from "node:assert/strict";
2
+ import crypto from "node:crypto";
3
+ import { cp, mkdir, mkdtemp, readFile, rm, symlink, 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 {
8
+ installBundledOfficialTool,
9
+ installLockedOfficialTool,
10
+ validateOfficialToolLock,
11
+ verifyOfficialToolTree
12
+ } from "../src/core/tools/official-tool-installer.js";
13
+
14
+ async function digest(file) {
15
+ return crypto.createHash("sha256").update(await readFile(file)).digest("hex");
16
+ }
17
+
18
+ async function fixture(t) {
19
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-official-tool-"));
20
+ t.after(() => rm(root, { recursive: true, force: true }));
21
+ const source = path.join(root, "source", "tools", "master-slave");
22
+ await mkdir(source, { recursive: true });
23
+ await writeFile(path.join(source, "tool.manifest.json"), `${JSON.stringify({ name: "master-slave", entry: "index.js" })}\n`);
24
+ await writeFile(path.join(source, "index.js"), "process.stdout.write('ok');\n");
25
+ await mkdir(path.join(source, "lib"));
26
+ await writeFile(path.join(source, "lib", "helper.js"), "export {};\n");
27
+ const files = {
28
+ "index.js": await digest(path.join(source, "index.js")),
29
+ "lib/helper.js": await digest(path.join(source, "lib", "helper.js")),
30
+ "tool.manifest.json": await digest(path.join(source, "tool.manifest.json"))
31
+ };
32
+ return { root, source, files };
33
+ }
34
+
35
+ function lock(files) {
36
+ return {
37
+ version: 1,
38
+ repository: "https://github.com/clasen/Arisa.git",
39
+ commit: "a".repeat(40),
40
+ tools: { "master-slave": { files } }
41
+ };
42
+ }
43
+
44
+ test("requires immutable commits and exact SHA-256 entries", () => {
45
+ assert.throws(
46
+ () => validateOfficialToolLock({ ...lock({ "index.js": "f".repeat(64) }), commit: "main" }, "master-slave"),
47
+ /immutable 40-character commit/
48
+ );
49
+ assert.throws(
50
+ () => validateOfficialToolLock(lock({ "../index.js": "f".repeat(64) }), "master-slave"),
51
+ /Invalid official tool file path/
52
+ );
53
+ });
54
+
55
+ test("verifies the exact file set and digests", async (t) => {
56
+ const { source, files } = await fixture(t);
57
+ assert.deepEqual(await verifyOfficialToolTree(source, files), { files: 3 });
58
+ await writeFile(path.join(source, "extra.js"), "unexpected\n");
59
+ await assert.rejects(() => verifyOfficialToolTree(source, files), /unexpected=extra.js/);
60
+ });
61
+
62
+ test("rejects symbolic links before deployment", async (t) => {
63
+ const { source, files } = await fixture(t);
64
+ await symlink(path.join(source, "index.js"), path.join(source, "link.js"));
65
+ await assert.rejects(() => verifyOfficialToolTree(source, { ...files, "link.js": files["index.js"] }), /symbolic link/);
66
+ });
67
+
68
+ test("installs a verified staged tree without overwriting an existing tool", async (t) => {
69
+ const { root, source, files } = await fixture(t);
70
+ const destination = path.join(root, "installed", "master-slave");
71
+ const checkout = async ({ checkoutDir }) => {
72
+ await mkdir(path.join(checkoutDir, "tools"), { recursive: true });
73
+ await cp(source, path.join(checkoutDir, "tools", "master-slave"), { recursive: true });
74
+ };
75
+ const result = await installLockedOfficialTool({
76
+ toolName: "master-slave",
77
+ lock: lock(files),
78
+ destination,
79
+ scratchRoot: root,
80
+ checkout,
81
+ validate: async () => {}
82
+ });
83
+ assert.equal(result.commit, "a".repeat(40));
84
+ assert.equal(await readFile(path.join(destination, "index.js"), "utf8"), "process.stdout.write('ok');\n");
85
+ await assert.rejects(
86
+ () => installLockedOfficialTool({ toolName: "master-slave", lock: lock(files), destination, scratchRoot: root, checkout }),
87
+ /Refusing to overwrite/
88
+ );
89
+ });
90
+
91
+ test("loads the bundled lock before selecting the canonical tool destination", async (t) => {
92
+ const { root, files } = await fixture(t);
93
+ const lockFile = path.join(root, "official-tools.lock.json");
94
+ await writeFile(lockFile, `${JSON.stringify(lock(files))}\n`);
95
+ const calls = [];
96
+ const result = await installBundledOfficialTool("master-slave", {
97
+ lockFile,
98
+ install: async (request) => {
99
+ calls.push(request);
100
+ return { installed: true };
101
+ }
102
+ });
103
+ assert.deepEqual(result, { installed: true });
104
+ assert.equal(calls[0].toolName, "master-slave");
105
+ assert.deepEqual(calls[0].lock, lock(files));
106
+ assert.match(calls[0].destination, /tools\/master-slave$/);
107
+ });
@@ -50,20 +50,14 @@ test("keeps chat tool state and config paths scoped below the chat directory for
50
50
  );
51
51
  });
52
52
 
53
- test("documents current traversal behavior for unsanitized tool names", () => {
54
- const expectedRoot = path.join(chatsDir, "chat-1", "state", "tools");
55
- const traversed = getChatToolStateDir("chat-1", "../../evil");
56
-
57
- assert.equal(path.resolve(traversed), path.join(chatsDir, "chat-1", "evil"));
58
- assert.equal(path.resolve(traversed).startsWith(`${expectedRoot}${path.sep}`), false);
53
+ test("rejects traversal and non-canonical chat tool names", () => {
54
+ assert.throws(() => getChatToolStateDir("chat-1", "../../evil"), /Invalid tool name/);
55
+ assert.throws(() => getChatToolConfigPath("chat-1", "Upper_Case"), /Invalid tool name/);
59
56
  });
60
57
 
61
- test("documents current traversal behavior for global tool state paths", () => {
62
- const expectedRoot = path.join(stateDir, "tools");
63
- const traversed = getToolStateDir("../../evil");
64
-
65
- assert.equal(path.resolve(traversed), path.join(path.dirname(stateDir), "evil"));
66
- assert.equal(path.resolve(traversed).startsWith(`${expectedRoot}${path.sep}`), false);
58
+ test("rejects traversal and non-canonical global tool names", () => {
59
+ assert.throws(() => getToolStateDir("../../evil"), /Invalid tool name/);
60
+ assert.throws(() => getToolStateDir("-invalid"), /Invalid tool name/);
67
61
  });
68
62
 
69
63
  test("creates POSIX IPC socket paths under the state directory", () => {