daemora 1.0.4 → 1.0.6

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.
Files changed (123) hide show
  1. package/LICENSE +663 -0
  2. package/README.md +69 -19
  3. package/SOUL.md +29 -26
  4. package/config/mcp.json +126 -66
  5. package/daemora-ui/README.md +11 -0
  6. package/package.json +12 -2
  7. package/skills/api-development.md +35 -0
  8. package/skills/artifacts-builder/SKILL.md +74 -0
  9. package/skills/artifacts-builder/scripts/bundle-artifact.sh +54 -0
  10. package/skills/artifacts-builder/scripts/init-artifact.sh +322 -0
  11. package/skills/artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  12. package/skills/brand-guidelines.md +73 -0
  13. package/skills/browser.md +77 -0
  14. package/skills/changelog-generator.md +104 -0
  15. package/skills/coding.md +26 -10
  16. package/skills/content-research-writer.md +538 -0
  17. package/skills/data-analysis.md +27 -0
  18. package/skills/debugging.md +33 -0
  19. package/skills/devops.md +37 -0
  20. package/skills/document-docx.md +197 -0
  21. package/skills/document-pdf.md +294 -0
  22. package/skills/document-pptx.md +484 -0
  23. package/skills/document-xlsx.md +289 -0
  24. package/skills/domain-name-brainstormer.md +212 -0
  25. package/skills/file-organizer.md +433 -0
  26. package/skills/frontend-design.md +42 -0
  27. package/skills/image-enhancer.md +99 -0
  28. package/skills/invoice-organizer.md +446 -0
  29. package/skills/lead-research-assistant.md +199 -0
  30. package/skills/mcp-builder/SKILL.md +328 -0
  31. package/skills/mcp-builder/reference/evaluation.md +602 -0
  32. package/skills/mcp-builder/reference/mcp_best_practices.md +915 -0
  33. package/skills/mcp-builder/reference/node_mcp_server.md +916 -0
  34. package/skills/mcp-builder/reference/python_mcp_server.md +752 -0
  35. package/skills/mcp-builder/scripts/connections.py +151 -0
  36. package/skills/mcp-builder/scripts/evaluation.py +373 -0
  37. package/skills/mcp-builder/scripts/example_evaluation.xml +22 -0
  38. package/skills/mcp-builder/scripts/requirements.txt +2 -0
  39. package/skills/meeting-insights-analyzer.md +327 -0
  40. package/skills/orchestration.md +93 -0
  41. package/skills/raffle-winner-picker.md +159 -0
  42. package/skills/slack-gif-creator/SKILL.md +646 -0
  43. package/skills/slack-gif-creator/core/color_palettes.py +302 -0
  44. package/skills/slack-gif-creator/core/easing.py +230 -0
  45. package/skills/slack-gif-creator/core/frame_composer.py +469 -0
  46. package/skills/slack-gif-creator/core/gif_builder.py +246 -0
  47. package/skills/slack-gif-creator/core/typography.py +357 -0
  48. package/skills/slack-gif-creator/core/validators.py +264 -0
  49. package/skills/slack-gif-creator/core/visual_effects.py +494 -0
  50. package/skills/slack-gif-creator/requirements.txt +4 -0
  51. package/skills/slack-gif-creator/templates/bounce.py +106 -0
  52. package/skills/slack-gif-creator/templates/explode.py +331 -0
  53. package/skills/slack-gif-creator/templates/fade.py +329 -0
  54. package/skills/slack-gif-creator/templates/flip.py +291 -0
  55. package/skills/slack-gif-creator/templates/kaleidoscope.py +211 -0
  56. package/skills/slack-gif-creator/templates/morph.py +329 -0
  57. package/skills/slack-gif-creator/templates/move.py +293 -0
  58. package/skills/slack-gif-creator/templates/pulse.py +268 -0
  59. package/skills/slack-gif-creator/templates/shake.py +127 -0
  60. package/skills/slack-gif-creator/templates/slide.py +291 -0
  61. package/skills/slack-gif-creator/templates/spin.py +269 -0
  62. package/skills/slack-gif-creator/templates/wiggle.py +300 -0
  63. package/skills/slack-gif-creator/templates/zoom.py +312 -0
  64. package/skills/system-admin.md +44 -0
  65. package/skills/tailored-resume-generator.md +345 -0
  66. package/skills/theme-factory/SKILL.md +59 -0
  67. package/skills/theme-factory/theme-showcase.pdf +0 -0
  68. package/skills/theme-factory/themes/arctic-frost.md +19 -0
  69. package/skills/theme-factory/themes/botanical-garden.md +19 -0
  70. package/skills/theme-factory/themes/desert-rose.md +19 -0
  71. package/skills/theme-factory/themes/forest-canopy.md +19 -0
  72. package/skills/theme-factory/themes/golden-hour.md +19 -0
  73. package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
  74. package/skills/theme-factory/themes/modern-minimalist.md +19 -0
  75. package/skills/theme-factory/themes/ocean-depths.md +19 -0
  76. package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
  77. package/skills/theme-factory/themes/tech-innovation.md +19 -0
  78. package/skills/video-downloader.md +99 -0
  79. package/skills/web-development.md +32 -0
  80. package/skills/webapp-testing/SKILL.md +96 -0
  81. package/skills/webapp-testing/examples/console_logging.py +35 -0
  82. package/skills/webapp-testing/examples/element_discovery.py +40 -0
  83. package/skills/webapp-testing/examples/static_html_automation.py +33 -0
  84. package/skills/webapp-testing/scripts/with_server.py +106 -0
  85. package/src/agents/SubAgentManager.js +134 -16
  86. package/src/agents/systemPrompt.js +427 -0
  87. package/src/api/openai-compat.js +212 -0
  88. package/src/channels/TelegramChannel.js +5 -2
  89. package/src/channels/index.js +7 -10
  90. package/src/cli.js +281 -55
  91. package/src/config/agentProfiles.js +1 -0
  92. package/src/config/default.js +15 -1
  93. package/src/config/models.js +314 -78
  94. package/src/config/permissions.js +12 -0
  95. package/src/core/AgentLoop.js +70 -50
  96. package/src/core/Compaction.js +111 -11
  97. package/src/core/MessageQueue.js +90 -0
  98. package/src/core/Task.js +13 -0
  99. package/src/core/TaskQueue.js +1 -1
  100. package/src/core/TaskRunner.js +81 -6
  101. package/src/index.js +725 -59
  102. package/src/mcp/MCPAgentRunner.js +48 -11
  103. package/src/mcp/MCPManager.js +40 -2
  104. package/src/models/ModelRouter.js +74 -4
  105. package/src/safety/DockerSandbox.js +212 -0
  106. package/src/safety/ExecApproval.js +118 -0
  107. package/src/scheduler/Heartbeat.js +56 -21
  108. package/src/services/cleanup.js +106 -0
  109. package/src/services/sessions.js +39 -1
  110. package/src/setup/wizard.js +125 -75
  111. package/src/skills/SkillLoader.js +132 -17
  112. package/src/storage/TaskStore.js +19 -1
  113. package/src/tools/browserAutomation.js +615 -104
  114. package/src/tools/executeCommand.js +19 -1
  115. package/src/tools/index.js +7 -1
  116. package/src/tools/manageAgents.js +55 -4
  117. package/src/tools/replyWithFile.js +62 -0
  118. package/src/tools/screenCapture.js +12 -1
  119. package/src/tools/taskManager.js +164 -0
  120. package/src/tools/useMCP.js +3 -1
  121. package/src/utils/Embeddings.js +236 -12
  122. package/src/webhooks/WebhookHandler.js +107 -0
  123. package/src/systemPrompt.js +0 -528
@@ -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 paramLine = params ? `\n- argsJson: \`{${params}}\`` : "";
95
- return `### ${fullName}(argsJson: string)\n${desc}${paramLine}`;
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
- return spawnSubAgent(taskDescription, {
106
- ...options,
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
  }
@@ -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]) => !name.startsWith("_comment") && typeof cfg === "object" && cfg.enabled !== false
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
- const meta = models[modelId];
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 = {
@@ -152,11 +218,12 @@ const _profileEnvMap = {
152
218
  * 1. explicitModel (caller override)
153
219
  * 2. Per-tenant modelRoutes[profile]
154
220
  * 3. Global CODE_MODEL / RESEARCH_MODEL / WRITER_MODEL / ANALYST_MODEL env vars
155
- * 4. Per-tenant general model override
156
- * 5. DEFAULT_MODEL env var / hardcoded default
221
+ * 4. SUB_AGENT_MODEL (global sub-agent default)
222
+ * 5. Per-tenant general model override
223
+ * 6. DEFAULT_MODEL env var / hardcoded default
157
224
  *
158
225
  * @param {string|null} profile - e.g. "coder", "researcher", "writer", "analyst"
159
- * @param {object} tenantConfig - resolvedConfig from TenantManager (may have .modelRoutes, .model)
226
+ * @param {object} tenantConfig - resolvedConfig from TenantManager (may have .modelRoutes, .model, .subAgentModel)
160
227
  * @param {string|null} explicitModel - Caller-supplied model override (highest priority)
161
228
  * @returns {string} Resolved model ID
162
229
  */
@@ -164,6 +231,9 @@ export function resolveModelForProfile(profile, tenantConfig = {}, explicitModel
164
231
  if (explicitModel) return explicitModel;
165
232
  if (profile && tenantConfig.modelRoutes?.[profile]) return tenantConfig.modelRoutes[profile];
166
233
  if (profile && process.env[_profileEnvMap[profile]]) return process.env[_profileEnvMap[profile]];
234
+ // Sub-agent model: tenant-level > env-level
235
+ if (tenantConfig.subAgentModel) return tenantConfig.subAgentModel;
236
+ if (process.env.SUB_AGENT_MODEL) return process.env.SUB_AGENT_MODEL;
167
237
  if (tenantConfig.model) return tenantConfig.model;
168
238
  return process.env.DEFAULT_MODEL || "openai:gpt-4.1-mini";
169
239
  }
@@ -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;