daemora 1.0.3 → 1.0.5
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/LICENSE +663 -0
- package/README.md +69 -19
- package/SOUL.md +25 -24
- package/daemora-ui/README.md +11 -0
- package/package.json +12 -2
- package/skills/api-development.md +35 -0
- package/skills/artifacts-builder/SKILL.md +74 -0
- package/skills/artifacts-builder/scripts/bundle-artifact.sh +54 -0
- package/skills/artifacts-builder/scripts/init-artifact.sh +322 -0
- package/skills/artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/brand-guidelines.md +73 -0
- package/skills/browser.md +77 -0
- package/skills/changelog-generator.md +104 -0
- package/skills/coding.md +26 -10
- package/skills/content-research-writer.md +538 -0
- package/skills/data-analysis.md +27 -0
- package/skills/debugging.md +33 -0
- package/skills/devops.md +37 -0
- package/skills/document-docx.md +197 -0
- package/skills/document-pdf.md +294 -0
- package/skills/document-pptx.md +484 -0
- package/skills/document-xlsx.md +289 -0
- package/skills/domain-name-brainstormer.md +212 -0
- package/skills/file-organizer.md +433 -0
- package/skills/frontend-design.md +42 -0
- package/skills/image-enhancer.md +99 -0
- package/skills/invoice-organizer.md +446 -0
- package/skills/lead-research-assistant.md +199 -0
- package/skills/mcp-builder/SKILL.md +328 -0
- package/skills/mcp-builder/reference/evaluation.md +602 -0
- package/skills/mcp-builder/reference/mcp_best_practices.md +915 -0
- package/skills/mcp-builder/reference/node_mcp_server.md +916 -0
- package/skills/mcp-builder/reference/python_mcp_server.md +752 -0
- package/skills/mcp-builder/scripts/connections.py +151 -0
- package/skills/mcp-builder/scripts/evaluation.py +373 -0
- package/skills/mcp-builder/scripts/example_evaluation.xml +22 -0
- package/skills/mcp-builder/scripts/requirements.txt +2 -0
- package/skills/meeting-insights-analyzer.md +327 -0
- package/skills/orchestration.md +93 -0
- package/skills/raffle-winner-picker.md +159 -0
- package/skills/slack-gif-creator/SKILL.md +646 -0
- package/skills/slack-gif-creator/core/color_palettes.py +302 -0
- package/skills/slack-gif-creator/core/easing.py +230 -0
- package/skills/slack-gif-creator/core/frame_composer.py +469 -0
- package/skills/slack-gif-creator/core/gif_builder.py +246 -0
- package/skills/slack-gif-creator/core/typography.py +357 -0
- package/skills/slack-gif-creator/core/validators.py +264 -0
- package/skills/slack-gif-creator/core/visual_effects.py +494 -0
- package/skills/slack-gif-creator/requirements.txt +4 -0
- package/skills/slack-gif-creator/templates/bounce.py +106 -0
- package/skills/slack-gif-creator/templates/explode.py +331 -0
- package/skills/slack-gif-creator/templates/fade.py +329 -0
- package/skills/slack-gif-creator/templates/flip.py +291 -0
- package/skills/slack-gif-creator/templates/kaleidoscope.py +211 -0
- package/skills/slack-gif-creator/templates/morph.py +329 -0
- package/skills/slack-gif-creator/templates/move.py +293 -0
- package/skills/slack-gif-creator/templates/pulse.py +268 -0
- package/skills/slack-gif-creator/templates/shake.py +127 -0
- package/skills/slack-gif-creator/templates/slide.py +291 -0
- package/skills/slack-gif-creator/templates/spin.py +269 -0
- package/skills/slack-gif-creator/templates/wiggle.py +300 -0
- package/skills/slack-gif-creator/templates/zoom.py +312 -0
- package/skills/system-admin.md +44 -0
- package/skills/tailored-resume-generator.md +345 -0
- package/skills/theme-factory/SKILL.md +59 -0
- package/skills/theme-factory/theme-showcase.pdf +0 -0
- package/skills/theme-factory/themes/arctic-frost.md +19 -0
- package/skills/theme-factory/themes/botanical-garden.md +19 -0
- package/skills/theme-factory/themes/desert-rose.md +19 -0
- package/skills/theme-factory/themes/forest-canopy.md +19 -0
- package/skills/theme-factory/themes/golden-hour.md +19 -0
- package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
- package/skills/theme-factory/themes/modern-minimalist.md +19 -0
- package/skills/theme-factory/themes/ocean-depths.md +19 -0
- package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
- package/skills/theme-factory/themes/tech-innovation.md +19 -0
- package/skills/video-downloader.md +99 -0
- package/skills/web-development.md +32 -0
- package/skills/webapp-testing/SKILL.md +96 -0
- package/skills/webapp-testing/examples/console_logging.py +35 -0
- package/skills/webapp-testing/examples/element_discovery.py +40 -0
- package/skills/webapp-testing/examples/static_html_automation.py +33 -0
- package/skills/webapp-testing/scripts/with_server.py +106 -0
- package/src/agents/SubAgentManager.js +57 -12
- package/src/api/openai-compat.js +212 -0
- package/src/channels/TelegramChannel.js +5 -2
- package/src/channels/index.js +7 -10
- package/src/cli.js +129 -50
- package/src/config/agentProfiles.js +1 -0
- package/src/config/default.js +10 -0
- package/src/config/models.js +317 -71
- package/src/config/permissions.js +12 -0
- package/src/core/AgentLoop.js +70 -50
- package/src/core/Compaction.js +84 -2
- package/src/core/MessageQueue.js +90 -0
- package/src/core/Task.js +13 -0
- package/src/core/TaskQueue.js +1 -1
- package/src/core/TaskRunner.js +80 -5
- package/src/index.js +328 -48
- package/src/mcp/MCPAgentRunner.js +48 -11
- package/src/mcp/MCPManager.js +40 -2
- package/src/models/ModelRouter.js +67 -1
- package/src/safety/DockerSandbox.js +212 -0
- package/src/safety/ExecApproval.js +118 -0
- package/src/scheduler/Heartbeat.js +56 -21
- package/src/services/cleanup.js +106 -0
- package/src/services/sessions.js +39 -1
- package/src/setup/wizard.js +75 -4
- package/src/skills/SkillLoader.js +104 -17
- package/src/storage/TaskStore.js +19 -1
- package/src/systemPrompt.js +171 -328
- package/src/tools/browserAutomation.js +615 -104
- package/src/tools/executeCommand.js +19 -1
- package/src/tools/index.js +6 -0
- package/src/tools/manageAgents.js +55 -4
- package/src/tools/replyWithFile.js +62 -0
- package/src/tools/screenCapture.js +12 -1
- package/src/tools/taskManager.js +164 -0
- package/src/tools/useMCP.js +3 -1
- package/src/utils/Embeddings.js +157 -10
- package/src/webhooks/WebhookHandler.js +107 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSubAgent } from "../agents/SubAgentManager.js";
|
|
2
2
|
import mcpManager from "./MCPManager.js";
|
|
3
|
+
import { createSession, getSession, setMessages } from "../services/sessions.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* MCP Agent Runner - spawns specialist sub-agents for individual MCP servers.
|
|
@@ -69,6 +70,8 @@ All MCP tool params must be passed as a single JSON string (the first and only a
|
|
|
69
70
|
* @returns {Promise<string>} - Agent's final response
|
|
70
71
|
*/
|
|
71
72
|
export async function runMCPAgent(serverName, taskDescription, options = {}) {
|
|
73
|
+
const { mainSessionId, ...restOptions } = options;
|
|
74
|
+
|
|
72
75
|
// Get only this server's tool functions
|
|
73
76
|
const serverTools = mcpManager.getServerTools(serverName);
|
|
74
77
|
|
|
@@ -80,33 +83,67 @@ export async function runMCPAgent(serverName, taskDescription, options = {}) {
|
|
|
80
83
|
return `MCP server "${serverName}" not found or has no tools. Available servers: ${available.join(", ")}`;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
// Build tool docs for this server's system prompt
|
|
86
|
+
// Build tool docs for this server's system prompt (include nested schemas)
|
|
84
87
|
const toolDocs = Object.keys(serverTools)
|
|
85
88
|
.map((fullName) => {
|
|
86
89
|
const entry = mcpManager.toolMap.get(fullName);
|
|
87
90
|
if (!entry) return `### ${fullName}(argsJson: string)`;
|
|
88
|
-
const schema = entry.inputSchema?.properties || {};
|
|
89
|
-
const required = entry.inputSchema?.required || [];
|
|
90
|
-
const params = Object.entries(schema)
|
|
91
|
-
.map(([k, v]) => `${k}${required.includes(k) ? "" : "?"}: ${v.type || "any"}`)
|
|
92
|
-
.join(", ");
|
|
93
91
|
const desc = entry.description || entry.toolName;
|
|
94
|
-
const
|
|
95
|
-
|
|
92
|
+
const schema = entry.inputSchema;
|
|
93
|
+
// Show full JSON schema so the sub-agent knows exact field names
|
|
94
|
+
let schemaDoc = "";
|
|
95
|
+
if (schema) {
|
|
96
|
+
try {
|
|
97
|
+
schemaDoc = "\n- Schema:\n```json\n" + JSON.stringify(schema, null, 2) + "\n```";
|
|
98
|
+
} catch {
|
|
99
|
+
const props = schema.properties || {};
|
|
100
|
+
const required = schema.required || [];
|
|
101
|
+
const params = Object.entries(props)
|
|
102
|
+
.map(([k, v]) => `${k}${required.includes(k) ? "" : "?"}: ${v.type || "any"}`)
|
|
103
|
+
.join(", ");
|
|
104
|
+
schemaDoc = params ? `\n- argsJson: \`{${params}}\`` : "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return `### ${fullName}(argsJson: string)\n${desc}${schemaDoc}`;
|
|
96
108
|
})
|
|
97
109
|
.join("\n\n");
|
|
98
110
|
|
|
99
111
|
const systemPromptOverride = buildMCPAgentSystemPrompt(serverName, toolDocs);
|
|
100
112
|
|
|
113
|
+
// Load sub-agent session history (persistent across calls)
|
|
114
|
+
const subSessionId = mainSessionId ? `${mainSessionId}--${serverName}` : null;
|
|
115
|
+
let historyMessages = [];
|
|
116
|
+
if (subSessionId) {
|
|
117
|
+
const subSession = getSession(subSessionId);
|
|
118
|
+
if (subSession && subSession.messages.length > 0) {
|
|
119
|
+
historyMessages = subSession.messages.map(m => ({ role: m.role, content: m.content }));
|
|
120
|
+
console.log(`[MCPAgentRunner] Loaded ${historyMessages.length} history messages for "${serverName}"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
console.log(
|
|
102
125
|
`[MCPAgentRunner] Spawning specialist for "${serverName}" (${Object.keys(serverTools).length} tools)`
|
|
103
126
|
);
|
|
104
127
|
|
|
105
|
-
|
|
106
|
-
...
|
|
128
|
+
const fullResult = await spawnSubAgent(taskDescription, {
|
|
129
|
+
...restOptions,
|
|
107
130
|
toolOverride: serverTools,
|
|
108
131
|
systemPromptOverride,
|
|
109
|
-
// MCP agents are always depth 1 - they don't spawn further sub-agents
|
|
110
132
|
depth: 1,
|
|
133
|
+
historyMessages,
|
|
134
|
+
returnFullResult: true,
|
|
111
135
|
});
|
|
136
|
+
|
|
137
|
+
// Save sub-agent session (cap at 100 messages)
|
|
138
|
+
if (subSessionId && fullResult.messages) {
|
|
139
|
+
let subSession = getSession(subSessionId);
|
|
140
|
+
if (!subSession) subSession = createSession(subSessionId);
|
|
141
|
+
const capped = fullResult.messages.length > 100
|
|
142
|
+
? fullResult.messages.slice(-100)
|
|
143
|
+
: fullResult.messages;
|
|
144
|
+
setMessages(subSessionId, capped);
|
|
145
|
+
console.log(`[MCPAgentRunner] Saved ${capped.length} messages to sub-session "${subSessionId}"`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return typeof fullResult === "string" ? fullResult : fullResult.text;
|
|
112
149
|
}
|
package/src/mcp/MCPManager.js
CHANGED
|
@@ -192,9 +192,47 @@ class MCPManager {
|
|
|
192
192
|
|
|
193
193
|
const servers = mcpConfig.mcpServers || {};
|
|
194
194
|
|
|
195
|
-
// Filter to only real, enabled servers
|
|
195
|
+
// Filter to only real, enabled servers with valid auth
|
|
196
196
|
const enabledServers = Object.entries(servers).filter(
|
|
197
|
-
([name, cfg]) =>
|
|
197
|
+
([name, cfg]) => {
|
|
198
|
+
if (name.startsWith("_comment") || typeof cfg !== "object" || cfg.enabled === false) return false;
|
|
199
|
+
|
|
200
|
+
// Validate auth: skip servers with placeholder or empty credentials
|
|
201
|
+
if (cfg.env) {
|
|
202
|
+
const hasPlaceholder = Object.entries(cfg.env).some(([, v]) =>
|
|
203
|
+
typeof v === "string" && (
|
|
204
|
+
v === "" ||
|
|
205
|
+
v.startsWith("YOUR_") ||
|
|
206
|
+
v === "your-token-here" ||
|
|
207
|
+
v === "your-key-here" ||
|
|
208
|
+
/^[A-Z_]+$/.test(v) // e.g. "GITHUB_TOKEN" as a value, not a reference
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
if (hasPlaceholder) {
|
|
212
|
+
console.log(`[MCPManager] Skipping "${name}" - env vars contain placeholder/empty values. Set real credentials.`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (cfg.headers) {
|
|
218
|
+
const expandedHeaders = Object.entries(cfg.headers).map(([k, v]) => {
|
|
219
|
+
if (typeof v === "string") {
|
|
220
|
+
return [k, v.replace(/\$\{([^}]+)\}/g, (_, envName) => process.env[envName] ?? "")];
|
|
221
|
+
}
|
|
222
|
+
return [k, v];
|
|
223
|
+
});
|
|
224
|
+
const hasEmpty = expandedHeaders.some(([, v]) => typeof v === "string" && v.trim() === "");
|
|
225
|
+
const hasPlaceholder = expandedHeaders.some(([, v]) =>
|
|
226
|
+
typeof v === "string" && (v.includes("YOUR_") || v === "Bearer " || v === "Bearer")
|
|
227
|
+
);
|
|
228
|
+
if (hasEmpty || hasPlaceholder) {
|
|
229
|
+
console.log(`[MCPManager] Skipping "${name}" - headers resolve to empty/placeholder values. Set env vars.`);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
198
236
|
);
|
|
199
237
|
|
|
200
238
|
if (enabledServers.length === 0) {
|
|
@@ -48,7 +48,28 @@ function getProvider(name, apiKeys = {}) {
|
|
|
48
48
|
* @returns {{ model: object, meta: object }} AI SDK model instance + metadata
|
|
49
49
|
*/
|
|
50
50
|
export function getModel(modelId, apiKeys = {}) {
|
|
51
|
-
|
|
51
|
+
let meta = models[modelId];
|
|
52
|
+
|
|
53
|
+
// Passthrough: if model isn't in the registry but follows "provider:model" format,
|
|
54
|
+
// create a dynamic entry so new models work without updating the registry.
|
|
55
|
+
if (!meta && modelId.includes(":")) {
|
|
56
|
+
const [providerName, modelName] = modelId.split(":", 2);
|
|
57
|
+
const knownProviders = ["openai", "anthropic", "google", "ollama"];
|
|
58
|
+
if (knownProviders.includes(providerName)) {
|
|
59
|
+
console.log(`[ModelRouter] Model "${modelId}" not in registry — using dynamic passthrough`);
|
|
60
|
+
meta = {
|
|
61
|
+
provider: providerName,
|
|
62
|
+
model: modelName,
|
|
63
|
+
contextWindow: 128_000,
|
|
64
|
+
compactAt: 90_000,
|
|
65
|
+
costPer1kInput: 0.001,
|
|
66
|
+
costPer1kOutput: 0.004,
|
|
67
|
+
capabilities: ["text", "tools"],
|
|
68
|
+
tier: "standard",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
if (!meta) {
|
|
53
74
|
throw new Error(`Unknown model: ${modelId}. Available: ${Object.keys(models).join(", ")}`);
|
|
54
75
|
}
|
|
@@ -138,6 +159,51 @@ export function listAvailableModels() {
|
|
|
138
159
|
return available;
|
|
139
160
|
}
|
|
140
161
|
|
|
162
|
+
// ── Thinking Level Resolution ──────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
const _thinkingAliases = { on: "low", min: "minimal", max: "high" };
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve thinking config for a model + level combination.
|
|
168
|
+
* Returns params to merge into the generateObject call, or null if no thinking.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} modelId - e.g. "anthropic:claude-sonnet-4-6"
|
|
171
|
+
* @param {string} level - "auto"|"off"|"minimal"|"low"|"medium"|"high"|"xhigh"
|
|
172
|
+
* @returns {{ thinkingParams: object } | null}
|
|
173
|
+
*/
|
|
174
|
+
export function resolveThinkingConfig(modelId, level = "auto") {
|
|
175
|
+
const normalized = _thinkingAliases[level] || level;
|
|
176
|
+
if (normalized === "off" || normalized === "auto") return null;
|
|
177
|
+
|
|
178
|
+
const provider = modelId.split(":")[0];
|
|
179
|
+
|
|
180
|
+
// Anthropic: thinking.budget_tokens
|
|
181
|
+
if (provider === "anthropic") {
|
|
182
|
+
const budgetMap = { minimal: 1024, low: 2048, medium: 4096, high: 8192, xhigh: 16384 };
|
|
183
|
+
const budget = budgetMap[normalized];
|
|
184
|
+
if (!budget) return null;
|
|
185
|
+
return { thinkingParams: { thinking: { type: "enabled", budgetTokens: budget } } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// OpenAI: reasoning.effort (only for o-series models)
|
|
189
|
+
if (provider === "openai") {
|
|
190
|
+
const effortMap = { minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "high" };
|
|
191
|
+
const effort = effortMap[normalized];
|
|
192
|
+
if (!effort) return null;
|
|
193
|
+
return { thinkingParams: { reasoning: { effort } } };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Google: thinkingConfig.thinkingBudget
|
|
197
|
+
if (provider === "google") {
|
|
198
|
+
const budgetMap = { minimal: 1024, low: 2048, medium: 4096, high: 8192, xhigh: 16384 };
|
|
199
|
+
const budget = budgetMap[normalized];
|
|
200
|
+
if (!budget) return null;
|
|
201
|
+
return { thinkingParams: { thinkingConfig: { thinkingBudget: budget } } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
141
207
|
// ── Task-Type Model Routing ────────────────────────────────────────────────────
|
|
142
208
|
|
|
143
209
|
const _profileEnvMap = {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Sandbox — run commands inside Docker containers for kernel-level isolation.
|
|
3
|
+
*
|
|
4
|
+
* Config:
|
|
5
|
+
* SANDBOX_MODE=docker — enable Docker isolation
|
|
6
|
+
* DOCKER_IMAGE=node:22-slim — base image
|
|
7
|
+
* DOCKER_MEMORY=512m — memory limit
|
|
8
|
+
* DOCKER_CPUS=0.5 — CPU limit
|
|
9
|
+
* DOCKER_NETWORK=none — network mode (none = no network)
|
|
10
|
+
* DOCKER_SCOPE=session — "session" (per session) | "shared" (one for all)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
14
|
+
import { config } from "../config/default.js";
|
|
15
|
+
|
|
16
|
+
// containerId → { scope, createdAt, lastUsedAt }
|
|
17
|
+
const _containers = new Map();
|
|
18
|
+
const CLEANUP_AFTER = 10 * 60 * 1000; // 10 min inactivity
|
|
19
|
+
|
|
20
|
+
class DockerSandbox {
|
|
21
|
+
constructor() {
|
|
22
|
+
this._available = null; // cached Docker availability check
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if Docker is available.
|
|
27
|
+
*/
|
|
28
|
+
isAvailable() {
|
|
29
|
+
if (this._available !== null) return this._available;
|
|
30
|
+
try {
|
|
31
|
+
execSync("docker info", { stdio: "pipe", timeout: 5000 });
|
|
32
|
+
this._available = true;
|
|
33
|
+
} catch {
|
|
34
|
+
this._available = false;
|
|
35
|
+
}
|
|
36
|
+
return this._available;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ensure a container exists for the given scope.
|
|
41
|
+
* @param {string} scopeId — session ID or "shared"
|
|
42
|
+
* @returns {string} containerId
|
|
43
|
+
*/
|
|
44
|
+
ensureContainer(scopeId = "shared") {
|
|
45
|
+
const existing = _containers.get(scopeId);
|
|
46
|
+
if (existing) {
|
|
47
|
+
// Check if container is still running
|
|
48
|
+
try {
|
|
49
|
+
const status = execSync(`docker inspect -f '{{.State.Running}}' ${existing.containerId}`, {
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
timeout: 5000,
|
|
53
|
+
}).trim();
|
|
54
|
+
if (status === "true") {
|
|
55
|
+
existing.lastUsedAt = Date.now();
|
|
56
|
+
return existing.containerId;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Container gone — remove from map and create new
|
|
60
|
+
_containers.delete(scopeId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sandbox = config.sandbox || {};
|
|
65
|
+
const image = sandbox.dockerImage || "node:22-slim";
|
|
66
|
+
const memory = sandbox.dockerMemory || "512m";
|
|
67
|
+
const cpus = sandbox.dockerCpus || "0.5";
|
|
68
|
+
const network = sandbox.dockerNetwork || "none";
|
|
69
|
+
|
|
70
|
+
// Create container with security constraints
|
|
71
|
+
const containerName = `daemora-sandbox-${scopeId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 30)}-${Date.now()}`;
|
|
72
|
+
|
|
73
|
+
const args = [
|
|
74
|
+
"docker", "run", "-d",
|
|
75
|
+
"--name", containerName,
|
|
76
|
+
"--memory", memory,
|
|
77
|
+
"--cpus", cpus,
|
|
78
|
+
"--network", network,
|
|
79
|
+
"--read-only",
|
|
80
|
+
"--cap-drop", "ALL",
|
|
81
|
+
"--tmpfs", "/tmp:rw,noexec,nosuid,size=100m",
|
|
82
|
+
"--tmpfs", "/workspace:rw,size=500m",
|
|
83
|
+
"-w", "/workspace",
|
|
84
|
+
image,
|
|
85
|
+
"tail", "-f", "/dev/null", // Keep container alive
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const containerId = execSync(args.join(" "), {
|
|
90
|
+
encoding: "utf-8",
|
|
91
|
+
timeout: 30000,
|
|
92
|
+
}).trim();
|
|
93
|
+
|
|
94
|
+
_containers.set(scopeId, {
|
|
95
|
+
containerId,
|
|
96
|
+
containerName,
|
|
97
|
+
createdAt: Date.now(),
|
|
98
|
+
lastUsedAt: Date.now(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.log(`[DockerSandbox] Created container ${containerName} (${containerId.slice(0, 12)}) for scope: ${scopeId}`);
|
|
102
|
+
return containerId;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`Failed to create Docker container: ${error.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Execute a command inside a container.
|
|
110
|
+
* @param {string} scopeId
|
|
111
|
+
* @param {string} command
|
|
112
|
+
* @param {object} opts — { timeout, cwd }
|
|
113
|
+
* @returns {string} command output
|
|
114
|
+
*/
|
|
115
|
+
exec(scopeId, command, opts = {}) {
|
|
116
|
+
const containerId = this.ensureContainer(scopeId);
|
|
117
|
+
const timeout = opts.timeout || 120_000;
|
|
118
|
+
const cwd = opts.cwd || "/workspace";
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const result = execSync(`docker exec -w "${cwd}" ${containerId} sh -c ${JSON.stringify(command)}`, {
|
|
122
|
+
encoding: "utf-8",
|
|
123
|
+
timeout,
|
|
124
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
125
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const entry = _containers.get(scopeId);
|
|
129
|
+
if (entry) entry.lastUsedAt = Date.now();
|
|
130
|
+
|
|
131
|
+
return result || "(command completed with no output)";
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.killed) {
|
|
134
|
+
return `Command timed out after ${timeout / 1000}s inside Docker container.`;
|
|
135
|
+
}
|
|
136
|
+
const parts = [];
|
|
137
|
+
if (error.stdout) parts.push(`stdout:\n${error.stdout.slice(0, 2000)}`);
|
|
138
|
+
if (error.stderr) parts.push(`stderr:\n${error.stderr.slice(0, 2000)}`);
|
|
139
|
+
const exitMsg = error.status !== undefined ? ` (exit code: ${error.status})` : "";
|
|
140
|
+
if (parts.length > 0) return `Command failed${exitMsg}:\n${parts.join("\n---\n")}`;
|
|
141
|
+
return `Command failed${exitMsg}: ${error.message}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Copy workspace files into the container.
|
|
147
|
+
*/
|
|
148
|
+
copyToContainer(scopeId, hostPath, containerPath = "/workspace") {
|
|
149
|
+
const containerId = this.ensureContainer(scopeId);
|
|
150
|
+
try {
|
|
151
|
+
execSync(`docker cp "${hostPath}/." ${containerId}:${containerPath}`, {
|
|
152
|
+
timeout: 30000,
|
|
153
|
+
stdio: "pipe",
|
|
154
|
+
});
|
|
155
|
+
return true;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.log(`[DockerSandbox] Copy failed: ${error.message}`);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove a container.
|
|
164
|
+
*/
|
|
165
|
+
removeContainer(scopeId) {
|
|
166
|
+
const entry = _containers.get(scopeId);
|
|
167
|
+
if (!entry) return;
|
|
168
|
+
try {
|
|
169
|
+
execSync(`docker rm -f ${entry.containerId}`, { stdio: "pipe", timeout: 10000 });
|
|
170
|
+
console.log(`[DockerSandbox] Removed container for scope: ${scopeId}`);
|
|
171
|
+
} catch {}
|
|
172
|
+
_containers.delete(scopeId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Cleanup inactive containers.
|
|
177
|
+
*/
|
|
178
|
+
cleanup() {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
for (const [scopeId, entry] of _containers.entries()) {
|
|
181
|
+
if (now - entry.lastUsedAt > CLEANUP_AFTER) {
|
|
182
|
+
this.removeContainer(scopeId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Remove all containers.
|
|
189
|
+
*/
|
|
190
|
+
removeAll() {
|
|
191
|
+
for (const scopeId of [..._containers.keys()]) {
|
|
192
|
+
this.removeContainer(scopeId);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* List active containers.
|
|
198
|
+
*/
|
|
199
|
+
list() {
|
|
200
|
+
return [..._containers.entries()].map(([scopeId, entry]) => ({
|
|
201
|
+
scopeId,
|
|
202
|
+
containerId: entry.containerId.slice(0, 12),
|
|
203
|
+
containerName: entry.containerName,
|
|
204
|
+
createdAt: new Date(entry.createdAt).toISOString(),
|
|
205
|
+
lastUsedAt: new Date(entry.lastUsedAt).toISOString(),
|
|
206
|
+
idleMs: Date.now() - entry.lastUsedAt,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const dockerSandbox = new DockerSandbox();
|
|
212
|
+
export default dockerSandbox;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exec Approval Manager — interactive approval gates for dangerous commands.
|
|
3
|
+
*
|
|
4
|
+
* When approval mode is enabled and a command matches dangerous patterns,
|
|
5
|
+
* the agent loop pauses and waits for user approval via the API.
|
|
6
|
+
*
|
|
7
|
+
* Config: approval.mode = "off" | "dangerous-only" | "all"
|
|
8
|
+
* API:
|
|
9
|
+
* GET /api/approvals — list pending approvals
|
|
10
|
+
* POST /api/approvals/:id — approve/deny { decision: "allow" | "allow-once" | "deny" }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { v4 as uuidv4 } from "uuid";
|
|
14
|
+
|
|
15
|
+
// Dangerous command patterns
|
|
16
|
+
const DANGEROUS_PATTERNS = [
|
|
17
|
+
/\brm\s+(-rf?|--recursive)\b/i,
|
|
18
|
+
/\bsudo\b/,
|
|
19
|
+
/\bdrop\s+(table|database)\b/i,
|
|
20
|
+
/\bkill\s+-9\b/,
|
|
21
|
+
/\bshutdown\b/,
|
|
22
|
+
/\breboot\b/,
|
|
23
|
+
/\bmkfs\b/,
|
|
24
|
+
/\bdd\s+if=/,
|
|
25
|
+
/\bcurl\b.*\|\s*(sh|bash|zsh)\b/,
|
|
26
|
+
/\bwget\b.*\|\s*(sh|bash|zsh)\b/,
|
|
27
|
+
/\bnpm\s+publish\b/,
|
|
28
|
+
/\bgit\s+push\s+.*--force\b/,
|
|
29
|
+
/\bgit\s+reset\s+--hard\b/,
|
|
30
|
+
/\bdocker\s+rm\b/,
|
|
31
|
+
/\bdocker\s+system\s+prune\b/,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
class ExecApprovalManager {
|
|
35
|
+
constructor() {
|
|
36
|
+
// approvalId → { command, taskId, createdAt, resolve, timer }
|
|
37
|
+
this._pending = new Map();
|
|
38
|
+
this._timeoutMs = 60_000; // 60 seconds default
|
|
39
|
+
this._mode = process.env.APPROVAL_MODE || "off"; // "off" | "dangerous-only" | "all"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get mode() {
|
|
43
|
+
return this._mode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if a command needs approval.
|
|
48
|
+
*/
|
|
49
|
+
needsApproval(command) {
|
|
50
|
+
if (this._mode === "off") return false;
|
|
51
|
+
if (this._mode === "all") return true;
|
|
52
|
+
// "dangerous-only" — check against patterns
|
|
53
|
+
return DANGEROUS_PATTERNS.some(p => p.test(command));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Request approval for a command. Returns a Promise that resolves with the decision.
|
|
58
|
+
* @param {string} command
|
|
59
|
+
* @param {string} taskId
|
|
60
|
+
* @returns {Promise<"allow"|"deny">}
|
|
61
|
+
*/
|
|
62
|
+
requestApproval(command, taskId) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const approvalId = uuidv4().slice(0, 12);
|
|
65
|
+
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
this._pending.delete(approvalId);
|
|
68
|
+
console.log(`[ExecApproval] Timeout for ${approvalId} — denying`);
|
|
69
|
+
resolve("deny");
|
|
70
|
+
}, this._timeoutMs);
|
|
71
|
+
|
|
72
|
+
this._pending.set(approvalId, {
|
|
73
|
+
command,
|
|
74
|
+
taskId,
|
|
75
|
+
createdAt: new Date().toISOString(),
|
|
76
|
+
resolve,
|
|
77
|
+
timer,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
console.log(`[ExecApproval] Waiting for approval ${approvalId}: "${command.slice(0, 80)}"`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a pending approval.
|
|
86
|
+
* @param {string} approvalId
|
|
87
|
+
* @param {"allow"|"allow-once"|"deny"} decision
|
|
88
|
+
* @returns {boolean} true if found and resolved
|
|
89
|
+
*/
|
|
90
|
+
resolveApproval(approvalId, decision) {
|
|
91
|
+
const entry = this._pending.get(approvalId);
|
|
92
|
+
if (!entry) return false;
|
|
93
|
+
|
|
94
|
+
clearTimeout(entry.timer);
|
|
95
|
+
this._pending.delete(approvalId);
|
|
96
|
+
|
|
97
|
+
const effective = decision === "allow-once" ? "allow" : decision;
|
|
98
|
+
console.log(`[ExecApproval] ${approvalId} → ${decision}`);
|
|
99
|
+
entry.resolve(effective);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List all pending approvals (for API).
|
|
105
|
+
*/
|
|
106
|
+
listPending() {
|
|
107
|
+
return [...this._pending.entries()].map(([id, entry]) => ({
|
|
108
|
+
id,
|
|
109
|
+
command: entry.command,
|
|
110
|
+
taskId: entry.taskId,
|
|
111
|
+
createdAt: entry.createdAt,
|
|
112
|
+
expiresIn: Math.max(0, this._timeoutMs - (Date.now() - new Date(entry.createdAt).getTime())),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const execApproval = new ExecApprovalManager();
|
|
118
|
+
export default execApproval;
|
|
@@ -5,12 +5,15 @@ import taskQueue from "../core/TaskQueue.js";
|
|
|
5
5
|
import eventBus from "../core/EventBus.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Heartbeat - periodic proactive
|
|
8
|
+
* Heartbeat - periodic proactive agent turns.
|
|
9
9
|
*
|
|
10
|
-
* Reads HEARTBEAT.md for user-defined
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Reads HEARTBEAT.md for user-defined instructions. Every N minutes,
|
|
11
|
+
* enqueues a heartbeat task with the HEARTBEAT.md content as prompt.
|
|
12
|
+
*
|
|
13
|
+
* Features:
|
|
14
|
+
* - Active hours: skip runs outside configurable window (default 8-22)
|
|
15
|
+
* - Duplicate suppression: skip if HEARTBEAT.md unchanged within 24h
|
|
16
|
+
* - Configurable via env vars or config
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
class Heartbeat {
|
|
@@ -21,11 +24,14 @@ class Heartbeat {
|
|
|
21
24
|
this.intervalMinutes = config.heartbeatIntervalMinutes;
|
|
22
25
|
this.lastCheck = null;
|
|
23
26
|
this.checkCount = 0;
|
|
27
|
+
this._lastContentHash = null;
|
|
28
|
+
this._lastContentAt = 0;
|
|
29
|
+
|
|
30
|
+
// Active hours config (env override or defaults)
|
|
31
|
+
this.activeHourStart = parseInt(process.env.HEARTBEAT_ACTIVE_START || "8", 10);
|
|
32
|
+
this.activeHourEnd = parseInt(process.env.HEARTBEAT_ACTIVE_END || "22", 10);
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
/**
|
|
27
|
-
* Start the heartbeat.
|
|
28
|
-
*/
|
|
29
35
|
start() {
|
|
30
36
|
if (!config.daemonMode) {
|
|
31
37
|
console.log(`[Heartbeat] Skipped - daemon mode not enabled`);
|
|
@@ -44,16 +50,13 @@ class Heartbeat {
|
|
|
44
50
|
);
|
|
45
51
|
|
|
46
52
|
console.log(
|
|
47
|
-
`[Heartbeat] Started (every ${this.intervalMinutes}
|
|
53
|
+
`[Heartbeat] Started (every ${this.intervalMinutes}min, active ${this.activeHourStart}:00-${this.activeHourEnd}:00)`
|
|
48
54
|
);
|
|
49
55
|
|
|
50
56
|
// Run first check after 1 minute
|
|
51
57
|
setTimeout(() => this.check(), 60000);
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
/**
|
|
55
|
-
* Stop the heartbeat.
|
|
56
|
-
*/
|
|
57
60
|
stop() {
|
|
58
61
|
this.running = false;
|
|
59
62
|
if (this.timer) {
|
|
@@ -63,20 +66,53 @@ class Heartbeat {
|
|
|
63
66
|
console.log(`[Heartbeat] Stopped`);
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
_isActiveHour() {
|
|
70
|
+
const hour = new Date().getHours();
|
|
71
|
+
return hour >= this.activeHourStart && hour < this.activeHourEnd;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_simpleHash(str) {
|
|
75
|
+
let hash = 0;
|
|
76
|
+
for (let i = 0; i < str.length; i++) {
|
|
77
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
78
|
+
}
|
|
79
|
+
return hash;
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
async check() {
|
|
70
83
|
if (!this.running) return;
|
|
71
84
|
|
|
85
|
+
// Active hours check
|
|
86
|
+
if (!this._isActiveHour()) {
|
|
87
|
+
console.log(`[Heartbeat] Outside active hours (${this.activeHourStart}:00-${this.activeHourEnd}:00) — skipping`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!existsSync(this.heartbeatPath)) return;
|
|
92
|
+
|
|
72
93
|
try {
|
|
73
|
-
const instructions = readFileSync(this.heartbeatPath, "utf-8");
|
|
94
|
+
const instructions = readFileSync(this.heartbeatPath, "utf-8").trim();
|
|
95
|
+
if (!instructions) {
|
|
96
|
+
console.log(`[Heartbeat] HEARTBEAT.md is empty — skipping`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Duplicate suppression: skip if same content within 24h
|
|
101
|
+
const contentHash = this._simpleHash(instructions);
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
if (contentHash === this._lastContentHash && (now - this._lastContentAt) < 24 * 60 * 60 * 1000) {
|
|
104
|
+
console.log(`[Heartbeat] HEARTBEAT.md unchanged within 24h — skipping`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this._lastContentHash = contentHash;
|
|
108
|
+
this._lastContentAt = now;
|
|
109
|
+
|
|
74
110
|
this.checkCount++;
|
|
75
111
|
this.lastCheck = new Date().toISOString();
|
|
76
112
|
|
|
77
113
|
console.log(`[Heartbeat] Check #${this.checkCount}...`);
|
|
78
114
|
|
|
79
|
-
const prompt = `
|
|
115
|
+
const prompt = `[Heartbeat check #${this.checkCount}] Follow the instructions in HEARTBEAT.md. If everything looks fine, respond "All clear." If something needs attention, describe what you found and take action.
|
|
80
116
|
|
|
81
117
|
Instructions from HEARTBEAT.md:
|
|
82
118
|
${instructions}
|
|
@@ -86,8 +122,9 @@ Current time: ${new Date().toISOString()}`;
|
|
|
86
122
|
taskQueue.enqueue({
|
|
87
123
|
input: prompt,
|
|
88
124
|
channel: "heartbeat",
|
|
89
|
-
sessionId:
|
|
125
|
+
sessionId: "heartbeat",
|
|
90
126
|
priority: 2,
|
|
127
|
+
type: "heartbeat",
|
|
91
128
|
});
|
|
92
129
|
|
|
93
130
|
eventBus.emitEvent("heartbeat:check", {
|
|
@@ -98,13 +135,11 @@ Current time: ${new Date().toISOString()}`;
|
|
|
98
135
|
}
|
|
99
136
|
}
|
|
100
137
|
|
|
101
|
-
/**
|
|
102
|
-
* Get stats.
|
|
103
|
-
*/
|
|
104
138
|
stats() {
|
|
105
139
|
return {
|
|
106
140
|
running: this.running,
|
|
107
141
|
intervalMinutes: this.intervalMinutes,
|
|
142
|
+
activeHours: `${this.activeHourStart}:00-${this.activeHourEnd}:00`,
|
|
108
143
|
lastCheck: this.lastCheck,
|
|
109
144
|
checkCount: this.checkCount,
|
|
110
145
|
};
|