arisa 4.0.24 → 4.1.2
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/README.md +6 -4
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +69 -12
- package/src/core/agent/core-tools.js +139 -0
- package/src/core/agent/runtime-context.js +14 -3
- package/src/core/agent/system-shell-tool.js +207 -0
- package/src/index.js +44 -10
- package/src/runtime/bootstrap.js +412 -36
- package/src/runtime/create-app.js +52 -9
- package/src/transport/telegram/bot.js +4 -3
- package/test/agent-tool-policy.test.js +91 -0
package/README.md
CHANGED
|
@@ -132,13 +132,15 @@ Notes:
|
|
|
132
132
|
On first run, Arisa will:
|
|
133
133
|
|
|
134
134
|
1. ask for a Telegram bot token
|
|
135
|
-
2. ask
|
|
136
|
-
3.
|
|
137
|
-
4.
|
|
138
|
-
5.
|
|
135
|
+
2. validate the token and ask whether to continue bootstrap from Telegram (default: yes)
|
|
136
|
+
3. when Telegram setup is selected, show a `https://t.me/<bot>?start=<setup-token>` link
|
|
137
|
+
4. authorize that Telegram chat and continue setup there: Pi provider, model, and Pi auth
|
|
138
|
+
5. ask from Telegram whether Arisa should keep running in background
|
|
139
139
|
6. validate that Pi Agent works
|
|
140
140
|
7. only then start listening to Telegram
|
|
141
141
|
|
|
142
|
+
Choosing `n` at the Telegram setup prompt keeps the previous CLI-only bootstrap flow.
|
|
143
|
+
|
|
142
144
|
Arisa does not run a persistent HTTP health server or Telegram webhook; Telegram uses long polling.
|
|
143
145
|
|
|
144
146
|
Telegram bot tokens can be created with:
|
package/package.json
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { unlink } from "node:fs/promises";
|
|
2
|
+
import { stat, unlink } from "node:fs/promises";
|
|
3
3
|
import { createAgentSession, SessionManager, defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "@sinclair/typebox";
|
|
5
5
|
import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
|
|
6
6
|
import { arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
|
|
7
7
|
import { withTimeout } from "./prompt-timeout.js";
|
|
8
|
+
import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
|
|
9
|
+
import { createSystemShellTool } from "./system-shell-tool.js";
|
|
8
10
|
import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
|
|
9
11
|
|
|
10
12
|
const piValidationTimeoutMs = 60_000;
|
|
13
|
+
const arisaToolNames = [
|
|
14
|
+
"list_tools",
|
|
15
|
+
"tool_help",
|
|
16
|
+
"tool_skills",
|
|
17
|
+
"set_tool_config",
|
|
18
|
+
"run_tool",
|
|
19
|
+
"list_scheduled_tasks",
|
|
20
|
+
"cancel_scheduled_task",
|
|
21
|
+
"cancel_all_scheduled_tasks",
|
|
22
|
+
"send_media_reply"
|
|
23
|
+
];
|
|
11
24
|
|
|
12
25
|
function isLocalBaseUrl(value) {
|
|
13
26
|
if (typeof value !== "string" || !value.trim()) return false;
|
|
@@ -68,6 +81,13 @@ function selectMediaReplyTool(toolRegistry) {
|
|
|
68
81
|
return tools.find(looksLikeTextToSpeechTool) || tools[0] || null;
|
|
69
82
|
}
|
|
70
83
|
|
|
84
|
+
async function assertDirectory(dir, label) {
|
|
85
|
+
const stats = await stat(dir);
|
|
86
|
+
if (!stats.isDirectory()) {
|
|
87
|
+
throw new Error(`${label} is not a directory: ${dir}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
export class AgentManager {
|
|
72
92
|
constructor({ config, artifactStore, toolRegistry, taskStore, logger }) {
|
|
73
93
|
this.config = config;
|
|
@@ -95,15 +115,15 @@ export class AgentManager {
|
|
|
95
115
|
this.sessions.delete(String(chatId));
|
|
96
116
|
}
|
|
97
117
|
|
|
98
|
-
createSessionManager(chatId) {
|
|
118
|
+
createSessionManager(chatId, workspaceDir = arisaInstallDir) {
|
|
99
119
|
const sessionKey = String(chatId);
|
|
100
120
|
const sessionDir = getChatPiSessionsDir(sessionKey);
|
|
101
121
|
if (this.pendingNewSessions.has(sessionKey)) {
|
|
102
122
|
this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
|
|
103
|
-
return { sessionManager: SessionManager.create(
|
|
123
|
+
return { sessionManager: SessionManager.create(workspaceDir, sessionDir), isNewSession: true };
|
|
104
124
|
}
|
|
105
125
|
this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
|
|
106
|
-
return { sessionManager: SessionManager.continueRecent(
|
|
126
|
+
return { sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir), isNewSession: false };
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
async validatePiAgent() {
|
|
@@ -156,23 +176,36 @@ export class AgentManager {
|
|
|
156
176
|
throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
|
|
157
177
|
}
|
|
158
178
|
|
|
159
|
-
const
|
|
179
|
+
const policy = buildPiToolPolicy({
|
|
180
|
+
config: this.config,
|
|
181
|
+
customToolNames: [...arisaToolNames, "system_shell"]
|
|
182
|
+
});
|
|
183
|
+
await assertDirectory(policy.workspaceDir, "pi.workspaceDir");
|
|
184
|
+
const { sessionManager, isNewSession } = this.createSessionManager(sessionKey, policy.workspaceDir);
|
|
160
185
|
const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
|
|
161
186
|
this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId}`);
|
|
162
|
-
const customTools =
|
|
187
|
+
const customTools = [
|
|
188
|
+
...this.createTools(telegram, chatId, policy),
|
|
189
|
+
createSystemShellTool({ workspaceDir: policy.workspaceDir, shell: policy.shell })
|
|
190
|
+
];
|
|
163
191
|
const { session } = await createAgentSession({
|
|
164
|
-
cwd:
|
|
192
|
+
cwd: policy.workspaceDir,
|
|
165
193
|
agentDir: arisaHomeDir,
|
|
166
194
|
authStorage,
|
|
167
195
|
modelRegistry,
|
|
168
196
|
model,
|
|
197
|
+
tools: policy.tools,
|
|
198
|
+
excludeTools: policy.excludeTools,
|
|
169
199
|
customTools,
|
|
170
200
|
sessionManager
|
|
171
201
|
});
|
|
172
202
|
|
|
173
203
|
if (!hasExistingSession) {
|
|
174
204
|
this.logger?.log("agent", `created new session for chat ${sessionKey}`);
|
|
175
|
-
this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${buildAgentRuntimeContext(
|
|
205
|
+
this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${buildAgentRuntimeContext({
|
|
206
|
+
workspaceDir: policy.workspaceDir,
|
|
207
|
+
coreTools: policy.coreTools
|
|
208
|
+
})}`);
|
|
176
209
|
}
|
|
177
210
|
|
|
178
211
|
const ctx = { session, modelId: effectiveModelId };
|
|
@@ -226,20 +259,44 @@ export class AgentManager {
|
|
|
226
259
|
return result;
|
|
227
260
|
}
|
|
228
261
|
|
|
229
|
-
createTools(telegram, chatId) {
|
|
262
|
+
createTools(telegram, chatId, policy = buildPiToolPolicy({ config: this.config, customToolNames: arisaToolNames })) {
|
|
230
263
|
const chatArtifactStore = this.artifactStore.forChat(chatId);
|
|
231
264
|
|
|
232
265
|
return [
|
|
233
266
|
defineTool({
|
|
234
267
|
name: "list_tools",
|
|
235
268
|
label: "List tools",
|
|
236
|
-
description: "List
|
|
269
|
+
description: "List Arisa core, native shell, and modular CLI tools with their capabilities.",
|
|
237
270
|
parameters: Type.Object({}),
|
|
238
271
|
execute: async () => {
|
|
239
272
|
await this.toolRegistry.load();
|
|
273
|
+
const coreTools = getCoreCodingTools({
|
|
274
|
+
tools: policy.tools,
|
|
275
|
+
excludeTools: policy.excludeTools
|
|
276
|
+
});
|
|
277
|
+
const nativeTools = [{
|
|
278
|
+
name: "system_shell",
|
|
279
|
+
source: "arisa-native",
|
|
280
|
+
description: "Run native system shell commands in the active Arisa workspace.",
|
|
281
|
+
workspaceDir: policy.workspaceDir,
|
|
282
|
+
shell: policy.shell.shellPath || (process.platform === "win32" ? "powershell" : "sh"),
|
|
283
|
+
enabled: !(policy.excludeTools || []).includes("system_shell")
|
|
284
|
+
}];
|
|
285
|
+
const cliTools = this.toolRegistry.list().map((tool) => ({
|
|
286
|
+
...tool,
|
|
287
|
+
source: "arisa-modular",
|
|
288
|
+
invocation: "run_tool"
|
|
289
|
+
}));
|
|
290
|
+
const result = {
|
|
291
|
+
workspaceDir: policy.workspaceDir,
|
|
292
|
+
coreTools,
|
|
293
|
+
nativeTools,
|
|
294
|
+
cliTools,
|
|
295
|
+
tools: [...coreTools.filter((tool) => tool.enabled), ...nativeTools.filter((tool) => tool.enabled), ...cliTools]
|
|
296
|
+
};
|
|
240
297
|
return {
|
|
241
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
242
|
-
details:
|
|
298
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
299
|
+
details: result
|
|
243
300
|
};
|
|
244
301
|
}
|
|
245
302
|
}),
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { arisaHomeDir } from "../../runtime/paths.js";
|
|
4
|
+
|
|
5
|
+
const defaultShellTimeoutMs = 60_000;
|
|
6
|
+
|
|
7
|
+
export const coreCodingToolCatalog = [
|
|
8
|
+
{
|
|
9
|
+
name: "read",
|
|
10
|
+
source: "pi-builtin",
|
|
11
|
+
description: "Read files and supported images from the active workspace.",
|
|
12
|
+
defaultEnabled: true
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "bash",
|
|
16
|
+
source: "pi-builtin",
|
|
17
|
+
description: "Run bash-compatible commands from the active workspace.",
|
|
18
|
+
defaultEnabled: true
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "edit",
|
|
22
|
+
source: "pi-builtin",
|
|
23
|
+
description: "Patch existing files in the active workspace.",
|
|
24
|
+
defaultEnabled: true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "write",
|
|
28
|
+
source: "pi-builtin",
|
|
29
|
+
description: "Create or overwrite files in the active workspace.",
|
|
30
|
+
defaultEnabled: true
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "grep",
|
|
34
|
+
source: "pi-builtin",
|
|
35
|
+
description: "Search file contents from the active workspace.",
|
|
36
|
+
defaultEnabled: false
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "find",
|
|
40
|
+
source: "pi-builtin",
|
|
41
|
+
description: "Find files from the active workspace.",
|
|
42
|
+
defaultEnabled: false
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "ls",
|
|
46
|
+
source: "pi-builtin",
|
|
47
|
+
description: "List directories from the active workspace.",
|
|
48
|
+
defaultEnabled: false
|
|
49
|
+
}
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function unique(values) {
|
|
53
|
+
return [...new Set(values)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function expandHomeDir(value) {
|
|
57
|
+
if (typeof value !== "string") return value;
|
|
58
|
+
if (value === "~") return os.homedir();
|
|
59
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
60
|
+
return path.join(os.homedir(), value.slice(2));
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeToolList(value) {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
const tools = value
|
|
68
|
+
.map((item) => String(item || "").trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
return tools.length ? unique(tools) : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
const tools = value
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((item) => item.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
return tools.length ? unique(tools) : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizePositiveInteger(value, fallback) {
|
|
85
|
+
const number = Number(value);
|
|
86
|
+
if (!Number.isFinite(number) || number <= 0) return fallback;
|
|
87
|
+
return Math.floor(number);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveWorkspaceDir(value, defaultWorkspaceDir = arisaHomeDir) {
|
|
91
|
+
const raw = typeof value === "string" && value.trim()
|
|
92
|
+
? value.trim()
|
|
93
|
+
: defaultWorkspaceDir;
|
|
94
|
+
return path.resolve(expandHomeDir(raw));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildAllowedTools({ configuredTools, customToolNames }) {
|
|
98
|
+
if (!configuredTools) return undefined;
|
|
99
|
+
return unique([...configuredTools, ...customToolNames]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getCoreCodingTools({ tools, excludeTools } = {}) {
|
|
103
|
+
const allowed = tools ? new Set(tools) : null;
|
|
104
|
+
const excluded = new Set(excludeTools || []);
|
|
105
|
+
|
|
106
|
+
return coreCodingToolCatalog.map((tool) => {
|
|
107
|
+
const enabled = allowed
|
|
108
|
+
? allowed.has(tool.name)
|
|
109
|
+
: tool.defaultEnabled;
|
|
110
|
+
return {
|
|
111
|
+
...tool,
|
|
112
|
+
enabled: enabled && !excluded.has(tool.name)
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function buildPiToolPolicy({
|
|
118
|
+
config,
|
|
119
|
+
customToolNames = [],
|
|
120
|
+
defaultWorkspaceDir = arisaHomeDir
|
|
121
|
+
} = {}) {
|
|
122
|
+
const configuredTools = normalizeToolList(config?.pi?.tools);
|
|
123
|
+
const excludeTools = normalizeToolList(config?.pi?.excludeTools);
|
|
124
|
+
const tools = buildAllowedTools({ configuredTools, customToolNames });
|
|
125
|
+
const workspaceDir = resolveWorkspaceDir(config?.pi?.workspaceDir, defaultWorkspaceDir);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
workspaceDir,
|
|
129
|
+
tools,
|
|
130
|
+
excludeTools,
|
|
131
|
+
coreTools: getCoreCodingTools({ tools, excludeTools }),
|
|
132
|
+
shell: {
|
|
133
|
+
shellPath: typeof config?.pi?.shellPath === "string" && config.pi.shellPath.trim()
|
|
134
|
+
? config.pi.shellPath.trim()
|
|
135
|
+
: "",
|
|
136
|
+
timeoutMs: normalizePositiveInteger(config?.pi?.shellTimeoutMs, defaultShellTimeoutMs)
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
-
import { arisaHomeDir, chatsDir, stateDir, toolStateDir, toolsDir } from "../../runtime/paths.js";
|
|
2
|
+
import { arisaHomeDir, arisaPackageDir, chatsDir, stateDir, toolStateDir, toolsDir } from "../../runtime/paths.js";
|
|
3
3
|
|
|
4
4
|
export const arisaInstallDir = fileURLToPath(new URL("../../..", import.meta.url));
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
function formatCoreTools(coreTools = []) {
|
|
7
|
+
const enabled = coreTools
|
|
8
|
+
.filter((tool) => tool.enabled)
|
|
9
|
+
.map((tool) => tool.name);
|
|
10
|
+
return enabled.length ? enabled.join(", ") : "(none)";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildAgentRuntimeContext({ workspaceDir = arisaHomeDir, coreTools = [] } = {}) {
|
|
7
14
|
return [
|
|
15
|
+
`workspaceDir: ${workspaceDir}`,
|
|
8
16
|
`arisaHomeDir: ${arisaHomeDir}`,
|
|
17
|
+
`arisaPackageDir: ${arisaPackageDir}`,
|
|
9
18
|
`arisaInstallDir: ${arisaInstallDir}`,
|
|
10
19
|
`userToolsDir: ${toolsDir}`,
|
|
11
20
|
`toolStateDir: ${toolStateDir}`,
|
|
12
21
|
`chatsDir: ${chatsDir}`,
|
|
13
|
-
`stateDir: ${stateDir}
|
|
22
|
+
`stateDir: ${stateDir}`,
|
|
23
|
+
`enabledCoreTools: ${formatCoreTools(coreTools)}`,
|
|
24
|
+
"Guidance: create or edit user tools under userToolsDir. Treat arisaPackageDir/arisaInstallDir as Arisa core and modify it only when the user explicitly asks for core changes."
|
|
14
25
|
].join("\n");
|
|
15
26
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import { constants, existsSync } from "node:fs";
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@sinclair/typebox";
|
|
7
|
+
|
|
8
|
+
const maxOutputBytes = 50 * 1024;
|
|
9
|
+
const defaultTimeoutMs = 60_000;
|
|
10
|
+
|
|
11
|
+
function isPowerShell(shellPath) {
|
|
12
|
+
const name = path.basename(shellPath).toLowerCase();
|
|
13
|
+
return name === "powershell.exe" || name === "powershell" || name === "pwsh.exe" || name === "pwsh";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isCmd(shellPath) {
|
|
17
|
+
const name = path.basename(shellPath).toLowerCase();
|
|
18
|
+
return name === "cmd.exe" || name === "cmd";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveNativeShell(shellPath = "") {
|
|
22
|
+
if (shellPath) {
|
|
23
|
+
if (isPowerShell(shellPath)) {
|
|
24
|
+
return {
|
|
25
|
+
shell: shellPath,
|
|
26
|
+
label: "powershell",
|
|
27
|
+
argsFor: (command) => ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (isCmd(shellPath)) {
|
|
31
|
+
return {
|
|
32
|
+
shell: shellPath,
|
|
33
|
+
label: "cmd",
|
|
34
|
+
argsFor: (command) => ["/d", "/s", "/c", command]
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
shell: shellPath,
|
|
39
|
+
label: "sh",
|
|
40
|
+
argsFor: (command) => ["-lc", command]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (process.platform === "win32") {
|
|
45
|
+
return {
|
|
46
|
+
shell: "powershell.exe",
|
|
47
|
+
label: "powershell",
|
|
48
|
+
argsFor: (command) => ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
shell: existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh",
|
|
54
|
+
label: "sh",
|
|
55
|
+
argsFor: (command) => ["-lc", command]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appendLimited(current, chunk, state) {
|
|
60
|
+
state.totalBytes += chunk.length;
|
|
61
|
+
|
|
62
|
+
const remaining = maxOutputBytes - state.storedBytes;
|
|
63
|
+
if (remaining <= 0) {
|
|
64
|
+
state.truncated = true;
|
|
65
|
+
return current;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const accepted = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
69
|
+
state.storedBytes += accepted.length;
|
|
70
|
+
if (accepted.length < chunk.length) {
|
|
71
|
+
state.truncated = true;
|
|
72
|
+
}
|
|
73
|
+
return current + accepted.toString("utf8");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function killProcessTree(child) {
|
|
77
|
+
if (!child.pid) return;
|
|
78
|
+
if (process.platform === "win32") {
|
|
79
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(child.pid)], {
|
|
80
|
+
stdio: "ignore",
|
|
81
|
+
windowsHide: true
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
process.kill(-child.pid, "SIGTERM");
|
|
88
|
+
} catch {
|
|
89
|
+
try {
|
|
90
|
+
child.kill("SIGTERM");
|
|
91
|
+
} catch {
|
|
92
|
+
// Process already exited.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatOutput({ stdout, stderr, exitCode, timedOut, truncated }) {
|
|
98
|
+
const sections = [];
|
|
99
|
+
if (stdout.trim()) sections.push(`stdout:\n${stdout.trimEnd()}`);
|
|
100
|
+
if (stderr.trim()) sections.push(`stderr:\n${stderr.trimEnd()}`);
|
|
101
|
+
if (!sections.length) sections.push(exitCode === 0 && !timedOut ? "(Success, no output)" : "(No output)");
|
|
102
|
+
if (timedOut) sections.push("Command timed out.");
|
|
103
|
+
if (truncated) sections.push(`[Output truncated to ${maxOutputBytes} bytes.]`);
|
|
104
|
+
return sections.join("\n\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runShellCommand({ command, cwd, shellPath, timeoutMs }) {
|
|
108
|
+
await access(cwd, constants.F_OK);
|
|
109
|
+
const nativeShell = resolveNativeShell(shellPath);
|
|
110
|
+
const child = spawn(nativeShell.shell, nativeShell.argsFor(command), {
|
|
111
|
+
cwd,
|
|
112
|
+
detached: process.platform !== "win32",
|
|
113
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
114
|
+
windowsHide: true
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let stdout = "";
|
|
118
|
+
let stderr = "";
|
|
119
|
+
const outputState = {
|
|
120
|
+
storedBytes: 0,
|
|
121
|
+
totalBytes: 0,
|
|
122
|
+
truncated: false
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let timedOut = false;
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
timedOut = true;
|
|
128
|
+
killProcessTree(child);
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
child.stdout.on("data", (chunk) => {
|
|
133
|
+
stdout = appendLimited(stdout, chunk, outputState);
|
|
134
|
+
});
|
|
135
|
+
child.stderr.on("data", (chunk) => {
|
|
136
|
+
stderr = appendLimited(stderr, chunk, outputState);
|
|
137
|
+
});
|
|
138
|
+
child.on("error", (error) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
resolve({
|
|
141
|
+
ok: false,
|
|
142
|
+
error,
|
|
143
|
+
stdout,
|
|
144
|
+
stderr,
|
|
145
|
+
shell: nativeShell,
|
|
146
|
+
timedOut,
|
|
147
|
+
truncated: outputState.truncated,
|
|
148
|
+
totalBytes: outputState.totalBytes
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
child.on("close", (exitCode) => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
resolve({
|
|
154
|
+
ok: !timedOut && exitCode === 0,
|
|
155
|
+
exitCode,
|
|
156
|
+
stdout,
|
|
157
|
+
stderr,
|
|
158
|
+
shell: nativeShell,
|
|
159
|
+
timedOut,
|
|
160
|
+
truncated: outputState.truncated,
|
|
161
|
+
totalBytes: outputState.totalBytes
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createSystemShellTool({ workspaceDir, shell = {} }) {
|
|
168
|
+
return defineTool({
|
|
169
|
+
name: "system_shell",
|
|
170
|
+
label: "System Shell",
|
|
171
|
+
description: "Run a command in the active Arisa workspace using the native system shell: PowerShell on Windows, and sh/bash-compatible shell on Unix.",
|
|
172
|
+
parameters: Type.Object({
|
|
173
|
+
command: Type.String({ description: "Command to execute in the active workspace." }),
|
|
174
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Optional timeout in milliseconds for this command." }))
|
|
175
|
+
}),
|
|
176
|
+
execute: async (_id, params) => {
|
|
177
|
+
const timeoutMs = Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0
|
|
178
|
+
? Math.floor(Number(params.timeoutMs))
|
|
179
|
+
: (shell.timeoutMs || defaultTimeoutMs);
|
|
180
|
+
const result = await runShellCommand({
|
|
181
|
+
command: params.command,
|
|
182
|
+
cwd: workspaceDir,
|
|
183
|
+
shellPath: shell.shellPath,
|
|
184
|
+
timeoutMs
|
|
185
|
+
});
|
|
186
|
+
const details = {
|
|
187
|
+
stdout: result.stdout,
|
|
188
|
+
stderr: result.stderr,
|
|
189
|
+
exitCode: result.exitCode ?? null,
|
|
190
|
+
shell: result.shell.label,
|
|
191
|
+
shellPath: result.shell.shell,
|
|
192
|
+
cwd: workspaceDir,
|
|
193
|
+
timedOut: result.timedOut,
|
|
194
|
+
truncated: result.truncated,
|
|
195
|
+
totalBytes: result.totalBytes
|
|
196
|
+
};
|
|
197
|
+
if (result.error) {
|
|
198
|
+
details.error = result.error.message;
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: "text", text: result.error?.message || formatOutput(result) }],
|
|
202
|
+
details,
|
|
203
|
+
isError: !result.ok
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
package/src/index.js
CHANGED
|
@@ -69,8 +69,20 @@ function toNestedOverrides(nestedFlags) {
|
|
|
69
69
|
|
|
70
70
|
function toServiceRunnerArgs(nestedFlags) {
|
|
71
71
|
const args = [];
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const serviceSafePiFlags = [
|
|
73
|
+
"pi.provider",
|
|
74
|
+
"pi.model",
|
|
75
|
+
"pi.workspaceDir",
|
|
76
|
+
"pi.tools",
|
|
77
|
+
"pi.excludeTools",
|
|
78
|
+
"pi.shellPath",
|
|
79
|
+
"pi.shellTimeoutMs"
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
for (const flag of serviceSafePiFlags) {
|
|
83
|
+
if (nestedFlags[flag]) {
|
|
84
|
+
args.push(`--${flag}`, nestedFlags[flag]);
|
|
85
|
+
}
|
|
74
86
|
}
|
|
75
87
|
return args;
|
|
76
88
|
}
|
|
@@ -104,14 +116,34 @@ async function startRuntimeApp() {
|
|
|
104
116
|
await app.start();
|
|
105
117
|
}
|
|
106
118
|
|
|
119
|
+
async function startBackgroundService() {
|
|
120
|
+
const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
console.log(`Arisa is already running in background (pid ${result.pid}).`);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
console.log(`Arisa started in background (pid ${result.pid}).`);
|
|
126
|
+
console.log(`Log file: ${result.logFile}`);
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
107
130
|
async function runForeground() {
|
|
108
131
|
const hasRuntimePiOverrides = Boolean(
|
|
109
132
|
runtimeOverrides?.pi?.model
|
|
110
133
|
|| runtimeOverrides?.pi?.provider
|
|
111
134
|
|| runtimeOverrides?.pi?.apiKey
|
|
135
|
+
|| runtimeOverrides?.pi?.workspaceDir
|
|
136
|
+
|| runtimeOverrides?.pi?.tools
|
|
137
|
+
|| runtimeOverrides?.pi?.excludeTools
|
|
138
|
+
|| runtimeOverrides?.pi?.shellPath
|
|
139
|
+
|| runtimeOverrides?.pi?.shellTimeoutMs
|
|
112
140
|
);
|
|
113
141
|
logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
|
|
114
|
-
await bootstrapIfNeeded({ force: forceBootstrap });
|
|
142
|
+
const bootstrapResult = await bootstrapIfNeeded({ force: forceBootstrap });
|
|
143
|
+
if (bootstrapResult.startInBackground) {
|
|
144
|
+
await startBackgroundService();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
115
147
|
try {
|
|
116
148
|
await startRuntimeApp();
|
|
117
149
|
} catch (error) {
|
|
@@ -126,7 +158,11 @@ async function runForeground() {
|
|
|
126
158
|
throw error;
|
|
127
159
|
}
|
|
128
160
|
console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
|
|
129
|
-
await bootstrapIfNeeded({ force: true });
|
|
161
|
+
const retryBootstrapResult = await bootstrapIfNeeded({ force: true });
|
|
162
|
+
if (retryBootstrapResult.startInBackground) {
|
|
163
|
+
await startBackgroundService();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
130
166
|
await startRuntimeApp();
|
|
131
167
|
return;
|
|
132
168
|
}
|
|
@@ -142,14 +178,12 @@ async function main() {
|
|
|
142
178
|
}
|
|
143
179
|
|
|
144
180
|
if (command === "start") {
|
|
145
|
-
await bootstrapIfNeeded({ force: forceBootstrap });
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
console.log(`Arisa is already running in background (pid ${result.pid}).`);
|
|
181
|
+
const bootstrapResult = await bootstrapIfNeeded({ force: forceBootstrap });
|
|
182
|
+
if (bootstrapResult.configCreated && bootstrapResult.viaTelegram && !bootstrapResult.startInBackground) {
|
|
183
|
+
console.log("Config saved. Arisa was not started in background.");
|
|
149
184
|
return;
|
|
150
185
|
}
|
|
151
|
-
|
|
152
|
-
console.log(`Log file: ${result.logFile}`);
|
|
186
|
+
await startBackgroundService();
|
|
153
187
|
return;
|
|
154
188
|
}
|
|
155
189
|
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import readline from "node:readline/promises";
|
|
3
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { Bot } from "grammy";
|
|
5
7
|
import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
|
|
6
8
|
import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
|
|
7
9
|
import { configFile, ensureArisaHome } from "./paths.js";
|
|
@@ -24,13 +26,13 @@ async function exists(file) {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piApiKey }) {
|
|
29
|
+
function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
|
|
28
30
|
return {
|
|
29
31
|
telegram: {
|
|
30
32
|
token: telegramApiKey,
|
|
31
33
|
maxChatIds: telegramMaxChatIds,
|
|
32
|
-
authorizedChatIds
|
|
33
|
-
chatMeta
|
|
34
|
+
authorizedChatIds,
|
|
35
|
+
chatMeta
|
|
34
36
|
},
|
|
35
37
|
pi: {
|
|
36
38
|
provider,
|
|
@@ -73,6 +75,64 @@ function sortBootstrapModels(provider, models) {
|
|
|
73
75
|
});
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
function createSetupToken() {
|
|
79
|
+
return crypto.randomBytes(18).toString("base64url");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parsePositiveInteger(value, fallback = null) {
|
|
83
|
+
const number = Number(value);
|
|
84
|
+
if (!Number.isFinite(number) || number <= 0) return fallback;
|
|
85
|
+
return Math.floor(number);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function selectByIndex(items, value, fallbackIndex = 0) {
|
|
89
|
+
const index = parsePositiveInteger(value, fallbackIndex + 1) - 1;
|
|
90
|
+
return items[Math.max(0, Math.min(items.length - 1, index))];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseYesNo(value, fallback = true) {
|
|
94
|
+
const text = String(value ?? "").trim().toLowerCase();
|
|
95
|
+
if (!text) return fallback;
|
|
96
|
+
if (["y", "yes", "s", "si", "sí"].includes(text)) return true;
|
|
97
|
+
if (["n", "no"].includes(text)) return false;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildInlineKeyboard(action, items) {
|
|
102
|
+
return {
|
|
103
|
+
inline_keyboard: items.map((item, index) => ([{
|
|
104
|
+
text: item.text,
|
|
105
|
+
callback_data: `${action}:${index}`
|
|
106
|
+
}]))
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getIncomingChatMeta(ctx) {
|
|
111
|
+
return {
|
|
112
|
+
languageCode: ctx.from?.language_code || "",
|
|
113
|
+
username: ctx.from?.username || "",
|
|
114
|
+
firstName: ctx.from?.first_name || "",
|
|
115
|
+
lastName: ctx.from?.last_name || ""
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatProviderOption(item) {
|
|
120
|
+
const authLabel = item.authConfigured ? "auth configured" : item.supportsOAuth ? "login or API key" : "API key";
|
|
121
|
+
return `${item.provider} (${item.modelCount} models, ${authLabel})`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatModelOption(model) {
|
|
125
|
+
const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
|
|
126
|
+
return capabilities ? `${model.id} [${capabilities}]` : model.id;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function selectPiLoginOption(options = []) {
|
|
130
|
+
return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
|
|
131
|
+
|| options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
|
|
132
|
+
|| options[0]
|
|
133
|
+
|| null;
|
|
134
|
+
}
|
|
135
|
+
|
|
76
136
|
async function maybeOpenExternal(url) {
|
|
77
137
|
if (!url) return;
|
|
78
138
|
await new Promise((resolve) => {
|
|
@@ -92,6 +152,12 @@ async function maybeOpenExternal(url) {
|
|
|
92
152
|
async function runInternalPiLogin(provider, { rl = null } = {}) {
|
|
93
153
|
const login = createPiOAuthLogin({
|
|
94
154
|
provider,
|
|
155
|
+
onSelect: async ({ message, options }) => {
|
|
156
|
+
const selected = selectPiLoginOption(options);
|
|
157
|
+
if (!selected) return undefined;
|
|
158
|
+
console.log(`${message}\nUsing: ${selected.label || selected.id}\n`);
|
|
159
|
+
return selected.id;
|
|
160
|
+
},
|
|
95
161
|
onAuth: async ({ url, instructions, controller }) => {
|
|
96
162
|
console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
|
|
97
163
|
await maybeOpenExternal(url);
|
|
@@ -118,43 +184,24 @@ async function runInternalPiLogin(provider, { rl = null } = {}) {
|
|
|
118
184
|
await login.promise;
|
|
119
185
|
}
|
|
120
186
|
|
|
121
|
-
|
|
122
|
-
await ensureArisaHome();
|
|
123
|
-
if (!force && await exists(configFile)) return;
|
|
124
|
-
|
|
125
|
-
const rl = readline.createInterface({ input, output });
|
|
126
|
-
const ask = async (label, fallback = "") => {
|
|
127
|
-
const suffix = fallback ? ` (${fallback})` : "";
|
|
128
|
-
const value = (await rl.question(`${label}${suffix}: `)).trim();
|
|
129
|
-
return value || fallback;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
console.log(`\n${ARISA_BANNER}`);
|
|
133
|
-
console.log("-------- https://arisa.sh --------\n");
|
|
134
|
-
console.log("Get Telegram bot token from https://t.me/BotFather");
|
|
135
|
-
const telegramApiKey = await ask("Telegram bot token");
|
|
187
|
+
async function collectCliBootstrapChoices({ telegramApiKey, rl, ask }) {
|
|
136
188
|
const telegramMaxChatIds = Number(await ask("Maximum authorized chat IDs", "1"));
|
|
137
189
|
|
|
138
190
|
const runtime = createPiRuntime();
|
|
139
191
|
const providers = sortBootstrapProviders(listPiProviders(runtime));
|
|
140
192
|
console.log("\nAvailable Pi providers:");
|
|
141
193
|
providers.forEach((item, index) => {
|
|
142
|
-
|
|
143
|
-
console.log(`${index + 1}. ${item.provider} (${item.modelCount} models, ${authLabel})`);
|
|
194
|
+
console.log(`${index + 1}. ${formatProviderOption(item)}`);
|
|
144
195
|
});
|
|
145
196
|
|
|
146
|
-
const
|
|
147
|
-
const selectedProvider = providers[Math.max(0, Math.min(providers.length - 1, selectedProviderIndex - 1))];
|
|
197
|
+
const selectedProvider = selectByIndex(providers, await ask("Select Pi provider by number", "1"));
|
|
148
198
|
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, runtime));
|
|
149
199
|
console.log(`\nAvailable models for ${selectedProvider.provider}:`);
|
|
150
200
|
models.forEach((model, index) => {
|
|
151
|
-
|
|
152
|
-
const suffix = capabilities ? ` [${capabilities}]` : "";
|
|
153
|
-
console.log(`${index + 1}. ${model.id}${suffix}`);
|
|
201
|
+
console.log(`${index + 1}. ${formatModelOption(model)}`);
|
|
154
202
|
});
|
|
155
203
|
|
|
156
|
-
const
|
|
157
|
-
const selectedModel = models[Math.max(0, Math.min(models.length - 1, selectedModelIndex - 1))];
|
|
204
|
+
const selectedModel = selectByIndex(models, await ask("Select Pi model by number", "1"));
|
|
158
205
|
const selectedAuthReady = hasProviderAuth(selectedProvider.provider, runtime);
|
|
159
206
|
const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, runtime);
|
|
160
207
|
console.log(`Selected model: ${selectedModel.provider}/${selectedModel.id}`);
|
|
@@ -189,17 +236,346 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
|
|
|
189
236
|
console.log(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
|
|
190
237
|
}
|
|
191
238
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
239
|
+
return {
|
|
240
|
+
config: buildConfig({
|
|
241
|
+
telegramApiKey,
|
|
242
|
+
telegramMaxChatIds,
|
|
243
|
+
provider: selectedProvider.provider,
|
|
244
|
+
model: selectedModel.id,
|
|
245
|
+
piApiKey
|
|
246
|
+
}),
|
|
247
|
+
startInBackground: false,
|
|
248
|
+
viaTelegram: false
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
|
|
253
|
+
const bot = new Bot(telegramApiKey);
|
|
254
|
+
const runtime = createPiRuntime();
|
|
255
|
+
const providers = sortBootstrapProviders(listPiProviders(runtime));
|
|
256
|
+
let setupChatId = null;
|
|
257
|
+
let chatMeta = {};
|
|
258
|
+
let state = "await-start";
|
|
259
|
+
let telegramMaxChatIds = 1;
|
|
260
|
+
let selectedProvider = null;
|
|
261
|
+
let selectedModel = null;
|
|
262
|
+
let piApiKey = "";
|
|
263
|
+
let activeLogin = null;
|
|
264
|
+
let completed = false;
|
|
265
|
+
let resolveResult;
|
|
266
|
+
let rejectResult;
|
|
267
|
+
|
|
268
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
269
|
+
resolveResult = resolve;
|
|
270
|
+
rejectResult = reject;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const sendSetupMessage = async (text, extra = {}) => {
|
|
274
|
+
if (!setupChatId) return;
|
|
275
|
+
await bot.api.sendMessage(setupChatId, text, extra);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const isSetupChat = (ctx) => setupChatId && ctx.chat?.id === setupChatId;
|
|
279
|
+
|
|
280
|
+
const complete = (startInBackground) => {
|
|
281
|
+
if (completed) return;
|
|
282
|
+
completed = true;
|
|
283
|
+
resolveResult({
|
|
284
|
+
config: buildConfig({
|
|
285
|
+
telegramApiKey,
|
|
286
|
+
telegramMaxChatIds,
|
|
287
|
+
authorizedChatIds: [setupChatId],
|
|
288
|
+
chatMeta: { [setupChatId]: chatMeta },
|
|
289
|
+
provider: selectedProvider.provider,
|
|
290
|
+
model: selectedModel.id,
|
|
291
|
+
piApiKey
|
|
292
|
+
}),
|
|
293
|
+
startInBackground,
|
|
294
|
+
viaTelegram: true
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const askProvider = async () => {
|
|
299
|
+
state = "provider";
|
|
300
|
+
await sendSetupMessage("Select the Pi provider Arisa should use:", {
|
|
301
|
+
reply_markup: buildInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })))
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const askModel = async () => {
|
|
306
|
+
state = "model";
|
|
307
|
+
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
|
|
308
|
+
await sendSetupMessage(`Select the model for ${selectedProvider.provider}:`, {
|
|
309
|
+
reply_markup: buildInlineKeyboard("model", models.map((model) => ({ text: formatModelOption(model) })))
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const askBackground = async () => {
|
|
314
|
+
state = "background";
|
|
315
|
+
await sendSetupMessage("Bootstrap complete. Keep Arisa running in background now?", {
|
|
316
|
+
reply_markup: {
|
|
317
|
+
inline_keyboard: [
|
|
318
|
+
[{ text: "Yes, start in background", callback_data: "background:yes" }],
|
|
319
|
+
[{ text: "No, continue in foreground", callback_data: "background:no" }]
|
|
320
|
+
]
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const askApiKey = async () => {
|
|
326
|
+
state = "pi-api-key";
|
|
327
|
+
await sendSetupMessage(`Send the Pi API key for ${selectedProvider.provider}.`);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const askAuthMethod = async () => {
|
|
331
|
+
const providerRuntime = createPiRuntime();
|
|
332
|
+
const selectedAuthReady = hasProviderAuth(selectedProvider.provider, providerRuntime);
|
|
333
|
+
const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, providerRuntime);
|
|
334
|
+
const buttons = [];
|
|
335
|
+
|
|
336
|
+
if (selectedAuthReady) {
|
|
337
|
+
buttons.push([{ text: "Use existing Pi auth", callback_data: "auth:existing" }]);
|
|
338
|
+
}
|
|
339
|
+
if (providerSupportsOAuth) {
|
|
340
|
+
buttons.push([{ text: selectedAuthReady ? "Run Pi login again" : "Start Pi login", callback_data: "auth:login" }]);
|
|
341
|
+
}
|
|
342
|
+
buttons.push([{ text: "Enter API key", callback_data: "auth:key" }]);
|
|
343
|
+
|
|
344
|
+
state = "auth-method";
|
|
345
|
+
await sendSetupMessage([
|
|
346
|
+
`Selected model: ${selectedProvider.provider}/${selectedModel.id}`,
|
|
347
|
+
`Existing Pi auth for ${selectedProvider.provider}: ${selectedAuthReady ? "yes" : "no"}`,
|
|
348
|
+
"Choose how Arisa should authenticate Pi."
|
|
349
|
+
].join("\n"), {
|
|
350
|
+
reply_markup: { inline_keyboard: buttons }
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const finishPiLogin = async (login) => {
|
|
355
|
+
try {
|
|
356
|
+
await login.promise;
|
|
357
|
+
activeLogin = null;
|
|
358
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
|
|
359
|
+
await sendSetupMessage(`Detected Pi auth for ${selectedProvider.provider}.`);
|
|
360
|
+
await askBackground();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
await sendSetupMessage(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
|
|
364
|
+
await askAuthMethod();
|
|
365
|
+
} catch (error) {
|
|
366
|
+
activeLogin = null;
|
|
367
|
+
await sendSetupMessage(`Pi login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
368
|
+
await askAuthMethod();
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const startPiLogin = async () => {
|
|
373
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
|
|
374
|
+
await sendSetupMessage(`Existing Pi auth for ${selectedProvider.provider} detected.`);
|
|
375
|
+
await askBackground();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
state = "pi-login";
|
|
380
|
+
const login = createPiOAuthLogin({
|
|
381
|
+
provider: selectedProvider.provider,
|
|
382
|
+
onSelect: async ({ message, options }) => {
|
|
383
|
+
const selected = selectPiLoginOption(options);
|
|
384
|
+
if (!selected) return undefined;
|
|
385
|
+
await sendSetupMessage(`${message}\nUsing: ${selected.label || selected.id}`);
|
|
386
|
+
return selected.id;
|
|
387
|
+
},
|
|
388
|
+
onAuth: async ({ url, instructions }) => {
|
|
389
|
+
await sendSetupMessage([
|
|
390
|
+
instructions || "Open this URL to continue Pi authentication:",
|
|
391
|
+
url,
|
|
392
|
+
"After login, paste the full redirect URL back here."
|
|
393
|
+
].join("\n"));
|
|
394
|
+
},
|
|
395
|
+
onDeviceCode: async ({ userCode, verificationUri, expiresInSeconds }) => {
|
|
396
|
+
const expiry = expiresInSeconds ? `\nExpires in ${Math.round(expiresInSeconds / 60)} minute(s).` : "";
|
|
397
|
+
await sendSetupMessage(`Open this URL: ${verificationUri}\nThen enter code: ${userCode}${expiry}`);
|
|
398
|
+
},
|
|
399
|
+
onPrompt: async ({ message, controller }) => {
|
|
400
|
+
await sendSetupMessage(`${message}\nReply here with the value.`);
|
|
401
|
+
return controller.waitForManualCode();
|
|
402
|
+
},
|
|
403
|
+
onProgress: (message) => {
|
|
404
|
+
if (message) console.log(`[bootstrap] Pi auth progress: ${message}`);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
activeLogin = login;
|
|
409
|
+
finishPiLogin(login);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
bot.catch((error) => {
|
|
413
|
+
console.error("Telegram setup bot error:", error);
|
|
198
414
|
});
|
|
199
415
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
416
|
+
bot.command("start", async (ctx) => {
|
|
417
|
+
if (String(ctx.match || "").trim() !== setupToken) {
|
|
418
|
+
await ctx.reply("Invalid setup link. Use the link shown in the Arisa bootstrap terminal.");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (setupChatId && ctx.chat.id !== setupChatId) {
|
|
423
|
+
await ctx.reply("This setup session is already connected to another chat.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
setupChatId = ctx.chat.id;
|
|
428
|
+
chatMeta = getIncomingChatMeta(ctx);
|
|
429
|
+
await ctx.reply([
|
|
430
|
+
`Connected to @${botInfo.username}.`,
|
|
431
|
+
"This chat will be the only Telegram chat authorized during setup."
|
|
432
|
+
].join("\n"));
|
|
433
|
+
await askProvider();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
bot.on("callback_query:data", async (ctx) => {
|
|
437
|
+
await ctx.answerCallbackQuery().catch(() => {});
|
|
438
|
+
if (!isSetupChat(ctx)) return;
|
|
439
|
+
|
|
440
|
+
const data = String(ctx.callbackQuery.data || "");
|
|
441
|
+
const [action, rawValue] = data.split(":");
|
|
442
|
+
|
|
443
|
+
if (action === "provider" && state === "provider") {
|
|
444
|
+
selectedProvider = providers[Number(rawValue)];
|
|
445
|
+
if (!selectedProvider) return;
|
|
446
|
+
await askModel();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (action === "model" && state === "model") {
|
|
451
|
+
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
|
|
452
|
+
selectedModel = models[Number(rawValue)];
|
|
453
|
+
if (!selectedModel) return;
|
|
454
|
+
await askAuthMethod();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (action === "auth" && state === "auth-method") {
|
|
459
|
+
if (rawValue === "existing") {
|
|
460
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
|
|
461
|
+
await askBackground();
|
|
462
|
+
} else {
|
|
463
|
+
await sendSetupMessage(`No existing Pi auth found for ${selectedProvider.provider}.`);
|
|
464
|
+
await askAuthMethod();
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (rawValue === "login") {
|
|
469
|
+
await startPiLogin();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (rawValue === "key") {
|
|
473
|
+
await askApiKey();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (action === "background" && state === "background") {
|
|
479
|
+
await sendSetupMessage(rawValue === "yes"
|
|
480
|
+
? "Saving config. Arisa will start in background now."
|
|
481
|
+
: "Saving config. Arisa will continue in foreground.");
|
|
482
|
+
complete(rawValue === "yes");
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
bot.on("message:text", async (ctx) => {
|
|
487
|
+
if (!isSetupChat(ctx)) return;
|
|
488
|
+
const text = String(ctx.message.text || "").trim();
|
|
489
|
+
if (!text || text.startsWith("/")) return;
|
|
490
|
+
|
|
491
|
+
if (state === "pi-api-key") {
|
|
492
|
+
piApiKey = text;
|
|
493
|
+
await askBackground();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (state === "pi-login" && activeLogin?.manualInputRequested) {
|
|
498
|
+
if (activeLogin.submitManualCode(text)) {
|
|
499
|
+
await ctx.reply("Got it. Finishing Pi login now...");
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (state === "background") {
|
|
505
|
+
const answer = parseYesNo(text, true);
|
|
506
|
+
if (answer === null) {
|
|
507
|
+
await ctx.reply("Please answer yes or no.");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
await ctx.reply(answer
|
|
511
|
+
? "Saving config. Arisa will start in background now."
|
|
512
|
+
: "Saving config. Arisa will continue in foreground.");
|
|
513
|
+
complete(answer);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await bot.api.deleteWebhook({ drop_pending_updates: true });
|
|
518
|
+
console.log("Waiting for Telegram setup to complete...");
|
|
519
|
+
const polling = bot.start().then(() => {
|
|
520
|
+
if (!completed) throw new Error("Telegram setup bot stopped before bootstrap completed.");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
return await Promise.race([resultPromise, polling]);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
rejectResult(error);
|
|
527
|
+
throw error;
|
|
528
|
+
} finally {
|
|
529
|
+
bot.stop();
|
|
530
|
+
await polling.catch(() => {});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function bootstrapIfNeeded({ force = false } = {}) {
|
|
535
|
+
await ensureArisaHome();
|
|
536
|
+
if (!force && await exists(configFile)) {
|
|
537
|
+
return { configCreated: false, viaTelegram: false, startInBackground: false };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const rl = readline.createInterface({ input, output });
|
|
541
|
+
const ask = async (label, fallback = "") => {
|
|
542
|
+
const suffix = fallback ? ` (${fallback})` : "";
|
|
543
|
+
const value = (await rl.question(`${label}${suffix}: `)).trim();
|
|
544
|
+
return value || fallback;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
console.log(`\n${ARISA_BANNER}`);
|
|
548
|
+
console.log("-------- https://arisa.sh --------\n");
|
|
549
|
+
console.log("Get Telegram bot token from https://t.me/BotFather");
|
|
550
|
+
const telegramApiKey = await ask("Telegram bot token");
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const setupProbeBot = new Bot(telegramApiKey);
|
|
554
|
+
const botInfo = await setupProbeBot.api.getMe();
|
|
555
|
+
const answer = parseYesNo(await ask("Continue bootstrap from Telegram?", "Y"), true);
|
|
556
|
+
const continueFromTelegram = answer === null ? true : answer;
|
|
557
|
+
|
|
558
|
+
let result;
|
|
559
|
+
if (continueFromTelegram) {
|
|
560
|
+
const setupToken = createSetupToken();
|
|
561
|
+
const setupLink = `https://t.me/${botInfo.username}?start=${setupToken}`;
|
|
562
|
+
console.log(`\nOpen this link to continue setup in Telegram:\n${setupLink}\n`);
|
|
563
|
+
await maybeOpenExternal(setupLink);
|
|
564
|
+
result = await runTelegramBootstrap({ telegramApiKey, setupToken, botInfo });
|
|
565
|
+
} else {
|
|
566
|
+
result = await collectCliBootstrapChoices({ telegramApiKey, rl, ask });
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
await writeFile(configFile, `${JSON.stringify(result.config, null, 2)}\n`, "utf8");
|
|
570
|
+
console.log(`\nConfig saved to ${configFile}\n`);
|
|
571
|
+
return {
|
|
572
|
+
configCreated: true,
|
|
573
|
+
viaTelegram: result.viaTelegram,
|
|
574
|
+
startInBackground: result.startInBackground
|
|
575
|
+
};
|
|
576
|
+
} finally {
|
|
577
|
+
rl.close();
|
|
578
|
+
}
|
|
203
579
|
}
|
|
204
580
|
|
|
205
581
|
export { configFile };
|
|
@@ -14,6 +14,31 @@ function normalizeString(value) {
|
|
|
14
14
|
return text ? text : "";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function normalizeStringList(value) {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
const items = value
|
|
20
|
+
.map((item) => normalizeString(item))
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
return items.length ? [...new Set(items)] : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof value === "string") {
|
|
26
|
+
const items = value
|
|
27
|
+
.split(",")
|
|
28
|
+
.map((item) => item.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
return items.length ? [...new Set(items)] : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizePositiveInteger(value) {
|
|
37
|
+
const number = Number(value);
|
|
38
|
+
if (!Number.isFinite(number) || number <= 0) return undefined;
|
|
39
|
+
return Math.floor(number);
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
function splitModelOverride(modelOverride) {
|
|
18
43
|
const separatorIndex = modelOverride.indexOf("/");
|
|
19
44
|
if (separatorIndex <= 0 || separatorIndex === modelOverride.length - 1) {
|
|
@@ -25,23 +50,41 @@ function splitModelOverride(modelOverride) {
|
|
|
25
50
|
};
|
|
26
51
|
}
|
|
27
52
|
|
|
28
|
-
function applyRuntimeOverrides(config, runtimeOverrides) {
|
|
53
|
+
export function applyRuntimeOverrides(config, runtimeOverrides) {
|
|
54
|
+
const piRuntimeOverrides = runtimeOverrides?.pi || {};
|
|
55
|
+
const pi = {};
|
|
29
56
|
const providerOverride = normalizeString(runtimeOverrides?.pi?.provider);
|
|
30
57
|
const modelOverride = normalizeString(runtimeOverrides?.pi?.model);
|
|
31
|
-
if (!providerOverride && !modelOverride) return config;
|
|
32
58
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
59
|
+
if (providerOverride || modelOverride) {
|
|
60
|
+
const splitOverride = modelOverride ? splitModelOverride(modelOverride) : null;
|
|
61
|
+
pi.provider = providerOverride || splitOverride?.provider || config.pi.provider;
|
|
62
|
+
pi.model = splitOverride && (!providerOverride || providerOverride === splitOverride.provider)
|
|
63
|
+
? splitOverride.model
|
|
64
|
+
: (modelOverride || config.pi.model);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const key of ["apiKey", "workspaceDir", "shellPath"]) {
|
|
68
|
+
const value = normalizeString(piRuntimeOverrides[key]);
|
|
69
|
+
if (value) pi[key] = value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tools = normalizeStringList(piRuntimeOverrides.tools);
|
|
73
|
+
if (tools) pi.tools = tools;
|
|
74
|
+
|
|
75
|
+
const excludeTools = normalizeStringList(piRuntimeOverrides.excludeTools);
|
|
76
|
+
if (excludeTools) pi.excludeTools = excludeTools;
|
|
77
|
+
|
|
78
|
+
const shellTimeoutMs = normalizePositiveInteger(piRuntimeOverrides.shellTimeoutMs);
|
|
79
|
+
if (shellTimeoutMs) pi.shellTimeoutMs = shellTimeoutMs;
|
|
80
|
+
|
|
81
|
+
if (!Object.keys(pi).length) return config;
|
|
38
82
|
|
|
39
83
|
return {
|
|
40
84
|
...config,
|
|
41
85
|
pi: {
|
|
42
86
|
...config.pi,
|
|
43
|
-
|
|
44
|
-
model
|
|
87
|
+
...pi
|
|
45
88
|
}
|
|
46
89
|
};
|
|
47
90
|
}
|
|
@@ -74,7 +74,8 @@ function buildPrompt({ ctx, artifact, transcript, toolResult }) {
|
|
|
74
74
|
parts.push(`Important: pre-reasoning media normalization could not be completed, so you do not have a transcript for this audio/video message.`);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
parts.push(`
|
|
77
|
+
parts.push(`Use read/write/edit for file work in the active workspace, bash for bash-compatible commands, and system_shell for native system commands such as PowerShell on Windows.`);
|
|
78
|
+
parts.push(`If you need an Arisa modular CLI tool, use list_tools/tool_help/run_tool.`);
|
|
78
79
|
parts.push(`If a tool config is missing, ask the user naturally and then use set_tool_config.`);
|
|
79
80
|
parts.push(`If the user wants a generated media reply, use send_media_reply.`);
|
|
80
81
|
return parts.join("\n");
|
|
@@ -130,7 +131,7 @@ async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
parts.push("Treat this as a new request for the chat and fulfill it now.");
|
|
133
|
-
parts.push("If needed, use tools.");
|
|
134
|
+
parts.push("If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool.");
|
|
134
135
|
return parts.filter(Boolean).join("\n");
|
|
135
136
|
}
|
|
136
137
|
|
|
@@ -142,7 +143,7 @@ function buildAsyncEventPrompt(task) {
|
|
|
142
143
|
task.payload.prompt ? `event: ${task.payload.prompt}` : null,
|
|
143
144
|
"A polling checker detected this external event. Evaluate it and decide the next action.",
|
|
144
145
|
"If it warrants no action, you may stay silent.",
|
|
145
|
-
"If needed, use tools."
|
|
146
|
+
"If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool."
|
|
146
147
|
].filter(Boolean).join("\n");
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, realpath } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { buildPiToolPolicy } from "../src/core/agent/core-tools.js";
|
|
7
|
+
import { createSystemShellTool } from "../src/core/agent/system-shell-tool.js";
|
|
8
|
+
import { applyRuntimeOverrides } from "../src/runtime/create-app.js";
|
|
9
|
+
import { arisaHomeDir } from "../src/runtime/paths.js";
|
|
10
|
+
|
|
11
|
+
test("builds default Pi tool policy for the Arisa home workspace", () => {
|
|
12
|
+
const policy = buildPiToolPolicy({ config: { pi: {} } });
|
|
13
|
+
|
|
14
|
+
assert.equal(policy.workspaceDir, path.resolve(arisaHomeDir));
|
|
15
|
+
assert.equal(policy.tools, undefined);
|
|
16
|
+
assert.equal(policy.excludeTools, undefined);
|
|
17
|
+
assert.deepEqual(
|
|
18
|
+
policy.coreTools.filter((tool) => tool.enabled).map((tool) => tool.name),
|
|
19
|
+
["read", "bash", "edit", "write"]
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("keeps Arisa custom tools available when pi.tools is configured", () => {
|
|
24
|
+
const policy = buildPiToolPolicy({
|
|
25
|
+
config: {
|
|
26
|
+
pi: {
|
|
27
|
+
tools: "read,bash",
|
|
28
|
+
excludeTools: "bash",
|
|
29
|
+
workspaceDir: "~/arisa-workspace",
|
|
30
|
+
shellTimeoutMs: "120000"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
customToolNames: ["run_tool", "system_shell"]
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
assert.deepEqual(policy.tools, ["read", "bash", "run_tool", "system_shell"]);
|
|
37
|
+
assert.deepEqual(policy.excludeTools, ["bash"]);
|
|
38
|
+
assert.equal(policy.shell.timeoutMs, 120000);
|
|
39
|
+
assert.equal(policy.workspaceDir, path.join(os.homedir(), "arisa-workspace"));
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
policy.coreTools.filter((tool) => tool.enabled).map((tool) => tool.name),
|
|
42
|
+
["read"]
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("applies runtime overrides without dropping persisted Pi config", () => {
|
|
47
|
+
const config = {
|
|
48
|
+
telegram: { token: "token" },
|
|
49
|
+
pi: {
|
|
50
|
+
provider: "openai-codex",
|
|
51
|
+
model: "gpt-5.5",
|
|
52
|
+
apiKey: "persisted"
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const next = applyRuntimeOverrides(config, {
|
|
57
|
+
pi: {
|
|
58
|
+
model: "anthropic/claude-opus-4-5",
|
|
59
|
+
workspaceDir: "/tmp/arisa",
|
|
60
|
+
tools: "read,bash",
|
|
61
|
+
excludeTools: "write",
|
|
62
|
+
shellTimeoutMs: "90000"
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(next.pi.provider, "anthropic");
|
|
67
|
+
assert.equal(next.pi.model, "claude-opus-4-5");
|
|
68
|
+
assert.equal(next.pi.apiKey, "persisted");
|
|
69
|
+
assert.equal(next.pi.workspaceDir, "/tmp/arisa");
|
|
70
|
+
assert.deepEqual(next.pi.tools, ["read", "bash"]);
|
|
71
|
+
assert.deepEqual(next.pi.excludeTools, ["write"]);
|
|
72
|
+
assert.equal(next.pi.shellTimeoutMs, 90000);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("system_shell runs commands from the configured workspace", async () => {
|
|
76
|
+
const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "arisa-shell-"));
|
|
77
|
+
const tool = createSystemShellTool({
|
|
78
|
+
workspaceDir,
|
|
79
|
+
shell: { timeoutMs: 10_000 }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = await tool.execute("call-1", {
|
|
83
|
+
command: "node -e \"process.stdout.write(process.cwd())\""
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const realWorkspaceDir = await realpath(workspaceDir);
|
|
87
|
+
assert.equal(result.isError, false);
|
|
88
|
+
assert.equal(result.details.cwd, workspaceDir);
|
|
89
|
+
assert.equal(result.details.exitCode, 0);
|
|
90
|
+
assert.equal(result.details.stdout, realWorkspaceDir);
|
|
91
|
+
});
|