daemora 1.0.0
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 +666 -0
- package/SOUL.md +104 -0
- package/config/hooks.json +14 -0
- package/config/mcp.json +145 -0
- package/package.json +86 -0
- package/skills/.gitkeep +0 -0
- package/skills/apple-notes.md +193 -0
- package/skills/apple-reminders.md +189 -0
- package/skills/camsnap.md +162 -0
- package/skills/coding.md +14 -0
- package/skills/documents.md +13 -0
- package/skills/email.md +13 -0
- package/skills/gif-search.md +196 -0
- package/skills/healthcheck.md +225 -0
- package/skills/image-gen.md +147 -0
- package/skills/model-usage.md +182 -0
- package/skills/obsidian.md +207 -0
- package/skills/pdf.md +211 -0
- package/skills/research.md +13 -0
- package/skills/skill-creator.md +142 -0
- package/skills/spotify.md +149 -0
- package/skills/summarize.md +230 -0
- package/skills/things.md +199 -0
- package/skills/tmux.md +204 -0
- package/skills/trello.md +183 -0
- package/skills/video-frames.md +202 -0
- package/skills/weather.md +127 -0
- package/src/a2a/A2AClient.js +136 -0
- package/src/a2a/A2AServer.js +316 -0
- package/src/a2a/AgentCard.js +79 -0
- package/src/agents/SubAgentManager.js +369 -0
- package/src/agents/Supervisor.js +192 -0
- package/src/channels/BaseChannel.js +104 -0
- package/src/channels/DiscordChannel.js +288 -0
- package/src/channels/EmailChannel.js +172 -0
- package/src/channels/GoogleChatChannel.js +316 -0
- package/src/channels/HttpChannel.js +26 -0
- package/src/channels/LineChannel.js +168 -0
- package/src/channels/SignalChannel.js +186 -0
- package/src/channels/SlackChannel.js +329 -0
- package/src/channels/TeamsChannel.js +272 -0
- package/src/channels/TelegramChannel.js +347 -0
- package/src/channels/WhatsAppChannel.js +219 -0
- package/src/channels/index.js +198 -0
- package/src/cli.js +1267 -0
- package/src/config/agentProfiles.js +120 -0
- package/src/config/channels.js +32 -0
- package/src/config/default.js +206 -0
- package/src/config/models.js +123 -0
- package/src/config/permissions.js +167 -0
- package/src/core/AgentLoop.js +446 -0
- package/src/core/Compaction.js +143 -0
- package/src/core/CostTracker.js +116 -0
- package/src/core/EventBus.js +46 -0
- package/src/core/Task.js +67 -0
- package/src/core/TaskQueue.js +206 -0
- package/src/core/TaskRunner.js +226 -0
- package/src/daemon/DaemonManager.js +301 -0
- package/src/hooks/HookRunner.js +230 -0
- package/src/index.js +482 -0
- package/src/mcp/MCPAgentRunner.js +112 -0
- package/src/mcp/MCPClient.js +186 -0
- package/src/mcp/MCPManager.js +412 -0
- package/src/models/ModelRouter.js +180 -0
- package/src/safety/AuditLog.js +135 -0
- package/src/safety/CircuitBreaker.js +126 -0
- package/src/safety/FilesystemGuard.js +169 -0
- package/src/safety/GitRollback.js +139 -0
- package/src/safety/HumanApproval.js +156 -0
- package/src/safety/InputSanitizer.js +72 -0
- package/src/safety/PermissionGuard.js +83 -0
- package/src/safety/Sandbox.js +70 -0
- package/src/safety/SecretScanner.js +100 -0
- package/src/safety/SecretVault.js +250 -0
- package/src/scheduler/Heartbeat.js +115 -0
- package/src/scheduler/Scheduler.js +228 -0
- package/src/services/models/outputSchema.js +15 -0
- package/src/services/openai.js +25 -0
- package/src/services/sessions.js +65 -0
- package/src/setup/theme.js +110 -0
- package/src/setup/wizard.js +788 -0
- package/src/skills/SkillLoader.js +168 -0
- package/src/storage/TaskStore.js +69 -0
- package/src/systemPrompt.js +526 -0
- package/src/tenants/TenantContext.js +19 -0
- package/src/tenants/TenantManager.js +379 -0
- package/src/tools/ToolRegistry.js +141 -0
- package/src/tools/applyPatch.js +144 -0
- package/src/tools/browserAutomation.js +223 -0
- package/src/tools/createDocument.js +265 -0
- package/src/tools/cronTool.js +105 -0
- package/src/tools/editFile.js +139 -0
- package/src/tools/executeCommand.js +123 -0
- package/src/tools/glob.js +67 -0
- package/src/tools/grep.js +121 -0
- package/src/tools/imageAnalysis.js +120 -0
- package/src/tools/index.js +173 -0
- package/src/tools/listDirectory.js +47 -0
- package/src/tools/manageAgents.js +47 -0
- package/src/tools/manageMCP.js +159 -0
- package/src/tools/memory.js +478 -0
- package/src/tools/messageChannel.js +45 -0
- package/src/tools/projectTracker.js +259 -0
- package/src/tools/readFile.js +52 -0
- package/src/tools/screenCapture.js +112 -0
- package/src/tools/searchContent.js +76 -0
- package/src/tools/searchFiles.js +75 -0
- package/src/tools/sendEmail.js +118 -0
- package/src/tools/sendFile.js +63 -0
- package/src/tools/textToSpeech.js +161 -0
- package/src/tools/transcribeAudio.js +82 -0
- package/src/tools/useMCP.js +29 -0
- package/src/tools/webFetch.js +150 -0
- package/src/tools/webSearch.js +134 -0
- package/src/tools/writeFile.js +26 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from "node:crypto";
|
|
4
|
+
import { config } from "../config/default.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TenantManager — per-user configuration and isolation for multi-tenant deployments.
|
|
8
|
+
*
|
|
9
|
+
* A tenant is any unique user identified by their channel + userId:
|
|
10
|
+
* tenantId = "telegram:123456789"
|
|
11
|
+
* tenantId = "slack:U012AB3CD"
|
|
12
|
+
* tenantId = "email:user@example.com"
|
|
13
|
+
*
|
|
14
|
+
* Each tenant can have:
|
|
15
|
+
* model — override the default model (e.g. cheaper model for free tier)
|
|
16
|
+
* allowedPaths — filesystem paths this tenant's tasks can access
|
|
17
|
+
* blockedPaths — paths always blocked for this tenant
|
|
18
|
+
* maxCostPerTask — per-task spend limit (overrides global)
|
|
19
|
+
* maxDailyCost — per-tenant daily budget
|
|
20
|
+
* tools — tool allowlist (empty = all tools allowed by permission tier)
|
|
21
|
+
* mcpServers — MCP server allowlist: ["github","linear"] | null (null = all allowed)
|
|
22
|
+
* modelRoutes — task-type model overrides: { coder: "anthropic:...", researcher: "google:..." }
|
|
23
|
+
* encryptedApiKeys — AES-256-GCM encrypted per-tenant API keys (managed via setApiKey/deleteApiKey)
|
|
24
|
+
* suspended — block all tasks from this tenant
|
|
25
|
+
* plan — "free" | "pro" | "admin" (for display / future rate limiting)
|
|
26
|
+
* createdAt — ISO timestamp of first message
|
|
27
|
+
* lastSeenAt — ISO timestamp of last message
|
|
28
|
+
* totalCost — lifetime spend for this tenant
|
|
29
|
+
* taskCount — total tasks submitted
|
|
30
|
+
* notes — free-text operator notes
|
|
31
|
+
*
|
|
32
|
+
* Storage: data/tenants/tenants.json (flat JSON map of tenantId → config)
|
|
33
|
+
* Workspaces: data/tenants/{tenantId}/workspace/ (isolated per-tenant directory)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const TENANTS_PATH = join(config.dataDir, "tenants", "tenants.json");
|
|
37
|
+
const TENANTS_DIR = join(config.dataDir, "tenants");
|
|
38
|
+
|
|
39
|
+
// ── Per-tenant API key encryption (AES-256-GCM) ───────────────────────────────
|
|
40
|
+
// Keys are stored as "iv:authTag:ciphertext" in tenant.encryptedApiKeys.
|
|
41
|
+
// The master key is derived from DAEMORA_TENANT_KEY env var via scrypt.
|
|
42
|
+
// If DAEMORA_TENANT_KEY is not set, an insecure dev fallback is used (warns via daemora doctor).
|
|
43
|
+
|
|
44
|
+
const _TENANT_CIPHER = "aes-256-gcm";
|
|
45
|
+
const _TENANT_SALT = "daemora-tenant-keys-v1";
|
|
46
|
+
|
|
47
|
+
function _getTenantKey() {
|
|
48
|
+
const master = process.env.DAEMORA_TENANT_KEY || "daemora-dev-insecure-fallback";
|
|
49
|
+
return scryptSync(master, _TENANT_SALT, 32);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _encryptTenantValue(plaintext) {
|
|
53
|
+
const key = _getTenantKey();
|
|
54
|
+
const iv = randomBytes(16);
|
|
55
|
+
const cipher = createCipheriv(_TENANT_CIPHER, key, iv);
|
|
56
|
+
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
57
|
+
const tag = cipher.getAuthTag();
|
|
58
|
+
return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _decryptTenantValue(str) {
|
|
62
|
+
try {
|
|
63
|
+
const parts = str.split(":");
|
|
64
|
+
if (parts.length < 3) return null;
|
|
65
|
+
const [ivHex, tagHex, ...cipherParts] = parts;
|
|
66
|
+
const cipherHex = cipherParts.join(":");
|
|
67
|
+
const key = _getTenantKey();
|
|
68
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
69
|
+
const tag = Buffer.from(tagHex, "hex");
|
|
70
|
+
const enc = Buffer.from(cipherHex, "hex");
|
|
71
|
+
const decipher = createDecipheriv(_TENANT_CIPHER, key, iv);
|
|
72
|
+
decipher.setAuthTag(tag);
|
|
73
|
+
return decipher.update(enc).toString("utf8") + decipher.final("utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
class TenantManager {
|
|
82
|
+
constructor() {
|
|
83
|
+
this._cache = null; // in-memory cache, invalidated on write
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get or auto-create a tenant record.
|
|
90
|
+
* Called on every incoming message to apply per-tenant config.
|
|
91
|
+
*/
|
|
92
|
+
getOrCreate(channel, userId) {
|
|
93
|
+
const id = _makeId(channel, userId);
|
|
94
|
+
const tenants = this._load();
|
|
95
|
+
|
|
96
|
+
if (!tenants[id]) {
|
|
97
|
+
if (!config.multiTenant?.autoRegister) return null;
|
|
98
|
+
tenants[id] = _defaultTenant(id);
|
|
99
|
+
this._save(tenants);
|
|
100
|
+
} else {
|
|
101
|
+
// Update lastSeenAt on every access
|
|
102
|
+
tenants[id].lastSeenAt = new Date().toISOString();
|
|
103
|
+
this._save(tenants);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { id, ...tenants[id] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get tenant by ID. Returns null if not found.
|
|
111
|
+
*/
|
|
112
|
+
get(tenantId) {
|
|
113
|
+
const tenants = this._load();
|
|
114
|
+
if (!tenants[tenantId]) return null;
|
|
115
|
+
return { id: tenantId, ...tenants[tenantId] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List all tenants.
|
|
120
|
+
*/
|
|
121
|
+
list() {
|
|
122
|
+
const tenants = this._load();
|
|
123
|
+
return Object.entries(tenants).map(([id, t]) => ({ id, ...t }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Update tenant config (partial update — only provided keys are changed).
|
|
128
|
+
*/
|
|
129
|
+
set(tenantId, updates) {
|
|
130
|
+
const tenants = this._load();
|
|
131
|
+
if (!tenants[tenantId]) {
|
|
132
|
+
tenants[tenantId] = _defaultTenant(tenantId);
|
|
133
|
+
}
|
|
134
|
+
const allowed = [
|
|
135
|
+
"model", "allowedPaths", "blockedPaths", "maxCostPerTask",
|
|
136
|
+
"maxDailyCost", "tools", "suspended", "plan", "notes",
|
|
137
|
+
"modelRoutes", "mcpServers",
|
|
138
|
+
];
|
|
139
|
+
for (const key of allowed) {
|
|
140
|
+
if (updates[key] !== undefined) tenants[tenantId][key] = updates[key];
|
|
141
|
+
}
|
|
142
|
+
tenants[tenantId].updatedAt = new Date().toISOString();
|
|
143
|
+
this._save(tenants);
|
|
144
|
+
return { id: tenantId, ...tenants[tenantId] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Record task cost against this tenant's lifetime totals.
|
|
149
|
+
*/
|
|
150
|
+
recordCost(tenantId, cost) {
|
|
151
|
+
if (!tenantId || !cost) return;
|
|
152
|
+
const tenants = this._load();
|
|
153
|
+
if (!tenants[tenantId]) return;
|
|
154
|
+
tenants[tenantId].totalCost = (tenants[tenantId].totalCost || 0) + cost;
|
|
155
|
+
tenants[tenantId].taskCount = (tenants[tenantId].taskCount || 0) + 1;
|
|
156
|
+
this._save(tenants);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Suspend a tenant (all their tasks will be rejected).
|
|
161
|
+
*/
|
|
162
|
+
suspend(tenantId, reason = "") {
|
|
163
|
+
return this.set(tenantId, { suspended: true, suspendReason: reason });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Unsuspend a tenant.
|
|
168
|
+
*/
|
|
169
|
+
unsuspend(tenantId) {
|
|
170
|
+
return this.set(tenantId, { suspended: false, suspendReason: "" });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Reset a tenant's config back to defaults (keep cost history).
|
|
175
|
+
*/
|
|
176
|
+
reset(tenantId) {
|
|
177
|
+
const tenants = this._load();
|
|
178
|
+
if (!tenants[tenantId]) return null;
|
|
179
|
+
const preserved = {
|
|
180
|
+
totalCost: tenants[tenantId].totalCost || 0,
|
|
181
|
+
taskCount: tenants[tenantId].taskCount || 0,
|
|
182
|
+
createdAt: tenants[tenantId].createdAt,
|
|
183
|
+
};
|
|
184
|
+
tenants[tenantId] = { ..._defaultTenant(tenantId), ...preserved };
|
|
185
|
+
this._save(tenants);
|
|
186
|
+
return { id: tenantId, ...tenants[tenantId] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Delete a tenant record entirely.
|
|
191
|
+
*/
|
|
192
|
+
delete(tenantId) {
|
|
193
|
+
const tenants = this._load();
|
|
194
|
+
if (!tenants[tenantId]) return false;
|
|
195
|
+
delete tenants[tenantId];
|
|
196
|
+
this._save(tenants);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Per-Tenant API Key Management ─────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Store a per-tenant API key, encrypted with AES-256-GCM.
|
|
204
|
+
* The key is stored in tenant.encryptedApiKeys[keyName].
|
|
205
|
+
*
|
|
206
|
+
* @param {string} tenantId - e.g. "telegram:123"
|
|
207
|
+
* @param {string} keyName - e.g. "OPENAI_API_KEY"
|
|
208
|
+
* @param {string} keyValue - plaintext key value
|
|
209
|
+
*/
|
|
210
|
+
setApiKey(tenantId, keyName, keyValue) {
|
|
211
|
+
const tenants = this._load();
|
|
212
|
+
if (!tenants[tenantId]) tenants[tenantId] = _defaultTenant(tenantId);
|
|
213
|
+
tenants[tenantId].encryptedApiKeys = tenants[tenantId].encryptedApiKeys || {};
|
|
214
|
+
tenants[tenantId].encryptedApiKeys[keyName] = _encryptTenantValue(keyValue);
|
|
215
|
+
tenants[tenantId].updatedAt = new Date().toISOString();
|
|
216
|
+
this._save(tenants);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Delete a per-tenant API key.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} tenantId
|
|
224
|
+
* @param {string} keyName
|
|
225
|
+
* @returns {boolean} true if deleted, false if not found
|
|
226
|
+
*/
|
|
227
|
+
deleteApiKey(tenantId, keyName) {
|
|
228
|
+
const tenants = this._load();
|
|
229
|
+
if (!tenants[tenantId]?.encryptedApiKeys?.[keyName]) return false;
|
|
230
|
+
delete tenants[tenantId].encryptedApiKeys[keyName];
|
|
231
|
+
tenants[tenantId].updatedAt = new Date().toISOString();
|
|
232
|
+
this._save(tenants);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* List the names (not values) of stored API keys for a tenant.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} tenantId
|
|
240
|
+
* @returns {string[]} key names
|
|
241
|
+
*/
|
|
242
|
+
listApiKeyNames(tenantId) {
|
|
243
|
+
const tenants = this._load();
|
|
244
|
+
return Object.keys(tenants[tenantId]?.encryptedApiKeys || {});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Decrypt and return all stored API keys for a tenant.
|
|
249
|
+
* Returns {} if none stored or decryption fails.
|
|
250
|
+
* These are passed through the call stack (NOT via process.env) to prevent cross-tenant bleed.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} tenantId
|
|
253
|
+
* @returns {object} e.g. { OPENAI_API_KEY: "sk-...", ANTHROPIC_API_KEY: "sk-ant-..." }
|
|
254
|
+
*/
|
|
255
|
+
getDecryptedApiKeys(tenantId) {
|
|
256
|
+
const tenants = this._load();
|
|
257
|
+
const encrypted = tenants[tenantId]?.encryptedApiKeys || {};
|
|
258
|
+
const result = {};
|
|
259
|
+
for (const [key, val] of Object.entries(encrypted)) {
|
|
260
|
+
const decrypted = _decryptTenantValue(val);
|
|
261
|
+
if (decrypted !== null) result[key] = decrypted;
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Workspace ─────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get (and create if missing) the isolated workspace directory for a tenant.
|
|
270
|
+
* This is the default allowed path when sandbox mode is active.
|
|
271
|
+
*/
|
|
272
|
+
getWorkspace(tenantId) {
|
|
273
|
+
const safe = tenantId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
274
|
+
const dir = join(TENANTS_DIR, safe, "workspace");
|
|
275
|
+
mkdirSync(dir, { recursive: true });
|
|
276
|
+
return dir;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Effective config resolution ───────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Resolve the effective config for a task, merging:
|
|
283
|
+
* tenant config > channel config > global config > defaults
|
|
284
|
+
*
|
|
285
|
+
* Returns the values TaskRunner should use for this specific task.
|
|
286
|
+
*/
|
|
287
|
+
resolveTaskConfig(tenant, channelModel) {
|
|
288
|
+
const sandboxEnabled = config.sandbox?.mode === "docker" || config.multiTenant?.isolateFilesystem;
|
|
289
|
+
|
|
290
|
+
// Filesystem: tenant paths > global paths > (if sandbox) tenant workspace only
|
|
291
|
+
let allowedPaths = tenant?.allowedPaths?.length
|
|
292
|
+
? tenant.allowedPaths
|
|
293
|
+
: config.filesystem?.allowedPaths || [];
|
|
294
|
+
|
|
295
|
+
// If sandbox mode and tenant has no custom paths, default to their workspace
|
|
296
|
+
if (sandboxEnabled && allowedPaths.length === 0 && tenant?.id) {
|
|
297
|
+
allowedPaths = [this.getWorkspace(tenant.id)];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const blockedPaths = tenant?.blockedPaths?.length
|
|
301
|
+
? tenant.blockedPaths
|
|
302
|
+
: config.filesystem?.blockedPaths || [];
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
model: tenant?.model || channelModel || config.defaultModel,
|
|
306
|
+
allowedPaths,
|
|
307
|
+
blockedPaths,
|
|
308
|
+
restrictCommands: config.filesystem?.restrictCommands || false,
|
|
309
|
+
maxCostPerTask: tenant?.maxCostPerTask ?? config.maxCostPerTask,
|
|
310
|
+
maxDailyCost: tenant?.maxDailyCost ?? config.maxDailyCost,
|
|
311
|
+
tools: tenant?.tools || null, // null = all tools allowed
|
|
312
|
+
sandbox: config.sandbox?.mode || "process",
|
|
313
|
+
mcpServers: tenant?.mcpServers ?? null, // null = all MCP servers allowed
|
|
314
|
+
modelRoutes: tenant?.modelRoutes || null, // null = use global env vars
|
|
315
|
+
apiKeys: tenant?.id ? this.getDecryptedApiKeys(tenant.id) : {}, // per-tenant API keys
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
_load() {
|
|
322
|
+
if (this._cache) return this._cache;
|
|
323
|
+
mkdirSync(TENANTS_DIR, { recursive: true });
|
|
324
|
+
if (!existsSync(TENANTS_PATH)) return {};
|
|
325
|
+
try {
|
|
326
|
+
this._cache = JSON.parse(readFileSync(TENANTS_PATH, "utf-8"));
|
|
327
|
+
return this._cache;
|
|
328
|
+
} catch {
|
|
329
|
+
return {};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_save(tenants) {
|
|
334
|
+
mkdirSync(TENANTS_DIR, { recursive: true });
|
|
335
|
+
writeFileSync(TENANTS_PATH, JSON.stringify(tenants, null, 2), "utf-8");
|
|
336
|
+
this._cache = tenants;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
stats() {
|
|
340
|
+
const tenants = this._load();
|
|
341
|
+
const list = Object.values(tenants);
|
|
342
|
+
return {
|
|
343
|
+
total: list.length,
|
|
344
|
+
suspended: list.filter(t => t.suspended).length,
|
|
345
|
+
totalCost: list.reduce((s, t) => s + (t.totalCost || 0), 0).toFixed(4),
|
|
346
|
+
totalTasks: list.reduce((s, t) => s + (t.taskCount || 0), 0),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function _makeId(channel, userId) {
|
|
352
|
+
return `${channel}:${userId}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function _defaultTenant(id) {
|
|
356
|
+
return {
|
|
357
|
+
model: null,
|
|
358
|
+
allowedPaths: [],
|
|
359
|
+
blockedPaths: [],
|
|
360
|
+
maxCostPerTask: null,
|
|
361
|
+
maxDailyCost: null,
|
|
362
|
+
tools: null,
|
|
363
|
+
mcpServers: null, // null = all MCP servers allowed; ["github","linear"] = allowlist
|
|
364
|
+
modelRoutes: null, // null = use global env vars; { coder: "anthropic:..." }
|
|
365
|
+
encryptedApiKeys: {}, // AES-256-GCM encrypted per-tenant API keys
|
|
366
|
+
suspended: false,
|
|
367
|
+
suspendReason: "",
|
|
368
|
+
plan: "free",
|
|
369
|
+
notes: "",
|
|
370
|
+
totalCost: 0,
|
|
371
|
+
taskCount: 0,
|
|
372
|
+
createdAt: new Date().toISOString(),
|
|
373
|
+
lastSeenAt: new Date().toISOString(),
|
|
374
|
+
updatedAt: new Date().toISOString(),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const tenantManager = new TenantManager();
|
|
379
|
+
export default tenantManager;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured tool registry.
|
|
3
|
+
* Each tool registers with metadata: name, category, params, permissionTier, isWrite, fn.
|
|
4
|
+
* Replaces loose imports + string descriptions with a single source of truth.
|
|
5
|
+
*/
|
|
6
|
+
class ToolRegistry {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.tools = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register a tool with structured metadata.
|
|
13
|
+
*/
|
|
14
|
+
register({ name, category, description, params, permissionTier, isWrite, fn }) {
|
|
15
|
+
if (!name || !fn) throw new Error(`Tool registration requires name and fn`);
|
|
16
|
+
this.tools.set(name, {
|
|
17
|
+
name,
|
|
18
|
+
category: category || "other",
|
|
19
|
+
description: description || "",
|
|
20
|
+
params: params || [],
|
|
21
|
+
permissionTier: permissionTier || "full",
|
|
22
|
+
isWrite: isWrite ?? false,
|
|
23
|
+
fn,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get tool function map { name: fn } — backward compatible with current toolFunctions export.
|
|
29
|
+
*/
|
|
30
|
+
getToolFunctions() {
|
|
31
|
+
const fns = {};
|
|
32
|
+
for (const [name, tool] of this.tools) {
|
|
33
|
+
fns[name] = tool.fn;
|
|
34
|
+
}
|
|
35
|
+
return fns;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get tool description strings — backward compatible with current toolDescriptions export.
|
|
40
|
+
*/
|
|
41
|
+
getToolDescriptions() {
|
|
42
|
+
const descs = [];
|
|
43
|
+
for (const [, tool] of this.tools) {
|
|
44
|
+
const paramStr = tool.params
|
|
45
|
+
.map((p) => `${p.name}${p.required ? "" : "?"}: ${p.type}`)
|
|
46
|
+
.join(", ");
|
|
47
|
+
descs.push(`${tool.name}(${paramStr}) - ${tool.description}`);
|
|
48
|
+
}
|
|
49
|
+
return descs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build tool docs for system prompt — grouped by category.
|
|
54
|
+
*/
|
|
55
|
+
buildToolDocs() {
|
|
56
|
+
const categories = {};
|
|
57
|
+
for (const [, tool] of this.tools) {
|
|
58
|
+
if (!categories[tool.category]) categories[tool.category] = [];
|
|
59
|
+
categories[tool.category].push(tool);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const categoryLabels = {
|
|
63
|
+
filesystem: "File Operations",
|
|
64
|
+
search: "Search",
|
|
65
|
+
system: "System",
|
|
66
|
+
web: "Web & Browser",
|
|
67
|
+
communication: "Communication",
|
|
68
|
+
documents: "Documents",
|
|
69
|
+
memory: "Memory",
|
|
70
|
+
agents: "Agents",
|
|
71
|
+
automation: "Automation",
|
|
72
|
+
other: "Other",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let doc = "# Available Tools\n\nAll tool params are STRINGS. Pass them as an array of strings.\n";
|
|
76
|
+
|
|
77
|
+
for (const [cat, tools] of Object.entries(categories)) {
|
|
78
|
+
doc += `\n## ${categoryLabels[cat] || cat}\n\n`;
|
|
79
|
+
for (const tool of tools) {
|
|
80
|
+
const paramStr = tool.params
|
|
81
|
+
.map((p) => `${p.name}${p.required ? "" : "?"}`)
|
|
82
|
+
.join(", ");
|
|
83
|
+
doc += `### ${tool.name}(${paramStr})\n`;
|
|
84
|
+
doc += `${tool.description}\n`;
|
|
85
|
+
if (tool.params.length > 0) {
|
|
86
|
+
for (const p of tool.params) {
|
|
87
|
+
doc += `- ${p.name}${p.required ? " (required)" : " (optional)"}: ${p.description || p.type}\n`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
doc += "\n";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return doc;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get tools allowed for a specific permission tier.
|
|
99
|
+
*/
|
|
100
|
+
getToolsForTier(tier) {
|
|
101
|
+
const tierOrder = { minimal: 0, standard: 1, full: 2 };
|
|
102
|
+
const tierLevel = tierOrder[tier] ?? 2;
|
|
103
|
+
const allowed = [];
|
|
104
|
+
for (const [name, tool] of this.tools) {
|
|
105
|
+
const toolLevel = tierOrder[tool.permissionTier] ?? 2;
|
|
106
|
+
if (toolLevel <= tierLevel) {
|
|
107
|
+
allowed.push(name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return allowed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get write tool names (for SAFEGUARD 2 in AgentLoop).
|
|
115
|
+
*/
|
|
116
|
+
getWriteTools() {
|
|
117
|
+
const names = [];
|
|
118
|
+
for (const [name, tool] of this.tools) {
|
|
119
|
+
if (tool.isWrite) names.push(name);
|
|
120
|
+
}
|
|
121
|
+
return new Set(names);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get tool metadata by name.
|
|
126
|
+
*/
|
|
127
|
+
get(name) {
|
|
128
|
+
return this.tools.get(name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get all tool names.
|
|
133
|
+
*/
|
|
134
|
+
getNames() {
|
|
135
|
+
return [...this.tools.keys()];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Singleton
|
|
140
|
+
const registry = new ToolRegistry();
|
|
141
|
+
export default registry;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* applyPatch(filePath, patch) — Apply a unified diff patch to a file.
|
|
3
|
+
* Handles multi-hunk edits that editFile's single find-replace can't do.
|
|
4
|
+
* Inspired by OpenClaw's apply_patch tool.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a unified diff string into an array of hunks.
|
|
11
|
+
* Each hunk: { origStart, origCount, newStart, newCount, lines }
|
|
12
|
+
*/
|
|
13
|
+
function parseUnifiedDiff(patch) {
|
|
14
|
+
const lines = patch.split("\n");
|
|
15
|
+
const hunks = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
|
|
18
|
+
// Skip file headers (--- +++)
|
|
19
|
+
while (i < lines.length && (lines[i].startsWith("---") || lines[i].startsWith("+++"))) {
|
|
20
|
+
i++;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
while (i < lines.length) {
|
|
24
|
+
const hunkHeader = lines[i].match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
|
|
25
|
+
if (!hunkHeader) { i++; continue; }
|
|
26
|
+
|
|
27
|
+
const origStart = parseInt(hunkHeader[1]) - 1; // 0-indexed
|
|
28
|
+
const origCount = hunkHeader[2] !== undefined ? parseInt(hunkHeader[2]) : 1;
|
|
29
|
+
const hunkLines = [];
|
|
30
|
+
i++;
|
|
31
|
+
|
|
32
|
+
while (i < lines.length && !lines[i].startsWith("@@")) {
|
|
33
|
+
hunkLines.push(lines[i]);
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
hunks.push({ origStart, origCount, lines: hunkLines });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return hunks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Apply a single hunk to file lines. Returns new lines array or null on failure.
|
|
45
|
+
* Supports fuzzy matching: tries exact position first, then scans ±10 lines.
|
|
46
|
+
*/
|
|
47
|
+
function applyHunk(fileLines, hunk, offset) {
|
|
48
|
+
const contextLines = hunk.lines.filter((l) => l.startsWith(" ") || l === "");
|
|
49
|
+
const removals = hunk.lines.filter((l) => l.startsWith("-")).map((l) => l.slice(1));
|
|
50
|
+
|
|
51
|
+
const tryAt = (pos) => {
|
|
52
|
+
// Verify context and removals match at this position
|
|
53
|
+
let ri = pos;
|
|
54
|
+
for (const hunkLine of hunk.lines) {
|
|
55
|
+
if (hunkLine.startsWith(" ") || hunkLine === "") {
|
|
56
|
+
// context line — must match
|
|
57
|
+
if (ri >= fileLines.length) return null;
|
|
58
|
+
if (fileLines[ri].trimEnd() !== hunkLine.slice(1).trimEnd()) return null;
|
|
59
|
+
ri++;
|
|
60
|
+
} else if (hunkLine.startsWith("-")) {
|
|
61
|
+
if (ri >= fileLines.length) return null;
|
|
62
|
+
if (fileLines[ri].trimEnd() !== hunkLine.slice(1).trimEnd()) return null;
|
|
63
|
+
ri++;
|
|
64
|
+
}
|
|
65
|
+
// "+" lines don't consume file lines
|
|
66
|
+
}
|
|
67
|
+
return ri; // end position
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Try intended position first, then fuzzy
|
|
71
|
+
const intendedPos = Math.max(0, hunk.origStart + offset);
|
|
72
|
+
const positions = [intendedPos];
|
|
73
|
+
for (let delta = 1; delta <= 10; delta++) {
|
|
74
|
+
if (intendedPos + delta < fileLines.length) positions.push(intendedPos + delta);
|
|
75
|
+
if (intendedPos - delta >= 0) positions.push(intendedPos - delta);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const pos of positions) {
|
|
79
|
+
const endPos = tryAt(pos);
|
|
80
|
+
if (endPos === null) continue;
|
|
81
|
+
|
|
82
|
+
// Build new file lines: everything before hunk + new lines + everything after
|
|
83
|
+
const before = fileLines.slice(0, pos);
|
|
84
|
+
const after = fileLines.slice(endPos);
|
|
85
|
+
const added = hunk.lines
|
|
86
|
+
.filter((l) => l.startsWith("+"))
|
|
87
|
+
.map((l) => l.slice(1));
|
|
88
|
+
const context = hunk.lines
|
|
89
|
+
.filter((l) => l.startsWith(" ") || l === "")
|
|
90
|
+
.map((l) => (l === "" ? "" : l.slice(1)));
|
|
91
|
+
|
|
92
|
+
// Reconstruct: before, context+additions interleaved, after
|
|
93
|
+
const middle = [];
|
|
94
|
+
for (const hl of hunk.lines) {
|
|
95
|
+
if (hl.startsWith(" ") || hl === "") middle.push(hl === "" ? "" : hl.slice(1));
|
|
96
|
+
else if (hl.startsWith("+")) middle.push(hl.slice(1));
|
|
97
|
+
// "-" lines are removed, not added
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { newLines: [...before, ...middle, ...after], newOffset: offset + (endPos - pos) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function applyPatch(filePath, patch) {
|
|
107
|
+
try {
|
|
108
|
+
if (!existsSync(filePath)) {
|
|
109
|
+
return `Error: File not found: ${filePath}`;
|
|
110
|
+
}
|
|
111
|
+
if (!patch || typeof patch !== "string") {
|
|
112
|
+
return "Error: patch must be a unified diff string";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const content = readFileSync(filePath, "utf-8");
|
|
116
|
+
let fileLines = content.split("\n");
|
|
117
|
+
const hunks = parseUnifiedDiff(patch);
|
|
118
|
+
|
|
119
|
+
if (hunks.length === 0) {
|
|
120
|
+
return "Error: No valid hunks found in patch. Ensure it is in unified diff format (with @@ headers).";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let offset = 0;
|
|
124
|
+
let appliedCount = 0;
|
|
125
|
+
|
|
126
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
127
|
+
const result = applyHunk(fileLines, hunks[h], offset);
|
|
128
|
+
if (!result) {
|
|
129
|
+
return `Error: Hunk ${h + 1} of ${hunks.length} failed to apply. Context lines did not match file content. Re-read the file and regenerate the patch.`;
|
|
130
|
+
}
|
|
131
|
+
fileLines = result.newLines;
|
|
132
|
+
offset = result.newOffset;
|
|
133
|
+
appliedCount++;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
writeFileSync(filePath, fileLines.join("\n"), "utf-8");
|
|
137
|
+
return `Applied ${appliedCount} hunk(s) to ${filePath} successfully.`;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return `Error applying patch: ${error.message}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const applyPatchDescription =
|
|
144
|
+
'applyPatch(filePath: string, patch: string) - Apply a unified diff patch to a file. Handles multi-hunk edits. patch must be in standard unified diff format starting with @@ hunk headers.';
|