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
@@ -5,6 +5,8 @@ import {
5
5
  getToolStateDir,
6
6
  getToolTmpDir
7
7
  } from "./paths.js";
8
+ import { ToolResourceNoteStore } from "../core/tools/tool-resource-note-store.js";
9
+ import { installBundledOfficialTool } from "../core/tools/official-tool-installer.js";
8
10
 
9
11
  function requireToolName(toolName) {
10
12
  if (typeof toolName !== "string" || !toolName.trim()) {
@@ -41,7 +43,14 @@ function normalizeLimit(limit) {
41
43
  return Math.min(value, 100);
42
44
  }
43
45
 
44
- export function createArisaCapabilities({ artifactStore, taskStore, toolRegistry, agentManager } = {}) {
46
+ export function createArisaCapabilities({
47
+ artifactStore,
48
+ taskStore,
49
+ toolRegistry,
50
+ agentManager,
51
+ resourceNotes = new ToolResourceNoteStore(),
52
+ installOfficialTool = installBundledOfficialTool
53
+ } = {}) {
45
54
  async function dispatch({ method, toolName, chatId = null, params = {} } = {}) {
46
55
  const scopedToolName = requireToolName(toolName);
47
56
 
@@ -70,6 +79,25 @@ export function createArisaCapabilities({ artifactStore, taskStore, toolRegistry
70
79
  );
71
80
  }
72
81
 
82
+ if (method === "tools.setResourceNote") {
83
+ const scopedChatId = requireChatId(chatId, method);
84
+ return resourceNotes.set(
85
+ scopedChatId,
86
+ scopedToolName,
87
+ requireString(params.resourceId, "resourceId"),
88
+ String(params.note ?? "")
89
+ );
90
+ }
91
+
92
+ if (method === "tools.getResourceNote") {
93
+ const scopedChatId = requireChatId(chatId, method);
94
+ return {
95
+ toolName: scopedToolName,
96
+ resourceId: requireString(params.resourceId, "resourceId"),
97
+ note: await resourceNotes.get(scopedChatId, scopedToolName, params.resourceId)
98
+ };
99
+ }
100
+
73
101
  if (method === "tools.run") {
74
102
  if (!agentManager?.runTool) {
75
103
  throw new Error("tools.run requires agentManager");
@@ -89,12 +117,23 @@ export function createArisaCapabilities({ artifactStore, taskStore, toolRegistry
89
117
  request: {
90
118
  artifact,
91
119
  text: params.text,
120
+ resourceId: params.resourceId,
92
121
  args: normalizeArgs(params.args)
93
122
  },
94
123
  chatId: scopedChatId
95
124
  });
96
125
  }
97
126
 
127
+ if (method === "tools.installOfficial") {
128
+ const name = requireString(params.name, "name");
129
+ if (params.confirmName !== name) {
130
+ throw new Error("tools.installOfficial requires confirmName equal to name");
131
+ }
132
+ const installed = await installOfficialTool(name);
133
+ await toolRegistry.load();
134
+ return installed;
135
+ }
136
+
98
137
  if (method === "artifacts.createText") {
99
138
  const scopedChatId = requireChatId(chatId, method);
100
139
  return artifactStore.forChat(scopedChatId).createText({
@@ -164,12 +203,13 @@ export function createArisaCapabilities({ artifactStore, taskStore, toolRegistry
164
203
 
165
204
  if (method === "agent.enqueueEvent") {
166
205
  const scopedChatId = requireChatId(chatId, method);
206
+ const resourceId = String(params.resourceId || "").trim();
167
207
  return taskStore.add({
168
208
  kind: "agent_event",
169
- payload: { prompt: requireString(params.prompt, "prompt") }
209
+ payload: { prompt: requireString(params.prompt, "prompt"), resourceId }
170
210
  }, {
171
211
  payload: { chatId: scopedChatId },
172
- source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId }
212
+ source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId, resourceId }
173
213
  });
174
214
  }
175
215
 
@@ -135,6 +135,15 @@ export async function createApp({ logger, runtimeOverrides, requestRestart } = {
135
135
  toolProcessSupervisor,
136
136
  daemonPolicy: config.daemons,
137
137
  doctorPolicy: config.doctor,
138
+ inspectInfrastructure: async () => {
139
+ const tool = toolRegistry.get("master-slave");
140
+ if (!tool) return null;
141
+ const result = await toolRegistry.run({
142
+ name: "master-slave",
143
+ request: { args: { action: "master.status" } }
144
+ });
145
+ return result.ok ? result.output?.json || null : { error: result.error };
146
+ },
138
147
  logger
139
148
  }),
140
149
  checkUpdates: async (chatId) => formatUpdateReport(await checkForUpdates({ chatId, toolRegistry })),
@@ -0,0 +1,77 @@
1
+ import { ArtifactStore } from "../core/artifacts/artifact-store.js";
2
+ import { loadConfig } from "../core/config/config-store.js";
3
+ import { TaskStore } from "../core/tasks/task-store.js";
4
+ import { ToolRegistry } from "../core/tools/tool-registry.js";
5
+ import { createDaemonRuntime } from "../core/tools/daemon-runtime.js";
6
+ import { createArisaCapabilities } from "./arisa-capabilities.js";
7
+ import { createHeadlessToolExecutor } from "./headless-tool-executor.js";
8
+ import { createIpcServer } from "./ipc/ipc-server.js";
9
+ import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
10
+
11
+ export async function createHeadlessApp({
12
+ logger,
13
+ configLoader = loadConfig,
14
+ artifactStoreFactory = () => new ArtifactStore(),
15
+ taskStoreFactory = () => new TaskStore(),
16
+ toolRegistryFactory = () => new ToolRegistry({ logger }),
17
+ supervisorFactory = (options) => createToolProcessSupervisor(options),
18
+ capabilitiesFactory = (options) => createArisaCapabilities(options),
19
+ ipcServerFactory = (options) => createIpcServer(options)
20
+ } = {}) {
21
+ logger?.log("app", "loading headless config");
22
+ const config = await configLoader();
23
+ const artifactStore = artifactStoreFactory();
24
+ const taskStore = taskStoreFactory();
25
+ const toolRegistry = toolRegistryFactory();
26
+ await toolRegistry.load();
27
+ const toolProcessSupervisor = supervisorFactory({ logger, policy: config.daemons, toolRegistry });
28
+ const toolExecutor = createHeadlessToolExecutor({ artifactStore, taskStore, toolRegistry });
29
+ const capabilities = capabilitiesFactory({
30
+ artifactStore,
31
+ taskStore,
32
+ toolRegistry,
33
+ agentManager: toolExecutor
34
+ });
35
+ const ipcServer = ipcServerFactory({ capabilities, logger });
36
+ const autoStartedDaemons = [];
37
+
38
+ async function startGlobalDaemons() {
39
+ for (const listed of toolRegistry.list()) {
40
+ const tool = toolRegistry.get(listed.name);
41
+ if (!tool?.daemon?.autoStart || tool.daemon.scope === "chat") continue;
42
+ const runtime = createDaemonRuntime({
43
+ toolName: tool.name,
44
+ entryPath: tool.entry,
45
+ scope: { type: "global" },
46
+ autoStart: true
47
+ });
48
+ await runtime.start();
49
+ autoStartedDaemons.push(runtime);
50
+ }
51
+ }
52
+
53
+ return {
54
+ toolRegistry,
55
+ toolProcessSupervisor,
56
+ ipcServer,
57
+ async start() {
58
+ let ipcStarted = false;
59
+ try {
60
+ await ipcServer.start();
61
+ ipcStarted = true;
62
+ await taskStore.recoverInterrupted();
63
+ await startGlobalDaemons();
64
+ await toolProcessSupervisor.start();
65
+ logger?.log("app", "Arisa Slave headless host started");
66
+ } catch (error) {
67
+ if (ipcStarted) await ipcServer.stop();
68
+ throw error;
69
+ }
70
+ },
71
+ async stop() {
72
+ await toolProcessSupervisor.stop();
73
+ await Promise.allSettled(autoStartedDaemons.splice(0).map((runtime) => runtime.stop()));
74
+ await ipcServer.stop();
75
+ }
76
+ };
77
+ }
@@ -224,6 +224,21 @@ export function formatDoctorReport(report) {
224
224
  lines.push("", "Daemons");
225
225
  lines.push(...reportRow("Checked", report.daemons.length));
226
226
  if (report.daemons.length) lines.push(...reportRow("Status", daemonResultSummary(report.daemons)));
227
+ if (report.infrastructure) {
228
+ lines.push("", "Master/Slave");
229
+ if (report.infrastructure.error) {
230
+ lines.push(...reportRow("Status", `unavailable: ${report.infrastructure.error}`));
231
+ } else {
232
+ lines.push(...reportRow("Role", report.infrastructure.role || "unknown"));
233
+ lines.push(...reportRow("Daemon", report.infrastructure.daemon?.state || "unknown"));
234
+ lines.push(...reportRow("Endpoint", report.infrastructure.endpoint || "not configured"));
235
+ lines.push(...reportRow("Identity", report.infrastructure.identityFingerprint || "not configured"));
236
+ lines.push(...reportRow("Paired", report.infrastructure.paired == null ? "n/a" : report.infrastructure.paired ? "yes" : "no"));
237
+ lines.push(...reportRow("Tools", report.infrastructure.toolCount ?? "unknown"));
238
+ lines.push(...reportRow("Jobs", `active=${report.infrastructure.jobs?.active ?? "unknown"}, queued=${report.infrastructure.jobs?.queued ?? "unknown"}, failed=${report.infrastructure.jobs?.failed ?? "unknown"}`));
239
+ lines.push(...reportRow("Pending secrets", report.infrastructure.pendingSecrets ?? "unknown"));
240
+ }
241
+ }
227
242
  if (report.system) {
228
243
  const memoryPercent = report.system.memoryTotal ? (report.system.memoryUsed / report.system.memoryTotal) * 100 : 0;
229
244
  const diskPercent = report.system.diskTotal ? (report.system.diskUsed / report.system.diskTotal) * 100 : 0;
@@ -257,7 +272,8 @@ export async function runDoctor({
257
272
  serviceStatus = getServiceStatus,
258
273
  stopDaemon = stopManagedDaemon,
259
274
  unregisterDaemon = unregisterManagedDaemon,
260
- inspectResources = inspectSystemResources
275
+ inspectResources = inspectSystemResources,
276
+ inspectInfrastructure = null
261
277
  }) {
262
278
  assertDoctorPolicy(doctorPolicy);
263
279
  const runtime = await agentManager.getRuntimeDiagnostic({
@@ -270,9 +286,18 @@ export async function runDoctor({
270
286
  repairs: [],
271
287
  attention: [],
272
288
  system: null,
273
- systemError: null
289
+ systemError: null,
290
+ infrastructure: null
274
291
  };
275
292
  addContextAttention(report);
293
+ if (inspectInfrastructure) {
294
+ try {
295
+ report.infrastructure = await inspectInfrastructure();
296
+ } catch (error) {
297
+ report.infrastructure = { error: error?.message || String(error) };
298
+ report.attention.push(`Master/Slave inspection failed: ${report.infrastructure.error}`);
299
+ }
300
+ }
276
301
  try {
277
302
  report.system = await inspectResources();
278
303
  } catch (error) {
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { unlink } from "node:fs/promises";
3
+
4
+ export function createHeadlessToolExecutor({ artifactStore, taskStore, toolRegistry } = {}) {
5
+ if (!artifactStore || !taskStore || !toolRegistry) {
6
+ throw new Error("Headless tool executor requires artifact, task, and tool stores");
7
+ }
8
+
9
+ return {
10
+ async runTool({ name, request, chatId }) {
11
+ await toolRegistry.load();
12
+ const result = await toolRegistry.run({ name, request, chatId });
13
+ const chatArtifacts = artifactStore.forChat(chatId);
14
+
15
+ if (result.output?.text) {
16
+ const artifact = await chatArtifacts.createText({
17
+ text: result.output.text,
18
+ source: { type: "tool", toolName: name },
19
+ metadata: { tool: name }
20
+ });
21
+ result.output.artifactId = artifact.id;
22
+ }
23
+ if (result.output?.filePath) {
24
+ const generated = await chatArtifacts.createFromFile({
25
+ originalPath: result.output.filePath,
26
+ fileName: result.output.fileName || path.basename(result.output.filePath),
27
+ kind: result.output.kind || "file",
28
+ mimeType: result.output.mimeType || "application/octet-stream",
29
+ source: { type: "tool", toolName: name },
30
+ metadata: { tool: name, delivery: result.output.delivery }
31
+ });
32
+ result.output.artifactId = generated.id;
33
+ await unlink(result.output.filePath).catch(() => {});
34
+ }
35
+ if (result.asyncTask || result.asyncTasks?.length) {
36
+ result.asyncTasks = await taskStore.addMany(result.asyncTasks || [result.asyncTask], {
37
+ payload: { chatId },
38
+ source: { type: "tool", toolName: name, chatId }
39
+ });
40
+ delete result.asyncTask;
41
+ }
42
+ return result;
43
+ }
44
+ };
45
+ }
@@ -27,6 +27,14 @@ export const toolsDir = path.join(arisaHomeDir, "tools");
27
27
  export const chatsDir = path.join(arisaHomeDir, "chats");
28
28
  export const toolStateDir = path.join(stateDir, "tools");
29
29
 
30
+ export function requireToolName(toolName) {
31
+ const name = String(toolName ?? "");
32
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) {
33
+ throw new Error(`Invalid tool name: ${name || "empty"}`);
34
+ }
35
+ return name;
36
+ }
37
+
30
38
  export function getChatDir(chatId) {
31
39
  return path.join(chatsDir, String(chatId));
32
40
  }
@@ -47,8 +55,12 @@ export function getChatToolUsageFile(chatId) {
47
55
  return path.join(getChatDir(chatId), "state", "tool-usage.json");
48
56
  }
49
57
 
58
+ export function getChatToolResourceNotesFile(chatId) {
59
+ return path.join(getChatDir(chatId), "state", "tool-resource-notes.json");
60
+ }
61
+
50
62
  export function getChatToolStateDir(chatId, toolName) {
51
- return path.join(getChatDir(chatId), "state", "tools", toolName);
63
+ return path.join(getChatDir(chatId), "state", "tools", requireToolName(toolName));
52
64
  }
53
65
 
54
66
  export function normalizeDaemonScope(scope = { type: "global" }) {
@@ -91,7 +103,7 @@ export function getChatPiSessionsDir(chatId, sessionRevision = 0) {
91
103
  }
92
104
 
93
105
  export function getToolDir(toolName) {
94
- return path.join(toolsDir, toolName);
106
+ return path.join(toolsDir, requireToolName(toolName));
95
107
  }
96
108
 
97
109
  export function getToolConfigPath(toolName) {
@@ -107,11 +119,11 @@ export function getChatTmpDir(chatId) {
107
119
  }
108
120
 
109
121
  export function getChatToolConfigPath(chatId, toolName) {
110
- return path.join(getChatConfigDir(chatId), "tools", toolName, "config.js");
122
+ return path.join(getChatConfigDir(chatId), "tools", requireToolName(toolName), "config.js");
111
123
  }
112
124
 
113
125
  export function getToolStateDir(toolName) {
114
- return path.join(toolStateDir, toolName);
126
+ return path.join(toolStateDir, requireToolName(toolName));
115
127
  }
116
128
 
117
129
  export function getToolTmpDir(toolName) {
@@ -0,0 +1,21 @@
1
+ import crypto from "node:crypto";
2
+ import { chmod, mkdir, open, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ export async function withSecureRequestFile({ directory, value, prefix = "request" }, useFile) {
6
+ if (typeof useFile !== "function") throw new Error("Secure request handoff requires a consumer");
7
+ await mkdir(directory, { recursive: true, mode: 0o700 });
8
+ await chmod(directory, 0o700);
9
+ const file = path.join(directory, `.${prefix}-${process.pid}-${crypto.randomUUID()}.json`);
10
+ const handle = await open(file, "wx", 0o600);
11
+ try {
12
+ await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
13
+ } finally {
14
+ await handle.close();
15
+ }
16
+ try {
17
+ return await useFile(file);
18
+ } finally {
19
+ await rm(file, { force: true });
20
+ }
21
+ }
@@ -0,0 +1,51 @@
1
+ import net from "node:net";
2
+
3
+ const SECRET_PATTERN = /^arisa_secret_v1_[A-Za-z0-9_-]{43}$/;
4
+
5
+ export function parseSlaveBootstrapUrl(value) {
6
+ if (typeof value !== "string" || !value) {
7
+ throw new Error("Arisa Slave requires one TCP bootstrap URL");
8
+ }
9
+ if (/\s/.test(value)) throw new Error("Slave bootstrap URL must not contain whitespace");
10
+ if (value.includes("?")) throw new Error("Slave bootstrap URL must not contain a query");
11
+ if (value.includes("#")) throw new Error("Slave bootstrap URL must not contain a fragment");
12
+
13
+ let parsed;
14
+ try {
15
+ parsed = new URL(value);
16
+ } catch {
17
+ throw new Error("Invalid Slave bootstrap URL");
18
+ }
19
+ if (parsed.protocol !== "tcp:") throw new Error("Slave bootstrap URL must use tcp");
20
+ if (parsed.username || parsed.password) throw new Error("Slave bootstrap URL must not contain user information");
21
+ if (parsed.search) throw new Error("Slave bootstrap URL must not contain a query");
22
+ if (parsed.hash) throw new Error("Slave bootstrap URL must not contain a fragment");
23
+ if (!parsed.port) throw new Error("Slave bootstrap URL requires an explicit port");
24
+
25
+ const authority = /^tcp:\/\/([^/]+)\//.exec(value)?.[1] || "";
26
+ const ipv6Authority = /^\[([^\]]+)]:(\d+)$/.exec(authority);
27
+ const ipv4Authority = /^([^:]+):(\d+)$/.exec(authority);
28
+ const bracketed = Boolean(ipv6Authority);
29
+ const host = ipv6Authority?.[1] || ipv4Authority?.[1] || "";
30
+ const ipVersion = net.isIP(host);
31
+ if (!ipVersion) throw new Error("Slave bootstrap URL requires an IPv4 or IPv6 literal");
32
+ if (ipVersion === 6 && !bracketed) throw new Error("IPv6 Slave bootstrap addresses must be bracketed");
33
+
34
+ const port = Number(parsed.port);
35
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
36
+ throw new Error("Slave bootstrap URL contains an invalid port");
37
+ }
38
+ const match = /^\/([^/]+)$/.exec(parsed.pathname);
39
+ if (!match) throw new Error("Slave bootstrap URL requires exactly one secret path segment");
40
+ const secret = match[1];
41
+ if (!SECRET_PATTERN.test(secret)) throw new Error("Slave bootstrap URL contains an invalid connection secret");
42
+
43
+ return {
44
+ endpoint: `tcp://${ipVersion === 6 ? `[${host}]` : host}:${port}`,
45
+ host,
46
+ ipVersion,
47
+ port,
48
+ secret,
49
+ url: value
50
+ };
51
+ }
@@ -0,0 +1,267 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import readline from "node:readline/promises";
5
+ import { stdin, stdout } from "node:process";
6
+ import { applyConfigDefaults } from "../core/config/config-defaults.js";
7
+ import { installLockedOfficialTool } from "../core/tools/official-tool-installer.js";
8
+ import { createHeadlessApp } from "./create-headless-app.js";
9
+ import { arisaPackageDir } from "./paths.js";
10
+ import { readPackageVersion, readRecentLogLines, followLogFile } from "./log-viewer.js";
11
+ import { parseSlaveBootstrapUrl } from "./slave-bootstrap-url.js";
12
+ import { withSecureRequestFile } from "./secure-request-file.js";
13
+ import {
14
+ controlSlaveService,
15
+ getSlavePaths,
16
+ installSlaveSystemdService,
17
+ isSlaveToolInstalled,
18
+ readSlaveServiceDescriptor,
19
+ registerSlaveServiceProcess,
20
+ resolveSlaveHome,
21
+ selectSlaveServiceAccount,
22
+ unregisterSlaveServiceProcess,
23
+ writeSlaveServiceDescriptor
24
+ } from "./slave-service.js";
25
+
26
+ const toolName = "master-slave";
27
+ const toolLockFile = new URL("../official-tools.lock.json", import.meta.url);
28
+
29
+ function exists(target) {
30
+ return access(target).then(() => true, () => false);
31
+ }
32
+
33
+ function runProcess(command, args, { cwd, env } = {}) {
34
+ return new Promise((resolve, reject) => {
35
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
36
+ let output = "";
37
+ let diagnostics = "";
38
+ child.stdout.on("data", (chunk) => { output += chunk.toString("utf8"); });
39
+ child.stderr.on("data", (chunk) => { diagnostics += chunk.toString("utf8"); });
40
+ child.once("error", reject);
41
+ child.once("close", (code, signal) => {
42
+ if (code === 0) resolve({ stdout: output, stderr: diagnostics });
43
+ else reject(new Error(`master-slave command failed (${signal || code}): ${diagnostics.trim().slice(-1000)}`));
44
+ });
45
+ });
46
+ }
47
+
48
+ export async function ensureSlaveConfig(paths) {
49
+ await mkdir(paths.state, { recursive: true, mode: 0o700 });
50
+ await mkdir(paths.toolsDir, { recursive: true, mode: 0o700 });
51
+ if (await exists(paths.configFile)) return { created: false, configFile: paths.configFile };
52
+ const config = applyConfigDefaults({ role: "slave", createdAt: new Date().toISOString() });
53
+ await writeFile(paths.configFile, `${JSON.stringify(config, null, 2)}\n`, { flag: "wx", mode: 0o600 });
54
+ return { created: true, configFile: paths.configFile };
55
+ }
56
+
57
+ export async function ensureMasterSlaveTool(paths, {
58
+ install = installLockedOfficialTool,
59
+ readLock = async () => JSON.parse(await readFile(toolLockFile, "utf8"))
60
+ } = {}) {
61
+ if (await isSlaveToolInstalled(paths, toolName)) return { installed: false };
62
+ let lock;
63
+ try {
64
+ lock = await readLock();
65
+ } catch (error) {
66
+ throw new Error("This Arisa build does not include the verified master-slave tool lock", { cause: error });
67
+ }
68
+ const result = await install({
69
+ toolName,
70
+ lock,
71
+ destination: path.join(paths.toolsDir, toolName)
72
+ });
73
+ return { installed: true, ...result };
74
+ }
75
+
76
+ export async function invokeSlaveTool(paths, args, { run = runProcess } = {}) {
77
+ const toolDir = path.join(paths.toolsDir, toolName);
78
+ const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
79
+ const entry = path.join(toolDir, manifest.entry || "index.js");
80
+ return withSecureRequestFile({
81
+ directory: paths.tmpDir,
82
+ prefix: "tool-request",
83
+ value: { args }
84
+ }, async (requestFile) => {
85
+ const result = await run(process.execPath, [entry, "run", "--request-file", requestFile], {
86
+ cwd: toolDir,
87
+ env: {
88
+ ...process.env,
89
+ ARISA_HOME: paths.home,
90
+ ARISA_SLAVE_HOME: paths.home,
91
+ ARISA_PACKAGE_DIR: arisaPackageDir,
92
+ ARISA_IPC_SOCKET: paths.ipcSocket || ""
93
+ }
94
+ });
95
+ const text = result.stdout.trim();
96
+ if (!text) throw new Error("master-slave returned no response");
97
+ const parsed = JSON.parse(text);
98
+ if (parsed.ok === false) throw new Error(parsed.error || "master-slave request failed");
99
+ return parsed;
100
+ });
101
+ }
102
+
103
+ async function askFromTerminal(prompt) {
104
+ const terminal = readline.createInterface({ input: stdin, output: stdout });
105
+ try {
106
+ return await terminal.question(`${prompt}: `);
107
+ } finally {
108
+ terminal.close();
109
+ }
110
+ }
111
+
112
+ async function listSlaveTools(paths) {
113
+ const entries = await readdir(paths.toolsDir, { withFileTypes: true }).catch(() => []);
114
+ const tools = [];
115
+ for (const entry of entries) {
116
+ if (!entry.isDirectory()) continue;
117
+ try {
118
+ const manifest = JSON.parse(await readFile(path.join(paths.toolsDir, entry.name, "tool.manifest.json"), "utf8"));
119
+ tools.push({ name: manifest.name, description: manifest.description || "" });
120
+ } catch {}
121
+ }
122
+ return tools.sort((left, right) => left.name.localeCompare(right.name));
123
+ }
124
+
125
+ async function showSlaveLogs(paths, { follow, output = process.stdout, signal } = {}) {
126
+ const version = await readPackageVersion();
127
+ const snapshot = await readRecentLogLines(paths.logFile, 100);
128
+ output.write(`Arisa Slave v${version} | Recent logs\n`);
129
+ output.write(`${follow ? "Following new logs; press Ctrl+C to exit." : "Log follow disabled."}\n\n`);
130
+ if (snapshot.text) output.write(`${snapshot.text}${snapshot.endsWithNewline ? "\n" : ""}`);
131
+ else output.write("No logs yet.\n");
132
+ if (!follow) return;
133
+ await followLogFile({
134
+ logFile: paths.logFile,
135
+ initialSize: snapshot.size,
136
+ initialIno: snapshot.ino,
137
+ write: (content) => output.write(content),
138
+ signal
139
+ });
140
+ }
141
+
142
+ function parseSlaveToolOutput(result) {
143
+ if (result?.output?.json && typeof result.output.json === "object") return result.output.json;
144
+ if (typeof result?.output?.text === "string") {
145
+ try {
146
+ return JSON.parse(result.output.text);
147
+ } catch {}
148
+ }
149
+ return result?.output && typeof result.output === "object" ? result.output : result;
150
+ }
151
+
152
+ export function formatSlaveStatus({ systemd, diagnostic }) {
153
+ const jobs = diagnostic?.jobs && typeof diagnostic.jobs === "object" ? diagnostic.jobs : {};
154
+ return [
155
+ "Arisa Slave status",
156
+ `Systemd: ${systemd.running ? "active" : systemd.status || "inactive"}`,
157
+ `Daemon: ${diagnostic?.daemon?.state || diagnostic?.daemonState || "unknown"}`,
158
+ `Role: ${diagnostic?.role || "unknown"}`,
159
+ `Endpoint: ${diagnostic?.endpoint || "not configured"}`,
160
+ `Identity: ${diagnostic?.identityFingerprint || diagnostic?.identity || "not configured"}`,
161
+ `Paired: ${diagnostic?.paired === true ? "yes" : diagnostic?.paired === false ? "no" : "unknown"}`,
162
+ `Tools: ${Number.isSafeInteger(diagnostic?.toolCount) ? diagnostic.toolCount : "unknown"}`,
163
+ `Jobs: active=${jobs.active ?? "unknown"}, queued=${jobs.queued ?? "unknown"}, failed=${jobs.failed ?? "unknown"}`,
164
+ `Pending secrets: ${Number.isSafeInteger(diagnostic?.pendingSecrets) ? diagnostic.pendingSecrets : "unknown"}`
165
+ ].join("\n");
166
+ }
167
+
168
+ export async function runSlaveBootstrap(url, {
169
+ paths = getSlavePaths(resolveSlaveHome()),
170
+ ask = askFromTerminal,
171
+ selectAccount = selectSlaveServiceAccount,
172
+ ensureTool = ensureMasterSlaveTool,
173
+ installService = installSlaveSystemdService,
174
+ invokeTool = invokeSlaveTool,
175
+ entryFile,
176
+ output = console,
177
+ platform = process.platform
178
+ } = {}) {
179
+ parseSlaveBootstrapUrl(url);
180
+ if (platform !== "linux") throw new Error("Arisa Slave service installation currently requires Linux with systemd");
181
+ const account = await selectAccount({ ask });
182
+ await ensureSlaveConfig(paths);
183
+ await ensureTool(paths);
184
+ const result = await withSecureRequestFile({
185
+ directory: paths.tmpDir,
186
+ prefix: "bootstrap",
187
+ value: { url }
188
+ }, (bootstrapFile) => invokeTool(paths, { action: "slave.bootstrap", bootstrapFile }));
189
+ await installService({ account, slaveHome: paths.home, entryFile });
190
+ await writeSlaveServiceDescriptor(paths, { version: 1, account, installedAt: new Date().toISOString() });
191
+ output.log(`Arisa Slave paired and running as ${account.user}${account.root ? " (root)" : ""}.`);
192
+ return result;
193
+ }
194
+
195
+ export async function runSlaveService({ paths = getSlavePaths(resolveSlaveHome()), logger } = {}) {
196
+ await ensureSlaveConfig(paths);
197
+ await registerSlaveServiceProcess(paths);
198
+ const app = await createHeadlessApp({ logger });
199
+ try {
200
+ await app.start();
201
+ return app;
202
+ } catch (error) {
203
+ await unregisterSlaveServiceProcess(paths);
204
+ throw error;
205
+ }
206
+ }
207
+
208
+ export async function runSlaveCli({
209
+ positionals = [],
210
+ flags = {},
211
+ logger,
212
+ entryFile,
213
+ output = console,
214
+ paths = getSlavePaths(resolveSlaveHome()),
215
+ controlService = controlSlaveService,
216
+ invokeTool = invokeSlaveTool,
217
+ toolInstalled = isSlaveToolInstalled
218
+ } = {}) {
219
+ if (flags["service-runner"]) {
220
+ const app = await runSlaveService({ paths, logger });
221
+ return { serviceRunner: true, app, paths };
222
+ }
223
+
224
+ const action = positionals[0];
225
+ if (flags.help || action === "help") {
226
+ output.log("Usage: arisa slave <tcp://ip:port/secret> | start | stop | restart | status | log | tools | unpair");
227
+ return { help: true };
228
+ }
229
+ if (action?.startsWith("tcp://")) {
230
+ if (positionals.length !== 1) throw new Error("arisa slave accepts exactly one bootstrap URL");
231
+ return runSlaveBootstrap(action, { paths, entryFile, output });
232
+ }
233
+ if (["start", "stop", "restart"].includes(action)) {
234
+ if (positionals.length !== 1) throw new Error(`arisa slave ${action} does not accept additional arguments`);
235
+ const result = await controlService(paths, action);
236
+ output.log(`Arisa Slave ${action} requested.`);
237
+ return result;
238
+ }
239
+ if (action === "status") {
240
+ if (positionals.length !== 1) throw new Error("arisa slave status does not accept additional arguments");
241
+ const systemd = await controlService(paths, "status");
242
+ const diagnostic = await toolInstalled(paths, toolName)
243
+ ? parseSlaveToolOutput(await invokeTool(paths, { action: "slave.status" }))
244
+ : { daemonState: "not-installed", role: "slave", paired: false, toolCount: 0, pendingSecrets: 0 };
245
+ output.log(formatSlaveStatus({ systemd, diagnostic }));
246
+ return { systemd, diagnostic };
247
+ }
248
+ if (action === "log") {
249
+ if (positionals.length !== 1) throw new Error("arisa slave log does not accept additional arguments");
250
+ await showSlaveLogs(paths, { follow: !flags["no-follow"] });
251
+ return { ok: true };
252
+ }
253
+ if (action === "tools") {
254
+ if (positionals.length !== 1) throw new Error("arisa slave tools does not accept additional arguments");
255
+ const tools = await listSlaveTools(paths);
256
+ for (const tool of tools) output.log(`${tool.name}${tool.description ? ` — ${tool.description}` : ""}`);
257
+ return { tools };
258
+ }
259
+ if (action === "unpair") {
260
+ if (positionals.length !== 1) throw new Error("arisa slave unpair does not accept additional arguments");
261
+ await readSlaveServiceDescriptor(paths);
262
+ const result = await invokeTool(paths, { action: "slave.unpair" });
263
+ output.log("Arisa Slave unpaired. The local service remains installed and stopped from reconnecting.");
264
+ return result;
265
+ }
266
+ throw new Error("Usage: arisa slave <tcp://ip:port/secret> | start | stop | restart | status | log | tools | unpair");
267
+ }