1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Lightweight config reference for tool context.
//!
//! WHY a separate type: edgecrab-core owns AppConfig but edgecrab-tools
//! can't depend on edgecrab-core (that would create a cycle). Instead,
//! we define a minimal config view here that edgecrab-core populates.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::execution_tmp::shared_tmp_dir;
use crate::tools::backends::{
BackendKind, DaytonaBackendConfig, DockerBackendConfig, ModalBackendConfig,
SingularityBackendConfig, SshBackendConfig,
};
use edgecrab_security::path_policy::PathPolicy;
/// Resolve the EdgeCrab home directory.
///
/// Resolution order:
/// 1. `EDGECRAB_HOME` env var
/// 2. `~/.edgecrab`
///
/// Duplicated from edgecrab-core/config.rs to avoid a circular crate dep.
pub fn resolve_edgecrab_home() -> PathBuf {
std::env::var("EDGECRAB_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
if cfg!(test) {
return std::env::temp_dir()
.join(format!("edgecrab-test-home-{}", std::process::id()));
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".edgecrab")
})
}
/// Minimal configuration view passed to tools via ToolContext.
///
/// Populated from AppConfig by the agent before tool dispatch.
/// Only includes fields that tools actually need.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(default)]
pub struct LspServerConfigRef {
pub command: String,
pub args: Vec<String>,
pub file_extensions: Vec<String>,
pub language_id: String,
pub root_markers: Vec<String>,
pub env: HashMap<String, String>,
pub initialization_options: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct AppConfigRef {
/// Whether the gateway process is running (gates send_message)
pub gateway_running: bool,
/// Whether Honcho integration is active (gates honcho_* tools)
pub honcho_active: bool,
/// Whether Home Assistant is configured (gates ha_* tools)
pub home_assistant_active: bool,
/// Max file size in bytes for read operations
pub max_file_read_bytes: usize,
/// Max output length for terminal commands
pub max_terminal_output: usize,
/// Additional file roots trusted by file tools beyond the active workspace.
pub file_allowed_roots: Vec<PathBuf>,
/// Denied prefixes layered on top of the workspace and allow-roots policy.
pub path_restrictions: Vec<PathBuf>,
/// Whether LSP tools are enabled for this session.
pub lsp_enabled: bool,
/// Max file size eligible for LSP document sync.
pub lsp_file_size_limit_bytes: u64,
/// Named language-server configurations keyed by logical language/server id.
pub lsp_servers: HashMap<String, LspServerConfigRef>,
/// EdgeCrab home directory (memory, skills, sessions storage root).
///
/// WHY renamed from workspace_root: memory and skills tools write to
/// `~/.edgecrab/memories/` and `~/.edgecrab/skills/` — NOT to the
/// user's project CWD. Using a descriptive name prevents the same
/// bug from recurring. The user's project CWD is `ToolContext::cwd`.
pub edgecrab_home: PathBuf,
/// Whether subagent delegation is enabled.
pub delegation_enabled: bool,
/// Optional model override for delegated children.
pub delegation_model: Option<String>,
/// Optional provider override used when delegation_model has no prefix.
pub delegation_provider: Option<String>,
/// Maximum number of children allowed in a single batch delegation call.
pub delegation_max_subagents: u32,
/// Default max iterations per child when delegate_task omits max_iterations.
pub delegation_max_iterations: u32,
/// Parent active toolsets used to ensure children cannot gain capabilities.
/// Empty means no explicit whitelist (all available toolsets).
pub parent_active_toolsets: Vec<String>,
/// Toolsets explicitly disabled for the current session.
///
/// WHY separate from `parent_active_toolsets`: when the session uses the
/// implicit "all toolsets" mode, the allow-list is empty but specific
/// toolsets may still be denied via config. Dispatch-time checks need the
/// explicit deny-list so a hallucinated tool call cannot bypass schema
/// filtering.
pub disabled_toolsets: Vec<String>,
/// Tools explicitly enabled for the current session.
///
/// WHY separate from toolsets: users may want one browser or MCP helper
/// without exposing the rest of that toolset.
pub enabled_tools: Vec<String>,
/// Tools explicitly disabled for the current session.
///
/// Disabled tools always win, even if their parent toolset is enabled.
pub disabled_tools: Vec<String>,
/// External skill directories to scan in addition to ~/.edgecrab/skills/.
/// Supports ~ and ${VAR} expansion (hermes-compatible paths).
pub external_skill_dirs: Vec<String>,
/// Skill names disabled globally or per-platform (merged).
/// Tools (skills_list) should skip these.
pub disabled_skills: Vec<String>,
/// Plugin names disabled globally or per-platform (merged for the current platform).
pub disabled_plugins: Vec<String>,
/// Install root used for plugin discovery and runtime assets.
pub plugin_install_dir: PathBuf,
/// Record browser sessions as WebM video files when true.
/// Mirrors `browser.record_sessions` in config.yaml.
pub browser_record_sessions: bool,
/// Browser CDP call timeout in seconds. Mirrors `browser.command_timeout`.
pub browser_command_timeout: u64,
/// Auto-cleanup browser recordings older than N hours.
pub browser_recording_max_age_hours: u64,
/// Whether automatic checkpoints are enabled.
/// Mirrors `checkpoints.enabled` in config.yaml (default: true).
pub checkpoints_enabled: bool,
/// Maximum number of checkpoints to keep per working directory.
/// Mirrors `checkpoints.max_snapshots` in config.yaml (default: 50).
pub checkpoints_max_snapshots: u32,
/// Skills to preload into the system prompt (from -s/--skill flags).
pub preloaded_skills: Vec<String>,
/// Env-var names allowed to bypass the subprocess security blocklist.
///
/// Populated from `terminal.env_passthrough` in config.yaml and
/// injected into the local env-passthrough registry on agent startup.
/// Skills that declare `required_environment_variables` also feed into
/// this registry at load time via `register_env_passthrough()`.
pub terminal_env_passthrough: Vec<String>,
// ── Terminal backend configuration (gap/backend B-01a/B-01b/B-02/B-03/B-04) ──
/// Which execution backend the terminal tool should use.
/// Defaults to `BackendKind::Local` (direct host execution).
/// Override via `EDGECRAB_TERMINAL_BACKEND=docker|ssh|modal` or config.yaml.
pub terminal_backend: BackendKind,
/// Docker-specific terminal backend configuration.
pub terminal_docker: DockerBackendConfig,
/// SSH-specific terminal backend configuration.
pub terminal_ssh: SshBackendConfig,
/// Modal-specific terminal backend configuration.
pub terminal_modal: ModalBackendConfig,
/// Daytona-specific terminal backend configuration.
pub terminal_daytona: DaytonaBackendConfig,
/// Singularity-specific terminal backend configuration.
pub terminal_singularity: SingularityBackendConfig,
/// Optional dedicated provider for auxiliary side tasks such as vision.
pub auxiliary_provider: Option<String>,
/// Optional dedicated model for auxiliary side tasks such as vision.
pub auxiliary_model: Option<String>,
/// Optional base URL override for the auxiliary provider.
pub auxiliary_base_url: Option<String>,
/// Optional API-key environment variable for the auxiliary provider.
pub auxiliary_api_key_env: Option<String>,
/// Preferred text-to-speech provider from config (`tts.provider`).
pub tts_provider: Option<String>,
/// Preferred text-to-speech voice from config (`tts.voice`).
pub tts_voice: Option<String>,
/// Optional Edge TTS rate override from config (`tts.rate`).
pub tts_rate: Option<String>,
/// Optional provider-specific TTS model (`tts.model`).
pub tts_model: Option<String>,
/// Optional ElevenLabs voice id from config (`tts.elevenlabs_voice_id`).
pub tts_elevenlabs_voice_id: Option<String>,
/// Optional ElevenLabs model id from config (`tts.elevenlabs_model_id`).
pub tts_elevenlabs_model_id: Option<String>,
/// Environment variable name for ElevenLabs credentials.
pub tts_elevenlabs_api_key_env: Option<String>,
/// Preferred speech-to-text provider from config (`stt.provider`).
pub stt_provider: Option<String>,
/// Preferred local Whisper model from config (`stt.whisper_model`).
pub stt_whisper_model: Option<String>,
/// Preferred image-generation provider from config (`image_generation.provider`).
pub image_provider: Option<String>,
/// Preferred image-generation model from config (`image_generation.model`).
pub image_model: Option<String>,
/// Whether the `moa` tool is enabled for this session.
pub moa_enabled: bool,
/// Default reference models for the `moa` tool.
pub moa_reference_models: Vec<String>,
/// Default aggregator model for the `moa` tool.
pub moa_aggregator_model: Option<String>,
/// Whether tool-result spill-to-artifact is enabled (default: true).
pub result_spill: bool,
/// Byte threshold above which tool results are spilled to artifact files.
pub result_spill_threshold: usize,
/// Number of preview lines kept in the spill stub.
pub result_spill_preview_lines: usize,
/// Maximum write payload size in KiB for file mutation tools.
///
/// WHY FP16 "Defaults protect, overrides empower": the default (32 KiB)
/// is safe for most LLM providers. Power users with models that handle
/// larger JSON tool arguments can increase this. The value is clamped to
/// [8, 256] KiB to prevent mis-configuration.
pub max_write_payload_kib: usize,
}
impl Default for AppConfigRef {
fn default() -> Self {
Self {
gateway_running: false,
honcho_active: false,
home_assistant_active: false,
max_file_read_bytes: 2 * 1024 * 1024, // 2 MB
max_terminal_output: 100_000, // 100K chars
file_allowed_roots: Vec::new(),
path_restrictions: Vec::new(),
lsp_enabled: true,
lsp_file_size_limit_bytes: 10_000_000,
lsp_servers: HashMap::new(),
edgecrab_home: resolve_edgecrab_home(),
delegation_enabled: true,
delegation_model: None,
delegation_provider: None,
delegation_max_subagents: 3,
delegation_max_iterations: 50,
parent_active_toolsets: Vec::new(),
disabled_toolsets: Vec::new(),
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
external_skill_dirs: Vec::new(),
disabled_skills: Vec::new(),
disabled_plugins: Vec::new(),
plugin_install_dir: resolve_edgecrab_home().join("plugins"),
browser_record_sessions: false,
browser_command_timeout: 30,
browser_recording_max_age_hours: 72,
checkpoints_enabled: true,
checkpoints_max_snapshots: 50,
preloaded_skills: Vec::new(),
terminal_env_passthrough: Vec::new(),
terminal_backend: BackendKind::Local,
terminal_docker: DockerBackendConfig::default(),
terminal_ssh: SshBackendConfig::default(),
terminal_modal: ModalBackendConfig::default(),
terminal_daytona: DaytonaBackendConfig::default(),
terminal_singularity: SingularityBackendConfig::default(),
auxiliary_provider: None,
auxiliary_model: None,
auxiliary_base_url: None,
auxiliary_api_key_env: None,
tts_provider: None,
tts_voice: None,
tts_rate: None,
tts_model: None,
tts_elevenlabs_voice_id: None,
tts_elevenlabs_model_id: None,
tts_elevenlabs_api_key_env: None,
stt_provider: None,
stt_whisper_model: None,
image_provider: None,
image_model: None,
moa_enabled: true,
moa_reference_models: Vec::new(),
moa_aggregator_model: None,
result_spill: true,
result_spill_threshold: 16_384,
result_spill_preview_lines: 80,
max_write_payload_kib: crate::edit_contract::DEFAULT_MAX_MUTATION_PAYLOAD_KIB,
}
}
}
impl AppConfigRef {
/// Effective maximum write payload in bytes, clamped to [8 KiB, 256 KiB].
pub fn max_write_payload_bytes(&self) -> usize {
crate::edit_contract::clamp_write_limit_bytes(self.max_write_payload_kib)
}
/// Whether a toolset is allowed in the current session.
///
/// Empty `parent_active_toolsets` means "no explicit whitelist" rather than
/// "nothing is allowed". The disabled list always wins.
pub fn is_toolset_enabled(&self, toolset: &str) -> bool {
(self.parent_active_toolsets.is_empty()
|| self.parent_active_toolsets.iter().any(|t| t == toolset))
&& !self.disabled_toolsets.iter().any(|t| t == toolset)
}
/// Whether a specific tool is allowed in the current session.
pub fn is_tool_enabled(&self, tool_name: &str, toolset: &str) -> bool {
crate::toolsets::tool_enabled(
Some(&self.parent_active_toolsets),
Some(&self.disabled_toolsets),
Some(&self.enabled_tools),
Some(&self.disabled_tools),
tool_name,
toolset,
)
}
pub fn is_plugin_enabled(&self, plugin_name: &str) -> bool {
!self
.disabled_plugins
.iter()
.any(|candidate| candidate == plugin_name)
}
/// Build the effective file path policy for a session workspace.
pub fn file_path_policy(&self, cwd: &std::path::Path) -> PathPolicy {
let file_tools_tmp_dir = self.file_tools_tmp_dir();
let _ = std::fs::create_dir_all(&file_tools_tmp_dir);
let mut allowed = self.file_allowed_roots.clone();
// On Termux, add the Termux data directory so file tools can access
// Termux-installed packages, shared storage, and user scripts.
if *edgecrab_types::IS_TERMUX {
if let Ok(prefix) = std::env::var("PREFIX") {
allowed.push(std::path::PathBuf::from(prefix));
} else {
allowed.push(std::path::PathBuf::from("/data/data/com.termux/files"));
}
}
PathPolicy::new(cwd.to_path_buf())
.with_virtual_tmp_root(file_tools_tmp_dir)
.with_allowed_roots(allowed)
.with_denied_roots(self.path_restrictions.clone())
}
pub fn lsp_server_for_extension(&self, ext: &str) -> Option<(&str, &LspServerConfigRef)> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
self.lsp_servers.iter().find_map(|(name, cfg)| {
cfg.file_extensions
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(&ext))
.then_some((name.as_str(), cfg))
})
}
// ── Well-known directory helpers ──────────────────────────────────────
//
// WHY methods instead of ad-hoc `.join("image_cache")` calls:
// Each directory name appears in multiple places (gateway adapters, vision
// tool, tests). A single method per directory is the single source of truth;
// rename one string and every caller updates automatically.
/// Where the TUI saves clipboard-pasted images before sending to vision.
pub fn tui_images_dir(&self) -> std::path::PathBuf {
self.edgecrab_home.join("images")
}
/// Where the WhatsApp Baileys bridge caches inbound images.
///
/// The Baileys Node bridge writes `img_<hex>.{jpg,png,…}` here when a
/// WhatsApp message with a photo arrives. The Rust gateway reads the
/// `mediaUrls` list from the bridge and forwards the path to the agent.
/// vision_analyze must trust this directory so the path-jail check passes.
pub fn gateway_image_cache_dir(&self) -> std::path::PathBuf {
self.edgecrab_home.join("image_cache")
}
/// Root directory for Rust-native gateway adapter media downloads.
///
/// Each adapter nests its files in a platform-named sub-directory, e.g.:
/// `gateway_media/telegram/`
/// `gateway_media/discord/`
///
/// vision_analyze trusts the root so all current and future platform
/// sub-directories are covered without per-platform changes.
pub fn gateway_media_dir(&self) -> std::path::PathBuf {
self.edgecrab_home.join("gateway_media")
}
/// Where gateway platform adapters cache inbound document attachments.
///
/// The WhatsApp Baileys bridge writes PDF and document files here
/// (e.g. `doc_<hex>_filename.pdf`) when a document message arrives.
/// `pdf_to_markdown` and other file tools must trust this directory so the
/// path-jail check passes when the agent processes a gateway-received document.
pub fn document_cache_dir(&self) -> std::path::PathBuf {
self.edgecrab_home.join("document_cache")
}
/// Dedicated temp root for file tools.
///
/// WHY not host `/tmp`: global temp directories are shared, nondeterministic,
/// and can expose unrelated process files. File tools get an EdgeCrab-owned
/// temp tree with stable semantics instead.
pub fn file_tools_tmp_dir(&self) -> std::path::PathBuf {
shared_tmp_dir(&self.edgecrab_home)
}
}