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.
- package/AGENTS.md +12 -4
- package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
- package/README.md +25 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +55 -12
- package/src/core/config/config-defaults.js +3 -1
- package/src/core/tools/daemon-processes.js +48 -6
- package/src/core/tools/daemon-runtime.js +370 -138
- package/src/core/tools/ipc-client.js +3 -0
- package/src/core/tools/official-tool-catalog.js +32 -0
- package/src/core/tools/official-tool-installer.js +183 -0
- package/src/core/tools/tool-registry.js +203 -18
- package/src/core/tools/tool-resource-note-store.js +78 -0
- package/src/index.js +25 -2
- package/src/official-tools.lock.json +40 -0
- package/src/runtime/arisa-capabilities.js +43 -3
- package/src/runtime/create-app.js +9 -0
- package/src/runtime/create-headless-app.js +77 -0
- package/src/runtime/doctor.js +27 -2
- package/src/runtime/headless-tool-executor.js +45 -0
- package/src/runtime/paths.js +16 -4
- package/src/runtime/secure-request-file.js +21 -0
- package/src/runtime/slave-bootstrap-url.js +51 -0
- package/src/runtime/slave-cli.js +267 -0
- package/src/runtime/slave-service.js +225 -0
- package/src/runtime/tool-usage-report.js +11 -3
- package/src/transport/telegram/bot.js +37 -7
- package/test/capabilities-security.test.js +29 -0
- package/test/daemon-catalog-conformance.test.js +3 -1
- package/test/daemon-runtime.test.js +58 -2
- package/test/official-tool-installer.test.js +107 -0
- package/test/paths.test.js +6 -12
- package/test/slave-cli.test.js +282 -0
- package/test/telegram-text-artifact.test.js +24 -1
- package/test/tool-capability-search.test.js +55 -0
- package/test/tool-registry-run.test.js +70 -1
- package/test/tool-resource-note.test.js +50 -0
- package/test/tool-usage.test.js +10 -5
- 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
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
package/test/paths.test.js
CHANGED
|
@@ -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("
|
|
54
|
-
|
|
55
|
-
|
|
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("
|
|
62
|
-
|
|
63
|
-
|
|
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", () => {
|