daemora 1.0.10 → 1.0.11
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 +37 -15
- package/SOUL.md +23 -4
- package/daemora-ui/dist/assets/index-BkPHvKYt.css +1 -0
- package/daemora-ui/dist/assets/index-ZiuOJUu0.js +92 -0
- package/daemora-ui/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/planning.md +168 -0
- package/src/agents/systemPrompt.js +2 -1
- package/src/cli.js +124 -4
- package/src/index.js +6 -2
- package/src/safety/CommandGuard.js +22 -1
- package/src/setup/theme.js +1 -0
- package/src/setup/wizard.js +220 -26
- package/src/tenants/TenantManager.js +37 -0
- package/src/tools/_paths.js +39 -0
- package/src/tools/applyPatch.js +6 -0
- package/src/tools/browserAutomation.js +18 -6
- package/src/tools/createDocument.js +4 -0
- package/src/tools/executeCommand.js +4 -2
- package/src/tools/generateImage.js +7 -3
- package/src/tools/replyWithFile.js +4 -0
- package/src/tools/screenCapture.js +6 -1
- package/src/tools/sendFile.js +4 -0
- package/src/tools/sshTool.js +2 -2
- package/src/tools/textToSpeech.js +21 -12
- package/src/tools/transcribeAudio.js +15 -4
- package/daemora-ui/dist/assets/index-D7W1-PNQ.js +0 -92
- package/daemora-ui/dist/assets/index-DzMLJeoL.css +0 -1
package/src/setup/wizard.js
CHANGED
|
@@ -7,7 +7,7 @@ import { banner, stepHeader, kv, summaryTable, completeBanner, t, S } from "./th
|
|
|
7
7
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const ROOT_DIR = join(__dirname, "..", "..");
|
|
10
|
-
const TOTAL_STEPS =
|
|
10
|
+
const TOTAL_STEPS = 11;
|
|
11
11
|
const OLLAMA_EMBED_MODEL = "all-minilm";
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -342,6 +342,15 @@ export async function runSetupWizard() {
|
|
|
342
342
|
envConfig.TWILIO_ACCOUNT_SID = guard(await p.password({ message: "Twilio Account SID" }));
|
|
343
343
|
envConfig.TWILIO_AUTH_TOKEN = guard(await p.password({ message: "Twilio Auth Token" }));
|
|
344
344
|
envConfig.TWILIO_WHATSAPP_FROM = guard(await p.text({ message: "WhatsApp From number", initialValue: "whatsapp:+14155238886" }));
|
|
345
|
+
|
|
346
|
+
const enableVoice = guard(await p.confirm({
|
|
347
|
+
message: "Enable voice calls? (needs a voice-capable Twilio number + public URL)",
|
|
348
|
+
initialValue: false,
|
|
349
|
+
}));
|
|
350
|
+
if (enableVoice) {
|
|
351
|
+
envConfig.TWILIO_PHONE_FROM = guard(await p.text({ message: "Twilio voice-capable phone number (e.g. +1234567890)" }));
|
|
352
|
+
envConfig.VOICE_WEBHOOK_BASE_URL = guard(await p.text({ message: "Public URL for voice webhooks (e.g. https://abc123.ngrok.io)" }));
|
|
353
|
+
}
|
|
345
354
|
}
|
|
346
355
|
|
|
347
356
|
if (channels.includes("discord")) {
|
|
@@ -378,22 +387,47 @@ export async function runSetupWizard() {
|
|
|
378
387
|
}
|
|
379
388
|
|
|
380
389
|
if (channels.includes("email")) {
|
|
381
|
-
p.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
"
|
|
385
|
-
"
|
|
386
|
-
"
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
390
|
+
const emailMethod = guard(await p.select({
|
|
391
|
+
message: "Email sending method",
|
|
392
|
+
options: [
|
|
393
|
+
{ value: "resend", label: "Resend (easiest)", hint: "Just an API key, no SMTP config" },
|
|
394
|
+
{ value: "gmail", label: "Gmail IMAP/SMTP", hint: "Full inbox read + send via Gmail app password" },
|
|
395
|
+
{ value: "both", label: "Both (Resend + Gmail)", hint: "Resend for sending, Gmail for reading inbox" },
|
|
396
|
+
],
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
if (emailMethod === "resend" || emailMethod === "both") {
|
|
400
|
+
p.note(
|
|
401
|
+
[
|
|
402
|
+
"1. Sign up free at https://resend.com",
|
|
403
|
+
"2. API Keys → Create API Key",
|
|
404
|
+
"3. Domains → add your sending domain (or use shared domain for testing)",
|
|
405
|
+
].join("\n"),
|
|
406
|
+
"Resend Setup"
|
|
407
|
+
);
|
|
408
|
+
envConfig.RESEND_API_KEY = guard(await p.password({ message: "Resend API key (re_...)" }));
|
|
409
|
+
envConfig.RESEND_FROM = guard(await p.text({ message: "From address", placeholder: "you@yourdomain.com" }));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (emailMethod === "gmail" || emailMethod === "both") {
|
|
413
|
+
p.note(
|
|
414
|
+
[
|
|
415
|
+
"Gmail setup:",
|
|
416
|
+
"1. Google Account › Security › 2-Step Verification → enable",
|
|
417
|
+
"2. Google Account › Security › App Passwords → Mail → create",
|
|
418
|
+
"3. Use the 16-char app password below (NOT your Gmail password)",
|
|
419
|
+
"",
|
|
420
|
+
"For other providers: change IMAP/SMTP hosts below.",
|
|
421
|
+
].join("\n"),
|
|
422
|
+
"Gmail IMAP/SMTP Setup"
|
|
423
|
+
);
|
|
424
|
+
envConfig.EMAIL_USER = guard(await p.text({ message: "Email address" }));
|
|
425
|
+
envConfig.EMAIL_PASSWORD = guard(await p.password({ message: "App password" }));
|
|
426
|
+
envConfig.EMAIL_IMAP_HOST = guard(await p.text({ message: "IMAP host", initialValue: "imap.gmail.com" }));
|
|
427
|
+
envConfig.EMAIL_SMTP_HOST = guard(await p.text({ message: "SMTP host", initialValue: "smtp.gmail.com" }));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
p.log.info("Optional: EMAIL_ALLOWLIST=alice@example.com,bob@example.com");
|
|
397
431
|
}
|
|
398
432
|
|
|
399
433
|
if (channels.includes("line")) {
|
|
@@ -633,8 +667,115 @@ export async function runSetupWizard() {
|
|
|
633
667
|
})];
|
|
634
668
|
p.log.success(`Channels: ${t.bold(activeChannels.join(", "))}`);
|
|
635
669
|
|
|
636
|
-
// ━━━ Step 6:
|
|
637
|
-
stepHeader(6, TOTAL_STEPS, "
|
|
670
|
+
// ━━━ Step 6: Tool API Keys (optional) ━━━
|
|
671
|
+
stepHeader(6, TOTAL_STEPS, "Tool API Keys");
|
|
672
|
+
|
|
673
|
+
p.note(
|
|
674
|
+
[
|
|
675
|
+
"Some built-in tools need their own API keys to work.",
|
|
676
|
+
"You can skip this now and add keys later via: daemora config set <KEY> <value>",
|
|
677
|
+
"",
|
|
678
|
+
` ${S.info} generateImage / textToSpeech / transcribeAudio → OPENAI_API_KEY`,
|
|
679
|
+
` ${S.info} textToSpeech (premium voices) → ELEVENLABS_API_KEY`,
|
|
680
|
+
` ${S.info} googlePlaces → GOOGLE_PLACES_API_KEY`,
|
|
681
|
+
` ${S.info} calendar (Google) → GOOGLE_CALENDAR_API_KEY`,
|
|
682
|
+
` ${S.info} database → DATABASE_URL / MYSQL_URL`,
|
|
683
|
+
` ${S.info} notification (ntfy) → NTFY_TOPIC + NTFY_TOKEN`,
|
|
684
|
+
` ${S.info} notification (pushover) → PUSHOVER_API_TOKEN + PUSHOVER_USER_KEY`,
|
|
685
|
+
` ${S.info} philipsHue → HUE_BRIDGE_IP + HUE_API_KEY`,
|
|
686
|
+
].join("\n"),
|
|
687
|
+
"Optional Tool Credentials"
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
const toolKeys = guard(await p.multiselect({
|
|
691
|
+
message: "Configure tool API keys? (space = toggle, enter = confirm)",
|
|
692
|
+
required: false,
|
|
693
|
+
options: [
|
|
694
|
+
{ value: "openai_tools", label: "OpenAI (images, TTS, transcription)", hint: "OPENAI_API_KEY — skip if already set as main provider" },
|
|
695
|
+
{ value: "elevenlabs", label: "ElevenLabs TTS", hint: "Premium voice synthesis" },
|
|
696
|
+
{ value: "google_places", label: "Google Places", hint: "Location search & details" },
|
|
697
|
+
{ value: "google_cal", label: "Google Calendar", hint: "Calendar read/write" },
|
|
698
|
+
{ value: "database", label: "Database", hint: "PostgreSQL / MySQL connection" },
|
|
699
|
+
{ value: "ntfy", label: "Ntfy notifications", hint: "Push notifications via ntfy.sh" },
|
|
700
|
+
{ value: "pushover", label: "Pushover notifications", hint: "Push notifications via Pushover" },
|
|
701
|
+
{ value: "hue", label: "Philips Hue", hint: "Smart light control" },
|
|
702
|
+
{ value: "sonos", label: "Sonos speaker", hint: "Music / audio control" },
|
|
703
|
+
{ value: "none", label: "Skip for now", hint: "Add later via daemora config set" },
|
|
704
|
+
],
|
|
705
|
+
}));
|
|
706
|
+
|
|
707
|
+
if (toolKeys.includes("openai_tools") && !envConfig.OPENAI_API_KEY) {
|
|
708
|
+
const key = guard(await p.password({ message: "OpenAI API key (for images, TTS, transcription)" }));
|
|
709
|
+
if (key) envConfig.OPENAI_API_KEY = key;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (toolKeys.includes("elevenlabs")) {
|
|
713
|
+
const key = guard(await p.password({ message: "ElevenLabs API key" }));
|
|
714
|
+
if (key) envConfig.ELEVENLABS_API_KEY = key;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (toolKeys.includes("google_places")) {
|
|
718
|
+
const key = guard(await p.password({ message: "Google Places API key" }));
|
|
719
|
+
if (key) envConfig.GOOGLE_PLACES_API_KEY = key;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (toolKeys.includes("google_cal")) {
|
|
723
|
+
const key = guard(await p.password({ message: "Google Calendar API key" }));
|
|
724
|
+
if (key) envConfig.GOOGLE_CALENDAR_API_KEY = key;
|
|
725
|
+
const calId = guard(await p.text({ message: "Calendar ID", initialValue: "primary" }));
|
|
726
|
+
if (calId) envConfig.GOOGLE_CALENDAR_ID = calId;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (toolKeys.includes("database")) {
|
|
730
|
+
p.note(
|
|
731
|
+
"Format: postgresql://user:pass@host:5432/db or mysql://user:pass@host:3306/db",
|
|
732
|
+
"Database URL"
|
|
733
|
+
);
|
|
734
|
+
const dbUrl = guard(await p.text({ message: "Database URL (PostgreSQL or MySQL)" }));
|
|
735
|
+
if (dbUrl) {
|
|
736
|
+
if (dbUrl.startsWith("mysql")) envConfig.MYSQL_URL = dbUrl;
|
|
737
|
+
else envConfig.DATABASE_URL = dbUrl;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (toolKeys.includes("ntfy")) {
|
|
742
|
+
envConfig.NTFY_URL = guard(await p.text({ message: "Ntfy server URL", initialValue: "https://ntfy.sh" }));
|
|
743
|
+
envConfig.NTFY_TOPIC = guard(await p.text({ message: "Ntfy topic name" }));
|
|
744
|
+
const ntfyToken = guard(await p.password({ message: "Ntfy access token (optional)" }));
|
|
745
|
+
if (ntfyToken) envConfig.NTFY_TOKEN = ntfyToken;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (toolKeys.includes("pushover")) {
|
|
749
|
+
envConfig.PUSHOVER_API_TOKEN = guard(await p.password({ message: "Pushover API token" }));
|
|
750
|
+
envConfig.PUSHOVER_USER_KEY = guard(await p.password({ message: "Pushover user key" }));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (toolKeys.includes("hue")) {
|
|
754
|
+
p.note(
|
|
755
|
+
[
|
|
756
|
+
"Find bridge IP: check your router or use the Hue app.",
|
|
757
|
+
"Get API key: press the bridge button, then run:",
|
|
758
|
+
' curl -X POST http://<bridge-ip>/api -d \'{"devicetype":"daemora"}\'',
|
|
759
|
+
].join("\n"),
|
|
760
|
+
"Philips Hue Setup"
|
|
761
|
+
);
|
|
762
|
+
envConfig.HUE_BRIDGE_IP = guard(await p.text({ message: "Hue Bridge IP" }));
|
|
763
|
+
envConfig.HUE_API_KEY = guard(await p.password({ message: "Hue API key" }));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (toolKeys.includes("sonos")) {
|
|
767
|
+
envConfig.SONOS_SPEAKER_IP = guard(await p.text({ message: "Sonos speaker IP address" }));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const toolCount = toolKeys.filter(k => k !== "none").length;
|
|
771
|
+
if (toolCount > 0) {
|
|
772
|
+
p.log.success(`${toolCount} tool integration(s) configured`);
|
|
773
|
+
} else {
|
|
774
|
+
p.log.info("No tool keys configured. Add later via: daemora config set <KEY> <value>");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ━━━ Step 7: Daemon Mode ━━━
|
|
778
|
+
stepHeader(7, TOTAL_STEPS, "Daemon Mode");
|
|
638
779
|
|
|
639
780
|
p.note(
|
|
640
781
|
[
|
|
@@ -662,8 +803,8 @@ export async function runSetupWizard() {
|
|
|
662
803
|
|
|
663
804
|
p.log.success(`Daemon: ${t.bold(daemonMode ? "Enabled" : "Disabled")}`);
|
|
664
805
|
|
|
665
|
-
// ━━━ Step
|
|
666
|
-
stepHeader(
|
|
806
|
+
// ━━━ Step 8: Data Cleanup ━━━
|
|
807
|
+
stepHeader(8, TOTAL_STEPS, "Data Cleanup");
|
|
667
808
|
|
|
668
809
|
const cleanupDays = guard(await p.select({
|
|
669
810
|
message: "Auto-delete old tasks, logs & sessions after how many days?",
|
|
@@ -679,8 +820,8 @@ export async function runSetupWizard() {
|
|
|
679
820
|
|
|
680
821
|
p.log.success(`Cleanup: ${t.bold(cleanupDays === "0" ? "Never" : cleanupDays + " days")}`);
|
|
681
822
|
|
|
682
|
-
// ━━━ Step
|
|
683
|
-
stepHeader(
|
|
823
|
+
// ━━━ Step 9: MCP Servers ━━━
|
|
824
|
+
stepHeader(9, TOTAL_STEPS, "MCP Tool Servers");
|
|
684
825
|
|
|
685
826
|
p.note(
|
|
686
827
|
[
|
|
@@ -973,8 +1114,8 @@ export async function runSetupWizard() {
|
|
|
973
1114
|
p.log.info("No MCP servers configured. Use `daemora mcp add` anytime to add one.");
|
|
974
1115
|
}
|
|
975
1116
|
|
|
976
|
-
// ━━━ Step
|
|
977
|
-
stepHeader(
|
|
1117
|
+
// ━━━ Step 10: Secret Vault ━━━
|
|
1118
|
+
stepHeader(10, TOTAL_STEPS, "Secret Vault");
|
|
978
1119
|
|
|
979
1120
|
p.note(
|
|
980
1121
|
[
|
|
@@ -1060,6 +1201,52 @@ export async function runSetupWizard() {
|
|
|
1060
1201
|
p.log.info("Vault skipped. API keys will be stored in .env (plaintext).");
|
|
1061
1202
|
}
|
|
1062
1203
|
|
|
1204
|
+
// ━━━ Step 10: Multi-Tenant Mode ━━━
|
|
1205
|
+
stepHeader(11, TOTAL_STEPS, "Multi-Tenant Mode");
|
|
1206
|
+
|
|
1207
|
+
let multiTenantMode = "personal";
|
|
1208
|
+
|
|
1209
|
+
p.note(
|
|
1210
|
+
` ${S.info} Personal — single user, global config (default)\n` +
|
|
1211
|
+
` ${S.info} Multi-Tenant — per-user isolation, cost limits, model overrides`,
|
|
1212
|
+
"Deployment mode"
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
const mtChoice = guard(await p.select({
|
|
1216
|
+
message: "How will you use Daemora?",
|
|
1217
|
+
options: [
|
|
1218
|
+
{ value: "personal", label: "Personal", hint: "Single user, no tenant isolation" },
|
|
1219
|
+
{ value: "multitenant", label: "Multi-Tenant", hint: "Multiple users via channels, per-user config" },
|
|
1220
|
+
],
|
|
1221
|
+
}));
|
|
1222
|
+
|
|
1223
|
+
if (mtChoice === "multitenant") {
|
|
1224
|
+
multiTenantMode = "multitenant";
|
|
1225
|
+
envConfig.MULTI_TENANT_ENABLED = "true";
|
|
1226
|
+
|
|
1227
|
+
const autoReg = guard(await p.confirm({
|
|
1228
|
+
message: "Auto-register tenants on first message?",
|
|
1229
|
+
initialValue: true,
|
|
1230
|
+
}));
|
|
1231
|
+
envConfig.AUTO_REGISTER_TENANTS = autoReg ? "true" : "false";
|
|
1232
|
+
|
|
1233
|
+
const isolateFs = guard(await p.confirm({
|
|
1234
|
+
message: "Isolate each tenant's filesystem? (recommended for shared deployments)",
|
|
1235
|
+
initialValue: true,
|
|
1236
|
+
}));
|
|
1237
|
+
envConfig.TENANT_ISOLATE_FILESYSTEM = isolateFs ? "true" : "false";
|
|
1238
|
+
|
|
1239
|
+
const setKey = guard(await p.confirm({
|
|
1240
|
+
message: "Generate a tenant encryption key? (encrypts per-tenant API keys at rest)",
|
|
1241
|
+
initialValue: true,
|
|
1242
|
+
}));
|
|
1243
|
+
if (setKey) {
|
|
1244
|
+
const { randomBytes: rb } = await import("crypto");
|
|
1245
|
+
envConfig.DAEMORA_TENANT_KEY = rb(16).toString("hex");
|
|
1246
|
+
p.log.success(`${S.check} DAEMORA_TENANT_KEY generated (32 hex chars)`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1063
1250
|
// ━━━ Write Config ━━━
|
|
1064
1251
|
const spin = p.spinner();
|
|
1065
1252
|
spin.start("Writing configuration");
|
|
@@ -1077,8 +1264,12 @@ export async function runSetupWizard() {
|
|
|
1077
1264
|
"Filesystem": ["ALLOWED_PATHS", "BLOCKED_PATHS", "RESTRICT_COMMANDS"],
|
|
1078
1265
|
"Telegram": ["TELEGRAM_BOT_TOKEN"],
|
|
1079
1266
|
"WhatsApp": ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_WHATSAPP_FROM"],
|
|
1080
|
-
"Email": ["
|
|
1267
|
+
"Email (Resend)": ["RESEND_API_KEY", "RESEND_FROM"],
|
|
1268
|
+
"Email (IMAP/SMTP)": ["EMAIL_USER", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"],
|
|
1269
|
+
"Voice Calls": ["TWILIO_PHONE_FROM", "VOICE_WEBHOOK_BASE_URL"],
|
|
1081
1270
|
"Daemon": ["DAEMON_MODE", "HEARTBEAT_INTERVAL_MINUTES"],
|
|
1271
|
+
"Tool Keys": ["ELEVENLABS_API_KEY", "GOOGLE_PLACES_API_KEY", "GOOGLE_CALENDAR_API_KEY", "GOOGLE_CALENDAR_ID", "DATABASE_URL", "MYSQL_URL", "NTFY_URL", "NTFY_TOPIC", "NTFY_TOKEN", "PUSHOVER_API_TOKEN", "PUSHOVER_USER_KEY", "HUE_BRIDGE_IP", "HUE_API_KEY", "SONOS_SPEAKER_IP"],
|
|
1272
|
+
"Multi-Tenant": ["MULTI_TENANT_ENABLED", "AUTO_REGISTER_TENANTS", "TENANT_ISOLATE_FILESYSTEM", "DAEMORA_TENANT_KEY"],
|
|
1082
1273
|
};
|
|
1083
1274
|
|
|
1084
1275
|
for (const [category, keys] of Object.entries(categories)) {
|
|
@@ -1139,6 +1330,9 @@ export async function runSetupWizard() {
|
|
|
1139
1330
|
["Daemon", daemonMode ? t.success("Enabled") : t.muted("Disabled")],
|
|
1140
1331
|
["MCP Servers", allEnabled.length > 0 ? t.bold(allEnabled.join(", ")) : t.muted("None")],
|
|
1141
1332
|
["Vault", vaultPassphrase ? t.success("Encrypted") : t.warning("Plaintext (.env)")],
|
|
1333
|
+
["Multi-Tenant", multiTenantMode === "multitenant"
|
|
1334
|
+
? (envConfig.TENANT_ISOLATE_FILESYSTEM === "true" ? t.success("Enabled (isolated)") : t.accent("Enabled"))
|
|
1335
|
+
: t.muted("Disabled (personal)")],
|
|
1142
1336
|
]);
|
|
1143
1337
|
|
|
1144
1338
|
// ━━━ Next Steps ━━━
|
|
@@ -127,6 +127,43 @@ class TenantManager {
|
|
|
127
127
|
* Update tenant config (partial update - only provided keys are changed).
|
|
128
128
|
*/
|
|
129
129
|
set(tenantId, updates) {
|
|
130
|
+
// Validate path arrays before applying
|
|
131
|
+
for (const field of ["allowedPaths", "blockedPaths"]) {
|
|
132
|
+
if (updates[field] !== undefined) {
|
|
133
|
+
if (!Array.isArray(updates[field])) {
|
|
134
|
+
throw new Error(`${field} must be an array`);
|
|
135
|
+
}
|
|
136
|
+
for (const p of updates[field]) {
|
|
137
|
+
if (typeof p !== "string") {
|
|
138
|
+
throw new Error(`${field} must contain strings`);
|
|
139
|
+
}
|
|
140
|
+
// Must be absolute — Unix (/...) or Windows (C:\...)
|
|
141
|
+
const isUnixAbs = p.startsWith("/");
|
|
142
|
+
const isWinAbs = /^[A-Za-z]:[\\\/]/.test(p);
|
|
143
|
+
if (!isUnixAbs && !isWinAbs) {
|
|
144
|
+
throw new Error(`${field} must contain absolute paths (got "${p}")`);
|
|
145
|
+
}
|
|
146
|
+
// Block null bytes (path injection)
|
|
147
|
+
if (p.includes("\0")) {
|
|
148
|
+
throw new Error(`${field} must not contain null bytes`);
|
|
149
|
+
}
|
|
150
|
+
// Block path traversal via /../ or \..\ sequences
|
|
151
|
+
const normalized = p.replace(/\\/g, "/");
|
|
152
|
+
if (/(^|\/)\.\.(\/|$)/.test(normalized)) {
|
|
153
|
+
throw new Error(`${field} must not contain ".." path traversal (got "${p}")`);
|
|
154
|
+
}
|
|
155
|
+
// Max length — OS limits are typically 260 (Win) or 4096 (Unix)
|
|
156
|
+
if (p.length > 4096) {
|
|
157
|
+
throw new Error(`${field} path too long (max 4096 chars)`);
|
|
158
|
+
}
|
|
159
|
+
// Block control characters (0x00-0x1F except nothing — all blocked)
|
|
160
|
+
if (/[\x00-\x1f]/.test(p)) {
|
|
161
|
+
throw new Error(`${field} must not contain control characters`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
130
167
|
const tenants = this._load();
|
|
131
168
|
if (!tenants[tenantId]) {
|
|
132
169
|
tenants[tenantId] = _defaultTenant(tenantId);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-aware temp/workspace directory helper.
|
|
3
|
+
*
|
|
4
|
+
* TENANT_ISOLATE_FILESYSTEM=true + tenant context active
|
|
5
|
+
* → data/tenants/{safeId}/workspace/{subdir}
|
|
6
|
+
* Otherwise
|
|
7
|
+
* → os.tmpdir()/{subdir}
|
|
8
|
+
*
|
|
9
|
+
* Auto-creates the directory.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { config } from "../config/default.js";
|
|
15
|
+
import tenantContext from "../tenants/TenantContext.js";
|
|
16
|
+
|
|
17
|
+
const TENANTS_DIR = join(config.dataDir, "tenants");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} [subdir] - e.g. "daemora-images", "daemora-tts"
|
|
21
|
+
* @returns {string} absolute directory path (already created)
|
|
22
|
+
*/
|
|
23
|
+
export function getTenantTmpDir(subdir) {
|
|
24
|
+
const store = tenantContext.getStore();
|
|
25
|
+
const tenantId = store?.tenant?.id;
|
|
26
|
+
|
|
27
|
+
if (tenantId && config.multiTenant?.isolateFilesystem) {
|
|
28
|
+
const safeId = tenantId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
29
|
+
const dir = subdir
|
|
30
|
+
? join(TENANTS_DIR, safeId, "workspace", subdir)
|
|
31
|
+
: join(TENANTS_DIR, safeId, "workspace");
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dir = subdir ? join(tmpdir(), subdir) : tmpdir();
|
|
37
|
+
if (subdir) mkdirSync(dir, { recursive: true });
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
package/src/tools/applyPatch.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
|
+
import filesystemGuard from "../safety/FilesystemGuard.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Parse a unified diff string into an array of hunks.
|
|
@@ -105,6 +106,11 @@ function applyHunk(fileLines, hunk, offset) {
|
|
|
105
106
|
|
|
106
107
|
export function applyPatch(filePath, patch) {
|
|
107
108
|
try {
|
|
109
|
+
const readCheck = filesystemGuard.checkRead(filePath);
|
|
110
|
+
if (!readCheck.allowed) return `Error: ${readCheck.reason}`;
|
|
111
|
+
const writeCheck = filesystemGuard.checkWrite(filePath);
|
|
112
|
+
if (!writeCheck.allowed) return `Error: ${writeCheck.reason}`;
|
|
113
|
+
|
|
108
114
|
if (!existsSync(filePath)) {
|
|
109
115
|
return `Error: File not found: ${filePath}`;
|
|
110
116
|
}
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* - Localhost allowed, private ranges blocked
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { join } from "path";
|
|
16
|
+
import { join, basename } from "path";
|
|
17
17
|
import { mkdirSync, existsSync } from "fs";
|
|
18
18
|
import { config } from "../config/default.js";
|
|
19
|
+
import filesystemGuard from "../safety/FilesystemGuard.js";
|
|
20
|
+
import { getTenantTmpDir } from "./_paths.js";
|
|
19
21
|
|
|
20
22
|
let browser = null;
|
|
21
23
|
let browserContext = null;
|
|
@@ -419,7 +421,7 @@ export async function browserAction(action, param1, param2) {
|
|
|
419
421
|
case "screenshot": {
|
|
420
422
|
const p = await ensureBrowser();
|
|
421
423
|
const opts = { fullPage: false };
|
|
422
|
-
let path =
|
|
424
|
+
let path = join(getTenantTmpDir("daemora-browser"), `screenshot-${Date.now()}.png`);
|
|
423
425
|
|
|
424
426
|
if (param1 && param1.startsWith("/")) {
|
|
425
427
|
path = param1;
|
|
@@ -428,6 +430,8 @@ export async function browserAction(action, param1, param2) {
|
|
|
428
430
|
try {
|
|
429
431
|
const locator = await getLocator(p, param1);
|
|
430
432
|
path = param2 || path;
|
|
433
|
+
const sc = filesystemGuard.checkWrite(path);
|
|
434
|
+
if (!sc.allowed) return `Error: ${sc.reason}`;
|
|
431
435
|
await locator.screenshot({ path });
|
|
432
436
|
return `Element screenshot saved: ${path}`;
|
|
433
437
|
} catch {
|
|
@@ -435,13 +439,17 @@ export async function browserAction(action, param1, param2) {
|
|
|
435
439
|
path = param1;
|
|
436
440
|
}
|
|
437
441
|
}
|
|
442
|
+
const sc2 = filesystemGuard.checkWrite(path);
|
|
443
|
+
if (!sc2.allowed) return `Error: ${sc2.reason}`;
|
|
438
444
|
if (param2 === "full") opts.fullPage = true;
|
|
439
445
|
await p.screenshot({ path, ...opts });
|
|
440
446
|
return `Screenshot saved: ${path}`;
|
|
441
447
|
}
|
|
442
448
|
|
|
443
449
|
case "pdf": {
|
|
444
|
-
const path = param1 ||
|
|
450
|
+
const path = param1 || join(getTenantTmpDir("daemora-browser"), `page-${Date.now()}.pdf`);
|
|
451
|
+
const pc = filesystemGuard.checkWrite(path);
|
|
452
|
+
if (!pc.allowed) return `Error: ${pc.reason}`;
|
|
445
453
|
await currentPage().pdf({ path, format: "A4", printBackground: true });
|
|
446
454
|
return `PDF saved: ${path}`;
|
|
447
455
|
}
|
|
@@ -638,9 +646,13 @@ export async function browserAction(action, param1, param2) {
|
|
|
638
646
|
page.waitForEvent("download", { timeout: 30000 }),
|
|
639
647
|
(await getLocator(page, param1)).click(),
|
|
640
648
|
]);
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
649
|
+
// Sanitize filename — strip path traversal, use only basename
|
|
650
|
+
const safeName = basename(download.suggestedFilename()).replace(/[^a-zA-Z0-9._-]/g, "_") || "download";
|
|
651
|
+
const dlPath = join(downloadDir, safeName);
|
|
652
|
+
const dc = filesystemGuard.checkWrite(dlPath);
|
|
653
|
+
if (!dc.allowed) return `Error: ${dc.reason}`;
|
|
654
|
+
await download.saveAs(dlPath);
|
|
655
|
+
return `Downloaded: ${dlPath} (${safeName})`;
|
|
644
656
|
}
|
|
645
657
|
|
|
646
658
|
// ── Viewport ────────────────────────────────────────────────────────
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, readFileSync } from "fs";
|
|
2
2
|
import { dirname } from "path";
|
|
3
|
+
import filesystemGuard from "../safety/FilesystemGuard.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Create Document - creates Markdown, text, PDF, or DOCX documents.
|
|
@@ -15,6 +16,9 @@ export async function createDocument(filePath, content, format) {
|
|
|
15
16
|
return "Error: filePath and content are required.";
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const writeCheck = filesystemGuard.checkWrite(filePath);
|
|
20
|
+
if (!writeCheck.allowed) return `Error: ${writeCheck.reason}`;
|
|
21
|
+
|
|
18
22
|
try {
|
|
19
23
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
20
24
|
|
|
@@ -56,7 +56,10 @@ export async function executeCommand(cmd, optionsJson) {
|
|
|
56
56
|
// ──────────────────────────────────────────────────────────────────────────
|
|
57
57
|
|
|
58
58
|
// ── Filesystem scope enforcement ───────────────────────────────────────────
|
|
59
|
-
|
|
59
|
+
// Prefer per-tenant resolved config (set by TaskRunner), fall back to global
|
|
60
|
+
const store = tenantContext.getStore();
|
|
61
|
+
const resolvedConfig = store?.resolvedConfig;
|
|
62
|
+
const allowedPaths = resolvedConfig?.allowedPaths || config.filesystem?.allowedPaths || [];
|
|
60
63
|
if (allowedPaths.length > 0) {
|
|
61
64
|
// Always check that the cwd is inside an allowed directory
|
|
62
65
|
const cwdGuard = filesystemGuard.checkRead(cwd);
|
|
@@ -93,7 +96,6 @@ export async function executeCommand(cmd, optionsJson) {
|
|
|
93
96
|
|
|
94
97
|
// ── Docker sandbox mode — route through container ──
|
|
95
98
|
if (config.sandbox?.mode === "docker" && dockerSandbox.isAvailable() && !background) {
|
|
96
|
-
const store = tenantContext.getStore();
|
|
97
99
|
const scope = config.sandbox.docker?.scope === "shared" ? "shared" : (store?.sessionId || "shared");
|
|
98
100
|
return dockerSandbox.exec(scope, cmd, { timeout, cwd });
|
|
99
101
|
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
7
|
import tenantContext from "../tenants/TenantContext.js";
|
|
8
|
+
import filesystemGuard from "../safety/FilesystemGuard.js";
|
|
9
|
+
import { getTenantTmpDir } from "./_paths.js";
|
|
9
10
|
|
|
10
11
|
export async function generateImage(prompt, optionsJson) {
|
|
11
12
|
if (!prompt) return "Error: prompt is required.";
|
|
@@ -48,13 +49,16 @@ export async function generateImage(prompt, optionsJson) {
|
|
|
48
49
|
if (images.length === 0) return "Error: No images returned.";
|
|
49
50
|
|
|
50
51
|
const saved = [];
|
|
51
|
-
const dir =
|
|
52
|
-
mkdirSync(dir, { recursive: true });
|
|
52
|
+
const dir = getTenantTmpDir("daemora-images");
|
|
53
53
|
|
|
54
54
|
for (let i = 0; i < images.length; i++) {
|
|
55
55
|
const b64 = images[i].b64_json;
|
|
56
56
|
const revised = images[i].revised_prompt || prompt;
|
|
57
57
|
const filePath = outputPath || join(dir, `image-${Date.now()}-${i}.png`);
|
|
58
|
+
if (outputPath) {
|
|
59
|
+
const wc = filesystemGuard.checkWrite(outputPath);
|
|
60
|
+
if (!wc.allowed) return `Error: ${wc.reason}`;
|
|
61
|
+
}
|
|
58
62
|
writeFileSync(filePath, Buffer.from(b64, "base64"));
|
|
59
63
|
saved.push({ path: filePath, revisedPrompt: revised });
|
|
60
64
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import tenantContext from "../tenants/TenantContext.js";
|
|
12
12
|
import channelRegistry from "../channels/index.js";
|
|
13
13
|
import { existsSync, statSync } from "node:fs";
|
|
14
|
+
import filesystemGuard from "../safety/FilesystemGuard.js";
|
|
14
15
|
|
|
15
16
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
16
17
|
|
|
@@ -18,6 +19,9 @@ export async function replyWithFile(filePath, caption) {
|
|
|
18
19
|
try {
|
|
19
20
|
if (!filePath) return "Error: filePath is required.";
|
|
20
21
|
|
|
22
|
+
const readCheck = filesystemGuard.checkRead(filePath);
|
|
23
|
+
if (!readCheck.allowed) return `Error: ${readCheck.reason}`;
|
|
24
|
+
|
|
21
25
|
if (!existsSync(filePath)) {
|
|
22
26
|
return `Error: File not found: ${filePath}`;
|
|
23
27
|
}
|
|
@@ -15,15 +15,20 @@ import { execSync } from "node:child_process";
|
|
|
15
15
|
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
16
16
|
import { platform } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import filesystemGuard from "../safety/FilesystemGuard.js";
|
|
19
|
+
import { getTenantTmpDir } from "./_paths.js";
|
|
18
20
|
|
|
19
21
|
export function screenCapture(optionsJson) {
|
|
20
22
|
try {
|
|
21
23
|
const opts = optionsJson ? JSON.parse(optionsJson) : {};
|
|
22
|
-
const outputDir = opts.outputDir || "
|
|
24
|
+
const outputDir = opts.outputDir || getTenantTmpDir("daemora-captures");
|
|
23
25
|
const region = opts.region; // { x, y, width, height } - screenshot only
|
|
24
26
|
const mode = (opts.mode || "screenshot").toLowerCase();
|
|
25
27
|
const duration = parseInt(opts.duration || "10", 10); // seconds - video only
|
|
26
28
|
|
|
29
|
+
const wc = filesystemGuard.checkWrite(outputDir);
|
|
30
|
+
if (!wc.allowed) return `Error: ${wc.reason}`;
|
|
31
|
+
|
|
27
32
|
if (!existsSync(outputDir)) {
|
|
28
33
|
mkdirSync(outputDir, { recursive: true });
|
|
29
34
|
}
|
package/src/tools/sendFile.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import channelRegistry from "../channels/index.js";
|
|
17
17
|
import { existsSync, statSync } from "node:fs";
|
|
18
|
+
import filesystemGuard from "../safety/FilesystemGuard.js";
|
|
18
19
|
|
|
19
20
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB - most platforms limit around this
|
|
20
21
|
|
|
@@ -24,6 +25,9 @@ export async function sendFile(channel, target, filePath, caption) {
|
|
|
24
25
|
if (!target) return "Error: target is required (chat ID, user ID, phone, or email)";
|
|
25
26
|
if (!filePath) return "Error: filePath is required";
|
|
26
27
|
|
|
28
|
+
const readCheck = filesystemGuard.checkRead(filePath);
|
|
29
|
+
if (!readCheck.allowed) return `Error: ${readCheck.reason}`;
|
|
30
|
+
|
|
27
31
|
if (!existsSync(filePath)) {
|
|
28
32
|
return `Error: File not found: ${filePath}`;
|
|
29
33
|
}
|
package/src/tools/sshTool.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
10
|
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { getTenantTmpDir } from "./_paths.js";
|
|
12
12
|
|
|
13
13
|
export async function sshTool(action, paramsJson) {
|
|
14
14
|
if (!action) return "Error: action required. Valid: exec, upload, download, tunnel";
|
|
@@ -97,7 +97,7 @@ export async function sshTool(action, paramsJson) {
|
|
|
97
97
|
|
|
98
98
|
if (action === "keygen") {
|
|
99
99
|
// Generate a new SSH key pair for the agent's use
|
|
100
|
-
const keyDir = join(
|
|
100
|
+
const keyDir = join(getTenantTmpDir("daemora-ssh"), `keygen-${randomBytes(4).toString("hex")}`);
|
|
101
101
|
mkdirSync(keyDir, { recursive: true });
|
|
102
102
|
const keyFile = join(keyDir, "id_ed25519");
|
|
103
103
|
try {
|