mobius-gateway 0.9.21

Headless authenticated gateway for möbius frontends
Documentation
use super::*;

fn commit_snapshot(store: &ExtensionStore, package: &Path) -> String {
    let digest = tree_digest(package).expect("package digest");
    let snapshot = store.snapshot_root(&digest);
    fs::create_dir_all(snapshot.parent().expect("snapshot parent")).expect("snapshot directory");
    fs::rename(package, &snapshot).expect("commit snapshot");
    digest
}

fn installed_plugin(digest: String, hooks: Vec<ExtensionHookRecord>) -> InstalledExtension {
    InstalledExtension {
        kind: ExtensionKind::Plugin,
        name: "hooked".into(),
        description: String::new(),
        version: None,
        source: ExtensionSource {
            url: "https://example.com/hooked.git".into(),
            reference: None,
            subdirectory: None,
        },
        resolved_revision: "a".repeat(40),
        digest,
        skills: Vec::new(),
        hooks,
        trusted_hook_digest: None,
    }
}

#[tokio::test]
async fn extension_git_ignores_home_git_config() {
    let home = tempfile::tempdir().expect("home");
    fs::write(
        home.path().join(".gitconfig"),
        "[mobius]\n\textensionMarker = inherited\n",
    )
    .expect("global Git config");
    let output = git_command()
        .env("HOME", home.path())
        .args(["config", "--get", "mobius.extensionMarker"])
        .output()
        .await
        .expect("read extension Git config");

    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
}

#[test]
fn hook_authorization_tracks_the_authoritative_catalog() {
    let digest = "a".repeat(64);
    let mut installed = installed_plugin(
        digest.clone(),
        vec![ExtensionHookRecord {
            event: "SessionStart".into(),
            matcher: None,
            command: "true".into(),
            timeout_seconds: 5,
        }],
    );
    installed.trusted_hook_digest = Some(digest.clone());
    let mut config = GatewayConfig::new(crate::config::DEFAULT_LISTEN, None).expect("config");
    config
        .installed_extensions
        .insert("plugin:hooked".into(), installed);
    let gateway = Arc::new(Mutex::new(config));
    let (_, authorization) = ResolvedPlugin {
        id: "plugin:hooked".into(),
        digest,
        root: PathBuf::from("package"),
        hooks_trusted: true,
    }
    .activation(Arc::clone(&gateway));
    let authorization = authorization.expect("trusted hook authorization");

    let mut launched = false;
    authorization(&mut || {
        launched = true;
        Ok(())
    })
    .expect("authorized launch");
    assert!(launched);
    gateway
        .lock()
        .expect("config")
        .installed_extensions
        .remove("plugin:hooked");
    launched = false;
    authorization(&mut || {
        launched = true;
        Ok(())
    })
    .expect("revoked launch");
    assert!(!launched);
}

#[test]
fn github_tree_url_selects_ref_and_subdirectory() {
    let source = ExtensionSource::parse(
        "https://github.com/example/repo/tree/main/path/to/skill",
        None,
        None,
    )
    .expect("source");

    assert_eq!(source.url, "https://github.com/example/repo");
    assert_eq!(source.reference.as_deref(), Some("main"));
    assert_eq!(source.subdirectory.as_deref(), Some("path/to/skill"));
}

#[test]
fn persisted_source_rejects_ref_whitespace() {
    let source = ExtensionSource {
        url: "https://example.com/repo.git".into(),
        reference: Some(" main ".into()),
        subdirectory: None,
    };

    assert!(source.validate().is_err());
}

#[test]
fn prune_removes_only_unreferenced_snapshots() {
    let temporary = tempfile::tempdir().expect("temporary extensions");
    let store = ExtensionStore {
        root: temporary.path().join("store"),
    };
    let retained = "a".repeat(64);
    let orphan = "b".repeat(64);
    fs::create_dir_all(store.snapshot_root(&retained)).expect("retained snapshot");
    fs::create_dir_all(store.snapshot_root(&orphan)).expect("orphan snapshot");
    let mut config = GatewayConfig::new(crate::config::DEFAULT_LISTEN, None).expect("config");
    config.installed_extensions.insert(
        "plugin:hooked".into(),
        installed_plugin(retained.clone(), Vec::new()),
    );

    store.prune(&config).expect("prune snapshots");

    assert!(store.snapshot_root(&retained).exists());
    assert!(!store.snapshot_directory(&orphan).exists());
}

#[test]
fn resolve_disables_missing_extensions_but_keeps_untrusted_plugin_skills() {
    let temporary = tempfile::tempdir().expect("temporary extensions");
    let store = ExtensionStore {
        root: temporary.path().join("store"),
    };
    let package = temporary.path().join("package");
    fs::create_dir_all(package.join(".codex-plugin")).expect("manifest directory");
    fs::create_dir_all(package.join("hooks")).expect("hooks directory");
    fs::write(
        package.join(".codex-plugin/plugin.json"),
        r#"{"name":"hooked"}"#,
    )
    .expect("manifest");
    fs::write(
            package.join("hooks/hooks.json"),
            r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"true","timeout":5}]}]}}"#,
        )
        .expect("hooks");
    let digest = commit_snapshot(&store, &package);
    let mut config = GatewayConfig::new(crate::config::DEFAULT_LISTEN, None).expect("config");
    config.installed_extensions.insert(
        "plugin:hooked".into(),
        installed_plugin(
            digest,
            vec![ExtensionHookRecord {
                event: "SessionStart".into(),
                matcher: None,
                command: "true".into(),
                timeout_seconds: 5,
            }],
        ),
    );

    let resolved = store
        .resolve(
            &config,
            &BTreeSet::from(["plugin:hooked".into(), "skill:missing".into()]),
        )
        .expect("disabled extensions");

    assert!(resolved.skill_roots.is_empty());
    assert_eq!(resolved.plugins.len(), 1);
    assert!(!resolved.plugins[0].hooks_trusted);
}

#[test]
fn startup_validation_rejects_catalog_metadata_that_hides_snapshot_hooks() {
    let temporary = tempfile::tempdir().expect("temporary extensions");
    let store = ExtensionStore {
        root: temporary.path().join("store"),
    };
    let package = temporary.path().join("package");
    fs::create_dir_all(package.join(".codex-plugin")).expect("manifest directory");
    fs::create_dir_all(package.join("hooks")).expect("hooks directory");
    fs::write(
        package.join(".codex-plugin/plugin.json"),
        r#"{"name":"hooked"}"#,
    )
    .expect("manifest");
    fs::write(
        package.join("hooks/hooks.json"),
        r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"true"}]}]}}"#,
    )
    .expect("hooks");
    let digest = commit_snapshot(&store, &package);
    let mut config = GatewayConfig::new(crate::config::DEFAULT_LISTEN, None).expect("config");
    config
        .installed_extensions
        .insert("plugin:hooked".into(), installed_plugin(digest, Vec::new()));

    let error = store
        .verify_installed_snapshots(&config)
        .expect_err("hidden hooks must fail closed");

    assert!(error.to_string().contains("metadata does not match"));
}

#[test]
fn startup_validation_rejects_tampered_snapshot() {
    let temporary = tempfile::tempdir().expect("temporary extensions");
    let store = ExtensionStore {
        root: temporary.path().join("store"),
    };
    let package = temporary.path().join("package");
    fs::create_dir_all(package.join(".codex-plugin")).expect("manifest directory");
    fs::create_dir_all(package.join("hooks")).expect("hooks directory");
    fs::write(
        package.join(".codex-plugin/plugin.json"),
        r#"{"name":"hooked"}"#,
    )
    .expect("manifest");
    fs::write(
        package.join("hooks/hooks.json"),
        r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"true"}]}]}}"#,
    )
    .expect("hooks");
    let digest = commit_snapshot(&store, &package);
    let mut config = GatewayConfig::new(crate::config::DEFAULT_LISTEN, None).expect("config");
    config.installed_extensions.insert(
        "plugin:hooked".into(),
        installed_plugin(
            digest.clone(),
            vec![ExtensionHookRecord {
                event: "SessionStart".into(),
                matcher: None,
                command: "true".into(),
                timeout_seconds: 10,
            }],
        ),
    );
    fs::write(
        store.snapshot_root(&digest).join("hooks/hooks.json"),
        r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"false"}]}]}}"#,
    )
    .expect("tamper snapshot");

    let error = store
        .verify_installed_snapshots(&config)
        .expect_err("tampered snapshot must fail at startup");

    assert!(error.to_string().contains("digest changed"));
}

#[cfg(unix)]
#[test]
fn tree_digest_has_unambiguous_path_and_content_framing() {
    use std::os::unix::fs::PermissionsExt as _;

    let temporary = tempfile::tempdir().expect("temporary packages");
    let first = temporary.path().join("first");
    let second = temporary.path().join("second");
    fs::create_dir(&first).expect("first package");
    fs::create_dir(&second).expect("second package");
    let first_file = first.join("a");
    let second_file = second.join("a\u{1}");
    fs::write(&first_file, b"\0content").expect("first file");
    fs::write(&second_file, b"content").expect("second file");
    fs::set_permissions(&first_file, fs::Permissions::from_mode(0o700)).expect("first mode");
    fs::set_permissions(&second_file, fs::Permissions::from_mode(0o600)).expect("second mode");

    assert_ne!(
        tree_digest(&first).expect("first digest"),
        tree_digest(&second).expect("second digest")
    );
}