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
|
@@ -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({
|
|
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
|
+
}
|
package/src/runtime/doctor.js
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/paths.js
CHANGED
|
@@ -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
|
+
}
|