use hatchery_node::protocol::{
ManagedWorktreeRetention, NodeId, SessionRecordRetentionConfig, WorkspaceId, WorktreeProfileId,
WorktreeProfileRevision,
};
use hatchery_node::{
default_node_endpoint, default_state_path, resolve_network_allowlist_catalog,
HistorySourceLayout, ManagedWorktreeProfile, NativeHistoryConfig, NativeHistoryRoot,
NodeSecretReference, NodeSecretResolveError, NodeSecretResolver, NodeSecretValue, NodeServer,
NodeServerConfig, WorkspaceConfig, WorktreeServiceMode, NETWORK_ALLOWLIST_CATALOG_ENV,
};
use gate4agent_types::AdapterId;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
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(|_| hatchery_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;
// Off by default. When set, check for claude/codex/kimi/grok and download
// only missing binaries. Never runs provider logins.
let mut install_provider_clis = false;
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");
}
}
"--install-provider-clis" => {
install_provider_clis = true;
}
"--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] [--install-provider-clis] [--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"
);
println!(
"--install-provider-clis: off by default; when set, download missing claude/codex/kimi/grok packages only (no logins)"
);
println!("node kit (default): dig2browser, mail4agent, session-restore, kernel WireGuard. Cores are individual features (m4a, d2b, session-restore, wireguard)");
#[cfg(feature = "wireguard")]
println!("kernel WireGuard peer (either side may dial): set {wg_iface} to a name plus {wg_key} (path only), {wg_addr}, {wg_peer_addr}, {wg_peer_key}; optional {wg_listen} (default 51820), {wg_endpoint} (omit so C2 dials this node). Not HQ. Not the UDP mesh stub.",
wg_iface = hatchery_node::WG_INTERFACE_ENV,
wg_key = hatchery_node::WG_PRIVATE_KEY_ENV,
wg_addr = hatchery_node::WG_ADDRESS_ENV,
wg_peer_addr = hatchery_node::WG_PEER_ADDRESS_ENV,
wg_peer_key = hatchery_node::WG_PEER_KEY_ENV,
wg_listen = hatchery_node::WG_LISTEN_PORT_ENV,
wg_endpoint = hatchery_node::WG_PEER_ENDPOINT_ENV,
);
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));
if install_provider_clis {
install_missing_provider_clis().unwrap_or_else(|error| fail(&error));
}
{
// Link the enabled cores into this process. Does not start a browser,
// provider CLI, or tunnel, and does not read secrets.
let _linked = hatchery_node::force_link();
tracing::info!(cores = ?hatchery_node::linked_core_names(), "node kit");
}
// Automatic mailbox: same daemon as the standalone mail4agent binary.
#[cfg(feature = "m4a")]
{
let mailbox = hatchery_node::start_mailbox().unwrap_or_else(|error| {
fail(&format!("mail4agent mailbox: {error}"))
});
// The daemon runs on its own thread; a bootstrap refusal or a bind
// failure ends it within moments. Report that instead of "started".
std::thread::sleep(std::time::Duration::from_millis(750));
if mailbox.is_finished() {
let error = mailbox.join().err().map_or_else(|| "mailbox exited".to_owned(), |e| e.to_string());
fail(&format!("mail4agent mailbox: {error}"));
}
tracing::info!("node kit mailbox started");
}
#[cfg(feature = "wireguard")]
if let Some(wg) = hatchery_node::node_wg_from_env().unwrap_or_else(|error| fail(&error)) {
tracing::info!(
interface = %wg.interface_name,
listen_port = wg.listen_port,
dials_peer = wg.peer_endpoint.is_some(),
"bringing up kernel wireguard node<->c2 peer"
);
hatchery_node::bring_up_node_wireguard(&wg)
.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());
}
}
/// 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}"))
}
/// Check PATH for claude/codex/kimi/grok. Download only missing packages.
/// Never runs provider login or auth flows. Off unless `--install-provider-clis`.
fn install_missing_provider_clis() -> Result<(), String> {
use std::process::Command;
// Prefer the deploy script when present (Docker image copies it to sbin).
let script_candidates = [
PathBuf::from("/usr/local/sbin/install-provider-clis"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("deploy/node/install-provider-clis.sh"),
];
for script in &script_candidates {
if script.is_file() {
let status = Command::new(script)
.env("INSTALL_PROVIDER_CLIS", "1")
.status()
.map_err(|error| format!("install-provider-clis: {error}"))?;
if status.success() {
return Ok(());
}
return Err(format!(
"install-provider-clis exited with {status}"
));
}
}
// Fallback: inline check + npm for claude/codex only.
for (name, npm_pkg) in [
("claude", Some("@anthropic-ai/claude-code")),
("codex", Some("@openai/codex")),
("kimi", None),
("grok", None),
] {
let present = Command::new("sh")
.args(["-c", &format!("command -v {name}")])
.status()
.map(|status| status.success())
.unwrap_or(false);
if present {
tracing::info!(provider = name, "provider CLI already present");
continue;
}
let Some(pkg) = npm_pkg else {
return Err(format!(
"install-provider-clis: {name} is missing and has no default package download; set PROVIDER_CLI_INSTALL"
));
};
tracing::info!(provider = name, package = pkg, "downloading missing provider CLI");
let status = Command::new("npm")
.args(["install", "-g", pkg])
.status()
.map_err(|error| format!("npm install -g {pkg}: {error}"))?;
if !status.success() {
return Err(format!("npm install -g {pkg} failed: {status}"));
}
}
Ok(())
}
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 hatchery_node::NodeServerError;
#[test]
fn node_history_is_disabled_without_explicit_history_root() {
assert_eq!(explicit_history_config(Vec::new()).unwrap(), None);
}
// The sample root is a Windows drive path; parsing it on unix exits the process.
#[cfg(windows)]
#[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);
}
}