gate4agent-node 0.4.1

Native gate4agent node server: wraps providers and owns PTY/inline sessions, the file browser, local git, and worktrees for its machine
Documentation
use gate4agent_node::{
    default_node_endpoint, default_state_path, ManagedWorktreeProfile, NodeServer,
    HistorySourceLayout, NativeHistoryConfig, NativeHistoryRoot, NodeServerConfig,
    WorkspaceConfig, WorktreeServiceMode, NETWORK_ALLOWLIST_CATALOG_ENV,
    resolve_network_allowlist_catalog,
};
use gate4agent_node::protocol::{
    ManagedWorktreeRetention, NodeId, SessionRecordRetentionConfig, WorktreeProfileId,
    WorktreeProfileRevision, WorkspaceId,
};
use gate4agent_types::AdapterId;
use std::collections::BTreeMap;
use std::path::PathBuf;

const NODE_TOKEN_ENV: &str = "GATE4AGENT_NODE_TOKEN";
/// Optional node-local bridge secret (distinct from NODE_TOKEN). Never logged.
const BRIDGE_TOKEN_ENV: &str = "GATE4AGENT_BRIDGE_TOKEN";

#[tokio::main(flavor = "current_thread")]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
        )
        .init();
    let mut endpoint = default_node_endpoint()
        .and_then(|path| {
            path.into_os_string().into_string().map_err(|_| {
                gate4agent_node::NodeServerError::InvalidEndpoint
            })
        })
        .unwrap_or_else(|error| fail(&error.to_string()));
    let mut api_listen = "127.0.0.1:18310"
        .parse()
        .expect("the built-in node API listen address must be valid");
    // Opt-in: default off. Loopback-only when set (enforced by with_bridge_listen).
    let mut bridge_listen: Option<std::net::SocketAddr> = None;
    let mut call_home: Option<std::net::SocketAddr> = None;
    let mut node_id = None;
    let mut workspaces = Vec::new();
    let mut worktree_modes = BTreeMap::new();
    let mut managed_profiles = Vec::new();
    let mut history_roots = Vec::new();
    let mut harness_mcp_helper: Option<PathBuf> = None;
    let mut network_allowlist_catalog: Option<PathBuf> = None;
    let mut session_record_retention = SessionRecordRetentionConfig::default();
    let mut args = std::env::args().skip(1);
    while let Some(argument) = args.next() {
        match argument.as_str() {
            "--endpoint" => endpoint = required_value("--endpoint", args.next()),
            "--api-listen" => {
                let value = required_value("--api-listen", args.next());
                api_listen = value
                    .parse()
                    .unwrap_or_else(|error| fail(&format!("--api-listen is invalid: {error}")));
            }
            // Node-envelope HTTP+WS bridge (separate door from --api-listen ops HTTP).
            // Loopback only. Off unless supplied. Optional GATE4AGENT_BRIDGE_TOKEN.
            "--bridge-listen" => {
                let value = required_value("--bridge-listen", args.next());
                let addr = value.parse().unwrap_or_else(|error| {
                    fail(&format!("--bridge-listen is invalid: {error}"))
                });
                if bridge_listen.replace(addr).is_some() {
                    fail("--bridge-listen may only be supplied once");
                }
            }
            // Both default to `0` (disabled) via `SessionRecordRetentionConfig::default`
            // above -- a fresh node must never start deleting durable
            // records until an operator has chosen real values here.
            "--session-record-retention-age-ms" => {
                let value = required_value("--session-record-retention-age-ms", args.next());
                session_record_retention.age_ms = parse_session_record_retention_age_ms(&value)
                    .unwrap_or_else(|error| fail(&error));
            }
            "--session-record-retention-keep" => {
                let value = required_value("--session-record-retention-keep", args.next());
                session_record_retention.keep_per_workspace =
                    parse_session_record_retention_keep(&value)
                        .unwrap_or_else(|error| fail(&error));
            }
            // Dial the relay instead of only waiting to be dialled -- for
            // a node the relay has no way to reach. The node stays the
            // wire's server either way; only who places the call changes.
            "--c2-dial" => {
                let value = required_value("--c2-dial", args.next());
                call_home = Some(value.parse().unwrap_or_else(|error| {
                    fail(&format!("--c2-dial is invalid: {error}"))
                }));
            }
            "--node-id" => {
                let value = required_value("--node-id", args.next());
                let parsed = NodeId::new(value)
                    .unwrap_or_else(|error| fail(&error.to_string()));
                if node_id.replace(parsed).is_some() {
                    fail("--node-id may only be supplied once");
                }
            }
            "--workspace" => {
                let value = required_value("--workspace", args.next());
                let (id, root) = value.split_once('=').unwrap_or_else(|| {
                    fail("--workspace requires ID=ABSOLUTE_PATH")
                });
                let workspace_id = WorkspaceId::new(id)
                    .unwrap_or_else(|error| fail(&error.to_string()));
                workspaces.push(
                    WorkspaceConfig::new(workspace_id, root)
                        .unwrap_or_else(|error| fail(&error.to_string())),
                );
            }
            "--worktree-mode" => {
                let value = required_value("--worktree-mode", args.next());
                let (id, mode) = value.split_once('=').unwrap_or_else(|| {
                    fail("--worktree-mode requires WORKSPACE_ID=manual|managed|off")
                });
                let workspace_id = WorkspaceId::new(id)
                    .unwrap_or_else(|error| fail(&error.to_string()));
                let mode = match mode {
                    "manual" => WorktreeServiceMode::Manual,
                    "managed" => WorktreeServiceMode::Managed,
                    "off" => WorktreeServiceMode::Off,
                    _ => fail("--worktree-mode requires manual, managed, or off"),
                };
                if worktree_modes.insert(workspace_id, mode).is_some() {
                    fail("--worktree-mode may only be supplied once per workspace");
                }
            }
            "--managed-worktree-profile" => {
                let value = required_value("--managed-worktree-profile", args.next());
                let (workspace, fields) = value.split_once('=').unwrap_or_else(|| {
                    fail("--managed-worktree-profile requires WORKSPACE_ID=PROFILE|REVISION|ABS_ROOT|BRANCH_PREFIX|BASE|RETENTION")
                });
                let workspace_id = WorkspaceId::new(workspace)
                    .unwrap_or_else(|error| fail(&error.to_string()));
                let fields = fields.split('|').collect::<Vec<_>>();
                if fields.len() != 6 {
                    fail("--managed-worktree-profile requires exactly six pipe-separated profile fields");
                }
                let retention = match fields[5] {
                    "remove-when-released" => ManagedWorktreeRetention::RemoveWhenReleased,
                    "retain" => ManagedWorktreeRetention::Retain,
                    _ => fail("managed worktree retention must be remove-when-released or retain"),
                };
                let profile = ManagedWorktreeProfile::new(
                    WorktreeProfileId::new(fields[0])
                        .unwrap_or_else(|error| fail(&error.to_string())),
                    WorktreeProfileRevision::new(fields[1])
                        .unwrap_or_else(|error| fail(&error.to_string())),
                    fields[2],
                    fields[3],
                    fields[4],
                    retention,
                ).unwrap_or_else(|error| fail(&error));
                managed_profiles.push((workspace_id, profile));
            }
            "--history-root" => {
                let value = required_value("--history-root", args.next());
                history_roots.push(parse_history_root(&value));
            }
            "--harness-mcp-helper" => {
                let value = PathBuf::from(required_value("--harness-mcp-helper", args.next()));
                if harness_mcp_helper.replace(value).is_some() {
                    fail("--harness-mcp-helper may only be supplied once");
                }
            }
            // Retained as a compatibility no-op. History discovery is opt-in;
            // Gate4Agent never derives provider storage from the process home.
            "--no-default-history" => {}
            // Station network allowlist catalog file (opaque ids, one per line).
            // Empty/unset keeps the empty-default catalog (unknown ids refuse).
            // Dig2 station probe is a separate optional feature — this flag is network ids only.
            "--network-allowlist-catalog" => {
                let value = PathBuf::from(required_value("--network-allowlist-catalog", args.next()));
                if network_allowlist_catalog.replace(value).is_some() {
                    fail("--network-allowlist-catalog may only be supplied once");
                }
            }
            "--help" | "-h" => {
                println!("gate4agent-node --node-id ID --workspace ID=ABSOLUTE_PATH [--worktree-mode ID=manual|managed|off] [--managed-worktree-profile 'ID=PROFILE|REVISION|ABS_ROOT|BRANCH_PREFIX|BASE|RETENTION'] [--history-root 'ADAPTER|LAYOUT|ABS_ROOT'] [--harness-mcp-helper ABSOLUTE_REGULAR_FILE] [--network-allowlist-catalog ABSOLUTE_REGULAR_FILE] [--endpoint ABSOLUTE_LOCAL_ENDPOINT] [--api-listen 127.0.0.1:PORT] [--bridge-listen 127.0.0.1:PORT] [--c2-dial 127.0.0.1:PORT] [--session-record-retention-age-ms MILLISECONDS] [--session-record-retention-keep COUNT]");
                println!("RETENTION: remove-when-released or retain");
                println!("--session-record-retention-age-ms/--session-record-retention-keep: retire dead Unavailable managed session records; both default to 0 (disabled)");
                println!("LAYOUT: single-ndjson|single-json|json-or-ndjson|ndjson-with-optional-index|summary-json-with-sibling-ndjson|metadata-json-with-sibling-json|session-json-with-sibling-message-json|readonly-sqlite-projection|state-json-with-index-and-sibling-ndjson");
                println!("control token: {NODE_TOKEN_ENV} environment variable");
                println!("--bridge-listen: opt-in node-envelope HTTP+WS bridge (loopback only; separate from --api-listen)");
                println!("optional bridge token: {BRIDGE_TOKEN_ENV} (never logged; distinct from control token)");
                println!("network allowlist catalog: --network-allowlist-catalog or {NETWORK_ALLOWLIST_CATALOG_ENV} (opaque ids, one per line; empty default)");
                println!("--c2-dial: dial a relay's call-home listener instead of waiting to be dialled");
                return;
            }
            unknown => fail(&format!("unknown argument: {unknown}")),
        }
    }
    let token = std::env::var(NODE_TOKEN_ENV)
        .unwrap_or_else(|_| fail(&format!("{NODE_TOKEN_ENV} is required")));
    std::env::remove_var(NODE_TOKEN_ENV);
    let node_id = node_id.unwrap_or_else(|| fail("--node-id is required"));
    for workspace in &mut workspaces {
        if let Some(mode) = worktree_modes.remove(workspace.workspace_id()) {
            *workspace = workspace.clone().with_worktree_service_mode(mode);
        }
    }
    if !worktree_modes.is_empty() {
        fail("--worktree-mode references an unknown workspace");
    }
    for (workspace_id, profile) in managed_profiles {
        let workspace = workspaces.iter_mut()
            .find(|workspace| workspace.workspace_id() == &workspace_id)
            .unwrap_or_else(|| fail("--managed-worktree-profile references an unknown workspace"));
        *workspace = workspace.clone().with_managed_worktree_profile(profile)
            .unwrap_or_else(|error| fail(&error.to_string()));
    }
    let state_path = default_state_path(&node_id).unwrap_or_else(|error| fail(&error.to_string()));
    let config = NodeServerConfig::new(endpoint, token, node_id, workspaces)
        .and_then(|config| config.with_state_path(state_path))
        .and_then(|config| config.with_api_listen(api_listen))
        .and_then(|config| match bridge_listen {
            Some(addr) => config.with_bridge_listen(addr),
            None => Ok(config),
        })
        .and_then(|config| match call_home {
            Some(relay) => config.with_call_home(relay),
            None => Ok(config),
        })
        .unwrap_or_else(|error| fail(&error.to_string()));
    // Optional bridge secret — read then scrub from the process environment.
    // Never print. Distinct from NODE_TOKEN (already removed above).
    let config = match std::env::var(BRIDGE_TOKEN_ENV) {
        Ok(value) => {
            std::env::remove_var(BRIDGE_TOKEN_ENV);
            config
                .with_bridge_token(value)
                .unwrap_or_else(|error| fail(&error.to_string()))
        }
        Err(_) => config,
    };
    let config = if let Some(history) = explicit_history_config(history_roots)
        .unwrap_or_else(|error| fail(&error))
    {
        config.with_history(history)
    } else {
        config
    };
    let config = if let Some(helper) = harness_mcp_helper {
        config.with_harness_mcp_helper(helper)
            .unwrap_or_else(|error| fail(&error.to_string()))
    } else {
        config
    };
    let config = config.with_session_record_retention(session_record_retention);
    // Station network allowlist catalog: CLI path wins, else env path, else empty.
    // Dig2 station probe is optional (`dig2-station-probe`). Never print GATE4AGENT_NODE_TOKEN.
    let allowlist_catalog = resolve_network_allowlist_catalog(network_allowlist_catalog)
        .unwrap_or_else(|error| fail(&error.to_string()));
    let config = config
        .with_network_allowlist_catalog(allowlist_catalog)
        .unwrap_or_else(|error| fail(&error.to_string()));
    let server = NodeServer::new(config).unwrap_or_else(|error| fail(&error.to_string()));
    if let Err(error) = server.run_until_ctrl_signal().await {
        fail(&error.to_string());
    }
}

fn explicit_history_config(
    roots: Vec<NativeHistoryRoot>,
) -> Result<Option<NativeHistoryConfig>, String> {
    if roots.is_empty() {
        Ok(None)
    } else {
        NativeHistoryConfig::new(roots)
            .map(Some)
            .map_err(|error| error.to_string())
    }
}

fn parse_history_root(value: &str) -> NativeHistoryRoot {
    let mut fields = value.splitn(3, '|');
    let adapter = fields.next().unwrap_or_default();
    let layout = fields.next().unwrap_or_default();
    let root = fields.next().unwrap_or_default();
    if adapter.is_empty() || layout.is_empty() || root.is_empty() {
        fail("--history-root requires ADAPTER|LAYOUT|ABS_ROOT");
    }
    let adapter = AdapterId::new(adapter)
        .unwrap_or_else(|error| fail(&error.to_string()));
    let layout = parse_history_layout(layout);
    NativeHistoryRoot::new(adapter, layout, root)
        .unwrap_or_else(|error| fail(&error.to_string()))
}

fn parse_history_layout(value: &str) -> HistorySourceLayout {
    match value {
        "single-ndjson" => HistorySourceLayout::SingleNdjson,
        "single-json" => HistorySourceLayout::SingleJson,
        "json-or-ndjson" => HistorySourceLayout::JsonOrNdjson,
        "ndjson-with-optional-index" => HistorySourceLayout::NdjsonWithOptionalIndex,
        "summary-json-with-sibling-ndjson" => {
            HistorySourceLayout::SummaryJsonWithSiblingNdjson
        }
        "metadata-json-with-sibling-json" => {
            HistorySourceLayout::MetadataJsonWithSiblingJson
        }
        "session-json-with-sibling-message-json" => {
            HistorySourceLayout::SessionJsonWithSiblingMessageJson
        }
        "readonly-sqlite-projection" => HistorySourceLayout::ReadOnlySqliteProjection,
        "state-json-with-index-and-sibling-ndjson" => {
            HistorySourceLayout::StateJsonWithIndexAndSiblingNdjson
        }
        _ => fail("--history-root contains an unsupported layout"),
    }
}

fn parse_session_record_retention_age_ms(value: &str) -> Result<u64, String> {
    value.parse()
        .map_err(|error| format!("--session-record-retention-age-ms is invalid: {error}"))
}

fn parse_session_record_retention_keep(value: &str) -> Result<u32, String> {
    value.parse()
        .map_err(|error| format!("--session-record-retention-keep is invalid: {error}"))
}

fn required_value(flag: &str, value: Option<String>) -> String {
    value.unwrap_or_else(|| fail(&format!("{flag} requires a value")))
}

fn fail(message: &str) -> ! {
    eprintln!("gate4agent-node: {message}");
    std::process::exit(2)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn node_history_is_disabled_without_explicit_history_root() {
        assert_eq!(explicit_history_config(Vec::new()).unwrap(), None);
    }

    #[test]
    fn explicit_history_root_remains_the_only_history_authority() {
        let root = parse_history_root(
            r"codex|ndjson-with-optional-index|C:\operator-approved-history",
        );
        let config = explicit_history_config(vec![root]).unwrap().unwrap();
        assert_eq!(config.roots().len(), 1);
    }

    #[test]
    fn session_record_retention_age_ms_parses_a_valid_flag_value() {
        assert_eq!(
            parse_session_record_retention_age_ms("604800000").unwrap(),
            604_800_000,
        );
    }

    #[test]
    fn session_record_retention_age_ms_refuses_a_non_numeric_value_by_name() {
        let error = parse_session_record_retention_age_ms("not-a-number").unwrap_err();
        assert!(error.starts_with("--session-record-retention-age-ms is invalid: "));
    }

    #[test]
    fn session_record_retention_keep_parses_a_valid_flag_value() {
        assert_eq!(parse_session_record_retention_keep("32").unwrap(), 32);
    }

    #[test]
    fn session_record_retention_keep_refuses_a_non_numeric_value_by_name() {
        let error = parse_session_record_retention_keep("not-a-number").unwrap_err();
        assert!(error.starts_with("--session-record-retention-keep is invalid: "));
    }
}