use gate4agent_node::{
default_node_endpoint, default_state_path, ManagedWorktreeProfile, NodeServer,
HistorySourceLayout, NativeHistoryConfig, NativeHistoryRoot, NodeSecretReference,
NodeSecretResolveError, NodeSecretResolver, NodeSecretValue, NodeServerConfig,
WorkspaceConfig, WorktreeServiceMode, NETWORK_ALLOWLIST_CATALOG_ENV,
resolve_network_allowlist_catalog,
};
use std::sync::Arc;
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";
/// Tip 6: 64 hex chars = 32-byte underlay AEAD key (never logged; ≠ BRIDGE_TOKEN).
const MESH_UNDERLAY_KEY_ENV: &str = "GATE4AGENT_MESH_UNDERLAY_KEY";
/// Tip 6: underlay probe/auth token barrier (never logged; ≠ BRIDGE_TOKEN).
const MESH_UNDERLAY_TOKEN_ENV: &str = "GATE4AGENT_MESH_UNDERLAY_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;
// Tip 6: optional underlay UDP accept (may be non-loopback); relays to bridge_listen.
let mut bridge_underlay_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");
}
}
// Tip 6: mesh underlay UDP door → TCP relay to local --bridge-listen.
// Requires BRIDGE_TOKEN + MESH_UNDERLAY_KEY + MESH_UNDERLAY_TOKEN.
// HQ dial-only (node accepts as NodePeer). Not a WireGuard daemon.
"--bridge-underlay-listen" => {
let value = required_value("--bridge-underlay-listen", args.next());
let addr = value.parse().unwrap_or_else(|error| {
fail(&format!("--bridge-underlay-listen is invalid: {error}"))
});
if bridge_underlay_listen.replace(addr).is_some() {
fail("--bridge-underlay-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] [--bridge-underlay-listen ADDR] [--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!("--bridge-underlay-listen: tip 6 mesh underlay UDP accept → TCP relay to --bridge-listen (requires {BRIDGE_TOKEN_ENV} + {MESH_UNDERLAY_KEY_ENV} + {MESH_UNDERLAY_TOKEN_ENV}; HQ dial-only; no WG daemon)");
println!("network allowlist catalog: --network-allowlist-catalog or {NETWORK_ALLOWLIST_CATALOG_ENV} (opaque ids, one per line; empty default)");
println!("session environment: materialized beside the state file (<state-file>.session-environments); secret references fail closed");
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 = match bridge_underlay_listen {
Some(bind) => {
let key_hex = std::env::var(MESH_UNDERLAY_KEY_ENV).unwrap_or_else(|_| {
fail(&format!(
"{MESH_UNDERLAY_KEY_ENV} is required with --bridge-underlay-listen (64 hex digits)"
))
});
std::env::remove_var(MESH_UNDERLAY_KEY_ENV);
let underlay_token = std::env::var(MESH_UNDERLAY_TOKEN_ENV).unwrap_or_else(|_| {
fail(&format!(
"{MESH_UNDERLAY_TOKEN_ENV} is required with --bridge-underlay-listen"
))
});
std::env::remove_var(MESH_UNDERLAY_TOKEN_ENV);
let transport_key = parse_underlay_key_hex(&key_hex)
.unwrap_or_else(|error| fail(&error));
config
.with_bridge_underlay(bind, transport_key, underlay_token)
.unwrap_or_else(|error| fail(&error.to_string()))
}
None => 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()));
// Same API the materialization tests use. The stock CLI has no secret
// store: a reference resolves Unavailable and materialize stays fail-closed.
// Root sits beside the durable state file so a bundle spawn can prepare
// a session environment before the child exists.
let config = with_cli_session_environment_materialization(config, &state_path)
.unwrap_or_else(|error| fail(&error));
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());
}
}
/// Stock CLI secret resolver. There is no secret product here: any
/// reference is unavailable, so a profile that needs a secret fails closed
/// inside materialize instead of inventing a value.
struct CliUnavailableSecretResolver;
impl NodeSecretResolver for CliUnavailableSecretResolver {
fn resolve(
&self,
_reference: &NodeSecretReference,
) -> Result<NodeSecretValue, NodeSecretResolveError> {
Err(NodeSecretResolveError::Unavailable)
}
}
/// Sibling of the state file, same shape as the delivery store root
/// (`<file>.delivery-store` → `<file>.session-environments`).
fn session_environment_root_for_state_path(state_path: &std::path::Path) -> Result<PathBuf, String> {
let parent = state_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| "state path has no parent for the session-environment root".to_owned())?;
let file_name = state_path.file_name().ok_or_else(|| {
"state path has no file name for the session-environment root".to_owned()
})?;
let mut name = file_name.to_os_string();
name.push(".session-environments");
Ok(parent.join(name))
}
fn with_cli_session_environment_materialization(
config: NodeServerConfig,
state_path: &std::path::Path,
) -> Result<NodeServerConfig, String> {
let root = session_environment_root_for_state_path(state_path)?;
config
.with_session_environment_materialization(root, Arc::new(CliUnavailableSecretResolver))
.map_err(|error| 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 parse_underlay_key_hex(value: &str) -> Result<[u8; 32], String> {
let value = value.trim();
if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(
"GATE4AGENT_MESH_UNDERLAY_KEY must be exactly 64 hex digits (32 bytes)".into(),
);
}
let mut out = [0u8; 32];
for (i, chunk) in value.as_bytes().chunks(2).enumerate() {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}
fn hex_nibble(b: u8) -> Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err("invalid hex digit in GATE4AGENT_MESH_UNDERLAY_KEY".into()),
}
}
fn fail(message: &str) -> ! {
eprintln!("gate4agent-node: {message}");
std::process::exit(2)
}
#[cfg(test)]
mod tests {
use super::*;
use gate4agent_node::NodeServerError;
#[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: "));
}
#[test]
fn cli_session_environment_root_sits_beside_the_state_file() {
let root = session_environment_root_for_state_path(std::path::Path::new(
"/var/lib/gate4agent/nodes/n1/state-v1.json",
))
.unwrap();
assert_eq!(
root,
PathBuf::from("/var/lib/gate4agent/nodes/n1/state-v1.json.session-environments"),
);
}
#[test]
fn cli_secret_resolver_fails_closed() {
let reference = NodeSecretReference::new("fixture-secret").unwrap();
match CliUnavailableSecretResolver.resolve(&reference) {
Err(NodeSecretResolveError::Unavailable) => {}
Ok(_) => panic!("cli secret resolver must not return a value"),
Err(other) => panic!("expected unavailable, got {other}"),
}
}
#[test]
fn cli_materializer_initializes_from_the_state_path() {
let scratch = std::env::temp_dir().join(format!(
"g4a-cli-mat-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir_all(&scratch).unwrap();
let workspace = scratch.join("workspace");
let state_parent = scratch.join("node");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::create_dir_all(&state_parent).unwrap();
let state_path = state_parent.join("state-v1.json");
let endpoint = scratch.join("node.sock");
let config = NodeServerConfig::new(
endpoint.to_string_lossy().as_ref(),
"fixture-token",
NodeId::new("cli-mat").unwrap(),
[WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &workspace).unwrap()],
)
.unwrap()
.with_state_path(&state_path)
.unwrap();
let bare = config.clone();
let config = with_cli_session_environment_materialization(config, &state_path).unwrap();
let server = NodeServer::new(config).unwrap();
drop(server);
let marker = session_environment_root_for_state_path(&state_path)
.unwrap()
.join(".gate4agent-materialization-root");
assert!(marker.is_file(), "materializer did not initialize beside the state file");
match bare.with_session_environment_materialization(
PathBuf::from("relative-root"),
Arc::new(CliUnavailableSecretResolver),
) {
Err(NodeServerError::InvalidSessionEnvironmentRoot) => {}
Ok(_) => panic!("relative session-environment root must be refused"),
Err(other) => panic!("expected invalid root, got {other}"),
}
let _ = std::fs::remove_dir_all(&scratch);
}
}