Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:11:58.966Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

terminal.rs

573 lines · 23.7 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.