Skip to repository content573 lines · 23.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:11:58.966Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
terminal.rs
1use agent_client_protocol::schema::v1 as acp;
2use anyhow::Result;
3use collections::HashMap;
4use futures::{FutureExt as _, future::Shared};
5use gpui::{App, AppContext, AsyncApp, Context, Entity, Task};
6use http_proxy::Allowlist;
7use language::LanguageRegistry;
8use markdown::Markdown;
9use project::Project;
10use serde::{Deserialize, Serialize};
11use std::{
12 collections::HashMap as StdHashMap,
13 path::PathBuf,
14 process::ExitStatus,
15 sync::{
16 Arc,
17 atomic::{AtomicBool, Ordering},
18 },
19 time::Instant,
20};
21use task::Shell;
22use util::get_default_system_shell_preferring_bash;
23
24/// Request to run a terminal command inside an OS-level sandbox.
25///
26/// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing
27/// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; Windows
28/// via Bubblewrap inside WSL), so callers describe the *intent* with plain data
29/// here rather than constructing platform-specific types directly.
30///
31/// Default is the fully-sandboxed run (no network, project-only writes).
32/// Setting `network` / `allow_fs_write` requests a relaxation; the caller is
33/// responsible for having obtained user approval before reaching this point.
34#[derive(Clone, Debug, Default)]
35pub struct SandboxWrap {
36 /// Directory subtrees the sandbox should allow writes to. Pass the
37 /// project's worktree paths (and any per-command scratch directory)
38 /// here — *not* the command's working directory, which is model-
39 /// controlled and would let the model widen its own writable scope.
40 pub writable_paths: Vec<PathBuf>,
41 /// Additional write subtrees the user explicitly approved for this
42 /// command (per-path write grants). Kept separate from `writable_paths`
43 /// to make the trust boundary explicit: these originate from
44 /// model-requested paths that passed a user-approval prompt. They are
45 /// merged with `writable_paths` when generating the sandbox policy.
46 pub extra_write_paths: Vec<PathBuf>,
47 /// Outbound network access explicitly approved for this command.
48 pub network: SandboxNetworkAccess,
49 /// Additional paths that should remain readable but not writable, even when
50 /// they fall under writable paths.
51 pub protected_paths: Vec<PathBuf>,
52 /// Allow unrestricted filesystem writes except for protected paths (ignores
53 /// ordinary writable paths).
54 pub allow_fs_write: bool,
55 /// Whether the project (and therefore this terminal) is local. The
56 /// enforcing proxy binds a loopback port on this host, so it can only
57 /// confine local commands; a remote terminal can't reach it.
58 pub is_local: bool,
59 /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to
60 /// provision inside WSL as the sandbox helper (version `latest` for dev
61 /// builds). Resolved by the agent (which can read the running app's release
62 /// info) and forwarded to the sandbox. `None` on other platforms, or when
63 /// the release can't be determined, in which case the WSL backend falls back
64 /// to running bwrap without in-sandbox bind validation.
65 pub wsl_zed_release: Option<(String, String)>,
66}
67
68#[derive(Clone, Debug, Default)]
69pub enum SandboxNetworkAccess {
70 /// Block all outbound network access.
71 #[default]
72 None,
73 /// Allow only hosts in this allowlist, enforced by routing HTTP/HTTPS
74 /// through an in-process proxy and confining the command to the proxy's
75 /// loopback port.
76 Restricted(Allowlist),
77 /// Allow unrestricted outbound network access.
78 All,
79}
80
81/// A structured, serializable reason the OS sandbox could not be created for a
82/// command. Mirrors the Linux/WSL Bubblewrap failure modes; surfaced to the user
83/// (and persisted in tool-call metadata) so the UI can
84/// explain what went wrong.
85#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
86pub enum LinuxWslSandboxError {
87 /// No usable `bwrap` binary was found on `PATH`.
88 BwrapNotFound,
89 /// The only `bwrap` found is setuid-root, which Zed refuses to run.
90 SetuidRejected,
91 /// `bwrap` is present but couldn't set up the sandbox (typically because
92 /// unprivileged user namespaces are disabled).
93 SandboxProbeFailed,
94 /// Any other failure, with a human-readable description.
95 Other(String),
96}
97
98impl From<sandbox::SandboxError> for LinuxWslSandboxError {
99 fn from(error: sandbox::SandboxError) -> Self {
100 match error {
101 sandbox::SandboxError::BwrapNotFound => Self::BwrapNotFound,
102 sandbox::SandboxError::BwrapSetuidRejected => Self::SetuidRejected,
103 sandbox::SandboxError::SandboxProbeFailed => Self::SandboxProbeFailed,
104 error => Self::Other(error.to_string()),
105 }
106 }
107}
108
109impl LinuxWslSandboxError {
110 /// A short, user-facing explanation of why the sandbox couldn't be created,
111 /// suitable for display in the agent panel.
112 pub fn user_facing_message(&self) -> String {
113 match self {
114 LinuxWslSandboxError::BwrapNotFound => {
115 "No usable `bwrap` binary was found on your PATH. Install Bubblewrap to let \
116 the agent sandbox terminal commands."
117 .to_string()
118 }
119 LinuxWslSandboxError::SetuidRejected => {
120 "The only `bwrap` available is setuid-root, which Omega refuses to run. Install \
121 a non-setuid Bubblewrap to let the agent sandbox terminal commands."
122 .to_string()
123 }
124 LinuxWslSandboxError::SandboxProbeFailed => {
125 "`bwrap` is installed but couldn't create a sandbox, likely because \
126 unprivileged user namespaces are disabled on this system."
127 .to_string()
128 }
129 LinuxWslSandboxError::Other(message) => message.clone(),
130 }
131 }
132
133 /// The slug of the sandboxing docs section that best explains how to resolve
134 /// this failure, for deep-linking from the UI. Pair with
135 /// `client::zed_urls::sandboxing_docs`.
136 pub fn docs_section(&self) -> &'static str {
137 match self {
138 // Both "no bwrap" and "only a setuid-root bwrap" are resolved by
139 // installing a non-setuid Bubblewrap.
140 LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => {
141 "installing-bubblewrap"
142 }
143 // A failed probe on Linux is almost always disabled unprivileged
144 // user namespaces, which the Ubuntu-specific section covers.
145 LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu",
146 // Catch-all (includes WSL/Windows messages): point at the platform
147 // overview for the current OS.
148 LinuxWslSandboxError::Other(_) => {
149 if cfg!(target_os = "windows") {
150 "windows"
151 } else {
152 "linux"
153 }
154 }
155 }
156 }
157}
158
159impl SandboxWrap {
160 /// Whether the OS sandbox for this request can actually be created right now,
161 /// returning a structured [`LinuxWslSandboxError`] when it can't.
162 ///
163 /// The sandbox implementation never runs a command unsandboxed on its own —
164 /// it aborts if it can't create the sandbox. This lets a caller decide, up
165 /// front, whether to run sandboxed, fall back to an unsandboxed run
166 /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on
167 /// Linux, so call it off the main thread. On platforms whose sandbox can't
168 /// fail to set up this way it always returns `Ok`.
169 pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> {
170 sandbox::Sandbox::can_create(&self.to_policy()).map_err(LinuxWslSandboxError::from)
171 }
172
173 /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`].
174 ///
175 /// This is the enforcement-policy construction point, so it **captures** each
176 /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical
177 /// path) rather than passing a re-resolvable path. A location that can't be
178 /// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed.
179 ///
180 /// This function has **no filesystem side effects**: it never creates paths.
181 /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe
182 /// and by real sandbox construction, and must behave identically. On Linux a
183 /// writable grant that doesn't exist yet simply can't be captured (bwrap
184 /// can't bind a missing path), so it's dropped here — the sanctioned way to
185 /// get a grant to a new directory is the `create_directory` tool, which
186 /// creates it (pinning the inode) before the grant is recorded. On macOS a
187 /// missing leaf still canonicalizes, so such grants are captured directly.
188 fn to_policy(&self) -> sandbox::SandboxPolicy {
189 let protected_paths = self
190 .protected_paths
191 .iter()
192 .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
193 .collect();
194 let fs = if self.allow_fs_write {
195 sandbox::SandboxFsPolicy::Unrestricted { protected_paths }
196 } else {
197 let writable_paths = self
198 .writable_paths
199 .iter()
200 .chain(self.extra_write_paths.iter())
201 // Capture only — never create anything here (see the doc comment):
202 // materializing an approved-but-missing grant is deferred to
203 // `Sandbox::new` so it can never happen during the `can_create`
204 // probe, before the user has approved the grant.
205 .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
206 .collect();
207 sandbox::SandboxFsPolicy::Restricted {
208 writable_paths,
209 protected_paths,
210 }
211 };
212 let network = match &self.network {
213 SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked,
214 SandboxNetworkAccess::All => sandbox::SandboxNetPolicy::Unrestricted,
215 SandboxNetworkAccess::Restricted(allowlist) => sandbox::SandboxNetPolicy::Restricted {
216 allowed_domains: allowlist
217 .patterns()
218 .iter()
219 .map(|pattern| pattern.to_string())
220 .collect(),
221 },
222 };
223 sandbox::SandboxPolicy { fs, network }
224 }
225}
226
227/// Why the OS sandbox was *not* applied to a terminal command, even though
228/// sandboxing is active for the thread. Persisted in tool-call metadata so the
229/// UI can explain the situation after the fact.
230///
231/// This is deliberately platform-agnostic — every variant exists on every
232/// platform — so the serialized form stored in the thread database never
233/// depends on which OS wrote it. Today only Linux/WSL can fail to create a
234/// sandbox (`ErrorLinuxWsl`), but the variant is named so macOS/Windows can
235/// grow their own failure cases later without a migration.
236#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237pub enum SandboxNotAppliedReason {
238 /// The user disabled the sandbox for the rest of this thread, so the command
239 /// ran without one. This happens either when the user approved a
240 /// model-requested `unsandboxed: true` escape "for this thread", or when
241 /// they chose to run unsandboxed for the thread after a sandbox-creation
242 /// failure (in which case a preceding tool call's reason is
243 /// [`SandboxNotAppliedReason::ErrorLinuxWsl`]).
244 DisabledForThisThread,
245 /// The Linux/WSL (Bubblewrap) sandbox could not be created for this command.
246 ErrorLinuxWsl(LinuxWslSandboxError),
247}
248
249/// The live sandbox kept alive for its per-command resources (the network proxy
250/// and, on macOS, the Seatbelt policy file) until the terminal exits.
251type SandboxConfigHandle = sandbox::Sandbox;
252
253/// Upper bound on preparing a WSL-sandboxed command. Deliberately generous:
254/// the first invocation after the WSL utility VM has shut down (or after boot)
255/// has to start the VM and the distro, which routinely takes 10-30 seconds on
256/// slow disks or under antivirus scanning.
257#[cfg(target_os = "windows")]
258pub(crate) const WSL_SANDBOX_WRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
259
260/// Wrap `(program, args)` for sandboxed execution, returning the wrapped
261/// invocation (program, argv, env) plus the live [`sandbox::Sandbox`] that must
262/// be kept alive for the command's duration. When `sandbox_wrap` is `None` the
263/// command is returned unchanged.
264///
265/// The sandbox owns the network proxy (for restricted-network policies) and any
266/// per-command policy file; the env it returns already routes through that
267/// proxy when applicable.
268pub(crate) async fn prepare_sandbox_wrap(
269 program: String,
270 args: Vec<String>,
271 cwd: Option<PathBuf>,
272 sandbox_wrap: Option<SandboxWrap>,
273 env: HashMap<String, String>,
274) -> anyhow::Result<(
275 String,
276 Vec<String>,
277 HashMap<String, String>,
278 Option<SandboxConfigHandle>,
279)> {
280 let Some(sandbox_wrap) = sandbox_wrap else {
281 return Ok((program, args, env, None));
282 };
283
284 let mut sandbox =
285 sandbox::Sandbox::new(sandbox_wrap.to_policy()).map_err(anyhow::Error::new)?;
286 // Windows/WSL only: tell the sandbox which Linux `zed` to provision inside
287 // WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere.
288 #[cfg(target_os = "windows")]
289 if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() {
290 sandbox.set_wsl_zed_release(channel, version);
291 }
292 let command = sandbox::CommandAndArgs {
293 program,
294 args,
295 env: env.into_iter().collect::<StdHashMap<_, _>>(),
296 cwd,
297 };
298 let wrapped = sandbox.wrap(&command).await.map_err(anyhow::Error::new)?;
299 Ok((
300 wrapped.program,
301 wrapped.args,
302 wrapped.env.into_iter().collect(),
303 Some(sandbox),
304 ))
305}
306
307pub struct Terminal {
308 id: acp::TerminalId,
309 command: Entity<Markdown>,
310 working_dir: Option<PathBuf>,
311 terminal: Entity<terminal::Terminal>,
312 started_at: Instant,
313 output: Option<TerminalOutput>,
314 output_byte_limit: Option<usize>,
315 _output_task: Shared<Task<acp::TerminalExitStatus>>,
316 /// Flag indicating whether this terminal was stopped by explicit user action
317 /// (e.g., clicking the Stop button). This is set before kill() is called
318 /// so that code awaiting wait_for_exit() can check it deterministically.
319 user_stopped: Arc<AtomicBool>,
320 /// The live sandbox (Seatbelt policy file and/or network proxy) kept alive
321 /// until the sandboxed command exits. `None` when the command isn't
322 /// sandboxed or after it finishes. Dropping it tears down the proxy on a
323 /// background thread (see `sandbox::Sandbox`'s `Drop`).
324 _sandbox: Option<SandboxConfigHandle>,
325}
326
327pub struct TerminalOutput {
328 pub ended_at: Instant,
329 pub exit_status: Option<ExitStatus>,
330 pub content: String,
331 pub original_content_len: usize,
332 pub content_line_count: usize,
333}
334
335impl Terminal {
336 pub fn new(
337 id: acp::TerminalId,
338 command_label: &str,
339 working_dir: Option<PathBuf>,
340 output_byte_limit: Option<usize>,
341 terminal: Entity<terminal::Terminal>,
342 language_registry: Arc<LanguageRegistry>,
343 sandbox: Option<SandboxConfigHandle>,
344 cx: &mut Context<Self>,
345 ) -> Self {
346 let command_task = terminal.read(cx).wait_for_completed_task(cx);
347 // Tear the sandbox down on a GPUI background thread when this entity is
348 // released, rather than relying on `Sandbox`'s `Drop` (which would spawn
349 // a throwaway thread) on whatever thread releases us. `on_release` hands
350 // us an `App`, so we can drive the teardown through the background
351 // executor with `drop_on_current_thread`.
352 cx.on_release(|this, cx| {
353 if let Some(sandbox) = this._sandbox.take() {
354 cx.background_executor()
355 .spawn(async move { sandbox.drop_on_current_thread() })
356 .detach();
357 }
358 })
359 .detach();
360 Self {
361 id,
362 _sandbox: sandbox,
363 command: cx.new(|cx| {
364 Markdown::new(
365 format!("```\n{}\n```", command_label).into(),
366 Some(language_registry.clone()),
367 None,
368 cx,
369 )
370 }),
371 working_dir,
372 terminal,
373 started_at: Instant::now(),
374 output: None,
375 output_byte_limit,
376 user_stopped: Arc::new(AtomicBool::new(false)),
377 _output_task: cx
378 .spawn(async move |this, cx| {
379 let exit_status = command_task.await;
380
381 this.update(cx, |this, cx| {
382 let (content, original_content_len) = this.truncated_output(cx);
383 let content_line_count = this.terminal.read(cx).total_lines();
384
385 this.output = Some(TerminalOutput {
386 ended_at: Instant::now(),
387 exit_status,
388 content,
389 original_content_len,
390 content_line_count,
391 });
392 // Free the sandbox (and its network proxy) as soon as
393 // the command finishes, rather than holding it until
394 // this entity is released. The proxy's teardown joins a
395 // listener thread, so run it on the background executor
396 // to keep it off the foreground thread.
397 if let Some(sandbox) = this._sandbox.take() {
398 cx.background_executor()
399 .spawn(async move { sandbox.drop_on_current_thread() })
400 .detach();
401 }
402 cx.notify();
403 })
404 .ok();
405
406 let exit_status = exit_status.map(portable_pty::ExitStatus::from);
407
408 acp::TerminalExitStatus::new()
409 .exit_code(exit_status.as_ref().map(|e| e.exit_code()))
410 .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned)))
411 })
412 .shared(),
413 }
414 }
415
416 pub fn id(&self) -> &acp::TerminalId {
417 &self.id
418 }
419
420 pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
421 self._output_task.clone()
422 }
423
424 pub fn kill(&mut self, cx: &mut App) {
425 self.terminal.update(cx, |terminal, _cx| {
426 terminal.kill_active_task();
427 });
428 }
429
430 /// Marks this terminal as stopped by user action and then kills it.
431 /// This should be called when the user explicitly clicks a Stop button.
432 pub fn stop_by_user(&mut self, cx: &mut App) {
433 self.user_stopped.store(true, Ordering::SeqCst);
434 self.kill(cx);
435 }
436
437 /// Returns whether this terminal was stopped by explicit user action.
438 pub fn was_stopped_by_user(&self) -> bool {
439 self.user_stopped.load(Ordering::SeqCst)
440 }
441
442 pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
443 if let Some(output) = self.output.as_ref() {
444 let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
445
446 acp::TerminalOutputResponse::new(
447 output.content.clone(),
448 output.original_content_len > output.content.len(),
449 )
450 .exit_status(
451 acp::TerminalExitStatus::new()
452 .exit_code(exit_status.as_ref().map(|e| e.exit_code()))
453 .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))),
454 )
455 } else {
456 let (current_content, original_len) = self.truncated_output(cx);
457 let truncated = current_content.len() < original_len;
458 acp::TerminalOutputResponse::new(current_content, truncated)
459 }
460 }
461
462 fn truncated_output(&self, cx: &App) -> (String, usize) {
463 let terminal = self.terminal.read(cx);
464 let mut content = terminal.get_content();
465
466 let original_content_len = content.len();
467
468 if let Some(limit) = self.output_byte_limit
469 && content.len() > limit
470 {
471 let mut end_ix = limit.min(content.len());
472 while !content.is_char_boundary(end_ix) {
473 end_ix -= 1;
474 }
475 // Don't truncate mid-line, clear the remainder of the last line
476 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
477 content.truncate(end_ix);
478 }
479
480 (content, original_content_len)
481 }
482
483 pub fn command(&self) -> &Entity<Markdown> {
484 &self.command
485 }
486
487 pub fn update_command_label(&self, label: &str, cx: &mut App) {
488 self.command.update(cx, |command, cx| {
489 command.replace(format!("```\n{}\n```", label), cx);
490 });
491 }
492
493 pub fn working_dir(&self) -> &Option<PathBuf> {
494 &self.working_dir
495 }
496
497 pub fn started_at(&self) -> Instant {
498 self.started_at
499 }
500
501 pub fn output(&self) -> Option<&TerminalOutput> {
502 self.output.as_ref()
503 }
504
505 pub fn inner(&self) -> &Entity<terminal::Terminal> {
506 &self.terminal
507 }
508
509 pub fn to_markdown(&self, cx: &App) -> String {
510 format!(
511 "Terminal:\n```\n{}\n```\n",
512 self.terminal.read(cx).get_content()
513 )
514 }
515}
516
517pub async fn create_terminal_entity(
518 command: String,
519 args: &[String],
520 env_vars: Vec<(String, String)>,
521 cwd: Option<PathBuf>,
522 project: &Entity<Project>,
523 cx: &mut AsyncApp,
524) -> Result<Entity<terminal::Terminal>> {
525 let mut env = if let Some(dir) = &cwd {
526 project
527 .update(cx, |project, cx| {
528 project.environment().update(cx, |env, cx| {
529 env.directory_environment(dir.clone().into(), cx)
530 })
531 })
532 .await
533 .unwrap_or_default()
534 } else {
535 Default::default()
536 };
537
538 // Disable pagers so agent/terminal commands don't hang behind interactive UIs
539 env.insert("PAGER".into(), "".into());
540 // Override user core.pager (e.g. delta) which Git prefers over PAGER
541 env.insert("GIT_PAGER".into(), "cat".into());
542 env.extend(env_vars);
543
544 // Use remote shell or default system shell, as appropriate
545 let shell = project
546 .update(cx, |project, cx| {
547 project
548 .remote_client()
549 .and_then(|r| r.read(cx).default_system_shell())
550 .map(Shell::Program)
551 })
552 .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash()));
553 let is_windows = project.read_with(cx, |project, cx| project.path_style(cx).is_windows());
554 let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows)
555 .redirect_stdin_to_dev_null()
556 .build(Some(command.clone()), &args);
557
558 project
559 .update(cx, |project, cx| {
560 project.create_terminal_task(
561 task::SpawnInTerminal {
562 command: Some(task_command),
563 args: task_args,
564 cwd,
565 env,
566 ..Default::default()
567 },
568 cx,
569 )
570 })
571 .await
572}
573