1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::fmt;
12use std::path::{Component, Path, PathBuf};
13use std::sync::Arc;
14use std::time::Duration;
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::Command;
17use tokio::sync::Semaphore;
18
19const MAX_EXTENSION_MANIFESTS: usize = 128;
20const MAX_EXTENSION_MESSAGE_BYTES: usize = 256 * 1024;
21const MAX_EXTENSION_CONCURRENT_INVOCATIONS: usize = 4;
22const EXTENSION_TIMEOUT: Duration = Duration::from_secs(5);
23const MAX_EXTENSION_VALUE_DEPTH: usize = 8;
24const MAX_EXTENSION_VALUE_NODES: usize = 256;
25const MAX_EXTENSION_STRING_BYTES: usize = 4_096;
26
27pub const EXTENSION_SCHEMA_VERSION: u32 = 1;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ExtensionSandbox {
33 LinuxBubblewrap,
34 MacSandboxExec,
35 Unavailable,
36}
37
38impl ExtensionSandbox {
39 pub fn detect() -> Self {
41 #[cfg(target_os = "linux")]
42 {
43 if std::process::Command::new("bwrap")
44 .arg("--version")
45 .stdout(std::process::Stdio::null())
46 .stderr(std::process::Stdio::null())
47 .status()
48 .is_ok_and(|status| status.success())
49 {
50 return Self::LinuxBubblewrap;
51 }
52 }
53 #[cfg(target_os = "macos")]
54 {
55 if Path::new("/usr/bin/sandbox-exec").is_file() {
56 return Self::MacSandboxExec;
57 }
58 }
59 Self::Unavailable
60 }
61
62 pub fn label(self) -> &'static str {
63 match self {
64 Self::LinuxBubblewrap => "linux-bubblewrap",
65 Self::MacSandboxExec => "macos-sandbox-exec",
66 Self::Unavailable => "unavailable",
67 }
68 }
69}
70
71pub(crate) fn experimental_extension_target_supported() -> bool {
73 cfg!(all(target_os = "linux", target_arch = "aarch64"))
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub enum ExtensionCapability {
80 CustomVerification,
81 SemanticRegionClassifier,
82 IntentEvidencePack,
83 ExtractionTransform,
84 SiteAdapter,
85 WorkflowTemplate,
86 TraceExporter,
87 KnowledgeStore,
88 StrictPolicy,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct ExtensionPermissions {
95 pub hosts: Vec<String>,
96 pub actions: Vec<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase", deny_unknown_fields)]
102pub struct ExtensionManifest {
103 pub schema_version: u32,
104 pub id: String,
105 pub version: String,
106 pub api_version: u32,
107 pub capabilities: Vec<ExtensionCapability>,
108 pub permissions: ExtensionPermissions,
109 pub entrypoint: Option<String>,
110}
111
112#[derive(Debug, Default)]
114pub struct ExtensionRegistry {
115 manifests: BTreeMap<String, ExtensionManifest>,
116}
117
118#[derive(Debug, Clone, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ExtensionInvocation {
122 pub protocol_version: u32,
123 pub extension_id: String,
124 pub capability: ExtensionCapability,
125 pub host: String,
126 pub action: String,
127 pub payload: Value,
128}
129
130#[derive(Debug, Clone, Deserialize)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct ExtensionGuardedAction {
139 pub action: String,
140 #[serde(default)]
141 pub target: Option<String>,
142 #[serde(default)]
143 pub value: Option<String>,
144 pub expected_revision: u64,
145}
146
147impl ExtensionGuardedAction {
148 fn validate(&self) -> Result<(), ExtensionError> {
149 if self.action.is_empty() || self.action.len() > 32 {
150 return Err(ExtensionError(
151 "extension guarded action name is out of bounds".into(),
152 ));
153 }
154 if self.expected_revision == 0 {
155 return Err(ExtensionError(
156 "extension guarded action requires a positive expectedRevision".into(),
157 ));
158 }
159 for (name, value, max) in [
160 ("target", self.target.as_deref(), 512),
161 ("value", self.value.as_deref(), 4_096),
162 ] {
163 if let Some(value) = value
164 && (value.is_empty() || value.len() > max)
165 {
166 return Err(ExtensionError(format!(
167 "extension guarded action {name} is out of bounds"
168 )));
169 }
170 }
171 Ok(())
172 }
173}
174
175#[derive(Debug, Deserialize)]
177#[serde(rename_all = "camelCase", deny_unknown_fields)]
178struct ExtensionResponse {
179 protocol_version: u32,
180 ok: bool,
181 #[serde(default)]
182 result: Option<Value>,
183 #[serde(default)]
184 error: Option<String>,
185}
186
187#[derive(Debug)]
189pub struct ExtensionHost {
190 root: PathBuf,
191 registry: ExtensionRegistry,
192 invocations: Arc<Semaphore>,
193 experimental_enabled: bool,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ExtensionError(pub String);
199
200impl fmt::Display for ExtensionError {
201 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
202 formatter.write_str(&self.0)
203 }
204}
205
206impl std::error::Error for ExtensionError {}
207
208impl ExtensionManifest {
209 pub fn validate(&self) -> Result<(), ExtensionError> {
211 const MAX_TEXT_BYTES: usize = 256;
212 const MAX_HOSTS: usize = 32;
213 const MAX_ACTIONS: usize = 32;
214 if self.schema_version != EXTENSION_SCHEMA_VERSION {
215 return Err(ExtensionError(format!(
216 "unsupported extension schema {}; expected {}",
217 self.schema_version, EXTENSION_SCHEMA_VERSION
218 )));
219 }
220 for (name, value) in [("id", self.id.as_str()), ("version", self.version.as_str())] {
221 if value.is_empty() || value.len() > MAX_TEXT_BYTES {
222 return Err(ExtensionError(format!(
223 "extension {name} must be 1..={MAX_TEXT_BYTES} bytes"
224 )));
225 }
226 }
227 if self.api_version == 0 {
228 return Err(ExtensionError(
229 "extension apiVersion must be positive".into(),
230 ));
231 }
232 if self.capabilities.is_empty() {
233 return Err(ExtensionError(
234 "extension must declare at least one capability".into(),
235 ));
236 }
237 if self.permissions.hosts.is_empty() || self.permissions.hosts.len() > MAX_HOSTS {
238 return Err(ExtensionError(format!(
239 "extension hosts must contain 1..={MAX_HOSTS} entries"
240 )));
241 }
242 if self.permissions.actions.is_empty() || self.permissions.actions.len() > MAX_ACTIONS {
243 return Err(ExtensionError(format!(
244 "extension actions must contain 1..={MAX_ACTIONS} entries"
245 )));
246 }
247 for host in &self.permissions.hosts {
248 if host.is_empty() || host.len() > MAX_TEXT_BYTES || host == "*" || host.contains('/') {
249 return Err(ExtensionError(format!(
250 "extension host is not an exact host: {host:?}"
251 )));
252 }
253 }
254 for action in &self.permissions.actions {
255 if !allowed_action(action) {
256 return Err(ExtensionError(format!(
257 "extension action is not allowed: {action:?}"
258 )));
259 }
260 }
261 if let Some(entrypoint) = &self.entrypoint
262 && (entrypoint.is_empty() || entrypoint.len() > MAX_TEXT_BYTES)
263 {
264 return Err(ExtensionError(
265 "extension entrypoint is out of bounds".into(),
266 ));
267 }
268 Ok(())
269 }
270}
271
272impl ExtensionRegistry {
273 pub fn register(&mut self, manifest: ExtensionManifest) -> Result<(), ExtensionError> {
275 manifest.validate()?;
276 if self.manifests.contains_key(&manifest.id) {
277 return Err(ExtensionError(format!(
278 "extension ID is already registered: {}",
279 manifest.id
280 )));
281 }
282 self.manifests.insert(manifest.id.clone(), manifest);
283 Ok(())
284 }
285
286 pub fn get(&self, id: &str) -> Option<&ExtensionManifest> {
288 self.manifests.get(id)
289 }
290
291 pub fn manifests(&self) -> impl Iterator<Item = &ExtensionManifest> {
293 self.manifests.values()
294 }
295
296 pub fn load_dir(directory: impl AsRef<Path>) -> Result<Self, ExtensionError> {
298 let directory = directory.as_ref();
299 let metadata = std::fs::symlink_metadata(directory).map_err(|error| {
300 ExtensionError(format!("extension directory is unavailable: {error}"))
301 })?;
302 if !metadata.is_dir() {
303 return Err(ExtensionError("extension path must be a directory".into()));
304 }
305 let mut entries = std::fs::read_dir(directory)
306 .map_err(|error| ExtensionError(format!("cannot read extension directory: {error}")))?
307 .collect::<Result<Vec<_>, _>>()
308 .map_err(|error| ExtensionError(format!("cannot enumerate extensions: {error}")))?;
309 entries.sort_by_key(|entry| entry.file_name());
310 if entries.len() > MAX_EXTENSION_MANIFESTS {
311 return Err(ExtensionError(format!(
312 "extension directory exceeds {MAX_EXTENSION_MANIFESTS} manifests"
313 )));
314 }
315 let mut registry = Self::default();
316 for entry in entries {
317 let path = entry.path();
318 if path.extension().and_then(|value| value.to_str()) != Some("json") {
319 continue;
320 }
321 let metadata = std::fs::symlink_metadata(&path)
322 .map_err(|error| ExtensionError(format!("cannot inspect manifest: {error}")))?;
323 if !metadata.is_file() {
324 return Err(ExtensionError(format!(
325 "extension manifest is not a regular file: {}",
326 path.display()
327 )));
328 }
329 let manifest: ExtensionManifest = serde_json::from_slice(
330 &std::fs::read(&path)
331 .map_err(|error| ExtensionError(format!("cannot read manifest: {error}")))?,
332 )
333 .map_err(|error| ExtensionError(format!("invalid extension manifest: {error}")))?;
334 registry.register(manifest)?;
335 }
336 Ok(registry)
337 }
338}
339
340impl ExtensionHost {
341 pub fn new(
343 root: impl AsRef<Path>,
344 registry: ExtensionRegistry,
345 ) -> Result<Self, ExtensionError> {
346 let root = std::fs::canonicalize(root.as_ref())
347 .map_err(|error| ExtensionError(format!("extension root is unavailable: {error}")))?;
348 if !root.is_dir() {
349 return Err(ExtensionError("extension root must be a directory".into()));
350 }
351 Ok(Self {
352 root,
353 registry,
354 invocations: Arc::new(Semaphore::new(MAX_EXTENSION_CONCURRENT_INVOCATIONS)),
355 experimental_enabled: false,
356 })
357 }
358
359 pub fn with_experimental_extensions(mut self) -> Self {
364 self.experimental_enabled = true;
365 self
366 }
367
368 pub fn sandbox(&self) -> ExtensionSandbox {
370 ExtensionSandbox::detect()
371 }
372
373 #[cfg(test)]
375 pub(crate) async fn invoke(
376 &self,
377 extension_id: &str,
378 capability: ExtensionCapability,
379 host: &str,
380 action: &str,
381 payload: Value,
382 ) -> Result<Value, ExtensionError> {
383 self.invoke_internal(extension_id, capability, host, action, payload, None)
384 .await
385 }
386
387 pub async fn invoke_sandboxed(
389 &self,
390 extension_id: &str,
391 capability: ExtensionCapability,
392 host: &str,
393 action: &str,
394 payload: Value,
395 ) -> Result<Value, ExtensionError> {
396 if !self.experimental_enabled {
397 return Err(ExtensionError(
398 "experimental extensions are disabled; opt in explicitly".into(),
399 ));
400 }
401 if !experimental_extension_target_supported() {
402 return Err(ExtensionError(
403 "experimental extensions are unavailable on this target".into(),
404 ));
405 }
406 let sandbox = self.sandbox();
407 if sandbox == ExtensionSandbox::Unavailable {
408 return Err(ExtensionError(
409 "no supported native extension sandbox is available".into(),
410 ));
411 }
412 self.invoke_internal(
413 extension_id,
414 capability,
415 host,
416 action,
417 payload,
418 Some(sandbox),
419 )
420 .await
421 }
422
423 pub async fn invoke_guarded(
431 &self,
432 session: &crate::browser::BrowserSession,
433 extension_id: &str,
434 capability: ExtensionCapability,
435 host: &str,
436 action: &str,
437 payload: Value,
438 ) -> Result<crate::browser::ActionOutcome, ExtensionError> {
439 let result = self
440 .invoke_sandboxed(extension_id, capability, host, action, payload)
441 .await?;
442 let guarded: ExtensionGuardedAction = serde_json::from_value(result).map_err(|error| {
443 ExtensionError(format!("invalid guarded extension action: {error}"))
444 })?;
445 guarded.validate()?;
446 let manifest = self
447 .registry
448 .get(extension_id)
449 .ok_or_else(|| ExtensionError("extension is not registered".into()))?;
450 if !manifest
451 .permissions
452 .actions
453 .iter()
454 .any(|allowed| allowed == &guarded.action)
455 {
456 return Err(ExtensionError(
457 "extension returned an action outside its declared permissions".into(),
458 ));
459 }
460 let target = || {
461 guarded
462 .target
463 .as_deref()
464 .ok_or_else(|| ExtensionError("guarded extension action requires a target".into()))
465 };
466 let revision = guarded.expected_revision;
467 match guarded.action.as_str() {
468 "click" => session
469 .click_with_revision(target()?, revision)
470 .await
471 .map_err(extension_browser_error),
472 "type" => session
473 .type_text_with_expected_revision(
474 guarded.value.as_deref().unwrap_or_default(),
475 Some(target()?),
476 Some(revision),
477 )
478 .await
479 .map_err(extension_browser_error),
480 "clear" => session
481 .clear_with_revision(target()?, Some(revision))
482 .await
483 .map_err(extension_browser_error),
484 "check" => session
485 .check_with_revision(target()?, Some(revision))
486 .await
487 .map_err(extension_browser_error),
488 "uncheck" => session
489 .uncheck_with_revision(target()?, Some(revision))
490 .await
491 .map_err(extension_browser_error),
492 "select" => session
493 .select_option_with_revision(
494 target()?,
495 guarded.value.as_deref().unwrap_or_default(),
496 Some(revision),
497 )
498 .await
499 .map_err(extension_browser_error),
500 other => Err(ExtensionError(format!(
501 "guarded extension action is not supported: {other}"
502 ))),
503 }
504 }
505
506 async fn invoke_internal(
507 &self,
508 extension_id: &str,
509 capability: ExtensionCapability,
510 host: &str,
511 action: &str,
512 payload: Value,
513 sandbox: Option<ExtensionSandbox>,
514 ) -> Result<Value, ExtensionError> {
515 let _permit = tokio::time::timeout(
516 EXTENSION_TIMEOUT,
517 Arc::clone(&self.invocations).acquire_owned(),
518 )
519 .await
520 .map_err(|_| ExtensionError("extension invocation queue timed out".into()))?
521 .map_err(|_| ExtensionError("extension invocation queue is closed".into()))?;
522 let manifest = self
523 .registry
524 .get(extension_id)
525 .ok_or_else(|| ExtensionError("extension is not registered".into()))?;
526 if !manifest.capabilities.contains(&capability) {
527 return Err(ExtensionError(
528 "extension capability was not declared".into(),
529 ));
530 }
531 if !manifest
532 .permissions
533 .hosts
534 .iter()
535 .any(|allowed| allowed == host)
536 {
537 return Err(ExtensionError(
538 "extension host permission was not declared".into(),
539 ));
540 }
541 if !manifest
542 .permissions
543 .actions
544 .iter()
545 .any(|allowed| allowed == action)
546 {
547 return Err(ExtensionError(
548 "extension action permission was not declared".into(),
549 ));
550 }
551 let entrypoint = manifest
552 .entrypoint
553 .as_deref()
554 .ok_or_else(|| ExtensionError("extension has no executable entrypoint".into()))?;
555 let entrypoint = confined_entrypoint(&self.root, entrypoint)?;
556 let invocation = ExtensionInvocation {
557 protocol_version: EXTENSION_SCHEMA_VERSION,
558 extension_id: extension_id.into(),
559 capability,
560 host: host.into(),
561 action: action.into(),
562 payload,
563 };
564 let encoded = serde_json::to_vec(&invocation)
565 .map_err(|error| ExtensionError(format!("cannot encode extension request: {error}")))?;
566 if encoded.len() > MAX_EXTENSION_MESSAGE_BYTES {
567 return Err(ExtensionError(
568 "extension request exceeds the size limit".into(),
569 ));
570 }
571 let mut command = match sandbox {
572 None => Command::new(entrypoint),
573 Some(ExtensionSandbox::LinuxBubblewrap) => {
574 let mut command = Command::new("bwrap");
575 command.args([
576 "--die-with-parent",
577 "--new-session",
578 "--unshare-all",
579 "--proc",
580 "/proc",
581 "--dev",
582 "/dev",
583 "--tmpfs",
584 "/tmp",
585 "--ro-bind",
586 ]);
587 command.arg(&self.root).arg(&self.root);
588 for path in ["/bin", "/usr", "/lib", "/lib64"] {
589 if Path::new(path).is_dir() {
590 command.arg("--ro-bind").arg(path).arg(path);
591 }
592 }
593 command.arg("--chdir").arg(&self.root).arg(entrypoint);
594 command
595 }
596 Some(ExtensionSandbox::MacSandboxExec) => {
597 let root = self
598 .root
599 .to_str()
600 .ok_or_else(|| ExtensionError("extension root is not valid UTF-8".into()))?
601 .replace('\\', "\\\\")
602 .replace('"', "\\\"");
603 let profile = format!(
604 "(version 1)(deny default)(allow process-exec)(allow file-read* (subpath \"{root}\"))(allow file-read* (subpath \"/bin\"))(allow file-read* (subpath \"/usr/bin\"))(allow file-read* (subpath \"/usr/lib\"))(allow file-write* (subpath \"/tmp\"))"
605 );
606 let mut command = Command::new("/usr/bin/sandbox-exec");
607 command.arg("-p").arg(profile).arg(entrypoint);
608 command
609 }
610 Some(ExtensionSandbox::Unavailable) => {
611 return Err(ExtensionError(
612 "no supported native extension sandbox is available".into(),
613 ));
614 }
615 };
616 let mut child = command
617 .current_dir(&self.root)
618 .stdin(std::process::Stdio::piped())
619 .stdout(std::process::Stdio::piped())
620 .stderr(std::process::Stdio::null())
621 .spawn()
622 .map_err(|error| {
623 ExtensionError(format!("extension process could not start: {error}"))
624 })?;
625 let mut stdin = child
626 .stdin
627 .take()
628 .ok_or_else(|| ExtensionError("extension stdin is unavailable".into()))?;
629 let mut stdout = BufReader::new(
630 child
631 .stdout
632 .take()
633 .ok_or_else(|| ExtensionError("extension stdout is unavailable".into()))?,
634 );
635 stdin
636 .write_all(&encoded)
637 .await
638 .map_err(|error| ExtensionError(format!("extension request failed: {error}")))?;
639 stdin
640 .write_all(b"\n")
641 .await
642 .map_err(|error| ExtensionError(format!("extension request failed: {error}")))?;
643 stdin
644 .shutdown()
645 .await
646 .map_err(|error| ExtensionError(format!("extension request failed: {error}")))?;
647 let result = tokio::time::timeout(EXTENSION_TIMEOUT, async {
648 let mut response = Vec::new();
649 let bytes = stdout
650 .read_until(b'\n', &mut response)
651 .await
652 .map_err(|error| ExtensionError(format!("extension response failed: {error}")))?;
653 if bytes == 0 || response.len() > MAX_EXTENSION_MESSAGE_BYTES {
654 return Err(ExtensionError(
655 "extension response is missing or oversized".into(),
656 ));
657 }
658 let response: ExtensionResponse = serde_json::from_slice(&response)
659 .map_err(|error| ExtensionError(format!("invalid extension response: {error}")))?;
660 if response.protocol_version != EXTENSION_SCHEMA_VERSION {
661 return Err(ExtensionError(
662 "unsupported extension response protocol".into(),
663 ));
664 }
665 match (response.ok, response.result, response.error) {
666 (true, Some(result), None) => {
667 validate_extension_value(&result)?;
668 Ok(result)
669 }
670 (false, None, Some(error)) if error.len() <= 512 => Err(ExtensionError(error)),
671 _ => Err(ExtensionError(
672 "extension response outcome is invalid".into(),
673 )),
674 }
675 })
676 .await;
677 let timed_out = result.is_err();
678 if timed_out {
679 let _ = child.kill().await;
680 }
681 let status = child
682 .wait()
683 .await
684 .map_err(|error| ExtensionError(format!("extension process failed: {error}")))?;
685 if !status.success() && !timed_out {
686 return Err(ExtensionError(
687 "extension process exited unsuccessfully".into(),
688 ));
689 }
690 if timed_out {
691 return Err(ExtensionError("extension invocation timed out".into()));
692 }
693 result.expect("extension timeout was handled")
694 }
695}
696
697fn extension_browser_error(error: impl fmt::Display) -> ExtensionError {
698 ExtensionError(format!("guarded extension action failed: {error}"))
699}
700
701fn validate_extension_value(value: &Value) -> Result<(), ExtensionError> {
702 fn visit(value: &Value, depth: usize, nodes: &mut usize) -> Result<(), ExtensionError> {
703 *nodes = nodes.saturating_add(1);
704 if *nodes > MAX_EXTENSION_VALUE_NODES {
705 return Err(ExtensionError(
706 "extension response contains too many values".into(),
707 ));
708 }
709 if depth > MAX_EXTENSION_VALUE_DEPTH {
710 return Err(ExtensionError(
711 "extension response nesting exceeds the limit".into(),
712 ));
713 }
714 match value {
715 Value::String(value) if value.len() > MAX_EXTENSION_STRING_BYTES => Err(
716 ExtensionError("extension response string is oversized".into()),
717 ),
718 Value::Array(values) => {
719 if values.len() > MAX_EXTENSION_VALUE_NODES {
720 return Err(ExtensionError(
721 "extension response array is oversized".into(),
722 ));
723 }
724 for value in values {
725 visit(value, depth + 1, nodes)?;
726 }
727 Ok(())
728 }
729 Value::Object(values) => {
730 for (key, value) in values {
731 let normalized = key.to_ascii_lowercase();
732 if ["authorization", "cookie", "password", "secret", "token"]
733 .iter()
734 .any(|blocked| normalized.contains(blocked))
735 {
736 return Err(ExtensionError(format!(
737 "extension response contains a sensitive field: {key}"
738 )));
739 }
740 visit(value, depth + 1, nodes)?;
741 }
742 Ok(())
743 }
744 _ => Ok(()),
745 }
746 }
747
748 let mut nodes = 0;
749 visit(value, 0, &mut nodes)
750}
751
752fn confined_entrypoint(root: &Path, entrypoint: &str) -> Result<PathBuf, ExtensionError> {
753 let path = Path::new(entrypoint);
754 if path.is_absolute()
755 || path
756 .components()
757 .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
758 {
759 return Err(ExtensionError(
760 "extension entrypoint escapes its root".into(),
761 ));
762 }
763 let path = std::fs::canonicalize(root.join(path))
764 .map_err(|error| ExtensionError(format!("extension entrypoint is unavailable: {error}")))?;
765 if !path.starts_with(root) || !path.is_file() {
766 return Err(ExtensionError(
767 "extension entrypoint is not a file below its root".into(),
768 ));
769 }
770 Ok(path)
771}
772
773fn allowed_action(action: &str) -> bool {
774 matches!(
775 action,
776 "navigate"
777 | "observe"
778 | "verify"
779 | "click"
780 | "type"
781 | "clear"
782 | "check"
783 | "uncheck"
784 | "select"
785 | "scroll"
786 | "extract"
787 )
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 fn manifest() -> ExtensionManifest {
795 ExtensionManifest {
796 schema_version: EXTENSION_SCHEMA_VERSION,
797 id: "example.docs-adapter".into(),
798 version: "1.0.0".into(),
799 api_version: 1,
800 capabilities: vec![ExtensionCapability::SiteAdapter],
801 permissions: ExtensionPermissions {
802 hosts: vec!["docs.example.com".into()],
803 actions: vec!["navigate".into(), "observe".into(), "extract".into()],
804 },
805 entrypoint: Some("metadata-only".into()),
806 }
807 }
808
809 #[test]
810 fn manifest_validation_is_strict_and_does_not_grant_execution() {
811 let mut registry = ExtensionRegistry::default();
812 registry.register(manifest()).unwrap();
813 assert_eq!(registry.manifests().count(), 1);
814 assert!(registry.register(manifest()).is_err());
815
816 let mut wildcard = manifest();
817 wildcard.permissions.hosts = vec!["*".into()];
818 assert!(wildcard.validate().is_err());
819 }
820
821 #[test]
822 fn mutation_permissions_are_explicitly_bounded() {
823 let mut extension = manifest();
824 extension.permissions.actions = vec!["evaluate".into()];
825 assert!(extension.validate().is_err());
826 }
827
828 #[cfg(unix)]
829 #[tokio::test]
830 async fn host_enforces_permissions_and_runs_one_bounded_rpc() {
831 use std::os::unix::fs::PermissionsExt;
832
833 let root =
834 std::env::temp_dir().join(format!("glass-extension-host-{}", std::process::id()));
835 std::fs::create_dir_all(&root).unwrap();
836 let entrypoint = root.join("extension.sh");
837 std::fs::write(
838 &entrypoint,
839 "#!/bin/sh\nread request\nprintf '%s\\n' '{\"protocolVersion\":1,\"ok\":true,\"result\":{\"accepted\":true}}'\n",
840 )
841 .unwrap();
842 std::fs::set_permissions(&entrypoint, std::fs::Permissions::from_mode(0o700)).unwrap();
843
844 let mut extension = manifest();
845 extension.entrypoint = Some("extension.sh".into());
846 let mut registry = ExtensionRegistry::default();
847 registry.register(extension).unwrap();
848 let host = ExtensionHost::new(&root, registry).unwrap();
849 let result = host
850 .invoke(
851 "example.docs-adapter",
852 ExtensionCapability::SiteAdapter,
853 "docs.example.com",
854 "observe",
855 serde_json::json!({"revision": 1}),
856 )
857 .await
858 .unwrap();
859 assert_eq!(result["accepted"], true);
860 assert!(
861 host.invoke(
862 "example.docs-adapter",
863 ExtensionCapability::SiteAdapter,
864 "other.example.com",
865 "observe",
866 serde_json::json!({}),
867 )
868 .await
869 .is_err()
870 );
871
872 std::fs::remove_file(entrypoint).unwrap();
873 std::fs::remove_dir(root).unwrap();
874 }
875
876 #[cfg(unix)]
877 #[tokio::test]
878 async fn first_party_reference_extensions_load_and_run_through_the_host() {
879 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
880 let registry = ExtensionRegistry::load_dir(&root).unwrap();
881 assert_eq!(registry.manifests().count(), 2);
882 let host = ExtensionHost::new(&root, registry).unwrap();
883
884 let title = host
885 .invoke(
886 "glass.first-party.title-extractor",
887 ExtensionCapability::ExtractionTransform,
888 "example.com",
889 "extract",
890 serde_json::json!({"title": "Example Domain"}),
891 )
892 .await
893 .unwrap();
894 assert_eq!(title["extension"], "title-extractor");
895
896 let evidence = host
897 .invoke(
898 "glass.first-party.intent-evidence",
899 ExtensionCapability::IntentEvidencePack,
900 "example.com",
901 "verify",
902 serde_json::json!({"role": "button"}),
903 )
904 .await
905 .unwrap();
906 assert_eq!(evidence["extension"], "intent-evidence");
907 }
908
909 #[cfg(unix)]
910 #[tokio::test]
911 async fn first_party_extensions_pass_cold_start_exit_and_restart_lifecycle() {
912 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
913 let registry = ExtensionRegistry::load_dir(&root).unwrap();
914 let host = ExtensionHost::new(&root, registry).unwrap();
915
916 for manifest in host.registry.manifests() {
917 let capability = manifest.capabilities[0];
918 let host_name = &manifest.permissions.hosts[0];
919 let action = &manifest.permissions.actions[0];
920 let first = host
921 .invoke(
922 &manifest.id,
923 capability,
924 host_name,
925 action,
926 serde_json::json!({"lifecycle": "cold-start"}),
927 )
928 .await
929 .unwrap();
930 let second = host
931 .invoke(
932 &manifest.id,
933 capability,
934 host_name,
935 action,
936 serde_json::json!({"lifecycle": "restart"}),
937 )
938 .await
939 .unwrap();
940 assert_eq!(first["extension"], second["extension"]);
941 }
942 }
943
944 #[cfg(unix)]
945 #[tokio::test]
946 async fn sandboxed_reference_extensions_pass_native_gate() {
947 if std::env::var("GLASS_EXTENSION_SANDBOX_E2E").as_deref() != Ok("1") {
948 eprintln!("skipping native extension sandbox gate; set GLASS_EXTENSION_SANDBOX_E2E=1");
949 return;
950 }
951 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
952 let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap())
953 .unwrap()
954 .with_experimental_extensions();
955 assert_ne!(host.sandbox(), ExtensionSandbox::Unavailable);
956 let result = host
957 .invoke_sandboxed(
958 "glass.first-party.title-extractor",
959 ExtensionCapability::ExtractionTransform,
960 "example.com",
961 "extract",
962 serde_json::json!({"title": "Example Domain"}),
963 )
964 .await
965 .unwrap();
966 assert_eq!(result["extension"], "title-extractor");
967 }
968
969 #[tokio::test]
970 async fn sandboxed_extensions_require_explicit_experimental_opt_in() {
971 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
972 let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap()).unwrap();
973 let error = host
974 .invoke_sandboxed(
975 "glass.first-party.title-extractor",
976 ExtensionCapability::ExtractionTransform,
977 "example.com",
978 "extract",
979 serde_json::json!({"title": "Example Domain"}),
980 )
981 .await
982 .unwrap_err();
983 assert!(error.0.contains("experimental extensions are disabled"));
984 }
985
986 #[test]
987 fn sandbox_detection_is_explicit_and_never_falls_back_silently() {
988 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
989 let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap()).unwrap();
990 assert!(!host.sandbox().label().is_empty());
991 assert_eq!(
992 host.invocations.available_permits(),
993 MAX_EXTENSION_CONCURRENT_INVOCATIONS
994 );
995 }
996
997 #[tokio::test]
998 async fn unavailable_sandbox_never_falls_back_to_an_unsandboxed_process() {
999 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
1000 let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap()).unwrap();
1001 let error = host
1002 .invoke_internal(
1003 "glass.first-party.title-extractor",
1004 ExtensionCapability::ExtractionTransform,
1005 "example.com",
1006 "extract",
1007 serde_json::json!({"title": "Example Domain"}),
1008 Some(ExtensionSandbox::Unavailable),
1009 )
1010 .await
1011 .unwrap_err();
1012 assert!(error.0.contains("no supported native extension sandbox"));
1013 }
1014
1015 #[test]
1016 fn guarded_extension_actions_require_revision_and_bounded_fields() {
1017 let action: ExtensionGuardedAction = serde_json::from_value(serde_json::json!({
1018 "action": "click",
1019 "target": "ref=r7:b42",
1020 "expectedRevision": 7
1021 }))
1022 .unwrap();
1023 action.validate().unwrap();
1024
1025 let mut missing_revision = action.clone();
1026 missing_revision.expected_revision = 0;
1027 assert!(missing_revision.validate().is_err());
1028
1029 let mut oversized = action;
1030 oversized.target = Some("x".repeat(513));
1031 assert!(oversized.validate().is_err());
1032 }
1033
1034 #[test]
1035 fn guarded_extension_actions_reject_unknown_fields() {
1036 assert!(
1037 serde_json::from_value::<ExtensionGuardedAction>(serde_json::json!({
1038 "action": "click",
1039 "target": "ref=r7:b42",
1040 "expectedRevision": 7,
1041 "dispatchDirectly": true
1042 }))
1043 .is_err()
1044 );
1045 }
1046
1047 #[test]
1048 fn extension_outputs_fail_closed_on_sensitive_fields_and_unbounded_shapes() {
1049 assert!(
1050 validate_extension_value(&serde_json::json!({
1051 "evidence": {"authorization": "redacted"}
1052 }))
1053 .is_err()
1054 );
1055 assert!(
1056 validate_extension_value(&serde_json::json!({
1057 "text": "x".repeat(MAX_EXTENSION_STRING_BYTES + 1)
1058 }))
1059 .is_err()
1060 );
1061 }
1062
1063 #[test]
1064 fn host_rejects_entrypoints_that_escape_root() {
1065 let root = std::env::current_dir().unwrap();
1066 let mut registry = ExtensionRegistry::default();
1067 let mut extension = manifest();
1068 extension.entrypoint = Some("../outside".into());
1069 registry.register(extension).unwrap();
1070 let host = ExtensionHost::new(&root, registry).unwrap();
1071 let runtime = tokio::runtime::Runtime::new().unwrap();
1072 let error = runtime.block_on(host.invoke(
1073 "example.docs-adapter",
1074 ExtensionCapability::SiteAdapter,
1075 "docs.example.com",
1076 "observe",
1077 serde_json::json!({}),
1078 ));
1079 assert!(error.unwrap_err().0.contains("escapes"));
1080 }
1081}