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