diff --git a/agents/hermes/mcp-config-transaction.py b/agents/hermes/mcp-config-transaction.py index 8385edd18f9..6ef646c506a 100755 --- a/agents/hermes/mcp-config-transaction.py +++ b/agents/hermes/mcp-config-transaction.py @@ -52,6 +52,7 @@ STRICT_HASH_PATH = "/etc/nemoclaw/hermes.config-hash" GUARD_PATH = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py" ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle" +GATEWAY_PUBLIC_PORT_PATH = "/run/nemoclaw/hermes-api-port" SERVICE_MANAGER_PATH = b"/usr/local/bin/nemoclaw-start" RELOAD_TIMEOUT_SECONDS = 300 SERVER_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$") @@ -93,7 +94,91 @@ MAX_ERROR_MESSAGE_LENGTH = 512 MAX_GATEWAY_PID_RECORD_BYTES = 4096 MCP_RACE_RECOVERY_ATTEMPTS = 3 +MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES = 16 +MAX_SERVICE_MANAGER_ENVIRONMENT_BYTES = 64 * 1024 GATEWAY_INTERNAL_PORT = 18642 + + +def _parse_gateway_public_port(raw: str) -> int: + """Parse one allocated Hermes API port.""" + if re.fullmatch(r"[0-9]+", raw) is None: + raise PermissionError("Hermes API port is malformed") + try: + port = int(raw, 10) + except ValueError as error: + raise PermissionError("Hermes API port is malformed") from error + if not 8642 <= port <= 8652: + raise PermissionError("Hermes API port is outside the allocated range") + return port + + +def _root_gateway_public_port_marker() -> int | None: + """Read the root-owned API-port marker without following links.""" + no_follow = getattr(os, "O_NOFOLLOW", 0) + if not no_follow: + raise PermissionError("Hermes API port marker cannot be opened safely") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | no_follow + + try: + descriptor = os.open(GATEWAY_PUBLIC_PORT_PATH, flags) + except FileNotFoundError: + return None + except OSError as error: + raise PermissionError( + "Hermes API port marker cannot be opened safely" + ) from error + + try: + before = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or before.st_uid != 0 + or before.st_gid != 0 + or stat.S_IMODE(before.st_mode) != 0o444 + or before.st_nlink != 1 + or before.st_size <= 0 + or before.st_size > MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES + ): + raise PermissionError("Hermes API port marker is unsafe") + raw = os.read(descriptor, MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES + 1) + after = os.fstat(descriptor) + if ( + len(raw) != before.st_size + or len(raw) > MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES + or ( + before.st_dev, + before.st_ino, + before.st_mode, + before.st_uid, + before.st_gid, + before.st_nlink, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) + != ( + after.st_dev, + after.st_ino, + after.st_mode, + after.st_uid, + after.st_gid, + after.st_nlink, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ) + ): + raise PermissionError("Hermes API port marker changed while reading") + finally: + os.close(descriptor) + + try: + decoded = raw.decode("ascii").strip() + except UnicodeDecodeError as error: + raise PermissionError("Hermes API port marker is malformed") from error + return _parse_gateway_public_port(decoded) + + GATEWAY_PUBLIC_PORT = 8642 TRUSTED_HERMES_GATEWAY_LAUNCHERS = { b"/usr/local/bin/hermes.real", @@ -955,6 +1040,73 @@ def _gateway_identity() -> tuple[int, object] | None: return numeric_pid, start_time +def _read_service_manager_environment(pid: int) -> bytes: + try: + with open(f"/proc/{pid}/environ", "rb") as environment_file: + raw = environment_file.read(MAX_SERVICE_MANAGER_ENVIRONMENT_BYTES + 1) + except FileNotFoundError as error: + raise PermissionError( + "Hermes service-manager environment is unavailable" + ) from error + if len(raw) > MAX_SERVICE_MANAGER_ENVIRONMENT_BYTES: + raise PermissionError("Hermes service-manager environment is too large") + return raw + + +def _service_manager_gateway_public_port( + identity: tuple[int, object], +) -> int: + gateway_pid = identity[0] + manager_pid = _process_parent_pid(gateway_pid) + if manager_pid is None or not _is_service_manager_process(manager_pid): + raise PermissionError( + "Hermes gateway is not running under the managed service lifecycle" + ) + + environment = _read_service_manager_environment(manager_pid) + if ( + _gateway_identity() != identity + or _process_parent_pid(gateway_pid) != manager_pid + or not _is_service_manager_process(manager_pid) + ): + raise PermissionError("Hermes service-manager identity changed while reading") + + prefix = b"NEMOCLAW_HERMES_API_PORT=" + values = [ + entry[len(prefix) :] + for entry in environment.split(b"\0") + if entry.startswith(prefix) + ] + if len(values) > 1: + raise PermissionError("Hermes service-manager API port is ambiguous") + if not values or not values[0]: + return 8642 + try: + decoded = values[0].decode("ascii") + except UnicodeDecodeError as error: + raise PermissionError( + "Hermes service-manager API port is malformed" + ) from error + return _parse_gateway_public_port(decoded) + + +def _resolve_gateway_public_port() -> int: + marker_port = _root_gateway_public_port_marker() + if marker_port is not None: + return marker_port + if os.geteuid() == 0: + raise PermissionError("Hermes root API port marker is unavailable") + identity = _gateway_identity() + if identity is None: + raise PermissionError("Hermes gateway identity is unavailable") + return _service_manager_gateway_public_port(identity) + + +def _configure_gateway_public_port() -> None: + global GATEWAY_PUBLIC_PORT + GATEWAY_PUBLIC_PORT = _resolve_gateway_public_port() + + def _gateway_health_endpoint_ready(port: int, timeout_seconds: float = 2) -> bool: connection = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout_seconds) try: @@ -981,12 +1133,12 @@ def probe_timeout() -> float: if internal_timeout <= 0 or not _gateway_health_endpoint_ready( GATEWAY_INTERNAL_PORT, internal_timeout ): - return False, "waiting-for-internal-health-on-18642" + return False, "waiting-for-internal-health" public_timeout = probe_timeout() if public_timeout <= 0 or not _gateway_health_endpoint_ready( GATEWAY_PUBLIC_PORT, public_timeout ): - return False, "waiting-for-public-relay-health-on-8642" + return False, "waiting-for-public-relay-health" return True, "waiting-for-stable-replacement-identity" @@ -1012,8 +1164,8 @@ def reload_gateway() -> bool: re_kick_sent = False phase_order = { "waiting-for-replacement-identity": 0, - "waiting-for-internal-health-on-18642": 1, - "waiting-for-public-relay-health-on-8642": 2, + "waiting-for-internal-health": 1, + "waiting-for-public-relay-health": 2, "waiting-for-stable-replacement-identity": 3, } last_safe_phase = "waiting-for-replacement-identity" @@ -1053,7 +1205,7 @@ def reload_gateway() -> bool: # The managed supervisor owns the public socat relay. Once the # replacement gateway is internally healthy, another gateway # signal cannot repair that relay and only creates crash churn. - and observed_phase != "waiting-for-public-relay-health-on-8642" + and observed_phase != "waiting-for-public-relay-health" and current is not None and _gateway_has_managed_parent(current[0]) and _gateway_identity() == current @@ -1122,6 +1274,7 @@ def probe() -> dict[str, object]: """Prove the packaged helper is available without mutating config.""" if os.geteuid() != 0: _assert_non_root_lifecycle_identity() + _configure_gateway_public_port() return {"ok": True} @@ -1129,6 +1282,7 @@ def execute(action: str, payload: dict[str, object]) -> dict[str, object]: _validate_payload(action, payload) if os.geteuid() != 0: _assert_non_root_lifecycle_identity() + _configure_gateway_public_port() return apply_transaction_and_reload(action, payload) diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py index e4269e9c74f..43b7593412a 100644 --- a/agents/hermes/plugin/__init__.py +++ b/agents/hermes/plugin/__init__.py @@ -1032,6 +1032,30 @@ def _load_hermes_config(): return None +def _hermes_api_port(): + """Read the per-sandbox port the OpenAI-compatible API is exposed on. + + NemoClaw allocates this port per sandbox so two Hermes sandboxes can serve + inference on one host. The plugin normally inherits the allocated value + from the managed supervisor. The root-separated topology also publishes a + root-owned marker for processes that do not inherit that environment. + """ + raw = os.environ.get("NEMOCLAW_HERMES_API_PORT", "").strip() + if not raw: + try: + with open("/run/nemoclaw/hermes-api-port") as f: + raw = f.read().strip() + except OSError: + return 8642 + if re.fullmatch(r"[0-9]+", raw) is None: + return 8642 + try: + port = int(raw) + except ValueError: + return 8642 + return port if 8642 <= port <= 8652 else 8642 + + def _get_sandbox_info(): """Gather sandbox status information.""" hermes_cfg = _load_hermes_config() @@ -1052,10 +1076,11 @@ def _get_sandbox_info(): provider = nemoclaw_cfg.get("provider", provider) # Check gateway health + api_port = _hermes_api_port() gateway_ok = False try: result = subprocess.run( - ["curl", "-sf", "http://localhost:8642/health"], + ["curl", "-sf", f"http://localhost:{api_port}/health"], capture_output=True, text=True, timeout=5, @@ -1072,7 +1097,7 @@ def _get_sandbox_info(): "provider": provider, "base_url": base_url, "gateway": "running" if gateway_ok else "stopped", - "port": 8642, + "port": api_port, } diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index c2d081e85e2..8bd97f10ad4 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -170,8 +170,33 @@ else fi fi -if [ "$_dashboard_port" -eq 8642 ]; then - echo "[SECURITY] Invalid Hermes dashboard port 8642 - reserved for the Hermes OpenAI-compatible API" >&2 +# The API port is a per-sandbox host resource: the host forwards the same +# number it is exposed on here, so two sandboxes on one host need two values. +# NemoClaw allocates the port and passes it in; the default keeps a sandbox +# whose create environment carries no value on the original port. +HERMES_DEFAULT_API_PORT=8642 +HERMES_API_PORT_RANGE_END=8652 +HERMES_RUNTIME_DIR=/run/nemoclaw +_api_port_raw="${NEMOCLAW_HERMES_API_PORT:-}" +if [ -z "$_api_port_raw" ]; then + PUBLIC_PORT="$HERMES_DEFAULT_API_PORT" +else + PUBLIC_PORT="$(printf '%s' "$_api_port_raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + _api_port_valid=1 + case "$PUBLIC_PORT" in + *[!0-9]* | '') _api_port_valid=0 ;; + esac + if [ "$_api_port_valid" -eq 1 ] && { [ "$PUBLIC_PORT" -lt "$HERMES_DEFAULT_API_PORT" ] || [ "$PUBLIC_PORT" -gt "$HERMES_API_PORT_RANGE_END" ]; }; then + _api_port_valid=0 + fi + if [ "$_api_port_valid" -ne 1 ]; then + echo "[SECURITY] Invalid NEMOCLAW_HERMES_API_PORT='${NEMOCLAW_HERMES_API_PORT}' - must be an integer from ${HERMES_DEFAULT_API_PORT} through ${HERMES_API_PORT_RANGE_END}" >&2 + exit 1 + fi +fi + +if [ "$_dashboard_port" -eq "$PUBLIC_PORT" ]; then + echo "[SECURITY] Invalid Hermes dashboard port ${_dashboard_port} - reserved for the Hermes OpenAI-compatible API" >&2 exit 1 fi @@ -181,7 +206,6 @@ else CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:${_dashboard_port}}" fi -PUBLIC_PORT=8642 # Hermes binds the API server to 127.0.0.1. Run it on an internal port and # use socat to expose the OpenAI-compatible API on PUBLIC_PORT. INTERNAL_PORT=18642 @@ -2888,6 +2912,59 @@ prepare_hermes_nonroot_runtime() { prepare_tirith_marker_retry || return 1 } +prepare_hermes_root_runtime_dir() { + local runtime_metadata + if [ -L "$HERMES_RUNTIME_DIR" ]; then + echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR is a symbolic link" >&2 + return 1 + fi + if [ ! -e "$HERMES_RUNTIME_DIR" ]; then + install -d -m 0755 -o root -g root -- "$HERMES_RUNTIME_DIR" || { + echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR could not be created safely" >&2 + return 1 + } + fi + if [ ! -d "$HERMES_RUNTIME_DIR" ] || [ -L "$HERMES_RUNTIME_DIR" ]; then + echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR is not a real directory" >&2 + return 1 + fi + runtime_metadata="$(stat -c '%u:%g:%a' -- "$HERMES_RUNTIME_DIR" 2>/dev/null)" || { + echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR metadata is unavailable" >&2 + return 1 + } + if [ "$runtime_metadata" != "0:0:755" ]; then + echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR must be root-owned with mode 0755" >&2 + return 1 + fi + return 0 +} + +publish_hermes_root_runtime_marker() { + local marker_name="$1" + local marker_value="$2" + local marker_path temporary_marker + case "$marker_name" in + '' | *[!A-Za-z0-9_-]*) + echo "[SECURITY] Refusing Hermes startup because the runtime marker name is invalid" >&2 + return 1 + ;; + esac + prepare_hermes_root_runtime_dir || return 1 + marker_path="${HERMES_RUNTIME_DIR}/${marker_name}" + temporary_marker="$(mktemp "${HERMES_RUNTIME_DIR}/.${marker_name}.XXXXXX")" || { + echo "[SECURITY] Refusing Hermes startup because ${marker_path} could not be prepared" >&2 + return 1 + } + if ! printf '%s\n' "$marker_value" >"$temporary_marker" \ + || ! chown root:root "$temporary_marker" \ + || ! chmod 0444 "$temporary_marker" \ + || ! mv -f -- "$temporary_marker" "$marker_path"; then + rm -f -- "$temporary_marker" + echo "[SECURITY] Refusing Hermes startup because ${marker_path} could not be published atomically" >&2 + return 1 + fi +} + prepare_hermes_root_runtime() { verify_hermes_config_integrity || return 1 ensure_hermes_config_root_mode || return 1 @@ -3285,10 +3362,12 @@ fi # add when the root-lifecycle marker identifies the legacy topology. # removalCondition: remove this marker stamp when OpenShell unifies the topology # or exposes an attested execution-identity capability. -install -d -m 0755 -o root -g root /run/nemoclaw -printf '%s\n' 'root-separated' >/run/nemoclaw/hermes-root-lifecycle -chown root:root /run/nemoclaw/hermes-root-lifecycle -chmod 0444 /run/nemoclaw/hermes-root-lifecycle +publish_hermes_root_runtime_marker hermes-root-lifecycle root-separated || exit 1 + +# SECURITY: publish the resolved API port as a root-owned read-only marker. +# Root-separated helpers read this marker. The temporary file receives its +# final ownership and mode before one atomic rename replaces any stale entry. +publish_hermes_root_runtime_marker hermes-api-port "$PUBLIC_PORT" || exit 1 # SECURITY: Protect gateway log from sandbox user tampering prepare_restricted_log /tmp/gateway.log gateway:gateway 600 diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 474c485bf0e..c92a94651ac 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -15,7 +15,7 @@ "src/lib/cli/nemoclaw-oclif-command.ts": 106, "src/lib/cli/terminal-style.ts": 44, "src/lib/core/json-types.ts": 37, - "src/lib/core/ports.ts": 87, + "src/lib/core/ports.ts": 88, "src/lib/core/shell-quote.ts": 26, "src/lib/core/url-utils.ts": 28, "src/lib/core/wait.ts": 35, @@ -27,7 +27,7 @@ "src/lib/runner.ts": 88, "src/lib/security/redact.ts": 51, "src/lib/state/onboard-session.ts": 36, - "src/lib/state/registry.ts": 97, + "src/lib/state/registry.ts": 99, "src/lib/state/state-root.ts": 21, "src/lib/subprocess-env.ts": 24, "src/lib/validation.ts": 25 @@ -43,7 +43,7 @@ "src/lib/actions/sandbox/policy-channel.ts": 29, "src/lib/actions/sandbox/process-recovery.ts": 21, "src/lib/actions/sandbox/rebuild-pipeline.ts": 28, - "src/lib/actions/sandbox/snapshot.ts": 39, + "src/lib/actions/sandbox/snapshot.ts": 40, "src/lib/actions/uninstall/run-plan.ts": 26, "src/lib/inference/onboard-probes.ts": 20, "src/lib/inference/vllm.ts": 21, @@ -55,7 +55,7 @@ }, "allowedCycles": [], "maxRootFiles": { - "src/lib/onboard": 307, + "src/lib/onboard": 308, "src/lib/actions": 19, "src/lib/actions/sandbox": 182, "src/lib/state": 37, diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 787ba05d16f..ae0ba116f31 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,7 +6,7 @@ "src/lib/inference/nim.test.ts": 2068, "src/lib/onboard/preflight.test.ts": 1904, "test/generate-openclaw-config.test.ts": 1915, - "test/install-preflight.test.ts": 3908, + "test/install-preflight.test.ts": 3860, "test/nemoclaw-start.test.ts": 4791, "test/onboard-messaging.test.ts": 2036, "test/onboard-selection.test.ts": 4767 diff --git a/docs/deployment/deploy-to-headless-server.mdx b/docs/deployment/deploy-to-headless-server.mdx index f6a229e1ea2..cb8b198552f 100644 --- a/docs/deployment/deploy-to-headless-server.mdx +++ b/docs/deployment/deploy-to-headless-server.mdx @@ -289,14 +289,17 @@ Print the Hermes dashboard URL: $$nemoclaw headless-agent dashboard-url --quiet ``` -The Hermes OpenAI-compatible API uses the loopback forward on port `8642`. +The Hermes OpenAI-compatible API uses the loopback forward on the sandbox's own API port, which onboarding allocates from `8642` through `8652`. +Run `openshell forward list`, read the port on the `headless-agent` row, and set `API_PORT` to it. For a Hermes sandbox, `gateway-token` is agent-aware and retrieves `API_SERVER_KEY` through the registered `bearer_token` web-auth contract. -Use it as a bearer token, then clear the shell variable: +Use it as a bearer token, then clear the shell variables: ```bash +API_PORT=8642 TOKEN=$($$nemoclaw headless-agent gateway-token --quiet) curl -fsS -H "Authorization: Bearer $TOKEN" \ - "http://127.0.0.1:8642/v1/models" + "http://127.0.0.1:$API_PORT/v1/models" +unset API_PORT unset TOKEN ``` diff --git a/docs/deployment/set-up-mcp-bridge.mdx b/docs/deployment/set-up-mcp-bridge.mdx index 8d94597d735..227220b9749 100644 --- a/docs/deployment/set-up-mcp-bridge.mdx +++ b/docs/deployment/set-up-mcp-bridge.mdx @@ -149,7 +149,7 @@ The validated transaction helper runs as a one-shot ordinary `openshell sandbox It runs as the normal sandbox identity, rejects the legacy root-separated runtime topology, validates the gateway PID and launcher before signaling it, updates the managed compatibility hash, verifies loopback health, and rolls back config and hashes if reload fails. Within the five-minute reload deadline, if the first signal has not converged after half the budget, the helper may send one additional `SIGUSR1` only after revalidating the gateway identity and managed parent. -Success requires a replacement gateway identity, healthy loopback endpoints on internal port `18642` and public port `8642`, and a stable final identity. +Success requires a replacement gateway identity, healthy loopback endpoints on internal port `18642` and the sandbox's public API port, and a stable final identity. There is no host listener, persistent control socket, MCP relay, or service for this operation. The command carries no MCP traffic or raw service credential, and its payload contains only the endpoint definition and OpenShell placeholder. diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index fc6b53c1f9b..b51f352af41 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -128,6 +128,9 @@ Use these details when your first-run path needs more control. Hermes forwards its dashboard on port `18789` and its OpenAI-compatible API on port `8642`. + Those two ports belong to the first Hermes sandbox on a host. + For a later Hermes sandbox, NemoClaw allocates the next free dashboard port from `18789` through `18799` and the next free API port from `8642` through `8652`. + Run `openshell forward list` to read the host bind for each of that sandbox's forwards. For a remote dashboard origin or tunnel, set `CHAT_UI_URL` to the externally reachable dashboard origin before onboarding. ```bash @@ -242,6 +245,7 @@ Use these details when your first-run path needs more control. This verification reports a warning instead of aborting onboarding when the configuration or egress path needs attention. Hermes exposes its browser dashboard on port `18789` and forwards its OpenAI-compatible API on port `8642` for local clients. + When either port is taken, NemoClaw allocates the next free port in that port's range, so the examples below use the first sandbox's ports. The dashboard assets are built into the sandbox image, so the dashboard starts without running `npm` as the sandbox user under `/opt/hermes`. Dashboard chat uses the prebuilt `/opt/hermes/ui-tui` bundle. To recover the dashboard manually, use `hermes dashboard --tui --skip-build` so recovery does not try to rebuild assets under root-owned installation paths. diff --git a/docs/manage-sandboxes/run-sandboxes.mdx b/docs/manage-sandboxes/run-sandboxes.mdx index d19c5eb2057..ac6dde2be48 100644 --- a/docs/manage-sandboxes/run-sandboxes.mdx +++ b/docs/manage-sandboxes/run-sandboxes.mdx @@ -50,7 +50,8 @@ When the default port is already held by another sandbox, `$$nemoclaw onboard` s -When the default API port is already held by another sandbox, `$$nemoclaw onboard` scans for the next free port and records it for the sandbox. +Each Hermes sandbox also needs its own OpenAI-compatible API port. +When the default API port `8642` is already held by another sandbox, `$$nemoclaw onboard` scans ports `8642` through `8652`, uses the next free port, and records it for the sandbox. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 2fd0fbdf684..a362daa1e01 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -700,7 +700,8 @@ Use `--control-ui-port ` to choose the host dashboard port for a sandbox. The value must be an integer from `1024` through `65535`. This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the previous registry value, and the default port. -For Hermes sandboxes, do not use port `8642`; NemoClaw reserves it for the Hermes OpenAI-compatible API and rejects it as a dashboard port before sandbox creation. +Do not use a port from `8642` through `8652` for any agent. +NemoClaw allocates each Hermes sandbox's OpenAI-compatible API port from that range, so it rejects every port in the range as a dashboard port before sandbox creation. If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`). Socket Mode requires both tokens. @@ -1213,6 +1214,8 @@ Host-side validation runs before the sandbox dispatch: The `agent` wrapper rejects Hermes sandboxes with guidance for the Hermes HTTP API. Hermes sandboxes expose an OpenAI-compatible API on port `8642` inside the sandbox, so non-interactive use does not need a wrapper command. +A second Hermes sandbox on the same host receives the next free port from `8642` through `8652`. +Run `openshell forward list` to read the host bind for each of that sandbox's forwards. Forward the port and POST chat completions directly: @@ -1913,8 +1916,8 @@ nemohermes my-assistant dashboard-url nemohermes my-assistant dashboard-url --quiet ``` -The Hermes OpenAI-compatible API remains separate on port `8642` and uses `/v1` for OpenAI-compatible clients. -Use `nemohermes my-assistant status` to see both the dashboard and API endpoints. +The Hermes OpenAI-compatible API is separate and serves `/v1` on a per-sandbox port that defaults to `8642`. +Run `openshell forward list` to read the host bind for the dashboard and API forwards. @@ -4084,7 +4087,7 @@ The experimental voice gateway has no Hermes or Deep Agents Code equivalent. NemoClaw reads the following environment variables to configure service ports, onboarding behavior, and lifecycle defaults. Set them before running `$$nemoclaw onboard` or any command that starts services. -All ports must be non-privileged integers between 1024 and 65535. +All ports must be non-privileged integers between 1024 and 65535, unless a variable's own description gives a narrower range. ### CLI Logging @@ -4175,12 +4178,18 @@ For OpenClaw, `NEMOCLAW_DASHBOARD_PORT` controls the OpenClaw dashboard forward. For Hermes, `NEMOCLAW_DASHBOARD_PORT` controls the built-in dashboard forward, which defaults to `18789`. -The Hermes OpenAI-compatible API remains separate on port `8642` and uses `/v1` for API clients. +The OpenAI-compatible API is separate and serves `/v1` on a per-sandbox port that defaults to `8642`. +If `8642` is already held by another sandbox or by a non-OpenShell host listener, onboarding scans `8642` through `8652` and uses the next free API port. +Set `NEMOCLAW_HERMES_API_PORT=` before you onboard a new sandbox to pin a port from `8642` through `8652`. +Onboarding does not check that a pinned port is free, so when another listener holds it the sandbox gets no API forward and onboarding warns that it could not start the forward. +The sandbox relay binds the port when the sandbox starts, so set this variable for an existing sandbox only together with `--recreate-sandbox`. +For an existing sandbox, a different value without `--recreate-sandbox` exits before the host forward changes. Set `NEMOCLAW_HERMES_DASHBOARD_TUI=1` only when you want Hermes' optional in-browser TUI tab. | Variable | Default | Service | |----------|---------|---------| | `NEMOCLAW_DASHBOARD_PORT` | 18789 | Hermes built-in dashboard forward port | +| `NEMOCLAW_HERMES_API_PORT` | 8642 | Hermes OpenAI-compatible API forward port | | `NEMOCLAW_HERMES_DASHBOARD_TUI` | 0 | Optional Hermes in-browser TUI tab | diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 02e2656e25e..18b553003d7 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -3166,7 +3166,8 @@ After the rebuild, make the intended change through a supported command such as ### Port 8642 in a browser shows a blank page or `Cannot GET /` -`nemohermes onboard` forwards port `8642`, but Hermes serves an OpenAI-compatible API at that port, not a chat dashboard. +`nemohermes onboard` forwards the sandbox's API port, which is `8642` for the first Hermes sandbox on a host. +Hermes serves an OpenAI-compatible API at that port, not a chat dashboard. A browser visit to `http://127.0.0.1:8642/` (or any non-API path) returns nothing renderable. Confirm the agent is healthy with the API health endpoint instead: @@ -3196,7 +3197,13 @@ openshell forward list # shows the host bind for each forw curl -sf http://127.0.0.1:8642/health # confirms the relayed endpoint answers ``` -If `openshell forward list` does not show port `8642`, run `nemohermes connect --probe-only` (or `nemohermes recover`) to ask the recovery path to re-establish every manifest-declared agent forward port that has gone missing. +If `openshell forward list` does not show the sandbox's API port, run `nemohermes connect --probe-only` (or `nemohermes recover`) to ask the recovery path to re-establish every manifest-declared agent forward port that has gone missing. +Recovery targets each sandbox's own ports. +A second Hermes sandbox on the same host receives the next free API port, so check which sandbox owns each row before assuming a missing `8642` row belongs to the sandbox you are debugging. +A Hermes sandbox onboarded before the API port became per-sandbox carries no allocated port and keeps `8642`. +A Hermes sandbox created after that change receives its own port during onboarding, so a second sandbox needs no further action. +To move a sandbox that predates the change onto its own port, set `NEMOCLAW_HERMES_API_PORT=` and rerun onboarding with `--recreate-sandbox`. +A recreate keeps the sandbox's registry row, so `--recreate-sandbox` without the variable keeps the recorded port. ### `nemohermes` reports `Sandbox 'X' already exists as OpenClaw` diff --git a/docs/security/credential-rotation.mdx b/docs/security/credential-rotation.mdx index 842f183e475..7d83513d714 100644 --- a/docs/security/credential-rotation.mdx +++ b/docs/security/credential-rotation.mdx @@ -216,10 +216,16 @@ $$nemoclaw agent --session-id credential-check \ Verify an inference key by forwarding the Hermes API and making a chat-completions request with the onboarded model. +A Hermes sandbox can serve its OpenAI-compatible API on a port other than `8642`. +The first Hermes sandbox on a host normally uses `8642`. +Run `openshell forward list` and find the rows for ``. +Select the row whose port is from `8642` through `8652`, not the dashboard row. +Replace `` below with that API port. + ```bash TOKEN=$($$nemoclaw gateway-token --quiet) -openshell forward start --background 8642 -curl -sN http://127.0.0.1:8642/v1/chat/completions \ +openshell forward start --background +curl -sN http://127.0.0.1:/v1/chat/completions \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"model":"","messages":[{"role":"user","content":"Reply with credential-check-ok"}],"stream":false}' diff --git a/scripts/checks/vitest-project-overlap.mts b/scripts/checks/vitest-project-overlap.mts index 48eb3926f05..340400860d6 100644 --- a/scripts/checks/vitest-project-overlap.mts +++ b/scripts/checks/vitest-project-overlap.mts @@ -43,6 +43,7 @@ const INSTALLER_INTEGRATION_TESTS = new Set([ "test/install-clone-ref.test.ts", "test/install-express-prompt.test.ts", "test/install-express-wsl-ollama.test.ts", + "test/install-hermes-forward-restore.test.ts", "test/install-managed-cli-reuse.test.ts", "test/install-openshell-version-pin.test.ts", "test/install-openshell-version-check.test.ts", diff --git a/scripts/install.sh b/scripts/install.sh index 4566500864a..cdee4dab6e8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -462,6 +462,48 @@ resolve_onboarded_agent() { fi } +# Read the API port the named sandbox was registered with. Each Hermes sandbox +# allocates its own, so the forward must target this sandbox's port rather than +# the default a sibling sandbox may already hold. +resolve_hermes_api_port() { + local registry_file + registry_file="$(nemoclaw_state_dir)/sandboxes.json" + if [[ ! -f "$registry_file" ]] || ! command_exists node; then + return 1 + fi + node -e ' + const fs = require("fs"); + try { + const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const sandboxes = data.sandboxes; + const name = process.argv[2]; + if ( + !sandboxes || + typeof sandboxes !== "object" || + Array.isArray(sandboxes) || + !Object.prototype.hasOwnProperty.call(sandboxes, name) + ) { + throw new Error("sandbox state is unavailable"); + } + const entry = sandboxes[name]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error("sandbox state is malformed"); + } + if (!Object.prototype.hasOwnProperty.call(entry, "hermesApiPort")) { + process.stdout.write("8642"); + } else { + const port = entry.hermesApiPort; + if (!Number.isInteger(port) || port < 8642 || port > 8652) { + throw new Error("Hermes API port is malformed"); + } + process.stdout.write(String(port)); + } + } catch { + process.exitCode = 1; + } + ' "$registry_file" "$1" 2>/dev/null +} + restore_onboard_forward_after_post_checks() { local sandbox_name agent_name agent_display port openshell_bin openshell_dir attempt selected_state_dir state_dir pid_file watcher_script watcher_pid sandbox_name="$(resolve_default_sandbox_name)" @@ -469,7 +511,11 @@ restore_onboard_forward_after_post_checks() { agent_display="$(agent_display_name "$agent_name")" case "$agent_name" in - hermes) port=8642 ;; + hermes) + if ! port="$(resolve_hermes_api_port "$sandbox_name")"; then + error "Could not restore the Hermes forward because the registered API port for sandbox '$sandbox_name' is unavailable or invalid." + fi + ;; *) return 0 ;; esac diff --git a/scripts/managed-gateway-control.py b/scripts/managed-gateway-control.py index 6c698ee3a88..efe81c23bca 100755 --- a/scripts/managed-gateway-control.py +++ b/scripts/managed-gateway-control.py @@ -1121,6 +1121,30 @@ def _openclaw_port(reader: ProcReader, supervisor: ProcessIdentity) -> int: return port +def _hermes_api_port(reader: ProcReader, supervisor: ProcessIdentity) -> int: + """Resolve the public API port from the managed supervisor environment. + + The host fixes this environment when it creates the sandbox. Reading it + through the pinned supervisor identity binds lifecycle health to a source + the same-UID sandbox user cannot replace with a filesystem entry. + """ + environment = _parse_environment( + reader.read_stable_file(supervisor, "environ", MAX_ENV_BYTES) + ) + raw = environment.get("NEMOCLAW_HERMES_API_PORT", "").strip() + if not raw: + return 8642 + if re.fullmatch(r"[0-9]+", raw) is None: + raise ControlError("GATEWAY_UNSAFE_CONFIG_PATH") + try: + port = int(raw, 10) + except ValueError as exc: + raise ControlError("GATEWAY_UNSAFE_CONFIG_PATH") from exc + if port < 8642 or port > 8652: + raise ControlError("GATEWAY_UNSAFE_CONFIG_PATH") + return port + + def _agent_spec( name: str, reader: ProcReader, supervisor: ProcessIdentity ) -> AgentSpec: @@ -1128,7 +1152,7 @@ def _agent_spec( return AgentSpec( name="hermes", port=18642, - readiness_checks=((8642, "/health"),), + readiness_checks=((_hermes_api_port(reader, supervisor), "/health"),), ) return AgentSpec(name="openclaw", port=_openclaw_port(reader, supervisor)) diff --git a/src/commands/sandbox/agent.ts b/src/commands/sandbox/agent.ts index 0324064d36a..db0352b4ea5 100644 --- a/src/commands/sandbox/agent.ts +++ b/src/commands/sandbox/agent.ts @@ -10,7 +10,7 @@ export default class SandboxAgentCommand extends NemoClawCommand { static strict = false; static summary = "Run one agent turn non-interactively in a sandbox"; static description = - "Pass through to the sandbox's registered agent command via `openshell sandbox exec`. OpenClaw sandboxes run `openclaw agent`; terminal-runtime sandboxes run their manifest-declared interactive command, such as `dcode` for LangChain Deep Agents Code. Normal turns stream the agent's response without owning a TTY; top-level OpenClaw `--json` uses a captured path that preserves JSON stdout and appends provenance to stderr. Useful for driving the sandbox from another process (CI job, multi-agent platform, evaluation harness). Hermes sandboxes exit non-zero with a redirect to the OpenAI-compatible API on port 8642 inside the sandbox."; + "Pass through to the sandbox's registered agent command via `openshell sandbox exec`. OpenClaw sandboxes run `openclaw agent`; terminal-runtime sandboxes run their manifest-declared interactive command, such as `dcode` for LangChain Deep Agents Code. Normal turns stream the agent's response without owning a TTY; top-level OpenClaw `--json` uses a captured path that preserves JSON stdout and appends provenance to stderr. Useful for driving the sandbox from another process (CI job, multi-agent platform, evaluation harness). Hermes sandboxes exit non-zero with a redirect to the OpenAI-compatible API inside the sandbox."; static usage = [" [agent-flags...]"]; static examples = [ '<%= config.bin %> sandbox agent alpha --agent work -m "Summarise README.md"', diff --git a/src/lib/actions/sandbox/agent/passthrough-help.ts b/src/lib/actions/sandbox/agent/passthrough-help.ts index 967c77ded90..b1045ce518e 100644 --- a/src/lib/actions/sandbox/agent/passthrough-help.ts +++ b/src/lib/actions/sandbox/agent/passthrough-help.ts @@ -38,6 +38,8 @@ export function printAgentPassthroughHelp(): void { console.log(" upstream command help from inside the sandbox."); console.log(""); console.log(" Hermes sandboxes are rejected with a"); - console.log(" redirect to the OpenAI-compatible API on port 8642 inside the sandbox."); + console.log(" redirect to the OpenAI-compatible API inside the sandbox."); + console.log(" The rejection message names the sandbox's API port."); + console.log(" Run `openshell forward list` to read its host bind."); console.log(""); } diff --git a/src/lib/actions/sandbox/agent/passthrough.test.ts b/src/lib/actions/sandbox/agent/passthrough.test.ts index 5996e0f0ba0..7c01fe7876e 100644 --- a/src/lib/actions/sandbox/agent/passthrough.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough.test.ts @@ -12,6 +12,7 @@ const getSandboxMock = vi.hoisted(() => () => null as { agent?: string | null; + hermesApiPort?: number | null; provider?: string | null; model?: string | null; endpointUrl?: string | null; @@ -123,6 +124,19 @@ describe("runAgentPassthrough", () => { expect(writes.join("")).toMatch(/port 8642/); }); + it("redirects to the sandbox's own API port rather than the default (#8543)", async () => { + getSandboxMock.mockReturnValue({ agent: "hermes", hermesApiPort: 8643 }); + const { writes, proc } = makeProcMock(); + await expect( + runAgentPassthrough("beta", { extraArgs: ["-m", "hi"] }, { process: proc }), + ).rejects.toThrow("__exit:2"); + const stderr = writes.join(""); + expect(stderr).toMatch(/port 8643/); + expect(stderr).toMatch(/openshell forward start --background 8643 beta/); + expect(stderr).toMatch(/http:\/\/127\.0\.0\.1:8643\/v1\/chat\/completions/); + expect(stderr).not.toMatch(/8642/); + }); + it("forwards extraArgs verbatim to `openclaw agent` for OpenClaw sandboxes with --no-tty enforced", async () => { const execNonJson = vi.fn(((): never => { throw new Error("__exit:0"); diff --git a/src/lib/actions/sandbox/agent/passthrough.ts b/src/lib/actions/sandbox/agent/passthrough.ts index b0fad34ef3a..a29afed9eec 100644 --- a/src/lib/actions/sandbox/agent/passthrough.ts +++ b/src/lib/actions/sandbox/agent/passthrough.ts @@ -15,7 +15,7 @@ import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:ch // Forwarding to `openclaw agent` against a non-OpenClaw sandbox triggers // an in-sandbox binary that does not exist (or exists with incompatible // flags), and would silently bypass the host-side guard intended to -// redirect Hermes callers to the OpenAI-compatible API on port 8642. +// redirect Hermes callers to the sandbox's OpenAI-compatible API port. // - Source boundary: the registry and agent manifest allowlist are // NemoClaw-owned. The in-sandbox invocation, its argv contract, and its // streaming behaviour are owned by the selected upstream agent command. @@ -105,6 +105,7 @@ import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:ch import { type AgentDefinition, isTerminalAgent, listAgents, loadAgent } from "../../../agent/defs"; import { CLI_NAME } from "../../../cli/branding"; +import { resolveSandboxHermesApiPort } from "../../../onboard/hermes-api-port"; import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit"; import { parseSandboxPhase } from "../../../state/gateway"; import * as registry from "../../../state/registry"; @@ -117,8 +118,8 @@ import { import { ensureLiveSandboxOrExit } from "../gateway-state"; import { hasAgentPassthroughHelpToken, printAgentPassthroughHelp } from "./passthrough-help"; import { - defaultGetOpenshellBinary, type AgentJsonPassthroughProcess, + defaultGetOpenshellBinary, runAgentJsonPassthrough, } from "./passthrough-json"; import { OLLAMA_LOCAL_PROVIDER, runOllamaRestartRecovery } from "./passthrough-ollama-recovery"; @@ -279,11 +280,14 @@ function rejectNonOpenclawAgent( proc.stderr.write( ` The \`sandbox agent\` wrapper cannot dispatch to sandbox '${sandboxName}' because it runs '${agent}'.\n`, ); - proc.stderr.write(" Hermes exposes an OpenAI-compatible API on port 8642 inside the sandbox;\n"); + const apiPort = resolveSandboxHermesApiPort(registry.getSandbox(sandboxName) ?? {}); + proc.stderr.write( + ` Hermes exposes an OpenAI-compatible API on port ${apiPort} inside the sandbox;\n`, + ); proc.stderr.write( - ` forward it with 'openshell forward start --background 8642 ${sandboxName}'\n`, + ` forward it with 'openshell forward start --background ${apiPort} ${sandboxName}'\n`, ); - proc.stderr.write(" and POST to http://127.0.0.1:8642/v1/chat/completions instead.\n"); + proc.stderr.write(` and POST to http://127.0.0.1:${apiPort}/v1/chat/completions instead.\n`); return proc.exit(2); } diff --git a/src/lib/actions/sandbox/forward-recovery-declared-ports.test.ts b/src/lib/actions/sandbox/forward-recovery-declared-ports.test.ts new file mode 100644 index 00000000000..0b0cf5f7363 --- /dev/null +++ b/src/lib/actions/sandbox/forward-recovery-declared-ports.test.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + captureOpenshell: vi.fn(), + runOpenshell: vi.fn((_args: string[], _options?: unknown) => ({ status: 0 })), + getSessionAgent: vi.fn(), + getSandbox: vi.fn(), + getHermesDashboardRecoveryConfig: vi.fn(() => null), + isLocalForwardReachable: vi.fn(() => true), +})); + +vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: mocks.captureOpenshell, + runOpenshell: mocks.runOpenshell, + isCommandTimeout: () => false, +})); + +vi.mock("../../agent/runtime", () => ({ + getSessionAgent: mocks.getSessionAgent, + hasGatewayRuntime: () => true, +})); + +vi.mock("../../state/registry", () => ({ + getSandbox: mocks.getSandbox, +})); + +vi.mock("./hermes-dashboard-recovery", () => ({ + getHermesDashboardRecoveryConfig: mocks.getHermesDashboardRecoveryConfig, + ensureHermesDashboardPortForwardIfEnabled: vi.fn(() => null), +})); + +vi.mock("./forward-health", async (importOriginal) => ({ + ...(await importOriginal()), + isLocalForwardReachable: mocks.isLocalForwardReachable, +})); + +const HERMES_AGENT = { forward_ports: [18789, 8642], forwardPort: 18789 }; + +function forwardList(rows: string[]): { status: number; output: string } { + return { + status: 0, + output: ["SANDBOX BIND PORT PID STATUS", ...rows].join("\n"), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.runOpenshell.mockReturnValue({ status: 0 }); + mocks.isLocalForwardReachable.mockReturnValue(true); + mocks.getHermesDashboardRecoveryConfig.mockReturnValue(null); + mocks.getSessionAgent.mockReturnValue(HERMES_AGENT); +}); + +describe("ensureDeclaredAgentForwardPortsHealthy", () => { + it("does not demand the manifest dashboard port from a sandbox that owns a different dashboard port (#8543)", async () => { + mocks.getSandbox.mockReturnValue({ + agent: "hermes", + dashboardPort: 18790, + hermesApiPort: 8643, + }); + mocks.captureOpenshell.mockReturnValue( + forwardList([ + "alpha 127.0.0.1 18789 101 running", + "alpha 127.0.0.1 8642 102 running", + "beta 127.0.0.1 8643 103 running", + ]), + ); + const { ensureDeclaredAgentForwardPortsHealthy } = await import("./forward-recovery"); + expect(ensureDeclaredAgentForwardPortsHealthy("beta", 18790)).toBe(true); + expect(mocks.runOpenshell).not.toHaveBeenCalled(); + }); + + it("recovers the sandbox's own API port rather than the sibling sandbox's (#8543)", async () => { + // The forward never appears in the list, so skip the settle waits and let + // the call fail fast; this asserts which port recovery targets, not that it + // converges. + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + mocks.isLocalForwardReachable.mockReturnValue(false); + mocks.getSandbox.mockReturnValue({ + agent: "hermes", + dashboardPort: 18790, + hermesApiPort: 8643, + }); + mocks.captureOpenshell.mockReturnValue( + forwardList(["alpha 127.0.0.1 18789 101 running", "alpha 127.0.0.1 8642 102 running"]), + ); + const { ensureDeclaredAgentForwardPortsHealthy } = await import("./forward-recovery"); + ensureDeclaredAgentForwardPortsHealthy("beta", 18790); + const startedPorts = mocks.runOpenshell.mock.calls + .map(([args]) => args) + .filter((args) => args[0] === "forward" && args[1] === "start") + .map((args) => args[3]); + expect(startedPorts).toContain("8643"); + expect(startedPorts).not.toContain("8642"); + }); + + it("keeps the default API port for a sandbox registered without one (#8543)", async () => { + mocks.getSandbox.mockReturnValue({ agent: "hermes", dashboardPort: 18789 }); + mocks.captureOpenshell.mockReturnValue( + forwardList(["beta 127.0.0.1 18789 101 running", "beta 127.0.0.1 8642 102 running"]), + ); + const { ensureDeclaredAgentForwardPortsHealthy } = await import("./forward-recovery"); + expect(ensureDeclaredAgentForwardPortsHealthy("beta", 18789)).toBe(true); + expect(mocks.runOpenshell).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index bcf4adcc366..f16e7754562 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -10,7 +10,7 @@ import { OPENSHELL_PROBE_TIMEOUT_MS, } from "../../adapters/openshell/timeouts"; import * as agentRuntime from "../../agent/runtime"; -import { DASHBOARD_PORT } from "../../core/ports"; +import { DASHBOARD_PORT, HERMES_OPENAI_API_PORT } from "../../core/ports"; import { waitUntil } from "../../core/wait"; import { getActiveMessagingHostForward } from "../../messaging/host-forward"; import { hydrateDerivedSandboxMessagingPlanFields } from "../../messaging/hydration"; @@ -18,6 +18,10 @@ import type { SandboxMessagingHostForwardPlan } from "../../messaging/manifest"; import { parseSandboxMessagingPlan } from "../../messaging/plan-validation"; import { isRemoteDashboardBindRequested } from "../../onboard/dockerfile-remote-dashboard-bind-contract"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; +import { + resolveSandboxHermesApiPort, + retargetHermesApiPortInUrl, +} from "../../onboard/hermes-api-port"; import { isWsl } from "../../platform"; import { ROOT } from "../../state/paths"; import * as registry from "../../state/registry"; @@ -112,6 +116,24 @@ export function resolveSandboxDashboardPort( return DASHBOARD_PORT; } +/** + * Resolve the health endpoint to probe inside the sandbox. + * + * Manifest probe URLs name the agent's default API port. Retarget them at this + * sandbox's own port so the probe reaches its relay rather than reporting the + * default port as unreachable. + */ +export function resolveSandboxHealthProbeUrl(sandboxName: string): string { + const agent = agentRuntime.getSessionAgent(sandboxName); + if (agent && agentRuntime.hasGatewayRuntime(agent)) { + return retargetHermesApiPortInUrl( + agentRuntime.getHealthProbeUrl(agent), + resolveSandboxHermesApiPort(registry.getSandbox(sandboxName) ?? {}), + ); + } + return `http://127.0.0.1:${resolveSandboxDashboardPort(sandboxName)}/health`; +} + /** * Tear down the host-side dashboard port-forward this sandbox created. * @@ -421,6 +443,14 @@ export function recoverMessagingHostForward( * primary dashboard port is owned by `ensureSandboxPortForward`; the * optional Hermes web dashboard port is owned by * `ensureHermesDashboardPortForwardIfEnabled`. + * + * Manifest entries name the agent's default ports, not this sandbox's. Both + * the dashboard port and the Hermes API port are per-sandbox host resources, so + * a second sandbox owns neither manifest default. Skip the manifest dashboard + * entry, which `ensureSandboxPortForward` already recovers at this sandbox's + * dashboard port, and resolve the manifest API entry against the sandbox's + * recorded API port, or recovery demands a port that belongs to a sibling + * sandbox and reports a failure the sandbox cannot repair. */ export function ensureDeclaredAgentForwardPortsHealthy( sandboxName: string, @@ -431,7 +461,12 @@ export function ensureDeclaredAgentForwardPortsHealthy( const declared = (agent as { forward_ports?: unknown }).forward_ports; if (!Array.isArray(declared) || declared.length === 0) return null; const hermesDashboard = getHermesDashboardRecoveryConfig(sandboxName); + const sandbox = registry.getSandbox(sandboxName); const skipSet = new Set([primaryPort]); + // The manifest's own primary entry is the default dashboard port. This + // sandbox's dashboard forward lives at primaryPort and is recovered by + // ensureSandboxPortForward, so the default must not be probed again. + if (isValidPort(agent.forwardPort)) skipSet.add(agent.forwardPort); if (hermesDashboard && Number.isInteger(hermesDashboard.publicPort)) { skipSet.add(hermesDashboard.publicPort); } @@ -441,14 +476,16 @@ export function ensureDeclaredAgentForwardPortsHealthy( if (typeof candidate !== "number") continue; if (!Number.isInteger(candidate) || candidate < 1024 || candidate > 65535) continue; if (skipSet.has(candidate)) continue; + const port = + candidate === HERMES_OPENAI_API_PORT ? resolveSandboxHermesApiPort(sandbox ?? {}) : candidate; sawCovered = true; - const health = isSandboxPortForwardHealthy(sandboxName, candidate); + const health = isSandboxPortForwardHealthy(sandboxName, port); if (health === true) continue; if (health === "occupied") { allHealthy = false; continue; } - if (!ensureSandboxPortForwardForPort(sandboxName, candidate)) { + if (!ensureSandboxPortForwardForPort(sandboxName, port)) { allHealthy = false; } } diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 3adc1954473..1a66befb7c0 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -38,6 +38,7 @@ import { recoverDeclaredAgentForwardPorts, recoverMessagingHostForward, resolveSandboxDashboardPort, + resolveSandboxHealthProbeUrl, } from "./forward-recovery"; import { classifyGatewayRestartFailure, @@ -119,9 +120,7 @@ function anyAuxiliaryRecovered(results: AuxiliaryRecoveryResult[]): boolean { } function getSandboxHealthProbeUrl(sandboxName: string): string { - const agent = agentRuntime.getSessionAgent(sandboxName); - if (agent && agentRuntime.hasGatewayRuntime(agent)) return agentRuntime.getHealthProbeUrl(agent); - return `http://127.0.0.1:${resolveSandboxDashboardPort(sandboxName)}/health`; + return resolveSandboxHealthProbeUrl(sandboxName); } /** diff --git a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts index 77564061353..cc73f2bf98a 100644 --- a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts @@ -9,24 +9,36 @@ import { HERMES_DASHBOARD_PORT_ENV, HERMES_DASHBOARD_TUI_ENV, } from "../../hermes-dashboard"; +import { HERMES_API_PORT_ENV } from "../../onboard/hermes-api-port"; import { resolveRebuildHermesDashboardEnv } from "./rebuild-durable-config"; import * as f from "./snapshot-restore-test-fixture"; const dashboardPortMocks = vi.hoisted(() => ({ findAvailableDashboardPort: vi.fn(() => 18901), getRegistryOccupiedDashboardPorts: vi.fn(() => new Map()), + getRegistryOccupiedHermesApiPorts: vi.fn(() => new Map()), withDashboardPortReservationLock: vi.fn(async (operation: () => unknown) => await operation()), })); +const hermesApiPortMocks = vi.hoisted(() => ({ + findAvailableHermesApiPort: vi.fn(() => 8643), +})); + vi.mock("../../onboard/dashboard-port", () => ({ findAvailableDashboardPort: dashboardPortMocks.findAvailableDashboardPort, getRegistryOccupiedDashboardPorts: dashboardPortMocks.getRegistryOccupiedDashboardPorts, + getRegistryOccupiedHermesApiPorts: dashboardPortMocks.getRegistryOccupiedHermesApiPorts, withDashboardPortReservationLock: dashboardPortMocks.withDashboardPortReservationLock, })); +vi.mock("../../onboard/hermes-api-port", async (importOriginal) => ({ + ...(await importOriginal()), + findAvailableHermesApiPort: hermesApiPortMocks.findAvailableHermesApiPort, +})); + beforeEach(f.resetSnapshotRestoreMocks); afterEach(f.cleanupSnapshotRestoreMocks); -describe("runSandboxSnapshot restore: clone dashboard port identity", () => { +describe("runSandboxSnapshot restore: clone port identity", () => { it("allocates the auto-created clone its own dashboard port instead of inheriting the source's (#6746)", async () => { let registeredClone: f.SandboxRecord | null = null; f.registerSandboxMock.mockImplementation( @@ -79,6 +91,85 @@ describe("runSandboxSnapshot restore: clone dashboard port identity", () => { ); }); + it("gives a Hermes clone its own API port instead of the source's (#8543)", async () => { + let registeredClone: f.SandboxRecord | null = null; + f.registerSandboxMock.mockImplementation( + (entry) => (registeredClone = entry as f.SandboxRecord), + ); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "hermes", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + dashboardPort: 18790, + hermesApiPort: 8642, + } + : registeredClone, + ); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); + expect(hermesApiPortMocks.findAvailableHermesApiPort).toHaveBeenCalledWith( + "beta", + undefined, + expect.any(String), + undefined, + expect.any(Map), + ); + const createArgs = f.streamSandboxCreateMock.mock.calls[0]?.[1] ?? []; + expect(createArgs).toContain(`${HERMES_API_PORT_ENV}=8643`); + expect(f.registerSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "beta", hermesApiPort: 8643 }), + ); + }); + + it("leaves a non-Hermes clone without an API port (#8543)", async () => { + let registeredClone: f.SandboxRecord | null = null; + f.registerSandboxMock.mockImplementation( + (entry) => (registeredClone = entry as f.SandboxRecord), + ); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "openclaw", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + dashboardPort: 18790, + } + : registeredClone, + ); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("idle") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); + expect(hermesApiPortMocks.findAvailableHermesApiPort).not.toHaveBeenCalled(); + const createArgs = f.streamSandboxCreateMock.mock.calls[0]?.[1] ?? []; + expect(createArgs.some((arg) => arg.startsWith(HERMES_API_PORT_ENV))).toBe(false); + expect(f.registerSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "beta", hermesApiPort: null }), + ); + }); + it("keeps a Hermes clone rebuildable with its new public port and inherited internal port (#6746)", async () => { dashboardPortMocks.findAvailableDashboardPort.mockReturnValueOnce(18902); let registeredClone: f.SandboxRecord | null = null; @@ -140,6 +231,7 @@ describe("runSandboxSnapshot restore: clone dashboard port identity", () => { `${HERMES_DASHBOARD_PORT_ENV}=18902`, `${HERMES_DASHBOARD_INTERNAL_PORT_ENV}=18901`, `${HERMES_DASHBOARD_TUI_ENV}=1`, + `${HERMES_API_PORT_ENV}=8643`, "nemoclaw-start", ]); expect(resolveRebuildHermesDashboardEnv("hermes", registeredClone as never, 18902)).toEqual({ @@ -188,6 +280,42 @@ describe("runSandboxSnapshot restore: clone dashboard port identity", () => { expect(f.registerSandboxMock).not.toHaveBeenCalled(); }); + it("aborts before deleting a --force destination when no Hermes API port is free (#8543)", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + hermesApiPortMocks.findAvailableHermesApiPort.mockImplementationOnce(() => { + throw new Error("All Hermes API ports in range 8642-8652 are occupied:"); + }); + f.getSandboxMock.mockImplementation((name) => ({ + name: name ?? "alpha", + agent: "hermes", + imageTag: `nemoclaw-${name}:test`, + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + dashboardPort: 18790, + hermesApiPort: 8642, + })); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect( + runSandboxSnapshot("alpha", { kind: "restore", to: "beta", force: true, yes: true }), + ).rejects.toMatchObject({ exitCode: 1 }); + + expect(hermesApiPortMocks.findAvailableHermesApiPort).toHaveBeenCalled(); + expect(consoleError.mock.calls.flat().join("\n")).toContain("are occupied"); + expect(f.lifecycleMock.events).not.toContain("delete"); + expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); + expect(f.registerSandboxMock).not.toHaveBeenCalled(); + }); + it("registers a clone of a source without a dashboard port with the field unset (#6746)", async () => { let registeredClone: f.SandboxRecord | null = null; f.registerSandboxMock.mockImplementation( diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 00ebf2ad7fe..9f1dee1ee83 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -30,10 +30,12 @@ import { listMessagingProviderSuffixes } from "../../messaging/channels"; import { findAvailableDashboardPort, getRegistryOccupiedDashboardPorts, + getRegistryOccupiedHermesApiPorts, withDashboardPortReservationLock, } from "../../onboard/dashboard-port"; import { isValidForwardPort } from "../../onboard/dashboard-runtime"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; +import { findAvailableHermesApiPort, HERMES_API_PORT_ENV } from "../../onboard/hermes-api-port"; import { resolveHermesDashboardOnboardState } from "../../onboard/hermes-dashboard"; import { isDcodeAgent, @@ -278,6 +280,33 @@ function allocateCloneDashboardPort( } } +// Allocate the clone's own API port. The source owns the host forward for its +// port, and the sandbox exposes the API on the same number it is forwarded on, +// so a clone that inherits the source's port gets no inference forward, and its +// gateway restart never converges. Returns null for an agent that has no +// per-sandbox API port, so the clone's field stays unset. Callers must invoke +// this before any destructive step so range exhaustion aborts before the +// mutation. +function allocateCloneHermesApiPort( + dstName: string, + srcEntry: { name?: string; agent?: string | null }, +): number | null { + if (srcEntry.agent !== "hermes") return null; + const forwards = captureOpenshell(["forward", "list"], { ignoreError: true }); + try { + return findAvailableHermesApiPort( + dstName, + undefined, + forwards.output || "", + undefined, + getRegistryOccupiedHermesApiPorts(dstName), + ); + } catch (err) { + console.error(` ${err instanceof Error ? err.message : String(err)}`); + snapshotExit(1); + } +} + function resolveCloneDashboardEnvArgs( srcEntry: SandboxEntry | { name: string }, dstDashboardPort: number | null, @@ -362,6 +391,7 @@ async function autoCreateSandboxFromSource( createPolicyPath: string, dstDashboardPort: number | null, dashboardEnvArgs: readonly string[], + dstHermesApiPort: number | null, ): Promise { const openshellBin = getOpenshellBinary(); const sourceObservabilityEnabled = @@ -370,6 +400,7 @@ async function autoCreateSandboxFromSource( "env", `NEMOCLAW_OBSERVABILITY=${sourceObservabilityEnabled ? "1" : "0"}`, ...dashboardEnvArgs, + ...(dstHermesApiPort === null ? [] : [`${HERMES_API_PORT_ENV}=${dstHermesApiPort}`]), "nemoclaw-start", ]; const createEnv = { ...process.env }; @@ -446,6 +477,8 @@ async function autoCreateSandboxFromSource( // `Sandbox GPU: enabled (CUDA verified)` based on another sandbox's run (#4231). sandboxGpuProof: null, dashboardPort: dstDashboardPort, + // The spread above carries the source's API port; the clone owns its own. + hermesApiPort: dstHermesApiPort, // The shared image keeps Hermes' image-baked internal listener port, but // the public WebUI port is a per-sandbox host resource and must follow the // clone's newly allocated dashboard port so rebuild validation converges. @@ -1288,6 +1321,7 @@ async function runSnapshotRestoreUnlocked( // removes the existing `--force` destination — matching the pre-delete // validation the image and gateway-route checks above already do (#3756). const dstDashboardPort = allocateCloneDashboardPort(targetSandbox, lockedSourceEntry); + const dstHermesApiPort = allocateCloneHermesApiPort(targetSandbox, lockedSourceEntry); const dashboardEnvArgs = resolveCloneDashboardEnvArgs(lockedSourceEntry, dstDashboardPort); const clonePolicy = await prepareSnapshotClonePolicy(lockedSourceEntry); try { @@ -1309,6 +1343,7 @@ async function runSnapshotRestoreUnlocked( clonePolicy.policyPath, dstDashboardPort, dashboardEnvArgs, + dstHermesApiPort, ); } finally { clonePolicy.cleanup?.(); diff --git a/src/lib/actions/uninstall/hermes-forward-watcher-installer.test.ts b/src/lib/actions/uninstall/hermes-forward-watcher-installer.test.ts index c9435b4d07d..c56eeb0a683 100644 --- a/src/lib/actions/uninstall/hermes-forward-watcher-installer.test.ts +++ b/src/lib/actions/uninstall/hermes-forward-watcher-installer.test.ts @@ -29,6 +29,12 @@ describe("Hermes forward watcher installer contract", () => { path.join(stateDir, "onboard-session.json"), JSON.stringify({ sandboxName: "created-by-onboard", agent: "hermes" }), ); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { "created-by-onboard": { hermesApiPort: 8643 } }, + }), + ); writeExecutable(openshell, "#!/usr/bin/env bash\nexit 0\n"); writeExecutable( path.join(fakeBin, "node"), @@ -69,4 +75,76 @@ exec ${JSON.stringify(process.execPath)} "$@" fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it.each([ + { + name: "legacy row", + registry: JSON.stringify({ sandboxes: { hermes: {} } }), + status: 0, + output: "8642", + }, + { + name: "allocated interior port", + registry: JSON.stringify({ sandboxes: { hermes: { hermesApiPort: 8645 } } }), + status: 0, + output: "8645", + }, + { + name: "missing sandbox row", + registry: JSON.stringify({ sandboxes: {} }), + status: 1, + output: "", + }, + { + name: "out-of-range port", + registry: JSON.stringify({ sandboxes: { hermes: { hermesApiPort: 9000 } } }), + status: 1, + output: "", + }, + { + name: "non-number port", + registry: JSON.stringify({ sandboxes: { hermes: { hermesApiPort: "8645" } } }), + status: 1, + output: "", + }, + { + name: "fractional numeric port", + registry: JSON.stringify({ sandboxes: { hermes: { hermesApiPort: 8645.5 } } }), + status: 1, + output: "", + }, + { + name: "malformed registry", + registry: "{", + status: 1, + output: "", + }, + ])("resolves $name without inventing recovery state", ({ registry, status, output }) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-port-state-")); + try { + const stateDir = path.join(tmp, ".nemoclaw"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "sandboxes.json"), registry); + const result = spawnSync( + "bash", + ["-c", 'source "$INSTALLER" 2>/dev/null; resolve_hermes_api_port "$SANDBOX"'], + { + cwd: REPOSITORY_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + INSTALLER, + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + SANDBOX: "hermes", + }, + }, + ); + + expect(result.status, result.stderr).toBe(status); + expect(result.stdout).toBe(output); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index b48ba579658..ee4af6d49ef 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -3,6 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +// The ready summary resolves the sandbox's API port from the registry. Stub the +// lookup so these unit tests never read the developer's real state file. +const getSandboxMock = vi.hoisted(() => + vi.fn((): { hermesApiPort?: number | null } | null => null), +); +vi.mock("../state/registry", () => ({ getSandbox: getSandboxMock })); + const mocks = vi.hoisted(() => ({ run: vi.fn(), })); @@ -134,6 +141,7 @@ describe("printDashboardUi with port 8642 outside the chat UI (#2078)", () => { beforeEach(() => { logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); noteSpy.mockReset(); + getSandboxMock.mockReturnValue(null); }); afterEach(() => { @@ -568,3 +576,98 @@ describe("collectHermesStartupDiagnostics", () => { expect(output).not.toContain(slackToken); }); }); + +describe("printDashboardUi announces per-sandbox Hermes API ports (#8543)", () => { + let logSpy: MockInstance; + const noteSpy = vi.fn(); + + const hermesShipped = makeAgent({ + name: "hermes", + displayName: "Hermes Agent", + forwardPort: 18789, + forward_ports: [18789, 8642], + healthProbe: { url: "http://localhost:8642/health", port: 8642, timeout_seconds: 90 }, + dashboard: { + kind: "ui", + label: "Dashboard", + path: "/", + healthPath: "/api/status", + auth: "session", + }, + }); + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + noteSpy.mockReset(); + getSandboxMock.mockReturnValue(null); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it("announces the sandbox's own API port instead of the manifest default", () => { + getSandboxMock.mockReturnValue({ hermesApiPort: 8643 }); + + printDashboardUi("hermes-clone", null, hermesShipped, { + note: noteSpy, + effectiveDashboardPort: 18790, + buildControlUiUrls: buildUrlsLoopback, + }); + + const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n"); + expect(output).toContain("Port 8643 must be forwarded before connecting."); + expect(output).toContain("http://127.0.0.1:8643/v1"); + expect(output).not.toContain("Port 8642 must be forwarded before connecting."); + }); + + it("announces the sandbox's own API port from an API-kind dashboard", () => { + const hermesApiDashboard = makeAgent({ + name: "hermes", + displayName: "Hermes Agent", + forwardPort: 18789, + forward_ports: [18789, 8642], + healthProbe: { url: "http://localhost:8642/health", port: 8642, timeout_seconds: 90 }, + dashboard: { + kind: "api", + label: "OpenAI-compatible API", + path: "/v1", + healthPath: "/health", + auth: "none", + }, + }); + getSandboxMock.mockReturnValue({ hermesApiPort: 8645 }); + + printDashboardUi("hermes-api-box", null, hermesApiDashboard, { + note: noteSpy, + buildControlUiUrls: buildUrlsLoopback, + }); + + const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n"); + expect(output).toContain("Hermes Agent OpenAI-compatible API"); + expect(output).toContain("Port 8645 must be forwarded before connecting."); + expect(output).toContain("http://127.0.0.1:8645/"); + expect(output).not.toContain("http://127.0.0.1:8642/"); + }); + + it("keeps the declared port for an agent that has no per-sandbox API port", () => { + getSandboxMock.mockReturnValue({ hermesApiPort: 8643 }); + const dualAgent = makeAgent({ + name: "experimental", + displayName: "Experimental", + forwardPort: 18789, + forward_ports: [18789, 9100], + healthProbe: { url: "http://localhost:9100/health", port: 9100, timeout_seconds: 30 }, + }); + + printDashboardUi("other-box", null, dualAgent, { + note: noteSpy, + effectiveDashboardPort: 18790, + buildControlUiUrls: buildUrlsLoopback, + }); + + const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n"); + expect(output).toContain("Port 9100 must be forwarded before connecting."); + expect(output).not.toContain("8643"); + }); +}); diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 48dc5337791..2b4b7b823f7 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -12,7 +12,9 @@ import { sleepSeconds } from "../core/wait"; import { getProviderSelectionConfig } from "../inference/config"; import { runSandboxConfigSync, sandboxConfigSyncArgs } from "../onboard/config-sync"; import { isValidForwardPort } from "../onboard/dashboard-runtime"; +import { resolveSandboxHermesApiPort } from "../onboard/hermes-api-port"; import { redact, run } from "../runner"; +import * as registry from "../state/registry"; import * as baseImage from "./base-image"; import { describeAgentBinaryFailure, verifyAgentBinaryAvailable } from "./binary-availability"; import { printOptionalDashboardUi } from "./dashboard-ui"; @@ -481,7 +483,7 @@ export function printDashboardUi( } printBearerTokenApiAccess(sandboxName, agent, cliName); printOptionalDashboardUi(agent, { ...deps, redactUrl: dashboardUrlForDisplay }); - printAdditionalForwardPorts(agent, info.port, deps.buildControlUiUrls); + printAdditionalForwardPorts(agent, info.port, deps.buildControlUiUrls, sandboxName); return; } @@ -497,7 +499,12 @@ export function printDashboardUi( effectiveDashboardPort, redactUrl: dashboardUrlForDisplay, }); - printAdditionalForwardPorts(agent, effectiveDashboardPort, deps.buildControlUiUrls); + printAdditionalForwardPorts( + agent, + effectiveDashboardPort, + deps.buildControlUiUrls, + sandboxName, + ); return; } @@ -522,7 +529,7 @@ export function printDashboardUi( effectiveDashboardPort, redactUrl: dashboardUrlForDisplay, }); - printAdditionalForwardPorts(agent, effectiveDashboardPort, deps.buildControlUiUrls); + printAdditionalForwardPorts(agent, effectiveDashboardPort, deps.buildControlUiUrls, sandboxName); } /** @@ -549,14 +556,25 @@ function printAdditionalForwardPorts( agent: AgentDefinition, primaryPort: number, buildControlUiUrls: (token: string | null, port: number) => string[], + sandboxName?: string, ): void { const declared = Array.isArray(agent.forward_ports) ? agent.forward_ports : []; if (declared.length === 0) return; - const apiPort = agent.healthProbe?.port; - for (const port of declared) { - if (!Number.isInteger(port) || port < 1024 || port > 65535) continue; - if (port === primaryPort || port === agent.forwardPort) continue; - const isApi = port === apiPort; + const declaredApiPort = agent.healthProbe?.port; + // The manifest names Hermes' default API port. This sandbox owns its own, so + // announce the port the operator actually has to forward. Only Hermes + // allocates a per-sandbox API port; every other agent keeps its declared one. + const sandboxApiPort = + agent.name === "hermes" + ? resolveSandboxHermesApiPort( + (sandboxName ? registry.getSandbox(sandboxName) : undefined) ?? {}, + ) + : 0; + for (const declaredPort of declared) { + if (!Number.isInteger(declaredPort) || declaredPort < 1024 || declaredPort > 65535) continue; + if (declaredPort === primaryPort || declaredPort === agent.forwardPort) continue; + const isApi = declaredPort === declaredApiPort; + const port = isApi && agent.name === "hermes" ? sandboxApiPort : declaredPort; const sectionLabel = isApi ? "OpenAI-compatible API" : "additional port"; console.log(""); console.log(` ${agent.displayName} ${sectionLabel}`); diff --git a/src/lib/core/ports.ts b/src/lib/core/ports.ts index e1ba0f4f881..a3de3361225 100644 --- a/src/lib/core/ports.ts +++ b/src/lib/core/ports.ts @@ -64,8 +64,23 @@ export const OLLAMA_PORT = parsePort("NEMOCLAW_OLLAMA_PORT", 11434); export const OLLAMA_PROXY_PORT = parsePort("NEMOCLAW_OLLAMA_PROXY_PORT", 11435); /** llama.cpp existing-server attachment port; fixed by the declarative serving contract. */ export { LLAMA_CPP_PORT }; -/** Hermes OpenAI-compatible API port (manifest `forward_ports[1]` / start.sh `PUBLIC_PORT`); reserved — never a valid dashboard port, for any agent. (#4984) */ +/** Default Hermes OpenAI-compatible API port (manifest `forward_ports[1]`; the default for start.sh `PUBLIC_PORT`). */ export const HERMES_OPENAI_API_PORT = 8642; +/** Start of the auto-allocation range for Hermes API ports (inclusive). */ +export const HERMES_API_PORT_RANGE_START = HERMES_OPENAI_API_PORT; +/** End of the auto-allocation range for Hermes API ports (inclusive). */ +export const HERMES_API_PORT_RANGE_END = 8652; + +/** + * The API port is a per-sandbox host resource: each Hermes sandbox exposes its + * OpenAI-compatible API on its own port allocated from + * `HERMES_API_PORT_RANGE_START` through `HERMES_API_PORT_RANGE_END`, so two + * sandboxes can serve inference on one host. Every port in that range is + * therefore unavailable as a dashboard port, for any agent. + */ +export function isHermesApiPort(port: number): boolean { + return port >= HERMES_API_PORT_RANGE_START && port <= HERMES_API_PORT_RANGE_END; +} /** Bedrock Runtime adapter port (default 11436, override via NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT). */ export const BEDROCK_RUNTIME_ADAPTER_PORT = parsePort( "NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT", diff --git a/src/lib/onboard/agent-dashboard-forward.test.ts b/src/lib/onboard/agent-dashboard-forward.test.ts index 45a08dec750..e323d99032c 100644 --- a/src/lib/onboard/agent-dashboard-forward.test.ts +++ b/src/lib/onboard/agent-dashboard-forward.test.ts @@ -37,6 +37,39 @@ describe("ensureAgentDashboardForward", () => { }); }); + it("forwards the sandbox's allocated API port instead of the manifest default (#8543)", () => { + const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "http://127.0.0.1:18789") => { + const parsed = new URL(chatUiUrl); + return Number(parsed.port); + }); + + expect( + ensureAgentDashboardForward({ + sandboxName: "hm", + agent: { + forwardPort: 18789, + forward_ports: [18789, 8642], + }, + ensureDashboardForward, + hermesApiPort: 8643, + preserveForwardPorts: [3978], + }), + ).toBe(18789); + + expect(ensureDashboardForward).toHaveBeenNthCalledWith(1, "hm", "http://127.0.0.1:18789", { + preserveSandboxPorts: [18789, 8643, 3978], + }); + expect(ensureDashboardForward).toHaveBeenNthCalledWith(2, "hm", "http://127.0.0.1:8643", { + preserveSandboxPorts: [18789, 8643, 3978], + allowPortReallocation: false, + }); + expect(ensureDashboardForward).not.toHaveBeenCalledWith( + "hm", + "http://127.0.0.1:8642", + expect.anything(), + ); + }); + it("keeps an explicit effective port and omits the replaced manifest default (#6277)", () => { const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "") => { return Number(new URL(chatUiUrl).port); diff --git a/src/lib/onboard/agent-dashboard-forward.ts b/src/lib/onboard/agent-dashboard-forward.ts index 786bf79d0c8..3cebf02d506 100644 --- a/src/lib/onboard/agent-dashboard-forward.ts +++ b/src/lib/onboard/agent-dashboard-forward.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { DASHBOARD_PORT } from "../core/ports"; +import { DASHBOARD_PORT, HERMES_OPENAI_API_PORT } from "../core/ports"; import { type DashboardRuntimeAgent, getAgentDeclaredForwardPorts, @@ -9,6 +9,7 @@ import { isValidForwardPort, shouldManageDashboardForAgent, } from "./dashboard-runtime"; +import { resolveOnboardHermesApiPort } from "./hermes-api-port"; export type EnsureDashboardForward = ( sandboxName: string, @@ -30,6 +31,8 @@ export function ensureAgentDashboardForward(options: { ensureDashboardForward: EnsureDashboardForward; chatUiUrl?: string; controlUiPort?: number; + /** Host port allocated to this sandbox's OpenAI-compatible API, when it has one. */ + hermesApiPort?: number | null; preserveForwardPorts?: readonly (number | null | undefined)[]; warn?: (message: string) => void; }): number { @@ -39,6 +42,7 @@ export function ensureAgentDashboardForward(options: { ensureDashboardForward, chatUiUrl, controlUiPort, + hermesApiPort, preserveForwardPorts = [], warn = (message: string) => console.warn(message), } = options; @@ -54,9 +58,15 @@ export function ensureAgentDashboardForward(options: { usesFixedApiPort && agent.dashboardUi && isValidForwardPort(controlUiPort) ? controlUiPort : null; - const declaredPorts = getAgentDeclaredForwardPorts(agent).filter( - (port) => port !== declaredPrimaryPort || port === agentDashboardPort, - ); + // The manifest names the agent's default API port. This sandbox owns its own, + // so forward the allocated port instead of the sibling sandbox's default. + const resolveDeclaredPort = (port: number): number => + port === HERMES_OPENAI_API_PORT + ? (hermesApiPort ?? resolveOnboardHermesApiPort(sandboxName, { warn })) + : port; + const declaredPorts = getAgentDeclaredForwardPorts(agent) + .filter((port) => port !== declaredPrimaryPort || port === agentDashboardPort) + .map(resolveDeclaredPort); const preservePorts = [ ...new Set([ agentDashboardPort, diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 3bf0c9f0db5..46ce6a2fdbb 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -32,6 +32,7 @@ type RunCaptureFn = typeof import("../runner").runCapture; type SandboxRegistryEntry = { name: string; dashboardPort?: number | null; + hermesApiPort?: number | null; scopeGatewayPort?: number; }; @@ -223,8 +224,9 @@ function mergeOccupiedPorts( * `listSandboxesFn` is an injectable seam for tests; production callers * leave it at the host-wide default. */ -export function getRegistryOccupiedDashboardPorts( +function getRegistryOccupiedPorts( currentSandboxName: string, + selectPort: (entry: SandboxRegistryEntry) => number | null | undefined, listSandboxesFn?: ListSandboxesFn, ): Map { const occupied = new Map(); @@ -235,6 +237,7 @@ export function getRegistryOccupiedDashboardPorts( ({ entry, gatewayPort }) => ({ name: entry.name, dashboardPort: entry.dashboardPort, + hermesApiPort: entry.hermesApiPort, scopeGatewayPort: gatewayPort, }), ), @@ -245,7 +248,7 @@ export function getRegistryOccupiedDashboardPorts( (entry.scopeGatewayPort === undefined || entry.scopeGatewayPort === GATEWAY_PORT) ) continue; - const port = entry.dashboardPort; + const port = selectPort(entry); if (typeof port !== "number" || !Number.isInteger(port) || port <= 0) continue; const owner = entry.scopeGatewayPort !== undefined && entry.scopeGatewayPort !== GATEWAY_PORT @@ -256,30 +259,81 @@ export function getRegistryOccupiedDashboardPorts( return occupied; } -export function findAvailableDashboardPort( +/** + * Cross-gateway occupancy view for dashboard ports, keyed by port. See + * {@link getRegistryOccupiedPorts} for why the registry supplements the + * per-gateway forward list. + */ +export function getRegistryOccupiedDashboardPorts( + currentSandboxName: string, + listSandboxesFn?: ListSandboxesFn, +): Map { + return getRegistryOccupiedPorts( + currentSandboxName, + (entry) => entry.dashboardPort, + listSandboxesFn, + ); +} + +/** + * Cross-gateway occupancy view for Hermes API ports. The API forward is a + * per-sandbox host resource just like the dashboard forward, so allocation + * needs the same host-wide registry view that `openshell forward list` cannot + * provide on its own. + */ +export function getRegistryOccupiedHermesApiPorts( + currentSandboxName: string, + listSandboxesFn?: ListSandboxesFn, +): Map { + return getRegistryOccupiedPorts( + currentSandboxName, + (entry) => entry.hermesApiPort, + listSandboxesFn, + ); +} + +/** A contiguous host-port range that one sandbox resource is allocated from. */ +export interface HostPortRange { + start: number; + end: number; + /** Names the resource in the range-exhausted error, e.g. "dashboard". */ + label: string; + /** Operator remedy appended to the range-exhausted error. */ + remedy: string; +} + +const DASHBOARD_RANGE: HostPortRange = { + start: DASHBOARD_PORT_RANGE_START, + end: DASHBOARD_PORT_RANGE_END, + label: "dashboard", + remedy: "Free a sandbox or use --control-ui-port with a port outside this range.", +}; + +/** + * Find the next available port in `range` for the given sandbox. Returns the + * preferred port when it is free or already owned by this sandbox, otherwise + * scans the range. Shared by the dashboard allocator and the Hermes API-port + * allocator so both apply the same forward-list, registry, and host-bind view. + */ +export function findAvailablePortInRange( sandboxName: string, preferredPort: number, forwardListOutput: string | null, + range: HostPortRange, isPortBoundCheck: (port: number) => boolean = isPortBoundOnHost, - // Default to an empty map so unit tests of this allocator do not become - // dependent on whatever sandboxes happen to live in the caller's real - // `~/.nemoclaw/sandboxes.json`. Production wrappers - // (`resolveCreateSandboxDashboardPort`, `ensureDashboardForward`) pass an - // explicit `getRegistryOccupiedDashboardPorts(sandboxName)` result. registryOccupiedPorts: ReadonlyMap = new Map(), ): number { const occupied = mergeOccupiedPorts(getOccupiedPorts(forwardListOutput), registryOccupiedPorts); const hostBoundPorts: number[] = []; - // Try the preferred port first (it may be outside the dashboard range when - // a caller passes --control-ui-port), then the rest of the range. Each port - // is probed at most once so we don't pay for `lsof` + `sudo lsof` + Node - // bind multiple times per port. + // Try the preferred port first (it may be outside the range when a caller + // passes --control-ui-port), then the rest of the range. Each port is probed + // at most once so we don't pay for `lsof` + `sudo lsof` + Node bind multiple + // times per port. const portsToScan = [ preferredPort, - ...Array.from( - { length: DASHBOARD_PORT_RANGE_END - DASHBOARD_PORT_RANGE_START + 1 }, - (_, i) => DASHBOARD_PORT_RANGE_START + i, - ).filter((p) => p !== preferredPort), + ...Array.from({ length: range.end - range.start + 1 }, (_, i) => range.start + i).filter( + (p) => p !== preferredPort, + ), ]; for (const p of portsToScan) { const pStr = String(p); @@ -292,17 +346,37 @@ export function findAvailableDashboardPort( } const ownerLines = [...occupied.entries()] - .filter( - ([p]) => Number(p) >= DASHBOARD_PORT_RANGE_START && Number(p) <= DASHBOARD_PORT_RANGE_END, - ) + .filter(([p]) => Number(p) >= range.start && Number(p) <= range.end) .map(([p, s]) => ` ${p} → ${s}`); const hostLines = hostBoundPorts - .filter((p) => p >= DASHBOARD_PORT_RANGE_START && p <= DASHBOARD_PORT_RANGE_END) + .filter((p) => p >= range.start && p <= range.end) .map((p) => ` ${p} → non-OpenShell host listener`); const lines = [...ownerLines, ...hostLines].join("\n"); throw new Error( - `All dashboard ports in range ${DASHBOARD_PORT_RANGE_START}-${DASHBOARD_PORT_RANGE_END} are occupied:\n${lines}\n` + - `Free a sandbox or use --control-ui-port with a port outside this range.`, + `All ${range.label} ports in range ${range.start}-${range.end} are occupied:\n${lines}\n` + + range.remedy, + ); +} + +export function findAvailableDashboardPort( + sandboxName: string, + preferredPort: number, + forwardListOutput: string | null, + isPortBoundCheck: (port: number) => boolean = isPortBoundOnHost, + // Default to an empty map so unit tests of this allocator do not become + // dependent on whatever sandboxes happen to live in the caller's real + // `~/.nemoclaw/sandboxes.json`. Production wrappers + // (`resolveCreateSandboxDashboardPort`, `ensureDashboardForward`) pass an + // explicit `getRegistryOccupiedDashboardPorts(sandboxName)` result. + registryOccupiedPorts: ReadonlyMap = new Map(), +): number { + return findAvailablePortInRange( + sandboxName, + preferredPort, + forwardListOutput, + DASHBOARD_RANGE, + isPortBoundCheck, + registryOccupiedPorts, ); } diff --git a/src/lib/onboard/hermes-api-port.test.ts b/src/lib/onboard/hermes-api-port.test.ts new file mode 100644 index 00000000000..cea7ee3cab1 --- /dev/null +++ b/src/lib/onboard/hermes-api-port.test.ts @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + findAvailableHermesApiPort, + HERMES_API_PORT_ENV, + readHermesApiPort, + resolveOnboardHermesApiPort, + resolveSandboxHermesApiPort, + retargetHermesApiPortInUrl, +} from "./hermes-api-port"; + +const noneBound = () => false; + +function forwardList(rows: string[]): string { + return ["SANDBOX BIND PORT PID STATUS", ...rows].join("\n"); +} + +describe("readHermesApiPort", () => { + it("falls back to the range start when unset", () => { + expect(readHermesApiPort({})).toBe(8642); + }); + + it.each([ + "8641", + "8653", + "9000", + "²", + ])("rejects %s outside the allocated Hermes API-port range", (value) => { + expect(() => readHermesApiPort({ [HERMES_API_PORT_ENV]: value })).toThrow( + /integer from 8642 through 8652/, + ); + }); +}); + +describe("findAvailableHermesApiPort", () => { + it("keeps the preferred port when no sandbox holds it", () => { + expect(findAvailableHermesApiPort("beta", 8642, "", noneBound, new Map())).toBe(8642); + }); + + it("skips a port another sandbox already forwards", () => { + const forwards = forwardList(["alpha 127.0.0.1 8642 101 running"]); + expect(findAvailableHermesApiPort("beta", 8642, forwards, noneBound, new Map())).toBe(8643); + }); + + it("keeps a port this sandbox already owns", () => { + const forwards = forwardList(["beta 127.0.0.1 8643 101 running"]); + expect(findAvailableHermesApiPort("beta", 8643, forwards, noneBound, new Map())).toBe(8643); + }); + + it("skips a port held by a sandbox on another gateway", () => { + const occupied = new Map([["8642", "alpha (gateway 9090)"]]); + expect(findAvailableHermesApiPort("beta", 8642, "", noneBound, occupied)).toBe(8643); + }); + + it("reports the occupants when the range is exhausted", () => { + expect(() => findAvailableHermesApiPort("beta", 8642, "", () => true, new Map())).toThrow( + /All Hermes API ports in range 8642-8652 are occupied/, + ); + }); +}); + +describe("resolveOnboardHermesApiPort", () => { + it("prefers an explicit environment value and republishes it", () => { + const env = { [HERMES_API_PORT_ENV]: "8650" }; + expect(resolveOnboardHermesApiPort("beta", { env, getSandbox: () => undefined })).toBe(8650); + expect(env[HERMES_API_PORT_ENV]).toBe("8650"); + }); + + it("rejects a conflicting existing-sandbox override before forward setup", () => { + const env = { [HERMES_API_PORT_ENV]: "8644" }; + const findAvailablePort = vi.fn(() => 8645); + + expect(() => + resolveOnboardHermesApiPort("beta", { + env, + getSandbox: () => ({ hermesApiPort: 8643 }), + findAvailablePort, + }), + ).toThrow(/serves its OpenAI-compatible API on port 8643.*--recreate-sandbox/); + expect(findAvailablePort).not.toHaveBeenCalled(); + }); + + it("applies a conflicting override only at a create or registration boundary", () => { + const env = { [HERMES_API_PORT_ENV]: "8644" }; + + expect( + resolveOnboardHermesApiPort("beta", { + env, + getSandbox: () => ({ hermesApiPort: 8643 }), + allowRegisteredOverride: true, + }), + ).toBe(8644); + expect(env[HERMES_API_PORT_ENV]).toBe("8644"); + }); + + it("accepts an explicit value that matches the registered port", () => { + const env = { [HERMES_API_PORT_ENV]: "8643" }; + + expect( + resolveOnboardHermesApiPort("beta", { + env, + getSandbox: () => ({ hermesApiPort: 8643 }), + }), + ).toBe(8643); + }); + + it("keeps a registered sandbox without a port on the default instead of allocating", () => { + const env: NodeJS.ProcessEnv = {}; + const findAvailablePort = vi.fn(() => 8643); + expect( + resolveOnboardHermesApiPort("beta", { + env, + getSandbox: () => ({}), + findAvailablePort, + }), + ).toBe(8642); + expect(findAvailablePort).not.toHaveBeenCalled(); + expect(env[HERMES_API_PORT_ENV]).toBe("8642"); + }); + + it("prefers the registered port over a fresh allocation", () => { + const env: NodeJS.ProcessEnv = {}; + const findAvailablePort = vi.fn(() => 8644); + expect( + resolveOnboardHermesApiPort("beta", { + env, + getSandbox: () => ({ hermesApiPort: 8643 }), + findAvailablePort, + }), + ).toBe(8643); + expect(findAvailablePort).not.toHaveBeenCalled(); + expect(env[HERMES_API_PORT_ENV]).toBe("8643"); + }); + + it("publishes a fresh allocation so later consumers agree on it", () => { + const env: NodeJS.ProcessEnv = {}; + const warn = vi.fn(); + expect( + resolveOnboardHermesApiPort("beta", { + env, + getSandbox: () => undefined, + findAvailablePort: () => 8644, + warn, + }), + ).toBe(8644); + expect(env[HERMES_API_PORT_ENV]).toBe("8644"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Using port 8644 instead")); + }); +}); + +describe("resolveSandboxHermesApiPort", () => { + it("keeps the default for a sandbox registered without a port", () => { + expect(resolveSandboxHermesApiPort({})).toBe(8642); + }); + + it("uses the registered port", () => { + expect(resolveSandboxHermesApiPort({ hermesApiPort: 8645 })).toBe(8645); + }); +}); + +describe("retargetHermesApiPortInUrl", () => { + it("retargets a manifest URL at the sandbox's own port", () => { + expect(retargetHermesApiPortInUrl("http://localhost:8642/health", 8643)).toBe( + "http://localhost:8643/health", + ); + }); + + it("leaves a URL that names another port alone", () => { + expect(retargetHermesApiPortInUrl("http://localhost:18789/health", 8643)).toBe( + "http://localhost:18789/health", + ); + }); + + it("leaves the URL alone for a sandbox on the default port", () => { + expect(retargetHermesApiPortInUrl("http://localhost:8642/health", 8642)).toBe( + "http://localhost:8642/health", + ); + }); +}); diff --git a/src/lib/onboard/hermes-api-port.ts b/src/lib/onboard/hermes-api-port.ts new file mode 100644 index 00000000000..89d4e95f835 --- /dev/null +++ b/src/lib/onboard/hermes-api-port.ts @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + HERMES_API_PORT_RANGE_END, + HERMES_API_PORT_RANGE_START, + HERMES_OPENAI_API_PORT, + isHermesApiPort, +} from "../core/ports"; +import * as registry from "../state/registry"; +import { + findAvailablePortInRange, + getRegistryOccupiedHermesApiPorts, + type HostPortRange, + isPortBoundOnHost, + type ListSandboxesFn, +} from "./dashboard-port"; + +export const HERMES_API_PORT_ENV = "NEMOCLAW_HERMES_API_PORT"; + +const HERMES_API_RANGE: HostPortRange = { + start: HERMES_API_PORT_RANGE_START, + end: HERMES_API_PORT_RANGE_END, + label: "Hermes API", + remedy: + "Destroy a listed Hermes sandbox or stop a listed non-OpenShell listener, then rerun onboarding.", +}; + +export function isValidHermesApiPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && isHermesApiPort(value); +} + +/** + * Read the requested Hermes API port from the environment, falling back to the + * range start. The sandbox exposes its OpenAI-compatible API on this port, and + * the host forward uses the same number, so the value has to survive from + * allocation through sandbox create into `start.sh`. + */ +export function readHermesApiPort(env: NodeJS.ProcessEnv = process.env): number { + const raw = env[HERMES_API_PORT_ENV]; + if (raw === undefined || raw.trim() === "") return HERMES_OPENAI_API_PORT; + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed) || !isValidHermesApiPort(Number(trimmed))) { + throw new Error( + `Invalid port: ${HERMES_API_PORT_ENV}="${raw}" must be an integer from 8642 through 8652`, + ); + } + return Number(trimmed); +} + +export function findAvailableHermesApiPort( + sandboxName: string, + preferredPort: number = HERMES_OPENAI_API_PORT, + forwardListOutput: string | null = null, + isPortBoundCheck: (port: number) => boolean = isPortBoundOnHost, + registryOccupiedPorts?: ReadonlyMap, + listSandboxesFn?: ListSandboxesFn, +): number { + return findAvailablePortInRange( + sandboxName, + preferredPort, + forwardListOutput, + HERMES_API_RANGE, + isPortBoundCheck, + registryOccupiedPorts ?? getRegistryOccupiedHermesApiPorts(sandboxName, listSandboxesFn), + ); +} + +/** + * Resolve the API port a sandbox actually uses. Sandboxes registered before the + * port became per-sandbox carry no value and keep the default, which is also + * what `start.sh` falls back to when the environment does not carry one. + */ +export function resolveSandboxHermesApiPort(sandbox: { hermesApiPort?: number | null }): number { + return isValidHermesApiPort(sandbox.hermesApiPort) + ? sandbox.hermesApiPort + : HERMES_OPENAI_API_PORT; +} + +/** + * Retarget a manifest-derived URL at the sandbox's own API port. + * + * Manifest URLs name the agent's default port. A probe that runs inside a + * sandbox whose relay listens elsewhere would otherwise target an unused port + * that the sandbox user can bind, instead of the port the relay listens on. + * Only the default port is rewritten, so a manifest that already names a + * different port is left alone. + */ +export function retargetHermesApiPortInUrl(url: string, apiPort: number): string { + if (apiPort === HERMES_OPENAI_API_PORT || !isValidHermesApiPort(apiPort)) return url; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + if (parsed.port !== String(HERMES_OPENAI_API_PORT)) return url; + parsed.port = String(apiPort); + return parsed.toString(); +} + +/** + * Resolve the API port for a sandbox and publish it to the environment so every + * later consumer in the same run agrees on one value. + * + * Onboarding resolves this port in places that never see each other's result: + * the sandbox-create environment, the registry row, the host forward, and the + * ready summary. Publishing through the environment is how the dashboard port + * already reaches its later consumers (`ensureAgentDashboardForward` writes + * `CHAT_UI_URL`), and it carries the value into the sandbox without threading an + * argument through the onboarding entrypoint. The ready summary instead reads + * the registry, which is equivalent because registration precedes it. + * + * An existing sandbox keeps its recorded port unless the caller is the actual + * create/recreate or created-sandbox registration boundary. Other consumers + * reject a conflicting explicit value before they mutate a host forward. A + * registered sandbox without a port predates this feature and already runs on + * the default. + * + * A recreate keeps its source row, so its create and registration boundaries + * may apply an explicit value. Without an explicit value, it preserves the + * recorded port. + */ +export function resolveOnboardHermesApiPort( + sandboxName: string, + options: { + env?: NodeJS.ProcessEnv; + getSandbox?: (name: string) => { hermesApiPort?: number | null } | undefined; + allowRegisteredOverride?: boolean; + forwardListOutput?: string | null; + findAvailablePort?: typeof findAvailableHermesApiPort; + warn?: (message: string) => void; + } = {}, +): number { + const env = options.env ?? process.env; + const hasRequestedPort = Boolean(env[HERMES_API_PORT_ENV]?.trim()); + const requested = readHermesApiPort(env); + const publish = (port: number): number => { + env[HERMES_API_PORT_ENV] = String(port); + return port; + }; + const registered = (options.getSandbox ?? registry.getSandbox)(sandboxName); + if (registered) { + const registeredPort = resolveSandboxHermesApiPort(registered); + if ( + hasRequestedPort && + requested !== registeredPort && + options.allowRegisteredOverride !== true + ) { + throw new Error( + `${HERMES_API_PORT_ENV}=${requested} conflicts with sandbox "${sandboxName}", which serves its OpenAI-compatible API on port ${registeredPort}. Rerun onboarding with --recreate-sandbox to apply a different port.`, + ); + } + return publish(hasRequestedPort ? requested : registeredPort); + } + if (hasRequestedPort) return publish(requested); + const port = (options.findAvailablePort ?? findAvailableHermesApiPort)( + sandboxName, + HERMES_OPENAI_API_PORT, + options.forwardListOutput ?? null, + ); + if (port !== HERMES_OPENAI_API_PORT) { + options.warn?.(` ! Port ${HERMES_OPENAI_API_PORT} is taken. Using port ${port} instead.`); + } + return publish(port); +} diff --git a/src/lib/onboard/hermes-dashboard.ts b/src/lib/onboard/hermes-dashboard.ts index 7d0cd72b886..c410be4f9e0 100644 --- a/src/lib/onboard/hermes-dashboard.ts +++ b/src/lib/onboard/hermes-dashboard.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { HERMES_OPENAI_API_PORT } from "../core/ports"; +import { isHermesApiPort } from "../core/ports"; import { HERMES_DASHBOARD_ENABLE_ENV, HERMES_DASHBOARD_INTERNAL_PORT_ENV, @@ -11,7 +11,7 @@ import { readHermesDashboardConfig, } from "../hermes-dashboard"; import type { SandboxEntry } from "../state/registry"; -import { RESERVED_HERMES_DASHBOARD_PORT_MESSAGE } from "./preflight-ports"; +import { reservedHermesDashboardPortMessage } from "./preflight-ports"; export interface HermesDashboardOnboardState { config: HermesDashboardConfig | null; @@ -31,18 +31,20 @@ export function resolveHermesDashboardOnboardState({ env: NodeJS.ProcessEnv; fail?: (message: string) => never; }): HermesDashboardOnboardState { - // #4984 — reject the reserved Hermes API port (HERMES_OPENAI_API_PORT) as the - // dashboard port for ANY agent, before any sandbox is built. Check both the - // resolved effectivePort (covers --control-ui-port / CHAT_UI_URL / persisted) - // and the raw env override, which the host otherwise silently drops so - // effectivePort never shows it. Message mirrors agents/hermes/start.sh:164. + // #4984 — reject a reserved Hermes API port as the dashboard port for ANY + // agent, before any sandbox is built. Every port in the API range is reserved + // because each Hermes sandbox allocates its own from that range. Check both + // the resolved effectivePort (covers --control-ui-port / CHAT_UI_URL / + // persisted) and the raw env override, which the host otherwise silently + // drops so effectivePort never shows it. This host guard rejects the whole + // API range; agents/hermes/start.sh rejects only this sandbox's resolved port. const rawDashboardPort = env.NEMOCLAW_DASHBOARD_PORT?.trim(); const requestedDashboardPort = rawDashboardPort ? Number(rawDashboardPort) : undefined; - if ( - effectivePort === HERMES_OPENAI_API_PORT || - requestedDashboardPort === HERMES_OPENAI_API_PORT - ) { - const message = RESERVED_HERMES_DASHBOARD_PORT_MESSAGE; + const reservedPort = [effectivePort, requestedDashboardPort].find( + (port): port is number => port !== undefined && isHermesApiPort(port), + ); + if (reservedPort !== undefined) { + const message = reservedHermesDashboardPortMessage(reservedPort); if (fail) return fail(message); throw new Error(message); } diff --git a/src/lib/onboard/managed-startup-profile.test.ts b/src/lib/onboard/managed-startup-profile.test.ts index 758bd18b3d3..388f9039c01 100644 --- a/src/lib/onboard/managed-startup-profile.test.ts +++ b/src/lib/onboard/managed-startup-profile.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { HERMES_API_PORT_RANGE_END, HERMES_API_PORT_RANGE_START } from "../core/ports"; import { decodeManagedStartupProfile, encodeManagedStartupProfile, @@ -960,8 +961,10 @@ describe("managed startup profile", () => { it.each([ ["publicPort", 8642], + ["publicPort", 8652], ["publicPort", 18_642], ["internalPort", 8642], + ["internalPort", 8652], ["internalPort", 18_642], ] as const)("rejects Hermes dashboard %s collisions with reserved API port %i", (field, port) => { expect(() => @@ -969,7 +972,31 @@ describe("managed startup profile", () => { ...HERMES_PROFILE, dashboard: { ...HERMES_PROFILE.dashboard, [field]: port }, }), - ).toThrow(/reserved API ports 8642 or 18642/); + ).toThrow(/reserved API ports 8642-8652 or 18642/); + }); + + it("reserves exactly the Hermes API port range the port module declares", () => { + for (let port = HERMES_API_PORT_RANGE_START; port <= HERMES_API_PORT_RANGE_END; port += 1) { + expect(() => + validateManagedStartupProfile({ + ...HERMES_PROFILE, + dashboard: { ...HERMES_PROFILE.dashboard, publicPort: port }, + }), + ).toThrow(/reserved API ports/); + } + + for (const port of [HERMES_API_PORT_RANGE_START - 1, HERMES_API_PORT_RANGE_END + 1]) { + expect(() => + validateManagedStartupProfile({ + ...HERMES_PROFILE, + dashboard: { + ...HERMES_PROFILE.dashboard, + url: `http://127.0.0.1:${port}`, + publicPort: port, + }, + }), + ).not.toThrow(); + } }); it.each([ diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 973f54c4209..9bb6567d150 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -867,7 +867,22 @@ const EXTRA_AGENTS_KEYS = new Set(["agents", "defaults", "main"]); const MANAGED_STARTUP_AGENT_SET = new Set(MANAGED_STARTUP_AGENTS); const DCODE_AUTO_APPROVAL_MODE_SET = new Set(MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES); const REASONING_EFFORT_SET = new Set(MANAGED_STARTUP_REASONING_EFFORTS); -const HERMES_RESERVED_API_PORTS = new Set([8642, 18_642]); +const HERMES_INTERNAL_API_PORT = 18_642; + +const HERMES_API_PORT_RANGE_START = 8642; +const HERMES_API_PORT_RANGE_END = 8652; + +function isHermesApiPort(port: number): boolean { + return port >= HERMES_API_PORT_RANGE_START && port <= HERMES_API_PORT_RANGE_END; +} + +// A dashboard must not use the internal API port, and must not use any port in +// the range that each Hermes sandbox allocates its public API port from. +function isHermesReservedApiPort(port: number): boolean { + return port === HERMES_INTERNAL_API_PORT || isHermesApiPort(port); +} + +const HERMES_RESERVED_API_PORT_LABEL = `${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END} or ${HERMES_INTERNAL_API_PORT}`; function isPlainObject(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; @@ -1539,8 +1554,10 @@ function validateDashboard( invalid("OpenClaw dashboard.mode must reflect its URL, bind address, and WSL exposure"); } const port = requirePort(dashboard.port, "dashboard.port", 1024); - if (port === 8642) - invalid("OpenClaw dashboard.port must not use reserved Hermes API port 8642"); + if (isHermesApiPort(port)) + invalid( + `OpenClaw dashboard.port must not use a reserved Hermes API port (${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END})`, + ); if (configuredDashboardPort(url) !== port) { invalid("OpenClaw dashboard.port must match dashboard.url"); } @@ -1586,8 +1603,10 @@ function validateDashboard( if (publicPort === internalPort) { invalid("Hermes dashboard publicPort and internalPort must differ"); } - if (HERMES_RESERVED_API_PORTS.has(publicPort) || HERMES_RESERVED_API_PORTS.has(internalPort)) { - invalid("Hermes dashboard ports must not use reserved API ports 8642 or 18642"); + if (isHermesReservedApiPort(publicPort) || isHermesReservedApiPort(internalPort)) { + invalid( + `Hermes dashboard ports must not use reserved API ports ${HERMES_RESERVED_API_PORT_LABEL}`, + ); } if (configuredDashboardPort(url) !== publicPort) { invalid("Hermes dashboard.publicPort must match dashboard.url"); diff --git a/src/lib/onboard/preflight-ports.ts b/src/lib/onboard/preflight-ports.ts index db7a9a4eb60..f68409c2c53 100644 --- a/src/lib/onboard/preflight-ports.ts +++ b/src/lib/onboard/preflight-ports.ts @@ -1,10 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { HERMES_OPENAI_API_PORT } from "../core/ports"; +import { + HERMES_API_PORT_RANGE_END, + HERMES_API_PORT_RANGE_START, + isHermesApiPort, +} from "../core/ports"; -/** Agent-neutral rejection message when {@link HERMES_OPENAI_API_PORT} is requested as a dashboard port; shared by both #4984 guards. */ -export const RESERVED_HERMES_DASHBOARD_PORT_MESSAGE = `[SECURITY] Invalid dashboard port ${HERMES_OPENAI_API_PORT} - reserved for the Hermes OpenAI-compatible API`; +/** Agent-neutral rejection when a Hermes API port is requested as a dashboard port; shared by both #4984 guards. */ +export function reservedHermesDashboardPortMessage(port: number): string { + return `[SECURITY] Invalid dashboard port ${port} - reserved for the Hermes OpenAI-compatible API (${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END})`; +} export type PreflightPortKind = "gateway" | "dashboard" | "other"; @@ -47,8 +53,10 @@ export function buildRequiredPreflightPorts(opts: { } /** - * Reject the reserved {@link HERMES_OPENAI_API_PORT} as a dashboard port at - * preflight (any agent) so onboarding fails fast at [1/8], before any sandbox. + * Reject a Hermes API port as a dashboard port at preflight (any agent) so + * onboarding fails fast at [1/8], before any sandbox. Every port in the API + * range is reserved because each Hermes sandbox allocates its own from that + * range, so a dashboard on any of them would collide with a sibling sandbox. * Mirrors the createSandbox guard in resolveHermesDashboardOnboardState. (#4984) */ export function assertDashboardPortNotReserved( @@ -58,7 +66,7 @@ export function assertDashboardPortNotReserved( process.exit(1); }, ): void { - if (dashboardPort === HERMES_OPENAI_API_PORT) { - fail(RESERVED_HERMES_DASHBOARD_PORT_MESSAGE); + if (dashboardPort !== null && isHermesApiPort(dashboardPort)) { + fail(reservedHermesDashboardPortMessage(dashboardPort)); } } diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index 563904cd0e0..ae48e6cba92 100644 --- a/src/lib/onboard/sandbox-create-launch.ts +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -6,6 +6,7 @@ import { formatEnvAssignment } from "../core/url-utils"; import { buildSubprocessEnv } from "../subprocess-env"; import { isValidProxyHost, isValidProxyPort } from "./dockerfile-patch"; import { appendExtraPlaceholderKeysEnvArg } from "./extra-placeholder-keys"; +import { HERMES_API_PORT_ENV, resolveOnboardHermesApiPort } from "./hermes-api-port"; import type { HermesDashboardOnboardState } from "./hermes-dashboard"; import { appendHermesDashboardEnvArgs } from "./hermes-dashboard"; import { appendHostProxyEnvArgs } from "./host-proxy-env"; @@ -186,7 +187,11 @@ export interface SandboxRuntimeEnvArgsInput { manageDashboard: boolean; getDashboardForwardPort(chatUiUrl: string): string; hermesDashboardState: HermesDashboardOnboardState; + /** Host port this sandbox exposes its OpenAI-compatible API on. */ + hermesApiPort?: number | null; extraPlaceholderKeys: readonly string[]; + /** Allow a create/recreate launch to replace a registered Hermes API port. */ + allowHermesApiPortOverride?: boolean; observabilityEnabled?: boolean; sandboxName?: string; env: NodeJS.ProcessEnv; @@ -221,6 +226,18 @@ export function buildSandboxRuntimeEnvArgs(input: SandboxRuntimeEnvArgsInput): { appendOpenClawDiagnosticRuntimeEnvArgs(envArgs, agent, env); appendOpenClawMcpToolsListTimeoutRuntimeEnvArg(envArgs, agent, env); appendHermesDashboardEnvArgs(envArgs, input.hermesDashboardState, formatEnvAssignment); + // The sandbox and its host forward share the API port number, so the + // allocated value has to reach start.sh before the socat relay binds. + if (agent?.name === "hermes" && input.sandboxName) { + const apiPort = + input.hermesApiPort ?? + resolveOnboardHermesApiPort(input.sandboxName, { + env, + warn: console.warn, + allowRegisteredOverride: input.allowHermesApiPortOverride, + }); + envArgs.push(formatEnvAssignment(HERMES_API_PORT_ENV, String(apiPort))); + } appendHostProxyEnvArgs(envArgs, env, { dropCredentialBearingProxyUrls: agent?.name === "langchain-deepagents-code" || input.omitCredentialEnv === true, @@ -281,6 +298,7 @@ export function prepareSandboxCreateLaunch(input: SandboxCreateLaunchInput): San extraPlaceholderKeys: input.extraPlaceholderKeys, observabilityEnabled: input.observabilityEnabled, sandboxName: input.sandboxName, + allowHermesApiPortOverride: true, env, }); diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index b1b751036fc..c39aa4b5961 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -19,6 +19,7 @@ import * as registry from "../state/registry"; import { cloneSandboxWorkloadReceipt } from "../state/registry/workload"; import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; import type { DcodeAutoApprovalMode } from "./dcode-auto-approval"; +import { resolveOnboardHermesApiPort } from "./hermes-api-port"; import { getHermesDashboardRegistryFields, type HermesDashboardOnboardState, @@ -71,6 +72,8 @@ export interface CreatedSandboxRegistryEntryInput { preservedMcpState?: SandboxMcpState; hermesToolGateways: string[]; hermesDashboardState: HermesDashboardOnboardState; + /** Host port this sandbox exposes its OpenAI-compatible API on. */ + hermesApiPort?: number | null; dashboardPort: number; dashboardRemoteBindPrepared?: boolean; lifecycleGeneration?: string; @@ -222,6 +225,14 @@ export function buildCreatedSandboxRegistryEntry( hermesToolGateways: input.hermesToolGateways.length > 0 ? [...input.hermesToolGateways] : undefined, ...getHermesDashboardRegistryFields(input.hermesDashboardState), + hermesApiPort: + input.agent?.name === "hermes" + ? (input.hermesApiPort ?? + resolveOnboardHermesApiPort(input.sandboxName, { + // Registration follows a successful create/recreate that applied this environment. + allowRegisteredOverride: true, + })) + : undefined, dashboardPort: input.dashboardPort, dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true, lifecycleGeneration: input.lifecycleGeneration, diff --git a/src/lib/state/gateway-registry.ts b/src/lib/state/gateway-registry.ts index fb1b08c71e3..fe99c369f22 100644 --- a/src/lib/state/gateway-registry.ts +++ b/src/lib/state/gateway-registry.ts @@ -20,6 +20,7 @@ const MAX_GATEWAY_DIRECTORY_ENTRIES = 1024; export interface GatewayRegistryEntry extends Record { name: string; dashboardPort?: number | null; + hermesApiPort?: number | null; gatewayName?: string | null; gatewayPort?: number | null; } @@ -95,17 +96,15 @@ function parseRegistry(filePath: string, raw: string): GatewayRegistryDocument { ) { throw stateError(`${filePath} has an invalid sandbox row for ${JSON.stringify(name)}`); } - if ( - value.dashboardPort !== undefined && - value.dashboardPort !== null && - (typeof value.dashboardPort !== "number" || - !Number.isInteger(value.dashboardPort) || - value.dashboardPort < 0 || - value.dashboardPort > 65535) - ) { - throw stateError( - `${filePath} has an invalid dashboardPort for sandbox ${JSON.stringify(name)}`, - ); + for (const field of ["dashboardPort", "hermesApiPort"] as const) { + const port = value[field]; + if ( + port !== undefined && + port !== null && + (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) + ) { + throw stateError(`${filePath} has an invalid ${field} for sandbox ${JSON.stringify(name)}`); + } } sandboxes[name] = value.dashboardPort === 0 diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 0b34e46eb97..3eea32f6e2e 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -184,6 +184,7 @@ export function registerSandbox(entry: SandboxEntry): void { hermesDashboardPort: entry.hermesDashboardPort ?? undefined, hermesDashboardInternalPort: entry.hermesDashboardInternalPort ?? undefined, hermesDashboardTui: entry.hermesDashboardTui === true ? true : undefined, + hermesApiPort: entry.hermesApiPort ?? undefined, dashboardPort: entry.dashboardPort ?? undefined, dashboardRemoteBindPrepared: entry.dashboardRemoteBindPrepared === true ? true : undefined, gatewayName: entry.gatewayName ?? undefined, diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index c85c1daf997..91c03dd9efd 100644 --- a/src/lib/state/registry/types.ts +++ b/src/lib/state/registry/types.ts @@ -141,6 +141,13 @@ export interface SandboxEntry extends Partial { hermesDashboardPort?: number | null; hermesDashboardInternalPort?: number | null; hermesDashboardTui?: boolean; + /** + * Host port this sandbox exposes its OpenAI-compatible API on. The sandbox + * and the host forward share the number, so two Hermes sandboxes on one host + * need two values. Rows written before the port became per-sandbox carry no + * value and resolve to the range start. + */ + hermesApiPort?: number | null; dashboardPort?: number | null; /** Remote dashboard exposure was included in the sandbox's generated config. */ dashboardRemoteBindPrepared?: boolean; diff --git a/test/hermes-api-port-marker.test.ts b/test/hermes-api-port-marker.test.ts new file mode 100644 index 00000000000..167f2d82972 --- /dev/null +++ b/test/hermes-api-port-marker.test.ts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { shellQuote } from "../src/lib/core/shell-quote"; +import { extractShellFunction } from "./support/hermes-shell-harness"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "agents", "hermes", "start.sh"); + +interface MarkerSetup { + shellPrelude: string[]; + targetPath: string | null; +} + +type MarkerSetupFn = ( + runtimeParent: string, + runtimeDir: string, + markerPath: string, + targetPath: string, +) => MarkerSetup; + +const trustedRuntimePrelude = ['stat() { printf "%s\\n" "0:0:755"; }', "chown() { return 0; }"]; + +function setupWritableRuntime( + _runtimeParent: string, + runtimeDir: string, + _markerPath: string, + _targetPath: string, +): MarkerSetup { + fs.mkdirSync(runtimeDir, { recursive: true }); + return { shellPrelude: trustedRuntimePrelude, targetPath: null }; +} + +function setupUntrustedRuntime( + _runtimeParent: string, + runtimeDir: string, + _markerPath: string, + _targetPath: string, +): MarkerSetup { + fs.mkdirSync(runtimeDir, { recursive: true }); + return { + shellPrelude: ['stat() { printf "%s\\n" "1000:1000:755"; }', "chown() { return 0; }"], + targetPath: null, + }; +} + +function setupStaleMarker( + _runtimeParent: string, + runtimeDir: string, + markerPath: string, + _targetPath: string, +): MarkerSetup { + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(markerPath, "8642\n"); + fs.chmodSync(markerPath, 0o444); + return { shellPrelude: trustedRuntimePrelude, targetPath: null }; +} + +function setupMarkerSymlink( + _runtimeParent: string, + runtimeDir: string, + markerPath: string, + targetPath: string, +): MarkerSetup { + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(targetPath, "attacker-target\n"); + fs.symlinkSync(targetPath, markerPath); + return { shellPrelude: trustedRuntimePrelude, targetPath }; +} + +function setupBlockedRuntime( + runtimeParent: string, + _runtimeDir: string, + _markerPath: string, + _targetPath: string, +): MarkerSetup { + fs.writeFileSync(runtimeParent, ""); + return { shellPrelude: trustedRuntimePrelude, targetPath: null }; +} + +function setupPublicationFailure( + _runtimeParent: string, + runtimeDir: string, + markerPath: string, + _targetPath: string, +): MarkerSetup { + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(markerPath, "8643\n"); + fs.chmodSync(markerPath, 0o444); + return { + shellPrelude: [...trustedRuntimePrelude, "mktemp() { return 1; }"], + targetPath: null, + }; +} + +function runHermesApiPortMarkerPublication(publicPort: number, setup: MarkerSetupFn) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-api-port-marker-")); + try { + const runtimeParent = path.join(tmpDir, "run"); + const runtimeDir = path.join(runtimeParent, "nemoclaw"); + const markerPath = path.join(runtimeDir, "hermes-api-port"); + const targetPath = path.join(tmpDir, "attacker-target"); + const fixture = setup(runtimeParent, runtimeDir, markerPath, targetPath); + + const scriptPath = path.join(tmpDir, "run.sh"); + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -uo pipefail", + "HERMES_DEFAULT_API_PORT=8642", + "HERMES_API_PORT_RANGE_END=8652", + `HERMES_RUNTIME_DIR=${shellQuote(runtimeDir)}`, + `HERMES_API_PORT_MARKER=${shellQuote(markerPath)}`, + `PUBLIC_PORT=${publicPort}`, + ...fixture.shellPrelude, + extractShellFunction(src, "prepare_hermes_root_runtime_dir"), + extractShellFunction(src, "publish_hermes_root_runtime_marker"), + 'publish_hermes_root_runtime_marker hermes-api-port "$PUBLIC_PORT"', + ].join("\n"), + { mode: 0o700 }, + ); + + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8" }); + const marker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, "utf-8").trim() : null; + const mode = marker === null ? null : (fs.statSync(markerPath).mode & 0o777).toString(8); + const target = + fixture.targetPath === null ? null : fs.readFileSync(fixture.targetPath, "utf-8").trim(); + return { result, marker, mode, target }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe("agents/hermes/start.sh root-owned API port marker", () => { + it("publishes the allocated port atomically with its final mode (#8543)", () => { + const run = runHermesApiPortMarkerPublication(8645, setupWritableRuntime); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.marker).toBe("8645"); + expect(run.mode).toBe("444"); + }); + + it("atomically replaces a read-only marker left by an earlier start (#8543)", () => { + const run = runHermesApiPortMarkerPublication(8645, setupStaleMarker); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.marker).toBe("8645"); + }); + + it("replaces a planted marker symlink without writing through it (#8543)", () => { + const run = runHermesApiPortMarkerPublication(8645, setupMarkerSymlink); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.marker).toBe("8645"); + expect(run.target).toBe("attacker-target"); + }); + + it("refuses a runtime directory outside the root-owned trust boundary (#8543)", () => { + const run = runHermesApiPortMarkerPublication(8645, setupUntrustedRuntime); + + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("must be root-owned with mode 0755"); + expect(run.marker).toBeNull(); + }); + + it("refuses a runtime path that cannot become a trusted directory (#8543)", () => { + const run = runHermesApiPortMarkerPublication(8645, setupBlockedRuntime); + + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("could not be created safely"); + expect(run.marker).toBeNull(); + }); + + it("fails closed without replacing a stale marker after publication failure (#8543)", () => { + const run = runHermesApiPortMarkerPublication(8642, setupPublicationFailure); + + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("could not be prepared"); + expect(run.marker).toBe("8643"); + }); +}); diff --git a/test/hermes-api-port-startup.test.ts b/test/hermes-api-port-startup.test.ts new file mode 100644 index 00000000000..a561bda4303 --- /dev/null +++ b/test/hermes-api-port-startup.test.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "agents", "hermes", "start.sh"); + +function runHermesApiPortBootstrap(apiPort: string) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-api-port-")); + const scriptPath = path.join(tmpDir, "run.sh"); + const source = fs.readFileSync(START_SCRIPT, "utf-8"); + const start = source.indexOf('NEMOCLAW_CMD=("$@")'); + const end = source.indexOf('\nHERMES="$(command -v hermes)"', start); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "set --", + source.slice(start, end).trimEnd(), + 'printf "PUBLIC_PORT=%s\\n" "$PUBLIC_PORT"', + ].join("\n"), + { mode: 0o700 }, + ); + + try { + return spawnSync("bash", [scriptPath], { + encoding: "utf-8", + timeout: 5000, + env: { + ...process.env, + NEMOCLAW_HERMES_API_PORT: apiPort, + }, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe("agents/hermes/start.sh API port allocation", () => { + it("accepts an allocated interior Hermes API port (#8543)", () => { + const run = runHermesApiPortBootstrap("8645"); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("PUBLIC_PORT=8645"); + }); + + it.each([ + "8641", + "8653", + "9000", + ])("rejects Hermes API port %s outside the allocation range", (port) => { + const run = runHermesApiPortBootstrap(port); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("Invalid NEMOCLAW_HERMES_API_PORT"); + }); +}); diff --git a/test/hermes-mcp-api-port.test.ts b/test/hermes-mcp-api-port.test.ts new file mode 100644 index 00000000000..f1baf3a54ec --- /dev/null +++ b/test/hermes-mcp-api-port.test.ts @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const TRANSACTION = path.resolve( + import.meta.dirname, + "..", + "agents/hermes/mcp-config-transaction.py", +); + +describe("Hermes MCP API port resolution", () => { + it("accepts only allocated ports from the stable service-manager environment (#8543)", () => { + const result = spawnSync( + "python3", + [ + "-c", + ` +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +identity = (41, 333) +module._gateway_identity = lambda: identity +module._process_parent_pid = lambda pid: 40 +module._is_service_manager_process = lambda pid: True + +accepted = [] +for raw in (b"8642", b"8645", b"8652"): + module._read_service_manager_environment = ( + lambda pid, value=raw: b"PATH=/usr/bin\\0NEMOCLAW_HERMES_API_PORT=" + + value + + b"\\0" + ) + accepted.append(module._service_manager_gateway_public_port(identity)) + +rejected = [] +for raw in ( + b"8641", + b"8653", + "²".encode("utf-8"), + b"8645\\0NEMOCLAW_HERMES_API_PORT=8646", +): + module._read_service_manager_environment = ( + lambda pid, value=raw: b"NEMOCLAW_HERMES_API_PORT=" + value + b"\\0" + ) + try: + module._service_manager_gateway_public_port(identity) + except PermissionError as error: + rejected.append(str(error)) + +module._read_service_manager_environment = lambda pid: b"PATH=/usr/bin\\0" +absent = module._service_manager_gateway_public_port(identity) + +module._gateway_identity = lambda: (41, 999) +module._read_service_manager_environment = ( + lambda pid: b"NEMOCLAW_HERMES_API_PORT=8645\\0" +) +identity_change = "" +try: + module._service_manager_gateway_public_port(identity) +except PermissionError as error: + identity_change = str(error) + +print(json.dumps({ + "accepted": accepted, + "rejected": rejected, + "absent": absent, + "identity_change": identity_change, +})) +`, + TRANSACTION, + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + accepted: [8642, 8645, 8652], + rejected: [ + "Hermes API port is outside the allocated range", + "Hermes API port is outside the allocated range", + "Hermes service-manager API port is malformed", + "Hermes service-manager API port is ambiguous", + ], + absent: 8642, + identity_change: "Hermes service-manager identity changed while reading", + }); + }); + + it("rejects a marker that the sandbox user could have shaped (#8543)", () => { + const result = spawnSync( + "python3", + [ + "-c", + ` +import importlib.util, json, os, pathlib, shutil, sys, tempfile +spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) + +root = pathlib.Path(tempfile.mkdtemp()) +module.GATEWAY_PUBLIC_PORT_PATH = str(root / "hermes-api-port") +absent = module._root_gateway_public_port_marker() + +def stage_writable_mode(path): + path.write_bytes(b"8645") + path.chmod(0o644) + +def stage_extra_hard_link(path): + path.write_bytes(b"8645") + path.chmod(0o444) + os.link(path, path.parent / "extra-link") + +def stage_oversized_record(path): + path.write_bytes(b"8" * 64) + path.chmod(0o444) + +class RootOwnedStat: + def __init__(self, real): + self._real = real + self.st_uid = 0 + self.st_gid = 0 + + def __getattr__(self, name): + return getattr(self._real, name) + +real_fstat = module.os.fstat + +unsafe = [] +for index, stage in enumerate( + (stage_writable_mode, stage_extra_hard_link, stage_oversized_record) +): + directory = root / f"case-{index}" + directory.mkdir() + marker = directory / "hermes-api-port" + stage(marker) + module.GATEWAY_PUBLIC_PORT_PATH = str(marker) + module.os.fstat = lambda descriptor: RootOwnedStat(real_fstat(descriptor)) + try: + module._root_gateway_public_port_marker() + except PermissionError as error: + unsafe.append(str(error)) + finally: + module.os.fstat = real_fstat + +accepted = root / "accepted" +accepted.mkdir() +sound_marker = accepted / "hermes-api-port" +sound_marker.write_bytes(b"8645") +sound_marker.chmod(0o444) +module.GATEWAY_PUBLIC_PORT_PATH = str(sound_marker) +module.os.fstat = lambda descriptor: RootOwnedStat(real_fstat(descriptor)) +try: + sound = module._root_gateway_public_port_marker() +finally: + module.os.fstat = real_fstat + +linked = root / "linked" +linked.mkdir() +target = linked / "hermes-api-port" +target.write_bytes(b"8645") +target.chmod(0o444) +symlink = linked / "symlink" +symlink.symlink_to(target) +module.GATEWAY_PUBLIC_PORT_PATH = str(symlink) +followed = "" +try: + module._root_gateway_public_port_marker() +except PermissionError as error: + followed = str(error) + +shutil.rmtree(root) + +print(json.dumps({ + "absent": absent, + "sound": sound, + "unsafe": unsafe, + "followed": followed, +})) +`, + TRANSACTION, + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + absent: null, + sound: 8645, + unsafe: [ + "Hermes API port marker is unsafe", + "Hermes API port marker is unsafe", + "Hermes API port marker is unsafe", + ], + followed: "Hermes API port marker cannot be opened safely", + }); + }); + + it("prefers the marker over the service-manager environment (#8543)", () => { + const result = spawnSync( + "python3", + [ + "-c", + ` +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) + +module._gateway_identity = lambda: (41, 333) +module._service_manager_gateway_public_port = lambda identity: 8649 + +module._root_gateway_public_port_marker = lambda: 8647 +marker_wins = module._resolve_gateway_public_port() + +module._root_gateway_public_port_marker = lambda: None +real_geteuid = module.os.geteuid + +module.os.geteuid = lambda: 0 +root_without_marker = "" +try: + module._resolve_gateway_public_port() +except PermissionError as error: + root_without_marker = str(error) + +module.os.geteuid = lambda: 1000 +same_uid_fallback = module._resolve_gateway_public_port() + +module._gateway_identity = lambda: None +without_identity = "" +try: + module._resolve_gateway_public_port() +except PermissionError as error: + without_identity = str(error) + +module.os.geteuid = real_geteuid + +print(json.dumps({ + "marker_wins": marker_wins, + "root_without_marker": root_without_marker, + "same_uid_fallback": same_uid_fallback, + "without_identity": without_identity, +})) +`, + TRANSACTION, + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + marker_wins: 8647, + root_without_marker: "Hermes root API port marker is unavailable", + same_uid_fallback: 8649, + without_identity: "Hermes gateway identity is unavailable", + }); + }); + + it("fails the probe with exit code 2 when the port cannot be resolved (#8543)", () => { + const result = spawnSync( + "python3", + [ + "-c", + ` +import importlib.util, pathlib, sys, tempfile +spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) + +module.GATEWAY_PUBLIC_PORT_PATH = str(pathlib.Path(tempfile.mkdtemp()) / "absent-marker") +module.os.geteuid = lambda: 0 +sys.argv = ["mcp-config-transaction.py", "probe"] +raise SystemExit(module.main()) +`, + TRANSACTION, + ], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(2); + expect(result.stderr.trim()).toBe("Hermes root API port marker is unavailable"); + expect(result.stdout).toBe(""); + }); +}); diff --git a/test/hermes-mcp-config-transaction.test.ts b/test/hermes-mcp-config-transaction.test.ts index 47b4d386d73..1d33819b42c 100644 --- a/test/hermes-mcp-config-transaction.test.ts +++ b/test/hermes-mcp-config-transaction.test.ts @@ -334,6 +334,7 @@ module.HERMES_DIR = sys.argv[3] module.CONFIG_PATH = os.path.join(module.HERMES_DIR, "config.yaml") module.os.geteuid = lambda: 1000 module._assert_non_root_lifecycle_identity = lambda: None +module._configure_gateway_public_port = lambda: None payload = { "server": "safe", "url": "https://mcp.example.test/mcp", @@ -1156,6 +1157,7 @@ def trusted_gateway(pid): return True module._is_trusted_gateway_process = trusted_gateway module._gateway_has_managed_parent = lambda pid: True +module._configure_gateway_public_port = lambda: None def signal_gateway(pid, sent_signal): observed["signal_uid"] = module.os.geteuid() observed["signal_pid"] = pid @@ -1372,8 +1374,9 @@ module.os.geteuid = lambda: 1000 module.os.lstat = lambda path: (_ for _ in ()).throw(FileNotFoundError(path)) module._gateway_identity = lambda: (123, 456) module._gateway_has_managed_parent = lambda pid: True +module._configure_gateway_public_port = lambda: None module.apply_transaction_and_reload = lambda action, payload: { - "ok": True, "changed": True, "reloaded": True + "ok": True, "changed": True, "reloaded": True, } result = module.execute("add", { "server": "fake", @@ -1392,24 +1395,6 @@ print(json.dumps(result, sort_keys=True)) }); }); - it("probes the same-UID helper without mutating config", () => { - const result = runPython(` -import importlib.util, json, sys -spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) -module = importlib.util.module_from_spec(spec) -sys.modules[spec.name] = module -spec.loader.exec_module(module) -module.os.geteuid = lambda: 1000 -module.os.lstat = lambda path: (_ for _ in ()).throw(FileNotFoundError(path)) -module._gateway_identity = lambda: (123, 456) -module._gateway_has_managed_parent = lambda pid: True -print(json.dumps(module.probe(), sort_keys=True)) -`); - - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ ok: true }); - }); - it("restores config and hashes after both desired-config reload signals fail", () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-mcp-rollback-")); const hermesDir = path.join(temp, ".hermes"); diff --git a/test/hermes-mcp-probe-api-port.test.ts b/test/hermes-mcp-probe-api-port.test.ts new file mode 100644 index 00000000000..92c50ba0155 --- /dev/null +++ b/test/hermes-mcp-probe-api-port.test.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const TRANSACTION = path.resolve( + import.meta.dirname, + "..", + "agents/hermes/mcp-config-transaction.py", +); + +describe("Hermes MCP lifecycle probe API port", () => { + it("probes the same-UID helper without mutating config (#8543)", () => { + const result = spawnSync( + "python3", + [ + "-c", + ` +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +module.os.geteuid = lambda: 1000 +module.os.lstat = lambda path: (_ for _ in ()).throw(FileNotFoundError(path)) +module._gateway_identity = lambda: (123, 456) +module._gateway_has_managed_parent = lambda pid: True +module._configure_gateway_public_port = lambda: None +print(json.dumps(module.probe(), sort_keys=True)) +`, + TRANSACTION, + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ ok: true }); + }); + + it("fails when the root API port marker cannot be resolved (#8543)", () => { + const result = spawnSync( + "python3", + [ + "-c", + ` +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +module.os.geteuid = lambda: 0 +module._root_gateway_public_port_marker = lambda: None + +probe_error = "" +try: + module.probe() +except PermissionError as error: + probe_error = str(error) + +sys.argv = [sys.argv[1], "probe"] +exit_code = module.main() +print(json.dumps({"probe_error": probe_error, "exit_code": exit_code})) +`, + TRANSACTION, + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + probe_error: "Hermes root API port marker is unavailable", + exit_code: 2, + }); + expect(result.stderr.trim()).toBe("Hermes root API port marker is unavailable"); + }); +}); diff --git a/test/hermes-mcp-reload-convergence.test.ts b/test/hermes-mcp-reload-convergence.test.ts index ad6d56b3d86..1ef8dc34fb0 100644 --- a/test/hermes-mcp-reload-convergence.test.ts +++ b/test/hermes-mcp-reload-convergence.test.ts @@ -38,7 +38,7 @@ module._gateway_has_managed_parent = lambda pid: True module._gateway_health_phase = lambda deadline=None: ( (True, "waiting-for-stable-replacement-identity") if len(signals) >= 2 - else (False, "waiting-for-internal-health-on-18642") + else (False, "waiting-for-internal-health") ) module.time.monotonic = lambda: clock["now"] def sleep(seconds): @@ -273,8 +273,8 @@ print(json.dumps({"internal": internal, "public": public, "stable": stable})) expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ - internal: [false, "waiting-for-internal-health-on-18642"], - public: [false, "waiting-for-public-relay-health-on-8642"], + internal: [false, "waiting-for-internal-health"], + public: [false, "waiting-for-public-relay-health"], stable: [true, "waiting-for-stable-replacement-identity"], }); }); @@ -296,7 +296,7 @@ def identity(): return (4242, 99) if identity_calls["count"] == 1 else (4243, 100) def health_phase(deadline=None): clock["now"] = deadline - return False, "waiting-for-internal-health-on-18642" + return False, "waiting-for-internal-health" module._gateway_identity = identity module._gateway_health_phase = health_phase module._gateway_has_managed_parent = lambda pid: True @@ -318,7 +318,7 @@ else: expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ error: - "Hermes gateway did not complete its managed MCP reload (last safe phase: waiting-for-internal-health-on-18642; re-kick attempted: no; re-kick sent: no)", + "Hermes gateway did not complete its managed MCP reload (last safe phase: waiting-for-internal-health; re-kick attempted: no; re-kick sent: no)", signals: [[4242, "SIGUSR1"]], }); }); @@ -352,8 +352,8 @@ def run_case(name): return (4243, 100) phases = { - "internal": (False, "waiting-for-internal-health-on-18642"), - "public": (False, "waiting-for-public-relay-health-on-8642"), + "internal": (False, "waiting-for-internal-health"), + "public": (False, "waiting-for-public-relay-health"), "stable": (True, "waiting-for-stable-replacement-identity"), } module._gateway_identity = identity @@ -384,7 +384,7 @@ print(json.dumps({name: run_case(name) for name in ( }, internal: { error: - "Hermes gateway did not complete its managed MCP reload (last safe phase: waiting-for-internal-health-on-18642; re-kick attempted: yes; re-kick sent: yes)", + "Hermes gateway did not complete its managed MCP reload (last safe phase: waiting-for-internal-health; re-kick attempted: yes; re-kick sent: yes)", signals: [ [4242, "SIGUSR1"], [4243, "SIGUSR1"], @@ -392,7 +392,7 @@ print(json.dumps({name: run_case(name) for name in ( }, public: { error: - "Hermes gateway did not complete its managed MCP reload (last safe phase: waiting-for-public-relay-health-on-8642; re-kick attempted: no; re-kick sent: no)", + "Hermes gateway did not complete its managed MCP reload (last safe phase: waiting-for-public-relay-health; re-kick attempted: no; re-kick sent: no)", signals: [[4242, "SIGUSR1"]], }, stable: { diff --git a/test/hermes-plugin-handlers.test.ts b/test/hermes-plugin-handlers.test.ts index 44e8469372f..4e16bd650c8 100644 --- a/test/hermes-plugin-handlers.test.ts +++ b/test/hermes-plugin-handlers.test.ts @@ -22,6 +22,59 @@ function runPython(script: string): string { } describe("Hermes NemoClaw plugin handlers", () => { + it("uses only allocated ASCII API ports from the supervisor or marker (#8543)", () => { + const output = runPython(` +import importlib.util +import io +import json +import os +import pathlib +import sys +import types + +plugin_path = pathlib.Path(sys.argv[1]) +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) +spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +def missing_marker(*_args, **_kwargs): + raise OSError("missing marker") + +module.open = missing_marker +environment_ports = {} +for value in ("8645", "²", "9000"): + os.environ["NEMOCLAW_HERMES_API_PORT"] = value + environment_ports[value] = module._hermes_api_port() + +marker_ports = {} +for value in ("8646", "9000", "not-a-port"): + module.open = lambda *_args, **_kwargs: io.StringIO(value + "\\n") + os.environ["NEMOCLAW_HERMES_API_PORT"] = "" + marker_ports[value] = module._hermes_api_port() + +print(json.dumps({ + "environment": environment_ports, + "marker": marker_ports, +}, sort_keys=True)) +`); + + expect(JSON.parse(output)).toEqual({ + environment: { + "8645": 8645, + "9000": 8642, + "²": 8642, + }, + marker: { + "8646": 8646, + "9000": 8642, + "not-a-port": 8642, + }, + }); + }); + it("accepts Hermes dispatch kwargs for status, info, and reload handlers", () => { const output = runPython(` import importlib.util diff --git a/test/install-hermes-forward-restore.test.ts b/test/install-hermes-forward-restore.test.ts new file mode 100644 index 00000000000..fd894c289ea --- /dev/null +++ b/test/install-hermes-forward-restore.test.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + INSTALLER_PAYLOAD, + TEST_SYSTEM_PATH, + writeExecutable, +} from "./helpers/installer-sourced-env"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); + +function callInstallerPayloadFn(fnCall: string, env: Record = {}) { + return spawnSync("bash", ["-c", `source "${INSTALLER_PAYLOAD}" 2>/dev/null; ${fnCall}`], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + HOME: os.tmpdir(), + PATH: TEST_SYSTEM_PATH, + ...env, + }, + }); +} + +describe("Hermes installer forward restore", () => { + it("fails closed without a registered port and restores the recorded port (#8543)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-forward-restore-")); + const fakeBin = path.join(tempDir, "bin"); + const stateDir = path.join(tempDir, ".nemoclaw"); + const openshellLog = path.join(tempDir, "openshell.log"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.symlinkSync(process.execPath, path.join(fakeBin, "node")); + fs.writeFileSync( + path.join(stateDir, "onboard-session.json"), + JSON.stringify({ sandboxName: "created-by-onboard", agent: "hermes" }), + ); + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$OPENSHELL_LOG" +case "$1 $2" in + "forward list") + echo "SANDBOX BIND PORT PID STATUS" + echo "created-by-onboard 127.0.0.1 8647 123 running" + ;; +esac +exit 0 +`, + ); + for (const command of ["curl", "sleep"]) { + writeExecutable(path.join(fakeBin, command), "#!/usr/bin/env bash\nexit 0\n"); + } + + const restoreEnv = { + HOME: tempDir, + NEMOCLAW_SKIP_FORWARD_WATCHER: "1", + OPENSHELL_LOG: openshellLog, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + }; + const restore = () => + callInstallerPayloadFn("restore_onboard_forward_after_post_checks", restoreEnv); + + const missingRegistry = restore(); + expect(missingRegistry.status).toBe(1); + expect(missingRegistry.stderr).toContain( + "registered API port for sandbox 'created-by-onboard' is unavailable or invalid", + ); + + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { "created-by-onboard": { hermesApiPort: 8647 } }, + }), + ); + const restored = restore(); + + expect(restored.status).toBe(0); + const openshellCalls = fs.readFileSync(openshellLog, "utf-8"); + expect(openshellCalls).toContain("forward stop 8647 created-by-onboard"); + expect(openshellCalls).toContain("forward start --background 8647 created-by-onboard"); + }); +}); diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 02ef8ac48d8..0aca1a68ac6 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -6,13 +6,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeNpmStub } from "./helpers/installer-run-fixture"; import { INSTALLER_PAYLOAD, readShellConstant, TEST_SYSTEM_PATH, writeExecutable, } from "./helpers/installer-sourced-env"; -import { writeNpmStub } from "./helpers/installer-run-fixture"; const INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); const CURL_PIPE_INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); @@ -2726,54 +2726,6 @@ exit 1 expect(pathValue.startsWith(`${localBin}:`)).toBe(true); }); - it("restore_onboard_forward_after_post_checks: restores Hermes forward from session", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-forward-restore-")); - const fakeBin = path.join(tmp, "bin"); - const stateDir = path.join(tmp, ".nemoclaw"); - const openshellLog = path.join(tmp, "openshell.log"); - fs.mkdirSync(fakeBin, { recursive: true }); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "onboard-session.json"), - JSON.stringify({ sandboxName: "created-by-onboard", agent: "hermes" }), - ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -printf '%s\\n' "$*" >> "$OPENSHELL_LOG" -if [ "$1" = "forward" ] && [ "$2" = "list" ]; then - echo "SANDBOX BIND PORT PID STATUS" - echo "created-by-onboard 127.0.0.1 8642 123 running" -fi -exit 0 -`, - ); - writeExecutable( - path.join(fakeBin, "curl"), - `#!/usr/bin/env bash -exit 0 -`, - ); - writeExecutable( - path.join(fakeBin, "sleep"), - `#!/usr/bin/env bash -exit 0 -`, - ); - - const r = callInstallerPayloadFn("restore_onboard_forward_after_post_checks", { - HOME: tmp, - NEMOCLAW_SKIP_FORWARD_WATCHER: "1", - OPENSHELL_LOG: openshellLog, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }); - - expect(r.status).toBe(0); - const openshellCalls = fs.readFileSync(openshellLog, "utf-8"); - expect(openshellCalls).toContain("forward stop 8642 created-by-onboard"); - expect(openshellCalls).toContain("forward start --background 8642 created-by-onboard"); - }); - // -- resolve_default_sandbox_name -- it("resolve_default_sandbox_name: returns 'my-assistant' with no registry", () => { diff --git a/test/managed-gateway-control.test.ts b/test/managed-gateway-control.test.ts index f31ee4d5fa0..eecfd668c3b 100644 --- a/test/managed-gateway-control.test.ts +++ b/test/managed-gateway-control.test.ts @@ -153,7 +153,7 @@ with tempfile.TemporaryDirectory() as root: 1, 1000, b"bash\0/usr/local/bin/nemoclaw-start\0", - b"PATH=/usr/bin\0NEMOCLAW_DASHBOARD_PORT=18789\0", + b"PATH=/usr/bin\0NEMOCLAW_DASHBOARD_PORT=18789\0NEMOCLAW_HERMES_API_PORT=8645\0", ) write_process( proc_root, @@ -183,7 +183,7 @@ with tempfile.TemporaryDirectory() as root: control._http_healthy_in_gateway_namespace = ( lambda _reader, _identity, port, path, *_args: (port, path) in { (18642, "/health"), - (8642, "/health"), + (8645, "/health"), } ) os.environ["NEMOCLAW_MANAGED_CONTROL_ALLOW_NONROOT_TEST"] = "1" @@ -204,6 +204,7 @@ with tempfile.TemporaryDirectory() as root: initial_proof = { "stable_zombie": [zombie.state, len(zombie.cmdline)], "supervisor": [supervisor.pid, supervisor.start_time, supervisor.parent_pid], + "api_port": hermes.readiness_checks[0][0], "gateway": [candidates[0].pid, candidates[0].start_time, candidates[0].parent_pid], "healthy": control._gateway_healthy(reader, candidates[0], hermes), } @@ -1302,6 +1303,7 @@ describe("managed gateway root control", () => { expect(output).toEqual({ initial: { stable_zombie: ["Z", 0], + api_port: 8645, supervisor: [40, "222", 1], gateway: [41, "333", 40], healthy: true, diff --git a/test/mcp-tool-discovery-image-contract.test.ts b/test/mcp-tool-discovery-image-contract.test.ts index 3b9030eab95..31f7fc5cddb 100644 --- a/test/mcp-tool-discovery-image-contract.test.ts +++ b/test/mcp-tool-discovery-image-contract.test.ts @@ -310,7 +310,7 @@ describe("MCP tool discovery image contract", () => { ); const expectedHashes = { "managed-startup-image-runtime.bundle": - "8522801ee753f87723ea5181ca52edbe5810e6d4aeeba9e678a6b28bddfbb51e", + "8b687ab5e159c0121459be26e1d49198d6dd64ab71071faca84c40c0651509d6", "mcp-tool-discovery/BUNDLED_PACKAGES.json": "df5dc8f167101085a8e73c444aa56854b2a4716a0bb7de9886fec4e50f402601", "mcp-tool-discovery/THIRD_PARTY_LICENSES.txt": diff --git a/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle b/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle index cdd04c0387e..93e086150bc 100644 --- a/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle +++ b/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle @@ -1,4 +1,4 @@ -var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:true})};var __copyProps=(to,from,except,desc)=>{if(from&&typeof from==="object"||typeof from==="function"){for(let key of __getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable})}return to};var __toESM=(mod,isNodeMode,target)=>(target=mod!=null?__create(__getProtoOf(mod)):{},__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:true}):target,mod));var __toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:true}),mod);var image_runtime_exports={};__export(image_runtime_exports,{applyManagedBootstrapEnvelope:()=>applyManagedBootstrapEnvelope,main:()=>main2,managedBootstrapEnvelopeClaimPaths:()=>managedBootstrapEnvelopeClaimPaths,readManagedBootstrapEnvelope:()=>readManagedBootstrapEnvelope,recoverManagedBootstrapEnvelopeClaim:()=>recoverManagedBootstrapEnvelopeClaim,verifyManagedBootstrapImageCompletion:()=>verifyManagedBootstrapImageCompletion,waitForManagedBootstrapImageCompletion:()=>waitForManagedBootstrapImageCompletion});module.exports=__toCommonJS(image_runtime_exports);var import_node_fs4=__toESM(require("node:fs"));var import_node_path4=__toESM(require("node:path"));var import_node_child_process=require("node:child_process");var import_node_crypto6=require("node:crypto");var import_node_fs3=__toESM(require("node:fs"));var import_node_path3=__toESM(require("node:path"));var import_node_buffer2=require("node:buffer");function isObjectRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}var ChannelManifestRegistry=class{manifests=new Map;constructor(manifests=[]){for(const manifest of manifests){this.register(manifest)}}register(manifest){if(this.manifests.has(manifest.id)){throw new Error(`Duplicate channel manifest id '${manifest.id}'`)}this.manifests.set(manifest.id,manifest);return this}get(channelId){return this.manifests.get(channelId)}list(){return Array.from(this.manifests.values())}listAvailable(ctx={}){const supportedChannelIds=Array.isArray(ctx.supportedChannelIds)?new Set(ctx.supportedChannelIds):null;return this.list().filter(manifest=>{if(ctx.agent&&!manifest.supportedAgents.includes(ctx.agent)){return false}if(supportedChannelIds&&!supportedChannelIds.has(manifest.id)){return false}return true})}};function createChannelManifestRegistry(manifests=[]){return new ChannelManifestRegistry(manifests)}var discordManifest={schemaVersion:1,id:"discord",displayName:"Discord",description:"Discord bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"DISCORD_BOT_TOKEN",prompt:{label:"Discord Bot Token",help:"Discord Developer Portal \u2192 Applications \u2192 Bot \u2192 Reset/Copy Token."}},{id:"serverId",kind:"config",required:false,envKey:"DISCORD_SERVER_ID",statePath:"discordGuilds.serverId",prompt:{label:"Discord Server ID (for guild workspace access)",help:"Enable Developer Mode in Discord, then right-click your server and copy the Server ID.",emptyValueMessage:"guild channels stay disabled"}},{id:"requireMention",kind:"config",required:false,envKey:"DISCORD_REQUIRE_MENTION",statePath:"discordGuilds.requireMention",promptWhenInput:"serverId",validValues:["0","1"],defaultValue:"1",prompt:{label:"Discord mention mode",help:"Choose whether the bot should reply only when @mentioned or to all messages in this server."}},{id:"userId",kind:"config",required:false,envKey:"DISCORD_USER_ID",statePath:"discordGuilds.userIds",promptWhenInput:"serverId",prompt:{label:"Discord User ID (optional guild allowlist)",help:"Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.",emptyValueMessage:"any member in the configured server can message the bot"}}],credentials:[{id:"discordBotToken",sourceInput:"botToken",providerName:"{sandboxName}-discord-bridge",providerEnvKey:"DISCORD_BOT_TOKEN",placeholder:"openshell:resolve:env:DISCORD_BOT_TOKEN"}],policyPresets:[{name:"discord",validationWarningLines:["For Discord preset validation, do not use curl as the success signal:","curl is not in the preset binary allowlist, so curl probes can fail even","when the policy is working. Use Node HTTPS against","https://discord.com/api/v10/gateway or validate the configured",'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")',"can also be inconclusive behind a proxy."]}],render:[{id:"discord-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.discord",value:{enabled:true,accounts:{default:{token:"{{credential.discordBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{discordProxyUrl}}",dmPolicy:"{{discord.allowedUsers.dmPolicy}}",allowFrom:"{{discord.allowedUsers.values}}"}}}}},{id:"discord-openclaw-guilds",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{discord.hasGuilds}}",fragment:{path:"channels.discord",value:{groupPolicy:"allowlist",guilds:"{{discord.guilds}}"}}},{id:"discord-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.discord",value:{enabled:true}}},{id:"discord-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["DISCORD_BOT_TOKEN={{credential.discordBotToken.placeholder}}","NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}","DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}","DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}"]},{id:"discord-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"discord",value:{require_mention:"{{discord.requireMention}}",free_response_channels:"",allowed_channels:"",auto_thread:true,reactions:true,channel_prompts:{}}}},{id:"discord-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.discord",value:{enabled:true}}}],runtime:{openclaw:{channelName:"discord",visibility:{configKeys:["discord"],logPatterns:["discord"]}}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/discord@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz"},required:true}],hooks:[{id:"discord-openclaw-bridge-health",phase:"health-check",handler:"discord.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"discord-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"discord-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"serverId",kind:"config"},{id:"requireMention",kind:"config"},{id:"userId",kind:"config"}]}]};var googlechatManifest={schemaVersion:1,id:"googlechat",displayName:"Google Chat",description:"Google Chat (Chat API) bot messaging (experimental)",enrollmentNotes:["\u2503 GOOGLE CHAT \u2014 appPrincipal","\u2503","\u2503 Workspace account \u2192 leave blank, done.","\u2503 Personal Gmail \u2192 needs the add-on's ~21-digit ID (not an email), stable across rebuilds.","\u2503","\u2503 If you already know it, paste it at the prompt and you're done.","\u2503 If not, leave it blank \u2014 the first DM reveals it once the sandbox is live:","\u2503","\u2503 1. Watch the gateway log:",'\u2503 nemoclaw logs --follow | grep "unexpected add-on principal"',"\u2503 2. DM the bot once \u2014 it won't reply yet, that's expected. The log prints:","\u2503 unexpected add-on principal: ","\u2503 3. Save that and rebuild:","\u2503 GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat","\u2503 nemoclaw rebuild --yes"],supportedAgents:["openclaw"],auth:{mode:"token-paste"},inputs:[{id:"serviceAccount",kind:"secret",required:true,envKey:"GOOGLECHAT_SERVICE_ACCOUNT",maskCap:40,formatHint:"Paste the entire service-account JSON key on one line (minified) \u2014 the whole downloaded JSON file.",maxTokenAttempts:3,prompt:{label:"Google Chat service account JSON",help:["\u2503 GOOGLE CHAT \u2014 service account key","\u2503","\u2503 Google Cloud Console \u2192 IAM & Admin \u2192 Service Accounts","\u2503 \u2192 your bot's SA \u2192 Keys \u2192 Add key \u2192 Create new key \u2192 JSON","\u2503","\u2503 A .json file downloads. Paste its contents below as ONE line (minified).",""].join("\n")}},{id:"audienceType",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE_TYPE",statePath:"googlechatConfig.audienceType",validValues:["app-url","project-number"],defaultValue:"app-url"},{id:"audience",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE",statePath:"googlechatConfig.audience",prompt:{label:"Google Chat webhook audience",help:"Usually filled automatically from the public tunnel URL. For audienceType 'project-number', enter your GCP project number instead.",emptyValueMessage:"inbound webhook verification will be unconfigured"}},{id:"appPrincipal",kind:"config",required:false,envKey:"GOOGLECHAT_APP_PRINCIPAL",statePath:"googlechatConfig.appPrincipal",formatPattern:"^[0-9]{6,32}$",formatHint:"appPrincipal is the add-on's numeric OAuth client ID (uniqueId, ~21 digits), not an email.",prompt:{label:"Google Chat appPrincipal",emptyValueMessage:"Workspace accounts do not need it; personal accounts must set it later"}},{id:"allowFrom",kind:"config",required:false,envKey:"GOOGLECHAT_ALLOWED_USERS",statePath:"allowedIds.googlechat",prompt:{label:"Google Chat DM allowlist (comma-separated user IDs)",help:"Optional: restrict who can DM the bot. Enter Google Chat user IDs (users/NNN) \u2014 NOT emails: the bot matches IDs only by default, so an email entry is ignored. Leave blank to require pairing (recommended).",emptyValueMessage:"bot will require manual pairing"}}],credentials:[],policyPresets:[{name:"googlechat",policyKeys:["googlechat"]}],render:[{id:"googlechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.googlechat",value:{enabled:true,serviceAccountFile:"/nonexistent/googlechat-gateway-minted-no-service-account-file",audienceType:"{{googlechatConfig.audienceType}}",audience:"{{googlechatConfig.audience}}",appPrincipal:"{{googlechatConfig.appPrincipal}}",webhookPath:"/googlechat",healthMonitor:{enabled:false},dm:{policy:"{{allowedIds.googlechat.dmPolicy}}",allowFrom:"{{allowedIds.googlechat.values}}"}}}},{id:"googlechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.googlechat",value:{enabled:true}}},{id:"googlechat-openclaw-gateway-reload-off",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"gateway.reload",value:{mode:"off"}}}],runtime:{openclaw:{channelName:"googlechat",visibility:{configKeys:["googlechat"],logPatterns:["googlechat"]},nodePreloads:[{module:"googlechat-trusted-proxy-fetch",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)",installedMessage:"[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)"},{module:"googlechat-outbound-auth",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat outbound-auth patch (gateway-minted bearer)",installedMessage:"[channels] Google Chat outbound-auth patch installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"-----BEGIN (?:RSA )?PRIVATE KEY-----",message:"[SECURITY] Google Chat service account private key leaked into {path} - refusing to serve",exitCode:78}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/googlechat@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-Dv0xOmcxAThEr6hoK+ioofHNu18hfbIceQrEHX3AHZPpOUiTJvToVpA5eX87NQINewwfSJf0gVhE6kSbSk2Aew=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.7.1.tgz"},required:true}],hooks:[{id:"googlechat-tunnel-audience-gate",phase:"enroll",handler:"googlechat.tunnelAudienceGate",inputs:["audienceType","audience"],outputs:[{id:"audience",kind:"config"}],onFailure:"skip-channel"},{id:"googlechat-service-account",phase:"enroll",handler:"googlechat.tokenPaste",outputs:[{id:"serviceAccount",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"googlechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appPrincipal",kind:"config"},{id:"allowFrom",kind:"config"}]}]};var slackRuntimeEnvAliases=[{envKey:"SLACK_BOT_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$",value:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",message:"[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias"},{envKey:"SLACK_APP_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$",value:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN",message:"[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias"}];var slackManifest={schemaVersion:1,id:"slack",displayName:"Slack",description:"Slack bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"SLACK_BOT_TOKEN",formatPattern:"^xoxb-[A-Za-z0-9_-]+$",formatHint:"Slack bot tokens start with 'xoxb-' (e.g. xoxb---).",prompt:{label:"Slack Bot Token",help:"Slack API \u2192 Your Apps \u2192 OAuth & Permissions \u2192 Bot User OAuth Token (xoxb-...)."}},{id:"appToken",kind:"secret",required:true,envKey:"SLACK_APP_TOKEN",formatPattern:"^xapp-[A-Za-z0-9_-]+$",formatHint:"Slack app tokens start with 'xapp-' (e.g. xapp----).",prompt:{label:"Slack App Token (Socket Mode)",help:"Slack API \u2192 Your Apps \u2192 Basic Information \u2192 App-Level Tokens (xapp-...)."}},{id:"allowedUsers",kind:"config",required:false,envKey:"SLACK_ALLOWED_USERS",statePath:"allowedIds.slack",prompt:{label:"Slack Member IDs (comma-separated allowlist)",help:"In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.",emptyValueMessage:"bot will require manual pairing"}},{id:"allowedChannels",kind:"config",required:false,envKey:"SLACK_ALLOWED_CHANNELS",statePath:"slackConfig.allowedChannels",prompt:{label:"Slack Channel IDs (comma-separated allowlist)",help:"Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.",emptyValueMessage:"channel @mentions stay unrestricted by channel ID"}}],credentials:[{id:"slackBotToken",sourceInput:"botToken",providerName:"{sandboxName}-slack-bridge",providerEnvKey:"SLACK_BOT_TOKEN",placeholder:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",primary:true},{id:"slackAppToken",sourceInput:"appToken",providerName:"{sandboxName}-slack-app",providerEnvKey:"SLACK_APP_TOKEN",placeholder:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"}],policyPresets:[{name:"slack",requiredAtCreate:true}],render:[{id:"slack-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.slack",value:{enabled:true,accounts:{default:{botToken:"{{credential.slackBotToken.placeholder}}",appToken:"{{credential.slackAppToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},dmPolicy:"{{allowedIds.slack.dmPolicy}}",allowFrom:"{{allowedIds.slack.values}}",groupPolicy:"{{allowedIds.slack.groupPolicy}}",channels:"{{allowedIds.slack.channels}}"}}}}},{id:"slack-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.slack",value:{enabled:true}}},{id:"slack-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["SLACK_BOT_TOKEN={{credential.slackBotToken.placeholder}}","SLACK_APP_TOKEN={{credential.slackAppToken.placeholder}}","SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}","SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}"]},{id:"slack-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.slack",value:{enabled:true,extra:{rich_blocks:true}}}}],runtime:{openclaw:{channelName:"slack",visibility:{configKeys:["slack"],logPatterns:["slack"]},envAliases:slackRuntimeEnvAliases,nodePreloads:[{module:"slack-channel-guard",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Slack channel guard (unhandled-rejection safety net)",installedMessage:"[channels] Slack channel guard installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)",message:"[SECURITY] Slack token leaked into {path} - refusing to serve",exitCode:78}]},hermes:{envAliases:slackRuntimeEnvAliases}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/slack@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"},required:true}],hooks:[{id:"slack-socket-mode-gateway-conflict",phase:"pre-enable",handler:"slack.socketModeGatewayConflict",onFailure:"abort"},{id:"slack-openclaw-bridge-health",phase:"health-check",handler:"slack.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"slack-socket-mode-gateway-status",phase:"status",handler:"slack.socketModeGatewayStatus",outputs:[{id:"gatewayOverlaps",kind:"status"}]},{id:"slack-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true},{id:"appToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"slack-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedUsers",kind:"config"},{id:"allowedChannels",kind:"config"}]},{id:"slack-credential-validation",phase:"reachability-check",handler:"slack.validateCredentials",inputs:["botToken","appToken"],onFailure:"skip-channel"}]};var teamsManifest={schemaVersion:1,id:"teams",displayName:"Microsoft Teams",description:"Microsoft Teams bot messaging (experimental)",enrollmentNotes:["Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.","Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"appId",kind:"config",required:true,envKey:"MSTEAMS_APP_ID",statePath:"teamsConfig.appId",prompt:{label:"Microsoft Teams Client ID",help:"Run `teams app create --endpoint https:///api/messages`, then copy CLIENT_ID."}},{id:"clientSecret",kind:"secret",required:true,envKey:"MSTEAMS_APP_PASSWORD",prompt:{label:"Microsoft Teams Client Secret",help:"Use the CLIENT_SECRET printed by `teams app create`. It is shown once; rotate it in Entra ID if it was lost."}},{id:"tenantId",kind:"config",required:true,envKey:"MSTEAMS_TENANT_ID",statePath:"teamsConfig.tenantId",prompt:{label:"Microsoft Teams Tenant ID",help:"Use the TENANT_ID printed by `teams app create` or shown by `teams status --verbose`."}},{id:"allowedUsers",kind:"config",required:false,envKey:"TEAMS_ALLOWED_USERS",statePath:"allowedIds.teams",prompt:{label:"Microsoft Teams AAD Object IDs (comma-separated allowlist)",help:"Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot."}},{id:"webhookPort",kind:"config",required:false,envKey:"MSTEAMS_PORT",statePath:"teamsConfig.webhookPort",defaultValue:"3978",prompt:{label:"Microsoft Teams webhook port",help:"Local bot webhook port to expose publicly. Defaults to 3978 and serves /api/messages."}},{id:"requireMention",kind:"config",required:false,envKey:"TEAMS_REQUIRE_MENTION",statePath:"teamsConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Microsoft Teams mention mode",help:"Controls OpenClaw group and channel behavior only. Direct messages are unaffected."}}],credentials:[{id:"teamsClientSecret",sourceInput:"clientSecret",providerName:"{sandboxName}-teams-bridge",providerEnvKey:"MSTEAMS_APP_PASSWORD",placeholder:"openshell:resolve:env:MSTEAMS_APP_PASSWORD",primary:true}],policyPresets:[{name:"teams",policyKeys:["teams"]}],hostForward:{port:"{{teamsConfig.webhookPort}}",label:"Microsoft Teams webhook"},render:[{id:"teams-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.msteams",value:{enabled:true,appId:"{{teamsConfig.appId}}",appPassword:"{{credential.teamsClientSecret.placeholder}}",tenantId:"{{teamsConfig.tenantId}}",webhook:{port:"{{teamsConfig.webhookPort}}",path:"/api/messages"},healthMonitor:{enabled:false},streaming:{mode:"off"},dmPolicy:"{{allowedIds.teams.dmPolicy}}",allowFrom:"{{allowedIds.teams.values}}",groupPolicy:"open",requireMention:"{{teamsConfig.requireMention}}"}}},{id:"teams-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.msteams",value:{enabled:true}}},{id:"teams-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TEAMS_CLIENT_ID={{teamsConfig.appId}}","TEAMS_CLIENT_SECRET={{credential.teamsClientSecret.placeholder}}","TEAMS_TENANT_ID={{teamsConfig.tenantId}}","TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}","TEAMS_PORT={{teamsConfig.webhookPort}}"]},{id:"teams-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.teams",value:{enabled:true}}}],runtime:{openclaw:{channelName:"msteams",visibility:{configKeys:["msteams"],logPatterns:["msteams","teams"]},nodePreloads:[{module:"msteams-message-hints",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Microsoft Teams message hint patch (native mentions)",installedMessage:"[channels] Microsoft Teams message hint patch installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/msteams@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz"},required:true},{id:"hermesTeamsAppsPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"microsoft-teams-apps==2.0.13.4",required:true},{id:"hermesAiohttpPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"aiohttp==3.14.3",required:true}],hooks:[{id:"teams-host-forward-port-conflict",phase:"pre-enable",handler:"teams.hostForwardPortConflict",inputs:["webhookPort"],onFailure:"abort"},{id:"teams-host-forward-port-status",phase:"status",handler:"teams.hostForwardPortStatus",outputs:[{id:"hostForwardPortOverlaps",kind:"status"}]},{id:"teams-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"clientSecret",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"teams-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appId",kind:"config",required:true},{id:"tenantId",kind:"config",required:true},{id:"allowedUsers",kind:"config"},{id:"webhookPort",kind:"config"},{id:"requireMention",kind:"config"}]}]};var telegramManifest={schemaVersion:1,id:"telegram",displayName:"Telegram",description:"Telegram bot messaging",diagnosticsProbe:"log-tail",enrollmentNotes:["For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).","After changing privacy mode, remove and re-add the bot to each group before testing @mentions."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"TELEGRAM_BOT_TOKEN",prompt:{label:"Telegram Bot Token",help:"Create a bot via @BotFather on Telegram, then copy the token."}},{id:"allowedIds",kind:"config",required:false,envKey:"TELEGRAM_ALLOWED_IDS",statePath:"allowedIds.telegram",prompt:{label:"Telegram User ID (for DM access)",help:"Send /start to @userinfobot on Telegram to get your numeric user ID.",emptyValueMessage:"bot will require manual pairing"}},{id:"requireMention",kind:"config",required:false,envKey:"TELEGRAM_REQUIRE_MENTION",statePath:"telegramConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Telegram group mention mode",help:"Controls Telegram group-chat behavior only \u2014 reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS."}},{id:"groupPolicy",kind:"config",required:false,envKey:"TELEGRAM_GROUP_POLICY",statePath:"telegramConfig.groupPolicy",validValues:["open","allowlist","disabled"],defaultValue:"open",prompt:{label:"Telegram group policy",help:"Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy."}}],credentials:[{id:"telegramBotToken",sourceInput:"botToken",providerName:"{sandboxName}-telegram-bridge",providerEnvKey:"TELEGRAM_BOT_TOKEN",placeholder:"openshell:resolve:env:TELEGRAM_BOT_TOKEN"}],policyPresets:[{name:"telegram",policyKeys:["telegram_bot"],agentPolicyKeys:{hermes:["telegram"]}}],render:[{id:"telegram-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.telegram",value:{enabled:true,accounts:{default:{botToken:"{{credential.telegramBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{proxyUrl}}",groupPolicy:"{{telegramConfig.groupPolicy}}",dmPolicy:"{{allowedIds.telegram.dmPolicy}}",allowFrom:"{{allowedIds.telegram.values}}"}}}}},{id:"telegram-openclaw-groups",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{telegramConfig.openclawGroups}}",fragment:{path:"channels.telegram.groups",value:"{{telegramConfig.openclawGroups}}"}},{id:"telegram-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.telegram",value:{enabled:true}}},{id:"telegram-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}","TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}"]},{id:"telegram-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"telegram",value:{require_mention:"{{telegramConfig.requireMention}}"}}},{id:"telegram-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.telegram",value:{enabled:true}}}],runtime:{openclaw:{channelName:"telegram",visibility:{configKeys:["telegram"],logPatterns:["telegram"]},nodePreloads:[{module:"telegram-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Telegram diagnostics (provider readiness + inference errors)",installedMessage:"[channels] Telegram diagnostics installed (NODE_OPTIONS updated)"}]}},hooks:[{id:"telegram-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"telegram-allowlist-aliases",phase:"enroll",handler:"telegram.allowlistAliases",outputs:[{id:"allowedIds",kind:"config"}]},{id:"telegram-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"requireMention",kind:"config"},{id:"allowedIds",kind:"config"}]},{id:"telegram-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"groupPolicy",kind:"config"}]},{id:"telegram-get-me-reachability",phase:"reachability-check",handler:"telegram.getMeReachability",inputs:["botToken"],onFailure:"skip-channel"},{id:"telegram-openclaw-bridge-health",phase:"health-check",handler:"telegram.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"telegram-gateway-conflict-status",phase:"status",handler:"telegram.gatewayConflictStatus",outputs:[{id:"bridgeHealth",kind:"status"}]},{id:"telegram-status-health",phase:"status",handler:"telegram.statusHealth",agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]}]};var wechatManifest={schemaVersion:1,id:"wechat",displayName:"WeChat",description:"WeChat (personal) bot messaging",enrollmentHelp:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only.",supportedAgents:["openclaw","hermes"],auth:{mode:"host-qr"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"WECHAT_BOT_TOKEN",prompt:{label:"WeChat Bot Token",help:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only."}},{id:"accountId",kind:"config",required:true,envKey:"WECHAT_ACCOUNT_ID",statePath:"wechatConfig.accountId"},{id:"baseUrl",kind:"config",required:false,envKey:"WECHAT_BASE_URL",statePath:"wechatConfig.baseUrl"},{id:"userId",kind:"config",required:false,envKey:"WECHAT_USER_ID",statePath:"wechatConfig.userId"},{id:"allowedIds",kind:"config",required:false,envKey:"WECHAT_ALLOWED_IDS",statePath:"allowedIds.wechat",prompt:{label:"WeChat User ID(s) (DM allowlist)",help:"Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.",emptyValueMessage:"bot will require manual pairing"}}],credentials:[{id:"wechatBotToken",sourceInput:"botToken",providerName:"{sandboxName}-wechat-bridge",providerEnvKey:"WECHAT_BOT_TOKEN",placeholder:"openshell:resolve:env:WECHAT_BOT_TOKEN"}],policyPresets:[{name:"wechat",policyKeys:["wechat_bridge"]}],render:[{id:"wechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.openclaw-weixin",value:{enabled:true}}},{id:"wechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WEIXIN_TOKEN={{credential.wechatBotToken.placeholder}}","WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}","WEIXIN_BASE_URL={{wechatConfig.baseUrl}}","WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}"]},{id:"wechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.weixin",value:{enabled:true}}}],runtime:{openclaw:{channelName:"openclaw-weixin",visibility:{configKeys:["openclaw-weixin"],logPatterns:["wechat","openclaw-weixin"]},nodePreloads:[{module:"wechat-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing WeChat diagnostics (provider readiness + inference errors)",installedMessage:"[channels] WeChat diagnostics installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@tencent-weixin/openclaw-weixin@2.4.3",pin:true,integrity:"sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",tarballUrl:"https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz",runtimeLock:{cachePath:"/usr/local/share/nemoclaw/wechat-npm-cache",installCacheEnvKey:"NEMOCLAW_WECHAT_NPM_INSTALL_CACHE",lockFile:"/usr/local/lib/nemoclaw/wechat-runtime/package-lock.json",projectsRoot:"/sandbox/.openclaw/npm/projects",verifierPath:"/usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts",offline:true,legacyPeerDeps:true},required:true}],hooks:[{id:"wechat-host-qr",phase:"enroll",handler:"wechat.ilinkLogin",inputs:["allowedIds"],outputs:[{id:"botToken",kind:"secret",required:true},{id:"accountId",kind:"config",required:true},{id:"baseUrl",kind:"config"},{id:"userId",kind:"config"},{id:"allowedIds",kind:"config"}],onFailure:"skip-channel"},{id:"wechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedIds",kind:"config"}]},{id:"wechat-seed-openclaw-account",phase:"post-agent-install",handler:"wechat.seedOpenClawAccount",agents:["openclaw"],inputs:["wechatConfig.accountId","wechatConfig.baseUrl","wechatConfig.userId","credential.wechatBotToken.placeholder"],outputs:[{id:"openclawWeixinAccountsIndex",kind:"build-file",required:true},{id:"openclawWeixinAccountFile",kind:"build-file",required:true},{id:"openclawConfigPatch",kind:"build-file",required:true}],onFailure:"abort"},{id:"wechat-health-check",phase:"health-check",handler:"wechat.healthCheck",inputs:["wechatConfig.accountId"],onFailure:"abort"}]};var whatsappManifest={schemaVersion:1,id:"whatsapp",displayName:"WhatsApp",description:"WhatsApp Web messaging (QR pairing)",enrollmentHelp:"WhatsApp Web pairs via QR code scanned with your phone \u2014 no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.",enrollmentNotes:["After pairing, run `nemoclaw channels status --channel whatsapp`. OpenClaw reports inbound delivery evidence; Hermes reports gateway and dashboard session-path diagnostics."],supportedAgents:["openclaw","hermes"],auth:{mode:"in-sandbox-qr"},inputs:[{id:"allowedIds",kind:"config",required:false,envKey:"WHATSAPP_ALLOWED_IDS",statePath:"allowedIds.whatsapp"}],credentials:[],policyPresets:["whatsapp"],render:[{id:"whatsapp-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.whatsapp",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false}}}}}},{id:"whatsapp-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.whatsapp",value:{enabled:true}}},{id:"whatsapp-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WHATSAPP_ENABLED=true","WHATSAPP_MODE=bot","WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}"]},{id:"whatsapp-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.whatsapp",value:{enabled:true}}}],runtime:{openclaw:{channelName:"whatsapp",visibility:{configKeys:["whatsapp"],logPatterns:["whatsapp"]},nodePreloads:[{module:"whatsapp-qr-compact",injectInto:["connect"],optional:true,installMessage:"[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/whatsapp@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz"},required:true}],hooks:[{id:"whatsapp-status-health",phase:"status",handler:"whatsapp.statusHealth",agents:["openclaw","hermes"],outputs:[{id:"channelHealth",kind:"status"}]}]};var BUILT_IN_CHANNEL_MANIFESTS=[telegramManifest,discordManifest,wechatManifest,slackManifest,whatsappManifest,teamsManifest,googlechatManifest];function createBuiltInChannelManifestRegistry(){return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS)}var EXACT_TEMPLATE_PATTERN=/^\{\{\s*([^}]+?)\s*\}\}$/;var TEMPLATE_REFERENCE_PATTERN=/\{\{\s*([^}]+?)\s*\}\}/g;function resolvedRenderTemplateReference(value){return{matched:true,value}}function resolveSandboxNameTemplate(value,sandboxName){return value.replaceAll("{sandboxName}",sandboxName)}function resolveRenderTemplatesInValue(value,context){if(typeof value==="string")return resolveRenderTemplatesInString(value,context);if(Array.isArray(value)){if(value.length===0)return value;const resolved=value.map(entry=>resolveRenderTemplatesInValue(entry,context)).filter(entry=>entry!==void 0);return resolved.length>0?resolved:void 0}if(value&&typeof value==="object"){const sourceEntries=Object.entries(value);if(sourceEntries.length===0)return value;const entries=sourceEntries.map(([key,entry])=>[key,resolveRenderTemplatesInValue(entry,context)]).filter(entry=>entry[1]!==void 0);return entries.length>0?Object.fromEntries(entries):void 0}return value}function isTruthyRenderTemplate(value,context){if(!value)return true;const resolved=resolveRenderTemplatesInString(value,context);if(resolved===void 0||resolved===null||resolved===false)return false;if(Array.isArray(resolved))return resolved.length>0;if(typeof resolved==="object")return Object.keys(resolved).length>0;if(typeof resolved==="string")return resolved.trim().length>0;return true}function resolveRenderTemplatesInString(value,context){const exact=value.match(EXACT_TEMPLATE_PATTERN);if(exact?.[1])return resolveTemplateReference(exact[1].trim(),context);let omitted=false;const resolved=value.replace(TEMPLATE_REFERENCE_PATTERN,(match,reference)=>{const replacement=resolveTemplateReference(reference.trim(),context);if(replacement===void 0||replacement===null){omitted=true;return""}if(Array.isArray(replacement))return replacement.map(String).join(",");if(typeof replacement==="object")return JSON.stringify(replacement);return String(replacement)});return omitted?void 0:resolved}function resolveTemplateReference(reference,context){const resolved=context.referenceResolver?.(reference,context);return resolved?.matched?resolved.value:"{{"+reference+"}}"}function allowedIds(context,channel){return parseList(stateValue(context,`allowedIds.${channel}`))}function stateValue(context,path5){const stateInput=context.inputs.find(input=>input.statePath===path5);if(stateInput?.value!==void 0)return stateInput.value;const inputId=path5.split(".").at(-1);return context.inputs.find(input=>input.inputId===inputId)?.value}function parseList(value){if(Array.isArray(value))return unique(value.map(String).map(cleanString).filter(Boolean));const text=cleanString(value);if(!text)return[];return unique(text.split(",").map(cleanString).filter(Boolean))}function parseBoolean(value){if(typeof value==="boolean")return value;const text=cleanString(value)?.toLowerCase();if(text==="1"||text==="true"||text==="yes"||text==="on")return true;if(text==="0"||text==="false"||text==="no"||text==="off")return false;return void 0}function nonEmptyString(value){return cleanString(value)||void 0}function cleanString(value){const text=String(value??"");if(/[\r\n]/.test(text)){throw new Error("Messaging template values must not contain line breaks.")}return text.trim()}function nonEmptyArray(values){return values.length>0?[...values]:void 0}function nonEmptyCsv(values){return values.length>0?values.join(","):void 0}function nonEmptyObject(value){return Object.keys(value).length>0?value:void 0}function unique(values){return[...new Set(values)]}var resolveDiscordTemplateReference=(reference,context)=>{if(reference==="discordProxyUrl")return resolvedRenderTemplateReference(void 0);switch(reference){case"discord.guilds":return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context)));case"discord.hasGuilds":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0);case"discord.guildIds.csv":return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context))));case"discord.allowedUsers.values":return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context)));case"discord.allowedUsers.csv":return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context)));case"discord.allowedUsers.dmPolicy":return resolvedRenderTemplateReference(discordAllowedUsers(context).length>0?"allowlist":void 0);case"discord.allowAllUsers":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0&&discordAllowedUsers(context).length===0?true:void 0);case"discord.requireMention":return resolvedRenderTemplateReference(discordRequireMention(context));default:return void 0}};function discordGuilds(context){const serverIds=parseList(stateValue(context,"discordGuilds.serverId"));if(serverIds.length===0)return{};const users=parseList(stateValue(context,"discordGuilds.userIds"));const requireMention=parseBoolean(stateValue(context,"discordGuilds.requireMention"))??true;return Object.fromEntries(serverIds.map(serverId=>[serverId,{requireMention,...users.length>0?{users}:{}}]))}function discordAllowedUsers(context){const users=new Set(allowedIds(context,"discord"));for(const guild of Object.values(discordGuilds(context))){for(const user of guild.users??[])users.add(String(user))}return[...users]}function discordRequireMention(context){for(const guild of Object.values(discordGuilds(context))){if(typeof guild.requireMention==="boolean")return guild.requireMention}return true}var DEFAULT_AUDIENCE_TYPE="app-url";var APP_PRINCIPAL_DISCOVERY_SENTINEL="000000000000000000000";var resolveGooglechatTemplateReference=(reference,context)=>{switch(reference){case"googlechatConfig.audienceType":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audienceType"))??DEFAULT_AUDIENCE_TYPE);case"googlechatConfig.audience":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audience")));case"googlechatConfig.appPrincipal":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.appPrincipal"))??APP_PRINCIPAL_DISCOVERY_SENTINEL);default:break}const allowReference=reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy)$/);if(!allowReference?.[1])return void 0;const ids=allowedIds(context,"googlechat");switch(allowReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};var resolveSlackTemplateReference=(reference,context)=>{if(reference==="slackConfig.allowedChannels.csv"){return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context)))}const allowedIdsReference=reference.match(/^allowedIds[.]slack[.](values|csv|dmPolicy|groupPolicy|channels)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"slack");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"groupPolicy":return resolvedRenderTemplateReference(ids.length>0||slackAllowedChannels(context).length>0?"allowlist":void 0);case"channels":return resolvedRenderTemplateReference(slackChannelConfig(context,ids));default:return void 0}};function slackChannelConfig(context,users){const allowedChannels=slackAllowedChannels(context);const entry={enabled:true,requireMention:true,...users.length>0?{users:[...users]}:{}};if(allowedChannels.length>0){return Object.fromEntries(allowedChannels.map(channelId=>[channelId,{...entry}]))}return users.length>0?{"*":entry}:void 0}function slackAllowedChannels(context){return parseList(stateValue(context,"slackConfig.allowedChannels"))}var DEFAULT_TEAMS_WEBHOOK_PORT=3978;var resolveTeamsTemplateReference=(reference,context)=>{switch(reference){case"teamsConfig.appId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.appId")));case"teamsConfig.tenantId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.tenantId")));case"teamsConfig.webhookPort":return resolvedRenderTemplateReference(teamsWebhookPort(context));case"teamsConfig.requireMention":return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"teamsConfig.requireMention")));default:break}const allowedIdsReference=reference.match(/^allowedIds[.]teams[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"teams");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function teamsWebhookPort(context){const raw=nonEmptyString(stateValue(context,"teamsConfig.webhookPort"));if(!raw)return DEFAULT_TEAMS_WEBHOOK_PORT;const port=Number(raw);if(!Number.isInteger(port)||port<1||port>65535){throw new Error("Microsoft Teams webhook port must be an integer TCP port between 1 and 65535.")}return port}var DEFAULT_PROXY_HOST="10.200.0.1";var DEFAULT_PROXY_PORT="3128";var DEFAULT_TELEGRAM_GROUP_POLICY="open";var TELEGRAM_GROUP_POLICIES=new Set(["open","allowlist","disabled"]);var resolveTelegramTemplateReference=(reference,context)=>{if(reference==="proxyUrl")return resolvedRenderTemplateReference(proxyUrl(context.env));if(reference==="telegramConfig.groupPolicy"){return resolvedRenderTemplateReference(telegramGroupPolicy(context))}if(reference==="telegramConfig.openclawGroups"){return resolvedRenderTemplateReference(telegramOpenClawGroups(context))}if(reference==="telegramConfig.requireMention"){return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"telegramConfig.requireMention")))}const allowedIdsReference=reference.match(/^allowedIds[.]telegram[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"telegram");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function proxyUrl(env){const host=nonEmptyString(env?.NEMOCLAW_PROXY_HOST)??DEFAULT_PROXY_HOST;const port=nonEmptyString(env?.NEMOCLAW_PROXY_PORT)??DEFAULT_PROXY_PORT;return`http://${host}:${port}`}function telegramGroupPolicy(context){const value=nonEmptyString(stateValue(context,"telegramConfig.groupPolicy"));return value&&TELEGRAM_GROUP_POLICIES.has(value)?value:DEFAULT_TELEGRAM_GROUP_POLICY}function telegramOpenClawGroups(context){if(telegramGroupPolicy(context)!=="open")return void 0;const requireMention=parseBoolean(stateValue(context,"telegramConfig.requireMention"));return requireMention===true?{"*":{requireMention:true}}:void 0}var WECHAT_ILINK_HOSTS=new Set(["ilinkai.weixin.qq.com","ilinkai.wechat.com"]);var WECHAT_ILINK_IDC_HOST_PATTERN=/^idc-[0-9]+[.]weixin[.]qq[.]com$/;function normalizeWechatIlinkBaseUrl(value){const raw=String(value??"");if(/[\r\n]/.test(raw)){throw new Error("WeChat baseUrl must not contain line breaks.")}const text=raw.trim();if(!text)return void 0;let url;try{url=new URL(text)}catch{throw new Error("WeChat baseUrl must be a valid URL.")}if(url.protocol!=="https:"){throw new Error("WeChat baseUrl must use HTTPS.")}if(url.username||url.password){throw new Error("WeChat baseUrl must not include credentials.")}if(!isWechatIlinkHost(url.hostname)){throw new Error("WeChat baseUrl must use an expected iLink host.")}if(url.pathname&&url.pathname!=="/"||url.search||url.hash){throw new Error("WeChat baseUrl must be an iLink origin URL.")}return url.origin}function isWechatIlinkHost(hostname){const normalized=hostname.toLowerCase();return WECHAT_ILINK_HOSTS.has(normalized)||WECHAT_ILINK_IDC_HOST_PATTERN.test(normalized)}var resolveWechatTemplateReference=(reference,context)=>{const wechatConfig=reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/);if(wechatConfig?.[1]){if(wechatConfig[1]==="baseUrl"){return resolvedRenderTemplateReference(normalizeWechatIlinkBaseUrl(stateValue(context,"wechatConfig.baseUrl")))}return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"wechatConfig."+wechatConfig[1])))}const allowedIdsReference=reference.match(/^allowedIds[.]wechat[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=wechatAllowedIds(context);switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function wechatAllowedIds(context){const ids=allowedIds(context,"wechat");const userId=nonEmptyString(stateValue(context,"wechatConfig.userId"));return userId&&!ids.includes(userId)?[userId,...ids]:ids}var resolveWhatsappTemplateReference=(reference,context)=>{const allowedIdsReference=reference.match(/^allowedIds[.]whatsapp[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"whatsapp");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};var BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS=[resolveTelegramTemplateReference,resolveDiscordTemplateReference,resolveWechatTemplateReference,resolveSlackTemplateReference,resolveWhatsappTemplateReference,resolveTeamsTemplateReference,resolveGooglechatTemplateReference];function createBuiltInRenderTemplateResolver(){return(reference,context)=>{for(const resolver of BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS){const resolved=resolver(reference,context);if(resolved)return resolved}return void 0}}var import_node_crypto=__toESM(require("node:crypto"));function hashCredential(value){const normalized=String(value??"").trim();if(!normalized)return null;return import_node_crypto.default.createHash("sha256").update(normalized).digest("hex")}function planCredentialBindings(manifest,context,inputs,environment=process.env){return manifest.credentials.map(credential=>{const sourceInput=inputs.find(input=>input.inputId===credential.sourceInput);const credentialAvailable=sourceInput?.credentialAvailable===true||context.credentialAvailability?.[credential.id]===true||context.credentialAvailability?.[`${manifest.id}.${credential.id}`]===true;const envKey=sourceInput?.sourceEnv??credential.providerEnvKey;const credentialHash=credentialAvailable?hashCredential(environment[envKey])??void 0:void 0;return{channelId:manifest.id,credentialId:credential.id,sourceInput:credential.sourceInput,providerName:resolveSandboxNameTemplate(credential.providerName,context.sandboxName),providerEnvKey:credential.providerEnvKey,placeholder:credential.placeholder,credentialAvailable,...credentialHash!==void 0?{credentialHash}:{}}})}function planHostForward(manifest,inputs,active,referenceResolver,environment=process.env){if(!active||!manifest.hostForward)return void 0;const context={inputs,env:environment,referenceResolver};if(!isTruthyRenderTemplate(manifest.hostForward.when,context))return void 0;const portValue=resolveRenderTemplatesInValue(manifest.hostForward.port,context);const port=normalizeForwardPort(manifest.id,portValue);return{channelId:manifest.id,port,label:manifest.hostForward.label}}function normalizeForwardPort(channelId,value){const port=typeof value==="number"?value:Number(String(value??"").trim());if(!Number.isInteger(port)||port<1||port>65535){throw new Error(`Channel manifest '${channelId}' declares invalid host forward port '${String(value)}'.`)}return port}var OPENSHELL_ENV_PLACEHOLDER_PREFIX="openshell:resolve:env:";var OPENSHELL_ALIAS_PLACEHOLDER_RE=/^[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-(.+)$/;function normalizeProviderPlaceholderForEnvKey(value,envKey){if(value.startsWith(OPENSHELL_ENV_PLACEHOLDER_PREFIX)){return placeholderSuffixMatchesEnvKey(value.slice(OPENSHELL_ENV_PLACEHOLDER_PREFIX.length),envKey)?`${OPENSHELL_ENV_PLACEHOLDER_PREFIX}${envKey}`:null}const aliasMatch=value.match(OPENSHELL_ALIAS_PLACEHOLDER_RE);if(!aliasMatch||!placeholderSuffixMatchesEnvKey(aliasMatch[1],envKey)){return null}return value.replace(/-OPENSHELL-RESOLVE-ENV-.+$/,`-OPENSHELL-RESOLVE-ENV-${envKey}`)}function placeholderSuffixMatchesEnvKey(suffix,envKey){if(suffix===envKey)return true;const revisionMatch=suffix.match(/^v[0-9]+_(.+)$/);return revisionMatch?.[1]===envKey}function hasFullPersistedCredentialBindingShape(binding){return typeof binding.channelId==="string"&&typeof binding.credentialId==="string"&&typeof binding.sourceInput==="string"&&typeof binding.providerName==="string"&&typeof binding.providerEnvKey==="string"&&typeof binding.placeholder==="string"&&typeof binding.credentialAvailable==="boolean"}function normalizeFullPersistedCredentialBindings(bindings){return bindings.map(binding=>({channelId:binding.channelId,credentialId:binding.credentialId,sourceInput:binding.sourceInput,providerName:binding.providerName,providerEnvKey:binding.providerEnvKey,placeholder:normalizeProviderPlaceholderForEnvKey(binding.placeholder,binding.providerEnvKey)??binding.placeholder,credentialAvailable:binding.credentialAvailable===true,...typeof binding.credentialHash==="string"?{credentialHash:binding.credentialHash}:{}}))}function normalizePersistedAgentCredentialPlaceholders(render,credentialBindings){const credentialEnvKeys=new Set(credentialBindings.map(binding=>binding.providerEnvKey).filter(Boolean));if(credentialEnvKeys.size===0)return[...render];return render.map(entry=>{if(entry.kind!=="env-lines")return entry;return{...entry,lines:entry.lines.map(line=>normalizeCredentialEnvLine(line,credentialEnvKeys))}})}function normalizeCredentialEnvLine(line,credentialEnvKeys){const index=line.indexOf("=");if(index<=0)return line;const envKey=line.slice(0,index).trim();if(!credentialEnvKeys.has(envKey))return line;const value=line.slice(index+1);const normalized=normalizeProviderPlaceholderForEnvKey(value,envKey);return normalized?`${envKey}=${normalized}`:line}function normalizePersistedSandboxMessagingPlanShape(plan,environment=process.env){const manifestRegistry=createBuiltInChannelManifestRegistry();const disabledChannels=plan.disabledChannels.filter(channelId=>typeof channelId==="string");const disabledSet=new Set(disabledChannels);const channels=plan.channels.map(channel=>normalizePersistedChannel(channel,disabledSet,manifestRegistry.get(channel.channelId),environment));const credentialBindings=normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment);const normalizedPlan={...plan,channels,disabledChannels,credentialBindings,networkPolicy:plan.networkPolicy&&Array.isArray(plan.networkPolicy.entries)?plan.networkPolicy:{presets:[],entries:[]},agentRender:normalizePersistedAgentCredentialPlaceholders(Array.isArray(plan.agentRender)?[...plan.agentRender]:[],credentialBindings),buildSteps:Array.isArray(plan.buildSteps)?[...plan.buildSteps]:[],...plan.runtimeSetup!==void 0?{runtimeSetup:normalizeRuntimeSetup(plan.runtimeSetup)}:{},stateUpdates:Array.isArray(plan.stateUpdates)?[...plan.stateUpdates]:[],healthChecks:Array.isArray(plan.healthChecks)?[...plan.healthChecks]:[]};return normalizedPlan}function normalizePersistedChannel(channel,disabledSet,manifest,environment){const disabled=channel.disabled??disabledSet.has(channel.channelId);const configured=channel.configured??true;const hasFullShape=hasFullChannelShape(channel);const inputs=hasFullShape?normalizeFullInputs(channel.channelId,channel.inputs??[]):normalizePersistedInputs(channel,manifest);const active=channel.active??(configured&&!disabled&&requiredInputsAvailable(manifest,inputs));const hostForward=manifest?planHostForward(manifest,inputs,active&&!disabled,createBuiltInRenderTemplateResolver(),environment):void 0;return{channelId:channel.channelId,displayName:channel.displayName??manifest?.displayName??channel.channelId,authMode:channel.authMode??manifest?.auth.mode??"none",active,selected:channel.selected??configured,configured,disabled,inputs,...hostForward?{hostForward}:{},hooks:Array.isArray(channel.hooks)?[...channel.hooks]:[]}}function normalizePersistedInputs(channel,manifest){const persistedById=new Map((channel.inputs??[]).filter(input=>typeof input.inputId==="string").map(input=>[input.inputId,input]));const fromManifest=(manifest?.inputs??[]).map(input=>inputReferenceFromManifest(channel.channelId,input,persistedById.get(input.id)));const manifestInputIds=new Set((manifest?.inputs??[]).map(input=>input.id));const unknownInputs=[...persistedById.values()].flatMap(input=>{if(!input.inputId||manifestInputIds.has(input.inputId))return[];return[normalizeUnknownInput(channel.channelId,input)]});return[...fromManifest,...unknownInputs]}function normalizeFullInputs(channelId,inputs){return inputs.filter(input=>typeof input.inputId==="string").map(input=>({channelId:typeof input.channelId==="string"?input.channelId:channelId,inputId:input.inputId,kind:input.kind==="secret"||input.kind==="config"?input.kind:"config",required:typeof input.required==="boolean"?input.required:false,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}))}function inputReferenceFromManifest(channelId,input,persisted){return{channelId,inputId:input.id,kind:input.kind,required:input.required,...input.envKey?{sourceEnv:input.envKey}:{},...input.kind==="config"&&input.statePath?{statePath:input.statePath}:{},...persisted?.credentialAvailable!==void 0?{credentialAvailable:persisted.credentialAvailable}:{},...persisted?.value!==void 0?{value:persisted.value}:{}}}function normalizeUnknownInput(channelId,input){const kind=input.kind==="secret"||input.kind==="config"?input.kind:"config";return{channelId,inputId:input.inputId,kind,required:input.required===true,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}}function requiredInputsAvailable(manifest,inputs){if(!manifest)return true;return manifest.inputs.every(manifestInput=>{if(!manifestInput.required)return true;const input=inputs.find(entry=>entry.inputId===manifestInput.id);if(!input)return false;if(input.kind==="secret")return input.credentialAvailable===true;if(input.value===void 0)return false;return typeof input.value==="string"?input.value.trim().length>0:true})}function normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment){const persisted=plan.credentialBindings??[];if(Array.isArray(plan.credentialBindings)&&plan.channels.every(hasFullChannelShape)&&persisted.every(hasFullPersistedCredentialBindingShape)){return normalizeFullPersistedCredentialBindings(persisted)}const manifests=channels.flatMap(channel=>{const manifest=manifestRegistry.get(channel.channelId);return manifest?[manifest]:[]});const planForBindings={...plan,channels,credentialBindings:[],networkPolicy:{presets:[],entries:[]},agentRender:[],buildSteps:[],runtimeSetup:{nodePreloads:[],envAliases:[],secretScans:[]},stateUpdates:[],healthChecks:[]};const generated=credentialBindingsFromManifests(planForBindings,manifests,new Map(channels.map(channel=>[channel.channelId,channel.inputs])),environment);return generated.map(binding=>overlayPersistedCredentialBinding(binding,persisted))}function credentialBindingsFromManifests(plan,manifests,inputRegistry,environment){const context=compilerContext(plan);return manifests.flatMap(manifest=>planCredentialBindings(manifest,context,inputRegistry.get(manifest.id)??[],environment).map(binding=>overlayPersistedCredentialBinding(binding,plan.credentialBindings)))}function overlayPersistedCredentialBinding(binding,persisted){const match=persisted.find(candidate=>credentialBindingMatches(binding,candidate));if(!match)return binding;return{...binding,credentialAvailable:typeof match.credentialAvailable==="boolean"?match.credentialAvailable:binding.credentialAvailable,...typeof match.credentialHash==="string"&&match.credentialHash.length>0?{credentialHash:match.credentialHash}:binding.credentialHash?{credentialHash:binding.credentialHash}:{}}}function credentialBindingMatches(binding,candidate){if(candidate.channelId&&candidate.channelId!==binding.channelId)return false;if(candidate.providerEnvKey&&candidate.providerEnvKey===binding.providerEnvKey)return true;if(candidate.credentialId&&candidate.credentialId===binding.credentialId)return true;if(candidate.sourceInput&&candidate.sourceInput===binding.sourceInput)return true;return false}function hasFullChannelShape(channel){return typeof channel.displayName==="string"&&typeof channel.authMode==="string"&&typeof channel.active==="boolean"&&typeof channel.selected==="boolean"&&typeof channel.configured==="boolean"&&typeof channel.disabled==="boolean"&&Array.isArray(channel.inputs)}function normalizeRuntimeSetup(setup){return{nodePreloads:Array.isArray(setup?.nodePreloads)?[...setup.nodePreloads]:[],envAliases:Array.isArray(setup?.envAliases)?[...setup.envAliases]:[],secretScans:Array.isArray(setup?.secretScans)?[...setup.secretScans]:[]}}function compilerContext(plan){return{sandboxName:plan.sandboxName,agent:plan.agent,workflow:plan.workflow,isInteractive:false,configuredChannels:plan.channels.map(channel=>channel.channelId),disabledChannels:plan.disabledChannels,credentialAvailability:credentialAvailabilityFromPlan(plan)}}function credentialAvailabilityFromPlan(plan){const availability={};for(const channel of plan.channels){for(const input of channel.inputs){if(input.kind!=="secret"||input.credentialAvailable!==true)continue;availability[input.inputId]=true;availability[`${channel.channelId}.${input.inputId}`]=true;if(input.sourceEnv)availability[input.sourceEnv]=true}}for(const credential of plan.credentialBindings){if(!credential.credentialAvailable)continue;availability[credential.credentialId]=true;availability[`${credential.channelId}.${credential.credentialId}`]=true;availability[credential.sourceInput]=true;availability[`${credential.channelId}.${credential.sourceInput}`]=true;availability[credential.providerEnvKey]=true}return availability}function normalizeMessagingChannelId(channelId){return channelId.trim().toLowerCase()}function enabledPlanChannels(plan){const disabled=new Set((plan.disabledChannels??[]).map(normalizeMessagingChannelId).filter(Boolean));return plan.channels.filter(channel=>{const channelId=normalizeMessagingChannelId(channel.channelId);return channelId.length>0&&channel.active&&!channel.disabled&&!disabled.has(channelId)})}function selectActiveMessagingChannelIds(plan){const seen=new Set;const channels=[];for(const item of enabledPlanChannels(plan)){const channel=normalizeMessagingChannelId(item.channelId);if(!channel||seen.has(channel))continue;seen.add(channel);channels.push(channel)}return channels}function selectEnabledMessagingAgentRender(plan){const active=new Set(selectActiveMessagingChannelIds(plan));return plan.agentRender.filter(render=>render.agent===plan.agent&&active.has(normalizeMessagingChannelId(render.channelId)))}function selectEnabledPostAgentInstallBuildFiles(plan){const active=new Set(selectActiveMessagingChannelIds(plan));const channels=enabledPlanChannels(plan);return plan.buildSteps.filter(step=>{const channelId=normalizeMessagingChannelId(step.channelId);if(!active.has(channelId)||step.kind!=="build-file")return false;if(!step.hookId)return true;const matchingChannels=channels.filter(channel=>normalizeMessagingChannelId(channel.channelId)===channelId);if(matchingChannels.length!==1)return false;const matchedHook=matchingChannels[0]?.hooks?.find(hook=>hook.id===step.hookId);return matchedHook!==void 0&&matchedHook.phase==="post-agent-install"})}function parseSandboxMessagingPlan(value,options={}){if(!isObjectRecord(value)||value.schemaVersion!==1||typeof value.sandboxName!=="string"||typeof value.agent!=="string"||typeof value.workflow!=="string"||!Array.isArray(value.channels)||!Array.isArray(value.disabledChannels)||!isOptionalObjectArray(value,"credentialBindings")||Object.hasOwn(value,"networkPolicy")&&!isObjectRecord(value.networkPolicy)||!isOptionalObjectArray(value,"agentRender")||!isOptionalObjectArray(value,"buildSteps")||!isRuntimeSetup(value.runtimeSetup)||!isOptionalObjectArray(value,"stateUpdates")||!isOptionalObjectArray(value,"healthChecks")){return null}if(options.sandboxName&&value.sandboxName!==options.sandboxName)return null;if(options.agent&&value.agent!==options.agent)return null;const supported=Array.isArray(options.supportedChannelIds)?new Set(options.supportedChannelIds):null;const normalizedChannelIds=new Set;for(const channel of value.channels){if(!isObjectRecord(channel)||typeof channel.channelId!=="string")return null;const normalizedChannelId=normalizeMessagingChannelId(channel.channelId);if(!normalizedChannelId||normalizedChannelId!==channel.channelId||normalizedChannelIds.has(normalizedChannelId)){return null}if(Object.hasOwn(channel,"configured")&&typeof channel.configured!=="boolean"){return null}if(Object.hasOwn(channel,"active")&&typeof channel.active!=="boolean")return null;if(Object.hasOwn(channel,"disabled")&&typeof channel.disabled!=="boolean")return null;if(Object.hasOwn(channel,"inputs")&&!Array.isArray(channel.inputs))return null;if(Object.hasOwn(channel,"hostForward")&&!isHostForward(channel.hostForward))return null;if(Object.hasOwn(channel,"hooks")&&!Array.isArray(channel.hooks))return null;if(Array.isArray(channel.inputs)&&channel.inputs.some(input=>!isObjectRecord(input)||typeof input.inputId!=="string"||Object.hasOwn(input,"channelId")&&input.channelId!==normalizedChannelId)){return null}if(Array.isArray(channel.hooks)&&channel.hooks.some(hook=>!isObjectRecord(hook)||Object.hasOwn(hook,"channelId")&&hook.channelId!==normalizedChannelId)){return null}if(Object.hasOwn(channel,"hostForward")&&isObjectRecord(channel.hostForward)&&channel.hostForward.channelId!==normalizedChannelId){return null}if(supported&&!supported.has(channel.channelId))return null;normalizedChannelIds.add(normalizedChannelId)}if(!value.disabledChannels.every(isCanonicalMessagingChannelId))return null;const disabledChannelIds=new Set(value.disabledChannels);if(disabledChannelIds.size!==value.disabledChannels.length||[...disabledChannelIds].some(channelId=>!normalizedChannelIds.has(channelId))||value.channels.some(channel=>isObjectRecord(channel)&&channel.disabled===true!==disabledChannelIds.has(String(channel.channelId)))){return null}if(!hasCanonicalChannelReferences(value.credentialBindings)||!hasCanonicalChannelReferences(value.agentRender)||!hasCanonicalChannelReferences(value.buildSteps)||!hasCanonicalChannelReferences(value.stateUpdates)||!hasCanonicalChannelReferences(value.healthChecks)||!hasCanonicalNetworkPolicyReferences(value.networkPolicy)||!hasCanonicalRuntimeSetupReferences(value.runtimeSetup)){return null}return cloneSandboxMessagingPlan(normalizePersistedSandboxMessagingPlanShape(value,options.environment))}function cloneSandboxMessagingPlan(plan){return JSON.parse(JSON.stringify(plan))}function isOptionalObjectArray(value,key){if(!Object.hasOwn(value,key))return true;const entries=value[key];return Array.isArray(entries)&&entries.every(isObjectRecord)}function isHostForward(value){return isObjectRecord(value)&&typeof value.channelId==="string"&&typeof value.port==="number"&&Number.isInteger(value.port)&&value.port>=1&&value.port<=65535&&typeof value.label==="string"}function isRuntimeSetup(value){if(value===void 0)return true;return isObjectRecord(value)&&Array.isArray(value.nodePreloads)&&Array.isArray(value.envAliases)&&Array.isArray(value.secretScans)&&value.nodePreloads.every(isObjectRecord)&&value.envAliases.every(isObjectRecord)&&value.secretScans.every(isObjectRecord)}function isCanonicalMessagingChannelId(value){return typeof value==="string"&&value.length>0&&normalizeMessagingChannelId(value)===value}function hasCanonicalChannelReferences(value){return value===void 0||Array.isArray(value)&&value.every(entry=>isObjectRecord(entry)&&isCanonicalMessagingChannelId(entry.channelId))}function hasCanonicalNetworkPolicyReferences(value){if(!isObjectRecord(value)||!Object.hasOwn(value,"entries"))return true;return hasCanonicalChannelReferences(value.entries)}function hasCanonicalRuntimeSetupReferences(value){if(value===void 0)return true;if(!isObjectRecord(value))return false;return["nodePreloads","envAliases","secretScans"].every(field=>hasCanonicalChannelReferences(value[field]))}var import_node_buffer=require("node:buffer");var import_node_crypto2=require("node:crypto");var import_node_util=require("node:util");var MANAGED_STARTUP_PROFILE_SCHEMA_VERSION=1;var MANAGED_STARTUP_PROFILE_MAX_BYTES=64*1024;var MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES=Math.ceil(MANAGED_STARTUP_PROFILE_MAX_BYTES/3)*4;var MAX_IDENTIFIER_BYTES=256;var MAX_MODEL_BYTES=1024;var MAX_URL_BYTES=2048;var MAX_LIST_ITEMS=128;var MAX_JSON_NODES=4096;var MAX_JSON_DEPTH=32;var MAX_TUNING_INTEGER=1e9;var MIN_HERMES_CONTEXT_WINDOW=64e3;var SHA256_RE=/^[a-f0-9]{64}$/;var CONTROL_CHARACTER_RE=/[\u0000-\u001f\u007f-\u009f]/u;var BASE64URL_RE=/^[A-Za-z0-9_-]+$/;var RAW_CA_PEM_RE=/-----BEGIN (?:TRUSTED )?CERTIFICATE-----/iu;var RAW_CA_PEM_BASE64_RE=/^LS0tLS1CRUdJTi(?:BDRVJUSUZJQ0FURS0tLS0t|BUlVTVEVEIENFUlRJRklDQVRFLS0tLS0)/u;var RAW_CA_DER_BASE64_RE=/^MII[A-Za-z0-9+/=\r\n]{253,}$/u;var RAW_CA_DATA_URI_RE=/data:application\/(?:pkix-cert|x-x509-ca-cert);base64,MII[A-Za-z0-9+/=]{253,}/iu;var URL_CANDIDATE_RE=/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;var UTF8_DECODER=new import_node_util.TextDecoder("utf-8",{fatal:true});var CREDENTIAL_SHAPED_NAME_PATTERN=/(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/iu;var CREDENTIAL_COMPOUND_NAME_PATTERN=/^(?:access|refresh|client|bearer|auth|api|private|signing|session|bot|app|resolved)(?:token|key|secret|password)$/iu;var CREDENTIAL_CAMEL_SUFFIX_PATTERN=/(?:apiKey|accessKey|secretKey|authToken|refreshToken|accessToken|clientSecret|privateKey|passcode|password|passwd|passphrase|bearerToken|botToken|appToken|sessionToken|signingKey|secretPublicKey|personalAccessToken|connectionString|webhookUrl)$/iu;var CREDENTIAL_CAMEL_BOUNDARY_PATTERN=/[a-z0-9](?:Token|Key|Secret|Password|Passphrase|Pat)$/u;var CREDENTIAL_ENV_NAME_PATTERN=/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/u;var CREDENTIAL_HEADER_NAME_PATTERN=/^(?:authorization|proxy-authorization|cookie|set-cookie|.+-(?:key|token|secret|password|passphrase|credential|auth)s?)$/iu;var PUBLIC_KEY_NAME_PATTERN=/^public[-_]?keys?$/iu;var PASS_CREDENTIAL_NAME_PATTERN=/(?:^|[-_])pass(?:wd)?$/iu;var NON_SECRET_KEY_METADATA_NAMES=new Set(["envKey","installCacheEnvKey","providerEnvKey","stateKey"]);var MESSAGING_CREDENTIAL_PLACEHOLDER_RE=/^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u;var SECRET_VALUE_PATTERNS=[/nvapi-[A-Za-z0-9_-]{10,}/u,/nvcf-[A-Za-z0-9_-]{10,}/u,/ghp_[A-Za-z0-9_-]{10,}/u,/github_pat_[A-Za-z0-9_]{30,}/u,/sk-(?:proj-|ant-)?[A-Za-z0-9_-]{10,}/u,/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/u,/A(?:K|S)IA[A-Z0-9]{16}/u,/hf_[A-Za-z0-9]{10,}/u,/glpat-[A-Za-z0-9_-]{10,}/u,/gsk_[A-Za-z0-9]{10,}/u,/pypi-[A-Za-z0-9_-]{10,}/u,/tvly-[A-Za-z0-9_-]{10,}/u,/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/u,/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/u,/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/u,/\bBearer\s+[A-Za-z0-9_.+/=-]{10,}/iu,/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----/u];var MANAGED_STARTUP_INFERENCE_APIS=["openai-completions","openai-responses","anthropic-messages"];var MANAGED_STARTUP_REASONING_EFFORTS=["default","low","medium","high"];var MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES=["disabled","thread-opt-in"];var MANAGED_STARTUP_HERMES_TOOL_GATEWAYS=["nous-web","nous-image","nous-audio","nous-browser","nous-code"];var MANAGED_STARTUP_AGENTS=["openclaw","hermes","langchain-deepagents-code"];var MANAGED_STARTUP_MESSAGING_AGENTS=["openclaw","hermes"];function freezeAgentCapabilities(capabilities){return Object.freeze({...capabilities,inferenceApis:Object.freeze([...capabilities.inferenceApis]),dashboardModes:Object.freeze([...capabilities.dashboardModes]),inputModalities:Object.freeze([...capabilities.inputModalities]),webSearchProviders:Object.freeze([...capabilities.webSearchProviders]),toolGateways:Object.freeze([...capabilities.toolGateways]),tuningFields:Object.freeze([...capabilities.tuningFields])})}var PROFILE_CAPABILITIES={openclaw:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["loopback","remote"],inputModalities:["text","image"],webSearchProviders:["brave","tavily"],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning","reasoningEffort"],supportsMessaging:true,supportsInferenceCompatibility:true,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:true,supportsAgentTimeout:true,supportsHeartbeat:true,supportsExtraAgents:true,supportsDeviceAuth:true,observability:"openclaw-otel",supportsMinimalBootstrap:true},hermes:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["disabled","loopback-forwarded"],inputModalities:[],webSearchProviders:["tavily"],toolGateways:[...MANAGED_STARTUP_HERMES_TOOL_GATEWAYS],tuningFields:["contextWindow"],supportsMessaging:true,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false},"langchain-deepagents-code":{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["reasoningEffort"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:true,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"dcode-marker",supportsMinimalBootstrap:false}};for(const agent of MANAGED_STARTUP_AGENTS){Object.defineProperty(PROFILE_CAPABILITIES,agent,{configurable:false,enumerable:true,value:freezeAgentCapabilities(PROFILE_CAPABILITIES[agent]),writable:false})}var MANAGED_STARTUP_PROFILE_CAPABILITIES=Object.freeze(PROFILE_CAPABILITIES);function affordance(input,profilePath,source="docker-arg",representation="value"){return{input,profilePath,source,representation}}var HOST_PROXY_AFFORDANCES=[affordance("HTTP_PROXY","proxy.hostHttpUrl","runtime-env"),affordance("http_proxy","proxy.hostHttpUrl","runtime-env","derived"),affordance("HTTPS_PROXY","proxy.hostHttpsUrl","runtime-env"),affordance("https_proxy","proxy.hostHttpsUrl","runtime-env","derived"),affordance("NO_PROXY","proxy.hostNoProxy","runtime-env"),affordance("no_proxy","proxy.hostNoProxy","runtime-env","derived")];var MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY={openclaw:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_PRIMARY_MODEL_REF","inference.primaryModelRef"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_INFERENCE_COMPAT_B64","inference.compatibility"),affordance("NEMOCLAW_INFERENCE_INPUTS","inference.inputModalities"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_AGENT_TIMEOUT","agentConfig.agentTimeoutSeconds"),affordance("NEMOCLAW_AGENT_HEARTBEAT_EVERY","agentConfig.heartbeatEvery"),affordance("NEMOCLAW_EXTRA_AGENTS_JSON_B64","agentConfig.extraAgents"),affordance("NEMOCLAW_DISABLE_DEVICE_AUTH","agentConfig.deviceAuth.disabled"),affordance("NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE","agentConfig.deviceAuth.optOutSource"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_OPENCLAW_OTEL","agentConfig.otel.enabled"),affordance("NEMOCLAW_OPENCLAW_OTEL_ENDPOINT","agentConfig.otel.endpointUrl"),affordance("NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME","agentConfig.otel.serviceName"),affordance("NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE","agentConfig.otel.sampleRate"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_BIND","dashboard.bindAddress"),affordance("NEMOCLAW_WSL_DASHBOARD_EXPOSURE","dashboard.wslExposure"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.port","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("NEMOCLAW_MINIMAL_BOOTSTRAP","agentConfig.minimalBootstrap","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],hermes:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER","tools.enabledGateways","docker-arg","derived"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64","tools.enabledGateways"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD","dashboard.mode","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT","dashboard.internalPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_TUI","dashboard.tuiEnabled","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost","runtime-env"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],"langchain-deepagents-code":[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_UPSTREAM_ENDPOINT_URL","inference.upstreamEndpointUrl"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_DCODE_AUTO_APPROVAL","agentConfig.autoApprovalMode"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_OBSERVABILITY","agentConfig.observabilityEnabled","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES]};function deferredRuntimeInput(input,owner,reason,admission="managed-launch-forwarded"){return Object.freeze({input,owner,admission,reason})}var MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS=Object.freeze({openclaw:Object.freeze([deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_SHADOW_DIAGNOSTICS","application-environment","operator shadow-diagnostics tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS","application-environment","operator MCP discovery timeout tuning is applied by the application environment transaction"),deferredRuntimeInput("OPENCLAW_HOME","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_STATE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_WORKSPACE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),hermes:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),"langchain-deepagents-code":Object.freeze([deferredRuntimeInput("NEMOCLAW_SANDBOX_NAME","engine-identity","the lifecycle engine owns instance identity outside reusable startup intent"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")])});function runtimeCleanupObligation(input,emittedFor,supportedFor,reason){return Object.freeze({input,emittedFor:Object.freeze([...emittedFor]),supportedFor:Object.freeze([...supportedFor]),owner:"application-environment",reason})}var MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS=Object.freeze([runtimeCleanupObligation("NEMOCLAW_DASHBOARD_BIND",["hermes"],["openclaw"],"generic managed-dashboard construction currently emits the OpenClaw-only bind control for Hermes"),runtimeCleanupObligation("NEMOCLAW_MINIMAL_BOOTSTRAP",["hermes","langchain-deepagents-code"],["openclaw"],"generic host-proxy construction currently emits the OpenClaw-only bootstrap control for other agents")]);var ManagedStartupProfileError=class extends Error{constructor(message){super(`Invalid managed startup profile: ${message}`);this.name="ManagedStartupProfileError"}};var PROFILE_KEYS=new Set(["schemaVersion","agent","agentConfig","inference","proxy","dashboard","tools","messaging","tuning","corporateCa"]);var INFERENCE_KEYS=new Set(["routeProvider","upstreamProvider","model","routedBaseUrl","upstreamEndpointUrl","api","primaryModelRef","compatibility","inputModalities"]);var PROXY_KEYS=new Set(["managedHost","managedPort","hostHttpUrl","hostHttpsUrl","hostNoProxy"]);var OPENCLAW_DASHBOARD_KEYS=new Set(["agent","mode","url","port","bindAddress","wslExposure"]);var HERMES_DASHBOARD_KEYS=new Set(["agent","mode","url","publicPort","internalPort","tuiEnabled"]);var DCODE_DASHBOARD_KEYS=new Set(["agent","mode"]);var TOOLS_KEYS=new Set(["disclosure","enabledGateways"]);var MESSAGING_KEYS=new Set(["plan"]);var TUNING_KEYS=new Set(["contextWindow","maxTokens","reasoning","reasoningEffort"]);var CORPORATE_CA_KEYS=new Set(["bundleSha256"]);var OPENCLAW_CONFIG_KEYS=new Set(["agent","webSearch","otel","agentTimeoutSeconds","heartbeatEvery","extraAgents","deviceAuth","minimalBootstrap"]);var HERMES_CONFIG_KEYS=new Set(["agent","webSearch"]);var DCODE_CONFIG_KEYS=new Set(["agent","autoApprovalMode","observabilityEnabled"]);var WEB_SEARCH_KEYS=new Set(["enabled","provider"]);var OTEL_KEYS=new Set(["enabled","endpointUrl","serviceName","sampleRate"]);var DEVICE_AUTH_KEYS=new Set(["disabled","optOutSource"]);var EXTRA_AGENTS_KEYS=new Set(["agents","defaults","main"]);var MANAGED_STARTUP_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DCODE_AUTO_APPROVAL_MODE_SET=new Set(MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES);var REASONING_EFFORT_SET=new Set(MANAGED_STARTUP_REASONING_EFFORTS);var HERMES_RESERVED_API_PORTS=new Set([8642,18642]);function isPlainObject(value){if(typeof value!=="object"||value===null||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function isCredentialShapedName(name){if(PUBLIC_KEY_NAME_PATTERN.test(name)||NON_SECRET_KEY_METADATA_NAMES.has(name))return false;return CREDENTIAL_SHAPED_NAME_PATTERN.test(name)||CREDENTIAL_COMPOUND_NAME_PATTERN.test(name)||CREDENTIAL_CAMEL_SUFFIX_PATTERN.test(name)||CREDENTIAL_CAMEL_BOUNDARY_PATTERN.test(name)||CREDENTIAL_ENV_NAME_PATTERN.test(name)||CREDENTIAL_HEADER_NAME_PATTERN.test(name)||PASS_CREDENTIAL_NAME_PATTERN.test(name)}function valueLooksLikeSecret(value){for(let index=0;index=2&&path5[0]==="messaging"&&path5[1]==="plan"&&typeof value==="string"&&MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)}function containsUrlWithCredentialMaterial(value){const candidates=value.match(URL_CANDIDATE_RE)??[];for(let index=0;index{if(isCredentialShapedName(key))credentialQuery=true});const fragment=url.hash.startsWith("#")?url.hash.slice(1):url.hash;const queryStart=fragment.indexOf("?");const fragmentParameters=new URLSearchParams(queryStart>=0?fragment.slice(queryStart+1):fragment);let credentialFragment=false;fragmentParameters.forEach((_fragmentValue,key)=>{if(isCredentialShapedName(key))credentialFragment=true});if(url.username||url.password||credentialQuery||credentialFragment)return true}catch{}}return false}function invalid(reason){throw new ManagedStartupProfileError(reason)}function payloadPath(path5){return path5.reduce((result,segment)=>segment.startsWith("[")?`${result}${segment}`:`${result}${result?".":""}${segment}`,"")}function mapArrayByIndex(values,mapper){const mapped=[];for(let index=0;index0&&values[insertion-1]>selected){Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:values[insertion-1],writable:true});insertion-=1}Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:selected,writable:true})}return values}function requireRecord(value,where){if(!isPlainObject(value))invalid(`${where} must be an object`);return value}function rejectUnknownKeys(value,allowed,where){const keys=Object.keys(value);for(let index=0;indexmaxBytes||CONTROL_CHARACTER_RE.test(value)){invalid(`${where} must be a bounded, non-empty string without control characters`)}return value}function requireStringEnum(value,allowed,where){const normalized=requireBoundedString(value,where);if(!allowed.has(normalized))invalid(`${where} is not supported`);return normalized}function requireNullablePositiveInteger(value,where){if(value===null)return null;if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>MAX_TUNING_INTEGER){invalid(`${where} must be null or a bounded positive integer`)}return value}function requirePositiveInteger(value,where,maximum=MAX_TUNING_INTEGER){if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>maximum){invalid(`${where} must be a bounded positive integer`)}return value}function requirePort(value,where,minimum=1){if(typeof value!=="number"||!Number.isInteger(value)||value<1||value>65535){invalid(`${where} must be a valid TCP port`)}if(valueMAX_LIST_ITEMS){invalid(`${where} must be a bounded string list`)}const items=mapArrayByIndex(value,item=>requireBoundedString(item,`${where} item`));const unique2=new Set;for(let index=0;index{if(depth>MAX_JSON_DEPTH)invalid(`${where} exceeds the JSON depth limit`);if(current===null||typeof current==="string"||typeof current==="boolean"){return current}if(typeof current==="number"){if(!Number.isFinite(current))invalid(`${where} contains a non-finite number`);return current}if(Array.isArray(current)){return mapArrayByIndex(current,item=>clone(item,depth+1))}if(!isPlainObject(current))invalid(`${where} contains a non-JSON value`);const result=options.nullPrototypeObjects?Object.create(null):{};const keys=Object.getOwnPropertyNames(current);for(let index=0;indexMAX_IDENTIFIER_BYTES||CONTROL_CHARACTER_RE.test(key)){invalid(`${where} contains an invalid object key`)}const descriptor=Object.getOwnPropertyDescriptor(current,key);if(!descriptor||!("value"in descriptor)){invalid(`${where} contains a non-JSON value`)}Object.defineProperty(result,key,{configurable:true,enumerable:true,value:clone(descriptor.value,depth+1),writable:true})}return result};return clone(value,0)}function requireJsonObjectOrNull(value,where){if(value===null)return null;if(!isPlainObject(value))invalid(`${where} must be null or a plain JSON object`);return cloneJsonValue(value,where,{nullPrototypeObjects:true})}function requireJsonObject(value,where){const object=requireJsonObjectOrNull(value,where);if(object===null)invalid(`${where} must be a plain JSON object`);return object}function requireHttpUrl(value,where){const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) URL`)}if(parsed.protocol!=="http:"&&parsed.protocol!=="https:"||parsed.username||parsed.password||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) URL without query or fragment data`)}const pathname=parsed.pathname.replace(/\/+$/u,"");return pathname===""?parsed.origin:`${parsed.origin}${pathname}`}function requireProxyUrl(value,allowedSchemes,where){if(value===null)return null;const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) proxy URL`)}if(!allowedSchemes.has(parsed.protocol)||parsed.username||parsed.password||parsed.pathname!=="/"||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) proxy origin`)}return parsed.origin}function requireManagedProxyHost(value,where){const host=requireBoundedString(value,where);if(!/^[A-Za-z0-9._-]+$/u.test(host)){invalid(`${where} must be a hostname or IPv4 address without a scheme or separators`)}return host}function isLoopbackUrl(value){const hostname=new URL(value).hostname.toLowerCase();return hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="[::1]"}function configuredDashboardPort(value){const explicit=new URL(value).port;return explicit===""?18789:Number(explicit)}function requireSampleRate(value,where){if(typeof value!=="number"||!Number.isFinite(value)||value<0||value>1){invalid(`${where} must be a number between 0 and 1`)}return value}function assertPayloadStructureAndCredentialShapes(root){const pending=[{value:root,depth:0,path:[]}];let discoveredNodes=1;let observedBytes=0;const observeText=value=>{observedBytes+=import_node_buffer.Buffer.byteLength(value,"utf8");if(observedBytes>MANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}};const reserveNode=depth=>{discoveredNodes+=1;if(discoveredNodes>MAX_JSON_NODES||depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}observedBytes+=1};while(pending.length>0){const current=pending.pop();if(!current)break;if(current.depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}if(typeof current.value==="string"){observeText(current.value);if(!isMessagingCredentialPlaceholder(current.path,current.value)&&valueLooksLikeSecret(current.value)){invalid(`payload field ${payloadPath(current.path)} contains credential-shaped string data`)}if(RAW_CA_PEM_RE.test(current.value)||RAW_CA_PEM_BASE64_RE.test(current.value)||RAW_CA_DER_BASE64_RE.test(current.value)||RAW_CA_DATA_URI_RE.test(current.value)){invalid(`payload field ${payloadPath(current.path)} contains raw certificate data; provide only the CA SHA-256 digest`)}if(containsUrlWithCredentialMaterial(current.value)){invalid(`payload field ${payloadPath(current.path)} contains a URL with embedded credentials`)}continue}if(Array.isArray(current.value)){if(Object.getPrototypeOf(current.value)!==Array.prototype){invalid("payload arrays must use the standard JSON prototype")}if("toJSON"in current.value){invalid("payload must not define a custom JSON serializer")}if(Object.getOwnPropertySymbols(current.value).length>0||Object.getOwnPropertyNames(current.value).length!==current.value.length+1){invalid("payload arrays must contain only indexed JSON values")}for(let index=0;index0||discoveredNodes+keys.length>MAX_JSON_NODES){invalid("payload structure exceeds the complexity limit")}for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}}function validateWebSearch(value,agent){const webSearch=requireRecord(value,"agentConfig.webSearch");rejectUnknownKeys(webSearch,WEB_SEARCH_KEYS,"agentConfig.webSearch");const provider=requireStringEnum(webSearch.provider,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].webSearchProviders),"agentConfig.webSearch.provider");return{enabled:requireBoolean(webSearch.enabled,"agentConfig.webSearch.enabled"),provider}}function validateOpenClawOtel(value){const otel=requireRecord(value,"agentConfig.otel");rejectUnknownKeys(otel,OTEL_KEYS,"agentConfig.otel");return{enabled:requireBoolean(otel.enabled,"agentConfig.otel.enabled"),endpointUrl:requireHttpUrl(otel.endpointUrl,"agentConfig.otel.endpointUrl"),serviceName:requireBoundedString(otel.serviceName,"agentConfig.otel.serviceName",MAX_IDENTIFIER_BYTES),sampleRate:requireSampleRate(otel.sampleRate,"agentConfig.otel.sampleRate")}}function validateExtraAgents(value){const extraAgents=requireRecord(value,"agentConfig.extraAgents");rejectUnknownKeys(extraAgents,EXTRA_AGENTS_KEYS,"agentConfig.extraAgents");if(!Array.isArray(extraAgents.agents)||extraAgents.agents.length>MAX_LIST_ITEMS){invalid("agentConfig.extraAgents.agents must be a bounded JSON object list")}return{agents:mapArrayByIndex(extraAgents.agents,(agent,index)=>requireJsonObject(agent,`agentConfig.extraAgents.agents[${String(index)}]`)),defaults:requireJsonObject(extraAgents.defaults,"agentConfig.extraAgents.defaults"),main:requireJsonObject(extraAgents.main,"agentConfig.extraAgents.main")}}function validateDeviceAuth(value){const deviceAuth=requireRecord(value,"agentConfig.deviceAuth");rejectUnknownKeys(deviceAuth,DEVICE_AUTH_KEYS,"agentConfig.deviceAuth");return{disabled:requireBoolean(deviceAuth.disabled,"agentConfig.deviceAuth.disabled"),optOutSource:requireStringEnum(deviceAuth.optOutSource,new Set(["operator","managed-onboard"]),"agentConfig.deviceAuth.optOutSource")}}function validateAgentConfig(value,expectedAgent){const config=requireRecord(value,"agentConfig");const agent=requireStringEnum(config.agent,MANAGED_STARTUP_AGENT_SET,"agentConfig.agent");if(agent!==expectedAgent)invalid("agentConfig.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(config,OPENCLAW_CONFIG_KEYS,"agentConfig");const heartbeatEvery=config.heartbeatEvery===null?null:requireBoundedString(config.heartbeatEvery,"agentConfig.heartbeatEvery",MAX_IDENTIFIER_BYTES);if(heartbeatEvery!==null&&!/^\d+(?:s|m|h)$/u.test(heartbeatEvery)){invalid("agentConfig.heartbeatEvery must be null or a duration ending in s, m, or h")}return{agent,webSearch:validateWebSearch(config.webSearch,agent),otel:validateOpenClawOtel(config.otel),agentTimeoutSeconds:requirePositiveInteger(config.agentTimeoutSeconds,"agentConfig.agentTimeoutSeconds"),heartbeatEvery,extraAgents:validateExtraAgents(config.extraAgents),deviceAuth:validateDeviceAuth(config.deviceAuth),minimalBootstrap:requireBoolean(config.minimalBootstrap,"agentConfig.minimalBootstrap")}}if(agent==="hermes"){rejectUnknownKeys(config,HERMES_CONFIG_KEYS,"agentConfig");return{agent,webSearch:validateWebSearch(config.webSearch,agent)}}rejectUnknownKeys(config,DCODE_CONFIG_KEYS,"agentConfig");return{agent,autoApprovalMode:requireStringEnum(config.autoApprovalMode,DCODE_AUTO_APPROVAL_MODE_SET,"agentConfig.autoApprovalMode"),observabilityEnabled:requireBoolean(config.observabilityEnabled,"agentConfig.observabilityEnabled")}}function validateDashboard(value,expectedAgent){const dashboard=requireRecord(value,"dashboard");const agent=requireStringEnum(dashboard.agent,MANAGED_STARTUP_AGENT_SET,"dashboard.agent");if(agent!==expectedAgent)invalid("dashboard.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(dashboard,OPENCLAW_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");const bindAddress=requireStringEnum(dashboard.bindAddress,new Set(["127.0.0.1","0.0.0.0"]),"dashboard.bindAddress");const wslExposure=requireBoolean(dashboard.wslExposure,"dashboard.wslExposure");const hasRemoteExposure=!isLoopbackUrl(url)||bindAddress==="0.0.0.0"||wslExposure;if(mode==="remote"!==hasRemoteExposure){invalid("OpenClaw dashboard.mode must reflect its URL, bind address, and WSL exposure")}const port=requirePort(dashboard.port,"dashboard.port",1024);if(port===8642)invalid("OpenClaw dashboard.port must not use reserved Hermes API port 8642");if(configuredDashboardPort(url)!==port){invalid("OpenClaw dashboard.port must match dashboard.url")}return{agent,mode,url,port,bindAddress,wslExposure}}if(agent==="hermes"){rejectUnknownKeys(dashboard,HERMES_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");if(!isLoopbackUrl(url)){invalid("Hermes dashboard.url must remain loopback; OpenShell owns the host forward")}if(mode==="disabled"){if(dashboard.publicPort!==null||dashboard.internalPort!==null||dashboard.tuiEnabled!==false){invalid("disabled Hermes dashboard must not configure ports or TUI")}return{agent,mode,url,publicPort:null,internalPort:null,tuiEnabled:false}}const publicPort=requirePort(dashboard.publicPort,"dashboard.publicPort",1024);const internalPort=requirePort(dashboard.internalPort,"dashboard.internalPort",1024);if(publicPort===internalPort){invalid("Hermes dashboard publicPort and internalPort must differ")}if(HERMES_RESERVED_API_PORTS.has(publicPort)||HERMES_RESERVED_API_PORTS.has(internalPort)){invalid("Hermes dashboard ports must not use reserved API ports 8642 or 18642")}if(configuredDashboardPort(url)!==publicPort){invalid("Hermes dashboard.publicPort must match dashboard.url")}return{agent,mode,url,publicPort,internalPort,tuiEnabled:requireBoolean(dashboard.tuiEnabled,"dashboard.tuiEnabled")}}rejectUnknownKeys(dashboard,DCODE_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled"){invalid("langchain-deepagents-code dashboard.mode must be disabled")}return{agent,mode:"disabled"}}function validateInference(value,agent){const inference=requireRecord(value,"inference");rejectUnknownKeys(inference,INFERENCE_KEYS,"inference");const routeProvider=requireBoundedString(inference.routeProvider,"inference.routeProvider");const model=requireBoundedString(inference.model,"inference.model",MAX_MODEL_BYTES);const api=requireStringEnum(inference.api,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inferenceApis),"inference.api");const upstreamEndpointUrl=inference.upstreamEndpointUrl===null?null:requireHttpUrl(inference.upstreamEndpointUrl,"inference.upstreamEndpointUrl");const primaryModelRef=inference.primaryModelRef===null?null:requireBoundedString(inference.primaryModelRef,"inference.primaryModelRef",MAX_MODEL_BYTES);const compatibility=requireJsonObjectOrNull(inference.compatibility,"inference.compatibility");const inputModalities=inference.inputModalities===null?null:requireEnumList(inference.inputModalities,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inputModalities),"inference.inputModalities",{allowEmpty:false});if(agent==="openclaw"){if(upstreamEndpointUrl!==null){invalid("inference.upstreamEndpointUrl must be null for openclaw")}if(primaryModelRef===null||inputModalities===null){invalid("openclaw requires primaryModelRef and inputModalities")}if(primaryModelRef!==`${routeProvider}/${model}`){invalid("openclaw primaryModelRef must match routeProvider and model")}}else{if(primaryModelRef!==null||compatibility!==null||inputModalities!==null){invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`)}if(agent==="hermes"&&upstreamEndpointUrl!==null){invalid("inference.upstreamEndpointUrl must be null for hermes")}}return{routeProvider,upstreamProvider:requireBoundedString(inference.upstreamProvider,"inference.upstreamProvider"),model,routedBaseUrl:requireHttpUrl(inference.routedBaseUrl,"inference.routedBaseUrl"),upstreamEndpointUrl,api,primaryModelRef,compatibility,inputModalities}}function validateProxy(value,agent){const proxy=requireRecord(value,"proxy");rejectUnknownKeys(proxy,PROXY_KEYS,"proxy");const hostHttpUrl=requireProxyUrl(proxy.hostHttpUrl,new Set(["http:"]),"proxy.hostHttpUrl");const hostHttpsUrl=requireProxyUrl(proxy.hostHttpsUrl,new Set(["http:","https:"]),"proxy.hostHttpsUrl");const hostNoProxy=requireStringList(proxy.hostNoProxy,"proxy.hostNoProxy");if(!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsHostProxyIntent&&(hostHttpUrl!==null||hostHttpsUrl!==null||hostNoProxy.length>0)){invalid(`${agent} rejects host proxy intent and accepts only its root-owned managed route`)}return{managedHost:requireManagedProxyHost(proxy.managedHost,"proxy.managedHost"),managedPort:requirePort(proxy.managedPort,"proxy.managedPort"),hostHttpUrl,hostHttpsUrl,hostNoProxy}}function validateTools(value,agent){const tools=requireRecord(value,"tools");rejectUnknownKeys(tools,TOOLS_KEYS,"tools");const enabledGateways=requireEnumList(tools.enabledGateways,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].toolGateways),"tools.enabledGateways",{allowEmpty:true});return{disclosure:requireStringEnum(tools.disclosure,new Set(["progressive","direct"]),"tools.disclosure"),enabledGateways}}function validateTuning(value,agent){const tuning=requireRecord(value,"tuning");rejectUnknownKeys(tuning,TUNING_KEYS,"tuning");const result={contextWindow:requireNullablePositiveInteger(tuning.contextWindow,"tuning.contextWindow"),maxTokens:requireNullablePositiveInteger(tuning.maxTokens,"tuning.maxTokens"),reasoning:requireNullableBoolean(tuning.reasoning,"tuning.reasoning"),reasoningEffort:tuning.reasoningEffort===null?null:requireStringEnum(tuning.reasoningEffort,REASONING_EFFORT_SET,"tuning.reasoningEffort")};if(agent==="openclaw"){if(result.contextWindow===null||result.maxTokens===null||result.reasoning===null||result.reasoningEffort===null){invalid("openclaw requires contextWindow, maxTokens, reasoning, and reasoningEffort tuning")}}else if(agent==="hermes"){if(result.contextWindow!==null&&result.contextWindowcanonicalizeJson(item));if(!isPlainObject(value))return value;const result={};const keys=sortStrings(Object.keys(value));for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`canonical payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}return serialized}function decodeManagedStartupProfile(encoded){if(typeof encoded!=="string"||encoded.length===0||import_node_buffer.Buffer.byteLength(encoded,"ascii")>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES||!BASE64URL_RE.test(encoded)||encoded.length%4===1){invalid("encoded payload is malformed or exceeds the size limit")}const bytes=import_node_buffer.Buffer.from(encoded,"base64url");if(bytes.length===0||bytes.length>MANAGED_STARTUP_PROFILE_MAX_BYTES||bytes.toString("base64url")!==encoded){invalid("encoded payload is malformed or exceeds the size limit")}let raw;try{raw=UTF8_DECODER.decode(bytes)}catch{invalid("payload is not valid UTF-8")}let parsed;try{parsed=JSON.parse(raw)}catch{invalid("payload is not valid JSON")}const profile=validateManagedStartupProfile(parsed);if(serializeManagedStartupProfile(profile)!==raw){invalid("payload is not in canonical form")}return profile}function fingerprintManagedStartupProfile(profile){return(0,import_node_crypto2.createHash)("sha256").update(serializeManagedStartupProfile(profile),"utf8").digest("hex")}var ManagedStartupAgentEnvironmentError=class extends Error{constructor(message){super(`Cannot map managed startup profile: ${message}`);this.name="ManagedStartupAgentEnvironmentError"}};var EMPTY_APPLICATION_ENVIRONMENT=Object.freeze({});var OPENCLAW_APPLICATION_RUNTIME_INPUTS=Object.freeze([["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","positive-safe-integer"],["NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","positive-finite-seconds"]]);function booleanFlag(value){return value?"1":"0"}function canonicalizeJson2(value){if(Array.isArray(value))return value.map(item=>canonicalizeJson2(item));if(value===null||typeof value!=="object")return value;const record=value;return Object.fromEntries(Object.keys(record).sort().map(key=>[key,canonicalizeJson2(record[key])]))}function encodeCanonicalJson(value){return import_node_buffer2.Buffer.from(JSON.stringify(canonicalizeJson2(value)),"utf8").toString("base64")}function sortedEnvironment(environment){return Object.freeze(Object.fromEntries(Object.entries(environment).sort(([left],[right])=>leftright?1:0)))}function canonicalApplicationRuntimeValue(name,raw,kind){if(raw.includes("\0")||/[\r\n]/u.test(raw)){throw new ManagedStartupAgentEnvironmentError(`${name} must be single-line text`)}const value=Number(raw.trim());const valid=kind==="positive-safe-integer"?Number.isSafeInteger(value)&&value>0:Number.isFinite(value)&&value>0;if(!valid){throw new ManagedStartupAgentEnvironmentError(`${name} must be ${kind==="positive-safe-integer"?"a positive safe integer":"finite positive seconds"}`)}return String(value)}function applicationRuntimePlan(profile,environment){const exportEnvironment={};if(profile.agent==="openclaw"){for(const[name,kind]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){const raw=environment[name];if(raw!==void 0){exportEnvironment[name]=canonicalApplicationRuntimeValue(name,raw,kind)}}}const unsetEnvironment=new Set(MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS.filter(({supportedFor})=>!supportedFor.includes(profile.agent)).map(({input})=>input));if(profile.agent!=="openclaw"){for(const[name]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){unsetEnvironment.add(name)}}return Object.freeze({exportEnvironment:sortedEnvironment(exportEnvironment),unsetEnvironment:Object.freeze([...unsetEnvironment].sort())})}function commonConfigurationEnvironment(profile){return{NEMOCLAW_INFERENCE_API:profile.inference.api,NEMOCLAW_INFERENCE_BASE_URL:profile.inference.routedBaseUrl,NEMOCLAW_INFERENCE_PROVIDER_ID:profile.inference.routeProvider,NEMOCLAW_MODEL:profile.inference.model,NEMOCLAW_TOOL_DISCLOSURE:profile.tools.disclosure,NEMOCLAW_UPSTREAM_PROVIDER:profile.inference.upstreamProvider}}function appendHostProxyEnvironment(environment,profile,options={}){if(options.preserveAmbientWhenAbsent===true&&profile.proxy.hostHttpUrl===null&&profile.proxy.hostHttpsUrl===null&&profile.proxy.hostNoProxy.length===0){return}const httpProxy=profile.proxy.hostHttpUrl??"";const httpsProxy=profile.proxy.hostHttpsUrl??"";const noProxy=profile.proxy.hostNoProxy.join(",");environment.HTTP_PROXY=httpProxy;environment.HTTPS_PROXY=httpsProxy;environment.NO_PROXY=noProxy;environment.http_proxy=httpProxy;environment.https_proxy=httpsProxy;environment.no_proxy=noProxy}function messagingEnvironment(profile,expectedAgent){if(profile.messaging.plan===null)return{};const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:expectedAgent});if(!plan){throw new ManagedStartupAgentEnvironmentError(`messaging.plan must contain a validated ${expectedAgent} messaging plan`)}const{workflow:_workflow,...imageBuildPlan}=plan;return{NEMOCLAW_MESSAGING_PLAN_B64:encodeCanonicalJson(imageBuildPlan)}}function corporateCaMaterial(profile){return Object.freeze({kind:"corporate-ca-handoff",legacyInput:"NEMOCLAW_CORPORATE_CA_B64",expectedSha256:profile.corporateCa.bundleSha256})}function rootOwnedFile(legacyInput,path5,value){return Object.freeze({kind:"root-owned-file",legacyInput,path:path5,contents:`${value} +var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:true})};var __copyProps=(to,from,except,desc)=>{if(from&&typeof from==="object"||typeof from==="function"){for(let key of __getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable})}return to};var __toESM=(mod,isNodeMode,target)=>(target=mod!=null?__create(__getProtoOf(mod)):{},__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:true}):target,mod));var __toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:true}),mod);var image_runtime_exports={};__export(image_runtime_exports,{applyManagedBootstrapEnvelope:()=>applyManagedBootstrapEnvelope,main:()=>main2,managedBootstrapEnvelopeClaimPaths:()=>managedBootstrapEnvelopeClaimPaths,readManagedBootstrapEnvelope:()=>readManagedBootstrapEnvelope,recoverManagedBootstrapEnvelopeClaim:()=>recoverManagedBootstrapEnvelopeClaim,verifyManagedBootstrapImageCompletion:()=>verifyManagedBootstrapImageCompletion,waitForManagedBootstrapImageCompletion:()=>waitForManagedBootstrapImageCompletion});module.exports=__toCommonJS(image_runtime_exports);var import_node_fs4=__toESM(require("node:fs"));var import_node_path4=__toESM(require("node:path"));var import_node_child_process=require("node:child_process");var import_node_crypto6=require("node:crypto");var import_node_fs3=__toESM(require("node:fs"));var import_node_path3=__toESM(require("node:path"));var import_node_buffer2=require("node:buffer");function isObjectRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}var ChannelManifestRegistry=class{manifests=new Map;constructor(manifests=[]){for(const manifest of manifests){this.register(manifest)}}register(manifest){if(this.manifests.has(manifest.id)){throw new Error(`Duplicate channel manifest id '${manifest.id}'`)}this.manifests.set(manifest.id,manifest);return this}get(channelId){return this.manifests.get(channelId)}list(){return Array.from(this.manifests.values())}listAvailable(ctx={}){const supportedChannelIds=Array.isArray(ctx.supportedChannelIds)?new Set(ctx.supportedChannelIds):null;return this.list().filter(manifest=>{if(ctx.agent&&!manifest.supportedAgents.includes(ctx.agent)){return false}if(supportedChannelIds&&!supportedChannelIds.has(manifest.id)){return false}return true})}};function createChannelManifestRegistry(manifests=[]){return new ChannelManifestRegistry(manifests)}var discordManifest={schemaVersion:1,id:"discord",displayName:"Discord",description:"Discord bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"DISCORD_BOT_TOKEN",prompt:{label:"Discord Bot Token",help:"Discord Developer Portal \u2192 Applications \u2192 Bot \u2192 Reset/Copy Token."}},{id:"serverId",kind:"config",required:false,envKey:"DISCORD_SERVER_ID",statePath:"discordGuilds.serverId",prompt:{label:"Discord Server ID (for guild workspace access)",help:"Enable Developer Mode in Discord, then right-click your server and copy the Server ID.",emptyValueMessage:"guild channels stay disabled"}},{id:"requireMention",kind:"config",required:false,envKey:"DISCORD_REQUIRE_MENTION",statePath:"discordGuilds.requireMention",promptWhenInput:"serverId",validValues:["0","1"],defaultValue:"1",prompt:{label:"Discord mention mode",help:"Choose whether the bot should reply only when @mentioned or to all messages in this server."}},{id:"userId",kind:"config",required:false,envKey:"DISCORD_USER_ID",statePath:"discordGuilds.userIds",promptWhenInput:"serverId",prompt:{label:"Discord User ID (optional guild allowlist)",help:"Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.",emptyValueMessage:"any member in the configured server can message the bot"}}],credentials:[{id:"discordBotToken",sourceInput:"botToken",providerName:"{sandboxName}-discord-bridge",providerEnvKey:"DISCORD_BOT_TOKEN",placeholder:"openshell:resolve:env:DISCORD_BOT_TOKEN"}],policyPresets:[{name:"discord",validationWarningLines:["For Discord preset validation, do not use curl as the success signal:","curl is not in the preset binary allowlist, so curl probes can fail even","when the policy is working. Use Node HTTPS against","https://discord.com/api/v10/gateway or validate the configured",'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")',"can also be inconclusive behind a proxy."]}],render:[{id:"discord-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.discord",value:{enabled:true,accounts:{default:{token:"{{credential.discordBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{discordProxyUrl}}",dmPolicy:"{{discord.allowedUsers.dmPolicy}}",allowFrom:"{{discord.allowedUsers.values}}"}}}}},{id:"discord-openclaw-guilds",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{discord.hasGuilds}}",fragment:{path:"channels.discord",value:{groupPolicy:"allowlist",guilds:"{{discord.guilds}}"}}},{id:"discord-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.discord",value:{enabled:true}}},{id:"discord-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["DISCORD_BOT_TOKEN={{credential.discordBotToken.placeholder}}","NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}","DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}","DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}"]},{id:"discord-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"discord",value:{require_mention:"{{discord.requireMention}}",free_response_channels:"",allowed_channels:"",auto_thread:true,reactions:true,channel_prompts:{}}}},{id:"discord-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.discord",value:{enabled:true}}}],runtime:{openclaw:{channelName:"discord",visibility:{configKeys:["discord"],logPatterns:["discord"]}}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/discord@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz"},required:true}],hooks:[{id:"discord-openclaw-bridge-health",phase:"health-check",handler:"discord.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"discord-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"discord-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"serverId",kind:"config"},{id:"requireMention",kind:"config"},{id:"userId",kind:"config"}]}]};var googlechatManifest={schemaVersion:1,id:"googlechat",displayName:"Google Chat",description:"Google Chat (Chat API) bot messaging (experimental)",enrollmentNotes:["\u2503 GOOGLE CHAT \u2014 appPrincipal","\u2503","\u2503 Workspace account \u2192 leave blank, done.","\u2503 Personal Gmail \u2192 needs the add-on's ~21-digit ID (not an email), stable across rebuilds.","\u2503","\u2503 If you already know it, paste it at the prompt and you're done.","\u2503 If not, leave it blank \u2014 the first DM reveals it once the sandbox is live:","\u2503","\u2503 1. Watch the gateway log:",'\u2503 nemoclaw logs --follow | grep "unexpected add-on principal"',"\u2503 2. DM the bot once \u2014 it won't reply yet, that's expected. The log prints:","\u2503 unexpected add-on principal: ","\u2503 3. Save that and rebuild:","\u2503 GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat","\u2503 nemoclaw rebuild --yes"],supportedAgents:["openclaw"],auth:{mode:"token-paste"},inputs:[{id:"serviceAccount",kind:"secret",required:true,envKey:"GOOGLECHAT_SERVICE_ACCOUNT",maskCap:40,formatHint:"Paste the entire service-account JSON key on one line (minified) \u2014 the whole downloaded JSON file.",maxTokenAttempts:3,prompt:{label:"Google Chat service account JSON",help:["\u2503 GOOGLE CHAT \u2014 service account key","\u2503","\u2503 Google Cloud Console \u2192 IAM & Admin \u2192 Service Accounts","\u2503 \u2192 your bot's SA \u2192 Keys \u2192 Add key \u2192 Create new key \u2192 JSON","\u2503","\u2503 A .json file downloads. Paste its contents below as ONE line (minified).",""].join("\n")}},{id:"audienceType",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE_TYPE",statePath:"googlechatConfig.audienceType",validValues:["app-url","project-number"],defaultValue:"app-url"},{id:"audience",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE",statePath:"googlechatConfig.audience",prompt:{label:"Google Chat webhook audience",help:"Usually filled automatically from the public tunnel URL. For audienceType 'project-number', enter your GCP project number instead.",emptyValueMessage:"inbound webhook verification will be unconfigured"}},{id:"appPrincipal",kind:"config",required:false,envKey:"GOOGLECHAT_APP_PRINCIPAL",statePath:"googlechatConfig.appPrincipal",formatPattern:"^[0-9]{6,32}$",formatHint:"appPrincipal is the add-on's numeric OAuth client ID (uniqueId, ~21 digits), not an email.",prompt:{label:"Google Chat appPrincipal",emptyValueMessage:"Workspace accounts do not need it; personal accounts must set it later"}},{id:"allowFrom",kind:"config",required:false,envKey:"GOOGLECHAT_ALLOWED_USERS",statePath:"allowedIds.googlechat",prompt:{label:"Google Chat DM allowlist (comma-separated user IDs)",help:"Optional: restrict who can DM the bot. Enter Google Chat user IDs (users/NNN) \u2014 NOT emails: the bot matches IDs only by default, so an email entry is ignored. Leave blank to require pairing (recommended).",emptyValueMessage:"bot will require manual pairing"}}],credentials:[],policyPresets:[{name:"googlechat",policyKeys:["googlechat"]}],render:[{id:"googlechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.googlechat",value:{enabled:true,serviceAccountFile:"/nonexistent/googlechat-gateway-minted-no-service-account-file",audienceType:"{{googlechatConfig.audienceType}}",audience:"{{googlechatConfig.audience}}",appPrincipal:"{{googlechatConfig.appPrincipal}}",webhookPath:"/googlechat",healthMonitor:{enabled:false},dm:{policy:"{{allowedIds.googlechat.dmPolicy}}",allowFrom:"{{allowedIds.googlechat.values}}"}}}},{id:"googlechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.googlechat",value:{enabled:true}}},{id:"googlechat-openclaw-gateway-reload-off",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"gateway.reload",value:{mode:"off"}}}],runtime:{openclaw:{channelName:"googlechat",visibility:{configKeys:["googlechat"],logPatterns:["googlechat"]},nodePreloads:[{module:"googlechat-trusted-proxy-fetch",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)",installedMessage:"[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)"},{module:"googlechat-outbound-auth",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat outbound-auth patch (gateway-minted bearer)",installedMessage:"[channels] Google Chat outbound-auth patch installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"-----BEGIN (?:RSA )?PRIVATE KEY-----",message:"[SECURITY] Google Chat service account private key leaked into {path} - refusing to serve",exitCode:78}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/googlechat@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-Dv0xOmcxAThEr6hoK+ioofHNu18hfbIceQrEHX3AHZPpOUiTJvToVpA5eX87NQINewwfSJf0gVhE6kSbSk2Aew=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.7.1.tgz"},required:true}],hooks:[{id:"googlechat-tunnel-audience-gate",phase:"enroll",handler:"googlechat.tunnelAudienceGate",inputs:["audienceType","audience"],outputs:[{id:"audience",kind:"config"}],onFailure:"skip-channel"},{id:"googlechat-service-account",phase:"enroll",handler:"googlechat.tokenPaste",outputs:[{id:"serviceAccount",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"googlechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appPrincipal",kind:"config"},{id:"allowFrom",kind:"config"}]}]};var slackRuntimeEnvAliases=[{envKey:"SLACK_BOT_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$",value:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",message:"[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias"},{envKey:"SLACK_APP_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$",value:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN",message:"[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias"}];var slackManifest={schemaVersion:1,id:"slack",displayName:"Slack",description:"Slack bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"SLACK_BOT_TOKEN",formatPattern:"^xoxb-[A-Za-z0-9_-]+$",formatHint:"Slack bot tokens start with 'xoxb-' (e.g. xoxb---).",prompt:{label:"Slack Bot Token",help:"Slack API \u2192 Your Apps \u2192 OAuth & Permissions \u2192 Bot User OAuth Token (xoxb-...)."}},{id:"appToken",kind:"secret",required:true,envKey:"SLACK_APP_TOKEN",formatPattern:"^xapp-[A-Za-z0-9_-]+$",formatHint:"Slack app tokens start with 'xapp-' (e.g. xapp----).",prompt:{label:"Slack App Token (Socket Mode)",help:"Slack API \u2192 Your Apps \u2192 Basic Information \u2192 App-Level Tokens (xapp-...)."}},{id:"allowedUsers",kind:"config",required:false,envKey:"SLACK_ALLOWED_USERS",statePath:"allowedIds.slack",prompt:{label:"Slack Member IDs (comma-separated allowlist)",help:"In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.",emptyValueMessage:"bot will require manual pairing"}},{id:"allowedChannels",kind:"config",required:false,envKey:"SLACK_ALLOWED_CHANNELS",statePath:"slackConfig.allowedChannels",prompt:{label:"Slack Channel IDs (comma-separated allowlist)",help:"Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.",emptyValueMessage:"channel @mentions stay unrestricted by channel ID"}}],credentials:[{id:"slackBotToken",sourceInput:"botToken",providerName:"{sandboxName}-slack-bridge",providerEnvKey:"SLACK_BOT_TOKEN",placeholder:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",primary:true},{id:"slackAppToken",sourceInput:"appToken",providerName:"{sandboxName}-slack-app",providerEnvKey:"SLACK_APP_TOKEN",placeholder:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"}],policyPresets:[{name:"slack",requiredAtCreate:true}],render:[{id:"slack-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.slack",value:{enabled:true,accounts:{default:{botToken:"{{credential.slackBotToken.placeholder}}",appToken:"{{credential.slackAppToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},dmPolicy:"{{allowedIds.slack.dmPolicy}}",allowFrom:"{{allowedIds.slack.values}}",groupPolicy:"{{allowedIds.slack.groupPolicy}}",channels:"{{allowedIds.slack.channels}}"}}}}},{id:"slack-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.slack",value:{enabled:true}}},{id:"slack-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["SLACK_BOT_TOKEN={{credential.slackBotToken.placeholder}}","SLACK_APP_TOKEN={{credential.slackAppToken.placeholder}}","SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}","SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}"]},{id:"slack-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.slack",value:{enabled:true,extra:{rich_blocks:true}}}}],runtime:{openclaw:{channelName:"slack",visibility:{configKeys:["slack"],logPatterns:["slack"]},envAliases:slackRuntimeEnvAliases,nodePreloads:[{module:"slack-channel-guard",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Slack channel guard (unhandled-rejection safety net)",installedMessage:"[channels] Slack channel guard installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)",message:"[SECURITY] Slack token leaked into {path} - refusing to serve",exitCode:78}]},hermes:{envAliases:slackRuntimeEnvAliases}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/slack@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"},required:true}],hooks:[{id:"slack-socket-mode-gateway-conflict",phase:"pre-enable",handler:"slack.socketModeGatewayConflict",onFailure:"abort"},{id:"slack-openclaw-bridge-health",phase:"health-check",handler:"slack.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"slack-socket-mode-gateway-status",phase:"status",handler:"slack.socketModeGatewayStatus",outputs:[{id:"gatewayOverlaps",kind:"status"}]},{id:"slack-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true},{id:"appToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"slack-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedUsers",kind:"config"},{id:"allowedChannels",kind:"config"}]},{id:"slack-credential-validation",phase:"reachability-check",handler:"slack.validateCredentials",inputs:["botToken","appToken"],onFailure:"skip-channel"}]};var teamsManifest={schemaVersion:1,id:"teams",displayName:"Microsoft Teams",description:"Microsoft Teams bot messaging (experimental)",enrollmentNotes:["Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.","Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"appId",kind:"config",required:true,envKey:"MSTEAMS_APP_ID",statePath:"teamsConfig.appId",prompt:{label:"Microsoft Teams Client ID",help:"Run `teams app create --endpoint https:///api/messages`, then copy CLIENT_ID."}},{id:"clientSecret",kind:"secret",required:true,envKey:"MSTEAMS_APP_PASSWORD",prompt:{label:"Microsoft Teams Client Secret",help:"Use the CLIENT_SECRET printed by `teams app create`. It is shown once; rotate it in Entra ID if it was lost."}},{id:"tenantId",kind:"config",required:true,envKey:"MSTEAMS_TENANT_ID",statePath:"teamsConfig.tenantId",prompt:{label:"Microsoft Teams Tenant ID",help:"Use the TENANT_ID printed by `teams app create` or shown by `teams status --verbose`."}},{id:"allowedUsers",kind:"config",required:false,envKey:"TEAMS_ALLOWED_USERS",statePath:"allowedIds.teams",prompt:{label:"Microsoft Teams AAD Object IDs (comma-separated allowlist)",help:"Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot."}},{id:"webhookPort",kind:"config",required:false,envKey:"MSTEAMS_PORT",statePath:"teamsConfig.webhookPort",defaultValue:"3978",prompt:{label:"Microsoft Teams webhook port",help:"Local bot webhook port to expose publicly. Defaults to 3978 and serves /api/messages."}},{id:"requireMention",kind:"config",required:false,envKey:"TEAMS_REQUIRE_MENTION",statePath:"teamsConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Microsoft Teams mention mode",help:"Controls OpenClaw group and channel behavior only. Direct messages are unaffected."}}],credentials:[{id:"teamsClientSecret",sourceInput:"clientSecret",providerName:"{sandboxName}-teams-bridge",providerEnvKey:"MSTEAMS_APP_PASSWORD",placeholder:"openshell:resolve:env:MSTEAMS_APP_PASSWORD",primary:true}],policyPresets:[{name:"teams",policyKeys:["teams"]}],hostForward:{port:"{{teamsConfig.webhookPort}}",label:"Microsoft Teams webhook"},render:[{id:"teams-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.msteams",value:{enabled:true,appId:"{{teamsConfig.appId}}",appPassword:"{{credential.teamsClientSecret.placeholder}}",tenantId:"{{teamsConfig.tenantId}}",webhook:{port:"{{teamsConfig.webhookPort}}",path:"/api/messages"},healthMonitor:{enabled:false},streaming:{mode:"off"},dmPolicy:"{{allowedIds.teams.dmPolicy}}",allowFrom:"{{allowedIds.teams.values}}",groupPolicy:"open",requireMention:"{{teamsConfig.requireMention}}"}}},{id:"teams-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.msteams",value:{enabled:true}}},{id:"teams-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TEAMS_CLIENT_ID={{teamsConfig.appId}}","TEAMS_CLIENT_SECRET={{credential.teamsClientSecret.placeholder}}","TEAMS_TENANT_ID={{teamsConfig.tenantId}}","TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}","TEAMS_PORT={{teamsConfig.webhookPort}}"]},{id:"teams-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.teams",value:{enabled:true}}}],runtime:{openclaw:{channelName:"msteams",visibility:{configKeys:["msteams"],logPatterns:["msteams","teams"]},nodePreloads:[{module:"msteams-message-hints",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Microsoft Teams message hint patch (native mentions)",installedMessage:"[channels] Microsoft Teams message hint patch installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/msteams@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz"},required:true},{id:"hermesTeamsAppsPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"microsoft-teams-apps==2.0.13.4",required:true},{id:"hermesAiohttpPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"aiohttp==3.14.3",required:true}],hooks:[{id:"teams-host-forward-port-conflict",phase:"pre-enable",handler:"teams.hostForwardPortConflict",inputs:["webhookPort"],onFailure:"abort"},{id:"teams-host-forward-port-status",phase:"status",handler:"teams.hostForwardPortStatus",outputs:[{id:"hostForwardPortOverlaps",kind:"status"}]},{id:"teams-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"clientSecret",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"teams-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appId",kind:"config",required:true},{id:"tenantId",kind:"config",required:true},{id:"allowedUsers",kind:"config"},{id:"webhookPort",kind:"config"},{id:"requireMention",kind:"config"}]}]};var telegramManifest={schemaVersion:1,id:"telegram",displayName:"Telegram",description:"Telegram bot messaging",diagnosticsProbe:"log-tail",enrollmentNotes:["For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).","After changing privacy mode, remove and re-add the bot to each group before testing @mentions."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"TELEGRAM_BOT_TOKEN",prompt:{label:"Telegram Bot Token",help:"Create a bot via @BotFather on Telegram, then copy the token."}},{id:"allowedIds",kind:"config",required:false,envKey:"TELEGRAM_ALLOWED_IDS",statePath:"allowedIds.telegram",prompt:{label:"Telegram User ID (for DM access)",help:"Send /start to @userinfobot on Telegram to get your numeric user ID.",emptyValueMessage:"bot will require manual pairing"}},{id:"requireMention",kind:"config",required:false,envKey:"TELEGRAM_REQUIRE_MENTION",statePath:"telegramConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Telegram group mention mode",help:"Controls Telegram group-chat behavior only \u2014 reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS."}},{id:"groupPolicy",kind:"config",required:false,envKey:"TELEGRAM_GROUP_POLICY",statePath:"telegramConfig.groupPolicy",validValues:["open","allowlist","disabled"],defaultValue:"open",prompt:{label:"Telegram group policy",help:"Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy."}}],credentials:[{id:"telegramBotToken",sourceInput:"botToken",providerName:"{sandboxName}-telegram-bridge",providerEnvKey:"TELEGRAM_BOT_TOKEN",placeholder:"openshell:resolve:env:TELEGRAM_BOT_TOKEN"}],policyPresets:[{name:"telegram",policyKeys:["telegram_bot"],agentPolicyKeys:{hermes:["telegram"]}}],render:[{id:"telegram-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.telegram",value:{enabled:true,accounts:{default:{botToken:"{{credential.telegramBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{proxyUrl}}",groupPolicy:"{{telegramConfig.groupPolicy}}",dmPolicy:"{{allowedIds.telegram.dmPolicy}}",allowFrom:"{{allowedIds.telegram.values}}"}}}}},{id:"telegram-openclaw-groups",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{telegramConfig.openclawGroups}}",fragment:{path:"channels.telegram.groups",value:"{{telegramConfig.openclawGroups}}"}},{id:"telegram-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.telegram",value:{enabled:true}}},{id:"telegram-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}","TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}"]},{id:"telegram-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"telegram",value:{require_mention:"{{telegramConfig.requireMention}}"}}},{id:"telegram-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.telegram",value:{enabled:true}}}],runtime:{openclaw:{channelName:"telegram",visibility:{configKeys:["telegram"],logPatterns:["telegram"]},nodePreloads:[{module:"telegram-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Telegram diagnostics (provider readiness + inference errors)",installedMessage:"[channels] Telegram diagnostics installed (NODE_OPTIONS updated)"}]}},hooks:[{id:"telegram-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"telegram-allowlist-aliases",phase:"enroll",handler:"telegram.allowlistAliases",outputs:[{id:"allowedIds",kind:"config"}]},{id:"telegram-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"requireMention",kind:"config"},{id:"allowedIds",kind:"config"}]},{id:"telegram-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"groupPolicy",kind:"config"}]},{id:"telegram-get-me-reachability",phase:"reachability-check",handler:"telegram.getMeReachability",inputs:["botToken"],onFailure:"skip-channel"},{id:"telegram-openclaw-bridge-health",phase:"health-check",handler:"telegram.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"telegram-gateway-conflict-status",phase:"status",handler:"telegram.gatewayConflictStatus",outputs:[{id:"bridgeHealth",kind:"status"}]},{id:"telegram-status-health",phase:"status",handler:"telegram.statusHealth",agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]}]};var wechatManifest={schemaVersion:1,id:"wechat",displayName:"WeChat",description:"WeChat (personal) bot messaging",enrollmentHelp:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only.",supportedAgents:["openclaw","hermes"],auth:{mode:"host-qr"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"WECHAT_BOT_TOKEN",prompt:{label:"WeChat Bot Token",help:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only."}},{id:"accountId",kind:"config",required:true,envKey:"WECHAT_ACCOUNT_ID",statePath:"wechatConfig.accountId"},{id:"baseUrl",kind:"config",required:false,envKey:"WECHAT_BASE_URL",statePath:"wechatConfig.baseUrl"},{id:"userId",kind:"config",required:false,envKey:"WECHAT_USER_ID",statePath:"wechatConfig.userId"},{id:"allowedIds",kind:"config",required:false,envKey:"WECHAT_ALLOWED_IDS",statePath:"allowedIds.wechat",prompt:{label:"WeChat User ID(s) (DM allowlist)",help:"Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.",emptyValueMessage:"bot will require manual pairing"}}],credentials:[{id:"wechatBotToken",sourceInput:"botToken",providerName:"{sandboxName}-wechat-bridge",providerEnvKey:"WECHAT_BOT_TOKEN",placeholder:"openshell:resolve:env:WECHAT_BOT_TOKEN"}],policyPresets:[{name:"wechat",policyKeys:["wechat_bridge"]}],render:[{id:"wechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.openclaw-weixin",value:{enabled:true}}},{id:"wechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WEIXIN_TOKEN={{credential.wechatBotToken.placeholder}}","WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}","WEIXIN_BASE_URL={{wechatConfig.baseUrl}}","WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}"]},{id:"wechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.weixin",value:{enabled:true}}}],runtime:{openclaw:{channelName:"openclaw-weixin",visibility:{configKeys:["openclaw-weixin"],logPatterns:["wechat","openclaw-weixin"]},nodePreloads:[{module:"wechat-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing WeChat diagnostics (provider readiness + inference errors)",installedMessage:"[channels] WeChat diagnostics installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@tencent-weixin/openclaw-weixin@2.4.3",pin:true,integrity:"sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",tarballUrl:"https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz",runtimeLock:{cachePath:"/usr/local/share/nemoclaw/wechat-npm-cache",installCacheEnvKey:"NEMOCLAW_WECHAT_NPM_INSTALL_CACHE",lockFile:"/usr/local/lib/nemoclaw/wechat-runtime/package-lock.json",projectsRoot:"/sandbox/.openclaw/npm/projects",verifierPath:"/usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts",offline:true,legacyPeerDeps:true},required:true}],hooks:[{id:"wechat-host-qr",phase:"enroll",handler:"wechat.ilinkLogin",inputs:["allowedIds"],outputs:[{id:"botToken",kind:"secret",required:true},{id:"accountId",kind:"config",required:true},{id:"baseUrl",kind:"config"},{id:"userId",kind:"config"},{id:"allowedIds",kind:"config"}],onFailure:"skip-channel"},{id:"wechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedIds",kind:"config"}]},{id:"wechat-seed-openclaw-account",phase:"post-agent-install",handler:"wechat.seedOpenClawAccount",agents:["openclaw"],inputs:["wechatConfig.accountId","wechatConfig.baseUrl","wechatConfig.userId","credential.wechatBotToken.placeholder"],outputs:[{id:"openclawWeixinAccountsIndex",kind:"build-file",required:true},{id:"openclawWeixinAccountFile",kind:"build-file",required:true},{id:"openclawConfigPatch",kind:"build-file",required:true}],onFailure:"abort"},{id:"wechat-health-check",phase:"health-check",handler:"wechat.healthCheck",inputs:["wechatConfig.accountId"],onFailure:"abort"}]};var whatsappManifest={schemaVersion:1,id:"whatsapp",displayName:"WhatsApp",description:"WhatsApp Web messaging (QR pairing)",enrollmentHelp:"WhatsApp Web pairs via QR code scanned with your phone \u2014 no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.",enrollmentNotes:["After pairing, run `nemoclaw channels status --channel whatsapp`. OpenClaw reports inbound delivery evidence; Hermes reports gateway and dashboard session-path diagnostics."],supportedAgents:["openclaw","hermes"],auth:{mode:"in-sandbox-qr"},inputs:[{id:"allowedIds",kind:"config",required:false,envKey:"WHATSAPP_ALLOWED_IDS",statePath:"allowedIds.whatsapp"}],credentials:[],policyPresets:["whatsapp"],render:[{id:"whatsapp-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.whatsapp",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false}}}}}},{id:"whatsapp-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.whatsapp",value:{enabled:true}}},{id:"whatsapp-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WHATSAPP_ENABLED=true","WHATSAPP_MODE=bot","WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}"]},{id:"whatsapp-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.whatsapp",value:{enabled:true}}}],runtime:{openclaw:{channelName:"whatsapp",visibility:{configKeys:["whatsapp"],logPatterns:["whatsapp"]},nodePreloads:[{module:"whatsapp-qr-compact",injectInto:["connect"],optional:true,installMessage:"[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/whatsapp@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz"},required:true}],hooks:[{id:"whatsapp-status-health",phase:"status",handler:"whatsapp.statusHealth",agents:["openclaw","hermes"],outputs:[{id:"channelHealth",kind:"status"}]}]};var BUILT_IN_CHANNEL_MANIFESTS=[telegramManifest,discordManifest,wechatManifest,slackManifest,whatsappManifest,teamsManifest,googlechatManifest];function createBuiltInChannelManifestRegistry(){return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS)}var EXACT_TEMPLATE_PATTERN=/^\{\{\s*([^}]+?)\s*\}\}$/;var TEMPLATE_REFERENCE_PATTERN=/\{\{\s*([^}]+?)\s*\}\}/g;function resolvedRenderTemplateReference(value){return{matched:true,value}}function resolveSandboxNameTemplate(value,sandboxName){return value.replaceAll("{sandboxName}",sandboxName)}function resolveRenderTemplatesInValue(value,context){if(typeof value==="string")return resolveRenderTemplatesInString(value,context);if(Array.isArray(value)){if(value.length===0)return value;const resolved=value.map(entry=>resolveRenderTemplatesInValue(entry,context)).filter(entry=>entry!==void 0);return resolved.length>0?resolved:void 0}if(value&&typeof value==="object"){const sourceEntries=Object.entries(value);if(sourceEntries.length===0)return value;const entries=sourceEntries.map(([key,entry])=>[key,resolveRenderTemplatesInValue(entry,context)]).filter(entry=>entry[1]!==void 0);return entries.length>0?Object.fromEntries(entries):void 0}return value}function isTruthyRenderTemplate(value,context){if(!value)return true;const resolved=resolveRenderTemplatesInString(value,context);if(resolved===void 0||resolved===null||resolved===false)return false;if(Array.isArray(resolved))return resolved.length>0;if(typeof resolved==="object")return Object.keys(resolved).length>0;if(typeof resolved==="string")return resolved.trim().length>0;return true}function resolveRenderTemplatesInString(value,context){const exact=value.match(EXACT_TEMPLATE_PATTERN);if(exact?.[1])return resolveTemplateReference(exact[1].trim(),context);let omitted=false;const resolved=value.replace(TEMPLATE_REFERENCE_PATTERN,(match,reference)=>{const replacement=resolveTemplateReference(reference.trim(),context);if(replacement===void 0||replacement===null){omitted=true;return""}if(Array.isArray(replacement))return replacement.map(String).join(",");if(typeof replacement==="object")return JSON.stringify(replacement);return String(replacement)});return omitted?void 0:resolved}function resolveTemplateReference(reference,context){const resolved=context.referenceResolver?.(reference,context);return resolved?.matched?resolved.value:"{{"+reference+"}}"}function allowedIds(context,channel){return parseList(stateValue(context,`allowedIds.${channel}`))}function stateValue(context,path5){const stateInput=context.inputs.find(input=>input.statePath===path5);if(stateInput?.value!==void 0)return stateInput.value;const inputId=path5.split(".").at(-1);return context.inputs.find(input=>input.inputId===inputId)?.value}function parseList(value){if(Array.isArray(value))return unique(value.map(String).map(cleanString).filter(Boolean));const text=cleanString(value);if(!text)return[];return unique(text.split(",").map(cleanString).filter(Boolean))}function parseBoolean(value){if(typeof value==="boolean")return value;const text=cleanString(value)?.toLowerCase();if(text==="1"||text==="true"||text==="yes"||text==="on")return true;if(text==="0"||text==="false"||text==="no"||text==="off")return false;return void 0}function nonEmptyString(value){return cleanString(value)||void 0}function cleanString(value){const text=String(value??"");if(/[\r\n]/.test(text)){throw new Error("Messaging template values must not contain line breaks.")}return text.trim()}function nonEmptyArray(values){return values.length>0?[...values]:void 0}function nonEmptyCsv(values){return values.length>0?values.join(","):void 0}function nonEmptyObject(value){return Object.keys(value).length>0?value:void 0}function unique(values){return[...new Set(values)]}var resolveDiscordTemplateReference=(reference,context)=>{if(reference==="discordProxyUrl")return resolvedRenderTemplateReference(void 0);switch(reference){case"discord.guilds":return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context)));case"discord.hasGuilds":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0);case"discord.guildIds.csv":return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context))));case"discord.allowedUsers.values":return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context)));case"discord.allowedUsers.csv":return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context)));case"discord.allowedUsers.dmPolicy":return resolvedRenderTemplateReference(discordAllowedUsers(context).length>0?"allowlist":void 0);case"discord.allowAllUsers":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0&&discordAllowedUsers(context).length===0?true:void 0);case"discord.requireMention":return resolvedRenderTemplateReference(discordRequireMention(context));default:return void 0}};function discordGuilds(context){const serverIds=parseList(stateValue(context,"discordGuilds.serverId"));if(serverIds.length===0)return{};const users=parseList(stateValue(context,"discordGuilds.userIds"));const requireMention=parseBoolean(stateValue(context,"discordGuilds.requireMention"))??true;return Object.fromEntries(serverIds.map(serverId=>[serverId,{requireMention,...users.length>0?{users}:{}}]))}function discordAllowedUsers(context){const users=new Set(allowedIds(context,"discord"));for(const guild of Object.values(discordGuilds(context))){for(const user of guild.users??[])users.add(String(user))}return[...users]}function discordRequireMention(context){for(const guild of Object.values(discordGuilds(context))){if(typeof guild.requireMention==="boolean")return guild.requireMention}return true}var DEFAULT_AUDIENCE_TYPE="app-url";var APP_PRINCIPAL_DISCOVERY_SENTINEL="000000000000000000000";var resolveGooglechatTemplateReference=(reference,context)=>{switch(reference){case"googlechatConfig.audienceType":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audienceType"))??DEFAULT_AUDIENCE_TYPE);case"googlechatConfig.audience":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audience")));case"googlechatConfig.appPrincipal":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.appPrincipal"))??APP_PRINCIPAL_DISCOVERY_SENTINEL);default:break}const allowReference=reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy)$/);if(!allowReference?.[1])return void 0;const ids=allowedIds(context,"googlechat");switch(allowReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};var resolveSlackTemplateReference=(reference,context)=>{if(reference==="slackConfig.allowedChannels.csv"){return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context)))}const allowedIdsReference=reference.match(/^allowedIds[.]slack[.](values|csv|dmPolicy|groupPolicy|channels)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"slack");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"groupPolicy":return resolvedRenderTemplateReference(ids.length>0||slackAllowedChannels(context).length>0?"allowlist":void 0);case"channels":return resolvedRenderTemplateReference(slackChannelConfig(context,ids));default:return void 0}};function slackChannelConfig(context,users){const allowedChannels=slackAllowedChannels(context);const entry={enabled:true,requireMention:true,...users.length>0?{users:[...users]}:{}};if(allowedChannels.length>0){return Object.fromEntries(allowedChannels.map(channelId=>[channelId,{...entry}]))}return users.length>0?{"*":entry}:void 0}function slackAllowedChannels(context){return parseList(stateValue(context,"slackConfig.allowedChannels"))}var DEFAULT_TEAMS_WEBHOOK_PORT=3978;var resolveTeamsTemplateReference=(reference,context)=>{switch(reference){case"teamsConfig.appId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.appId")));case"teamsConfig.tenantId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.tenantId")));case"teamsConfig.webhookPort":return resolvedRenderTemplateReference(teamsWebhookPort(context));case"teamsConfig.requireMention":return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"teamsConfig.requireMention")));default:break}const allowedIdsReference=reference.match(/^allowedIds[.]teams[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"teams");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function teamsWebhookPort(context){const raw=nonEmptyString(stateValue(context,"teamsConfig.webhookPort"));if(!raw)return DEFAULT_TEAMS_WEBHOOK_PORT;const port=Number(raw);if(!Number.isInteger(port)||port<1||port>65535){throw new Error("Microsoft Teams webhook port must be an integer TCP port between 1 and 65535.")}return port}var DEFAULT_PROXY_HOST="10.200.0.1";var DEFAULT_PROXY_PORT="3128";var DEFAULT_TELEGRAM_GROUP_POLICY="open";var TELEGRAM_GROUP_POLICIES=new Set(["open","allowlist","disabled"]);var resolveTelegramTemplateReference=(reference,context)=>{if(reference==="proxyUrl")return resolvedRenderTemplateReference(proxyUrl(context.env));if(reference==="telegramConfig.groupPolicy"){return resolvedRenderTemplateReference(telegramGroupPolicy(context))}if(reference==="telegramConfig.openclawGroups"){return resolvedRenderTemplateReference(telegramOpenClawGroups(context))}if(reference==="telegramConfig.requireMention"){return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"telegramConfig.requireMention")))}const allowedIdsReference=reference.match(/^allowedIds[.]telegram[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"telegram");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function proxyUrl(env){const host=nonEmptyString(env?.NEMOCLAW_PROXY_HOST)??DEFAULT_PROXY_HOST;const port=nonEmptyString(env?.NEMOCLAW_PROXY_PORT)??DEFAULT_PROXY_PORT;return`http://${host}:${port}`}function telegramGroupPolicy(context){const value=nonEmptyString(stateValue(context,"telegramConfig.groupPolicy"));return value&&TELEGRAM_GROUP_POLICIES.has(value)?value:DEFAULT_TELEGRAM_GROUP_POLICY}function telegramOpenClawGroups(context){if(telegramGroupPolicy(context)!=="open")return void 0;const requireMention=parseBoolean(stateValue(context,"telegramConfig.requireMention"));return requireMention===true?{"*":{requireMention:true}}:void 0}var WECHAT_ILINK_HOSTS=new Set(["ilinkai.weixin.qq.com","ilinkai.wechat.com"]);var WECHAT_ILINK_IDC_HOST_PATTERN=/^idc-[0-9]+[.]weixin[.]qq[.]com$/;function normalizeWechatIlinkBaseUrl(value){const raw=String(value??"");if(/[\r\n]/.test(raw)){throw new Error("WeChat baseUrl must not contain line breaks.")}const text=raw.trim();if(!text)return void 0;let url;try{url=new URL(text)}catch{throw new Error("WeChat baseUrl must be a valid URL.")}if(url.protocol!=="https:"){throw new Error("WeChat baseUrl must use HTTPS.")}if(url.username||url.password){throw new Error("WeChat baseUrl must not include credentials.")}if(!isWechatIlinkHost(url.hostname)){throw new Error("WeChat baseUrl must use an expected iLink host.")}if(url.pathname&&url.pathname!=="/"||url.search||url.hash){throw new Error("WeChat baseUrl must be an iLink origin URL.")}return url.origin}function isWechatIlinkHost(hostname){const normalized=hostname.toLowerCase();return WECHAT_ILINK_HOSTS.has(normalized)||WECHAT_ILINK_IDC_HOST_PATTERN.test(normalized)}var resolveWechatTemplateReference=(reference,context)=>{const wechatConfig=reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/);if(wechatConfig?.[1]){if(wechatConfig[1]==="baseUrl"){return resolvedRenderTemplateReference(normalizeWechatIlinkBaseUrl(stateValue(context,"wechatConfig.baseUrl")))}return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"wechatConfig."+wechatConfig[1])))}const allowedIdsReference=reference.match(/^allowedIds[.]wechat[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=wechatAllowedIds(context);switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function wechatAllowedIds(context){const ids=allowedIds(context,"wechat");const userId=nonEmptyString(stateValue(context,"wechatConfig.userId"));return userId&&!ids.includes(userId)?[userId,...ids]:ids}var resolveWhatsappTemplateReference=(reference,context)=>{const allowedIdsReference=reference.match(/^allowedIds[.]whatsapp[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"whatsapp");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};var BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS=[resolveTelegramTemplateReference,resolveDiscordTemplateReference,resolveWechatTemplateReference,resolveSlackTemplateReference,resolveWhatsappTemplateReference,resolveTeamsTemplateReference,resolveGooglechatTemplateReference];function createBuiltInRenderTemplateResolver(){return(reference,context)=>{for(const resolver of BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS){const resolved=resolver(reference,context);if(resolved)return resolved}return void 0}}var import_node_crypto=__toESM(require("node:crypto"));function hashCredential(value){const normalized=String(value??"").trim();if(!normalized)return null;return import_node_crypto.default.createHash("sha256").update(normalized).digest("hex")}function planCredentialBindings(manifest,context,inputs,environment=process.env){return manifest.credentials.map(credential=>{const sourceInput=inputs.find(input=>input.inputId===credential.sourceInput);const credentialAvailable=sourceInput?.credentialAvailable===true||context.credentialAvailability?.[credential.id]===true||context.credentialAvailability?.[`${manifest.id}.${credential.id}`]===true;const envKey=sourceInput?.sourceEnv??credential.providerEnvKey;const credentialHash=credentialAvailable?hashCredential(environment[envKey])??void 0:void 0;return{channelId:manifest.id,credentialId:credential.id,sourceInput:credential.sourceInput,providerName:resolveSandboxNameTemplate(credential.providerName,context.sandboxName),providerEnvKey:credential.providerEnvKey,placeholder:credential.placeholder,credentialAvailable,...credentialHash!==void 0?{credentialHash}:{}}})}function planHostForward(manifest,inputs,active,referenceResolver,environment=process.env){if(!active||!manifest.hostForward)return void 0;const context={inputs,env:environment,referenceResolver};if(!isTruthyRenderTemplate(manifest.hostForward.when,context))return void 0;const portValue=resolveRenderTemplatesInValue(manifest.hostForward.port,context);const port=normalizeForwardPort(manifest.id,portValue);return{channelId:manifest.id,port,label:manifest.hostForward.label}}function normalizeForwardPort(channelId,value){const port=typeof value==="number"?value:Number(String(value??"").trim());if(!Number.isInteger(port)||port<1||port>65535){throw new Error(`Channel manifest '${channelId}' declares invalid host forward port '${String(value)}'.`)}return port}var OPENSHELL_ENV_PLACEHOLDER_PREFIX="openshell:resolve:env:";var OPENSHELL_ALIAS_PLACEHOLDER_RE=/^[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-(.+)$/;function normalizeProviderPlaceholderForEnvKey(value,envKey){if(value.startsWith(OPENSHELL_ENV_PLACEHOLDER_PREFIX)){return placeholderSuffixMatchesEnvKey(value.slice(OPENSHELL_ENV_PLACEHOLDER_PREFIX.length),envKey)?`${OPENSHELL_ENV_PLACEHOLDER_PREFIX}${envKey}`:null}const aliasMatch=value.match(OPENSHELL_ALIAS_PLACEHOLDER_RE);if(!aliasMatch||!placeholderSuffixMatchesEnvKey(aliasMatch[1],envKey)){return null}return value.replace(/-OPENSHELL-RESOLVE-ENV-.+$/,`-OPENSHELL-RESOLVE-ENV-${envKey}`)}function placeholderSuffixMatchesEnvKey(suffix,envKey){if(suffix===envKey)return true;const revisionMatch=suffix.match(/^v[0-9]+_(.+)$/);return revisionMatch?.[1]===envKey}function hasFullPersistedCredentialBindingShape(binding){return typeof binding.channelId==="string"&&typeof binding.credentialId==="string"&&typeof binding.sourceInput==="string"&&typeof binding.providerName==="string"&&typeof binding.providerEnvKey==="string"&&typeof binding.placeholder==="string"&&typeof binding.credentialAvailable==="boolean"}function normalizeFullPersistedCredentialBindings(bindings){return bindings.map(binding=>({channelId:binding.channelId,credentialId:binding.credentialId,sourceInput:binding.sourceInput,providerName:binding.providerName,providerEnvKey:binding.providerEnvKey,placeholder:normalizeProviderPlaceholderForEnvKey(binding.placeholder,binding.providerEnvKey)??binding.placeholder,credentialAvailable:binding.credentialAvailable===true,...typeof binding.credentialHash==="string"?{credentialHash:binding.credentialHash}:{}}))}function normalizePersistedAgentCredentialPlaceholders(render,credentialBindings){const credentialEnvKeys=new Set(credentialBindings.map(binding=>binding.providerEnvKey).filter(Boolean));if(credentialEnvKeys.size===0)return[...render];return render.map(entry=>{if(entry.kind!=="env-lines")return entry;return{...entry,lines:entry.lines.map(line=>normalizeCredentialEnvLine(line,credentialEnvKeys))}})}function normalizeCredentialEnvLine(line,credentialEnvKeys){const index=line.indexOf("=");if(index<=0)return line;const envKey=line.slice(0,index).trim();if(!credentialEnvKeys.has(envKey))return line;const value=line.slice(index+1);const normalized=normalizeProviderPlaceholderForEnvKey(value,envKey);return normalized?`${envKey}=${normalized}`:line}function normalizePersistedSandboxMessagingPlanShape(plan,environment=process.env){const manifestRegistry=createBuiltInChannelManifestRegistry();const disabledChannels=plan.disabledChannels.filter(channelId=>typeof channelId==="string");const disabledSet=new Set(disabledChannels);const channels=plan.channels.map(channel=>normalizePersistedChannel(channel,disabledSet,manifestRegistry.get(channel.channelId),environment));const credentialBindings=normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment);const normalizedPlan={...plan,channels,disabledChannels,credentialBindings,networkPolicy:plan.networkPolicy&&Array.isArray(plan.networkPolicy.entries)?plan.networkPolicy:{presets:[],entries:[]},agentRender:normalizePersistedAgentCredentialPlaceholders(Array.isArray(plan.agentRender)?[...plan.agentRender]:[],credentialBindings),buildSteps:Array.isArray(plan.buildSteps)?[...plan.buildSteps]:[],...plan.runtimeSetup!==void 0?{runtimeSetup:normalizeRuntimeSetup(plan.runtimeSetup)}:{},stateUpdates:Array.isArray(plan.stateUpdates)?[...plan.stateUpdates]:[],healthChecks:Array.isArray(plan.healthChecks)?[...plan.healthChecks]:[]};return normalizedPlan}function normalizePersistedChannel(channel,disabledSet,manifest,environment){const disabled=channel.disabled??disabledSet.has(channel.channelId);const configured=channel.configured??true;const hasFullShape=hasFullChannelShape(channel);const inputs=hasFullShape?normalizeFullInputs(channel.channelId,channel.inputs??[]):normalizePersistedInputs(channel,manifest);const active=channel.active??(configured&&!disabled&&requiredInputsAvailable(manifest,inputs));const hostForward=manifest?planHostForward(manifest,inputs,active&&!disabled,createBuiltInRenderTemplateResolver(),environment):void 0;return{channelId:channel.channelId,displayName:channel.displayName??manifest?.displayName??channel.channelId,authMode:channel.authMode??manifest?.auth.mode??"none",active,selected:channel.selected??configured,configured,disabled,inputs,...hostForward?{hostForward}:{},hooks:Array.isArray(channel.hooks)?[...channel.hooks]:[]}}function normalizePersistedInputs(channel,manifest){const persistedById=new Map((channel.inputs??[]).filter(input=>typeof input.inputId==="string").map(input=>[input.inputId,input]));const fromManifest=(manifest?.inputs??[]).map(input=>inputReferenceFromManifest(channel.channelId,input,persistedById.get(input.id)));const manifestInputIds=new Set((manifest?.inputs??[]).map(input=>input.id));const unknownInputs=[...persistedById.values()].flatMap(input=>{if(!input.inputId||manifestInputIds.has(input.inputId))return[];return[normalizeUnknownInput(channel.channelId,input)]});return[...fromManifest,...unknownInputs]}function normalizeFullInputs(channelId,inputs){return inputs.filter(input=>typeof input.inputId==="string").map(input=>({channelId:typeof input.channelId==="string"?input.channelId:channelId,inputId:input.inputId,kind:input.kind==="secret"||input.kind==="config"?input.kind:"config",required:typeof input.required==="boolean"?input.required:false,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}))}function inputReferenceFromManifest(channelId,input,persisted){return{channelId,inputId:input.id,kind:input.kind,required:input.required,...input.envKey?{sourceEnv:input.envKey}:{},...input.kind==="config"&&input.statePath?{statePath:input.statePath}:{},...persisted?.credentialAvailable!==void 0?{credentialAvailable:persisted.credentialAvailable}:{},...persisted?.value!==void 0?{value:persisted.value}:{}}}function normalizeUnknownInput(channelId,input){const kind=input.kind==="secret"||input.kind==="config"?input.kind:"config";return{channelId,inputId:input.inputId,kind,required:input.required===true,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}}function requiredInputsAvailable(manifest,inputs){if(!manifest)return true;return manifest.inputs.every(manifestInput=>{if(!manifestInput.required)return true;const input=inputs.find(entry=>entry.inputId===manifestInput.id);if(!input)return false;if(input.kind==="secret")return input.credentialAvailable===true;if(input.value===void 0)return false;return typeof input.value==="string"?input.value.trim().length>0:true})}function normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment){const persisted=plan.credentialBindings??[];if(Array.isArray(plan.credentialBindings)&&plan.channels.every(hasFullChannelShape)&&persisted.every(hasFullPersistedCredentialBindingShape)){return normalizeFullPersistedCredentialBindings(persisted)}const manifests=channels.flatMap(channel=>{const manifest=manifestRegistry.get(channel.channelId);return manifest?[manifest]:[]});const planForBindings={...plan,channels,credentialBindings:[],networkPolicy:{presets:[],entries:[]},agentRender:[],buildSteps:[],runtimeSetup:{nodePreloads:[],envAliases:[],secretScans:[]},stateUpdates:[],healthChecks:[]};const generated=credentialBindingsFromManifests(planForBindings,manifests,new Map(channels.map(channel=>[channel.channelId,channel.inputs])),environment);return generated.map(binding=>overlayPersistedCredentialBinding(binding,persisted))}function credentialBindingsFromManifests(plan,manifests,inputRegistry,environment){const context=compilerContext(plan);return manifests.flatMap(manifest=>planCredentialBindings(manifest,context,inputRegistry.get(manifest.id)??[],environment).map(binding=>overlayPersistedCredentialBinding(binding,plan.credentialBindings)))}function overlayPersistedCredentialBinding(binding,persisted){const match=persisted.find(candidate=>credentialBindingMatches(binding,candidate));if(!match)return binding;return{...binding,credentialAvailable:typeof match.credentialAvailable==="boolean"?match.credentialAvailable:binding.credentialAvailable,...typeof match.credentialHash==="string"&&match.credentialHash.length>0?{credentialHash:match.credentialHash}:binding.credentialHash?{credentialHash:binding.credentialHash}:{}}}function credentialBindingMatches(binding,candidate){if(candidate.channelId&&candidate.channelId!==binding.channelId)return false;if(candidate.providerEnvKey&&candidate.providerEnvKey===binding.providerEnvKey)return true;if(candidate.credentialId&&candidate.credentialId===binding.credentialId)return true;if(candidate.sourceInput&&candidate.sourceInput===binding.sourceInput)return true;return false}function hasFullChannelShape(channel){return typeof channel.displayName==="string"&&typeof channel.authMode==="string"&&typeof channel.active==="boolean"&&typeof channel.selected==="boolean"&&typeof channel.configured==="boolean"&&typeof channel.disabled==="boolean"&&Array.isArray(channel.inputs)}function normalizeRuntimeSetup(setup){return{nodePreloads:Array.isArray(setup?.nodePreloads)?[...setup.nodePreloads]:[],envAliases:Array.isArray(setup?.envAliases)?[...setup.envAliases]:[],secretScans:Array.isArray(setup?.secretScans)?[...setup.secretScans]:[]}}function compilerContext(plan){return{sandboxName:plan.sandboxName,agent:plan.agent,workflow:plan.workflow,isInteractive:false,configuredChannels:plan.channels.map(channel=>channel.channelId),disabledChannels:plan.disabledChannels,credentialAvailability:credentialAvailabilityFromPlan(plan)}}function credentialAvailabilityFromPlan(plan){const availability={};for(const channel of plan.channels){for(const input of channel.inputs){if(input.kind!=="secret"||input.credentialAvailable!==true)continue;availability[input.inputId]=true;availability[`${channel.channelId}.${input.inputId}`]=true;if(input.sourceEnv)availability[input.sourceEnv]=true}}for(const credential of plan.credentialBindings){if(!credential.credentialAvailable)continue;availability[credential.credentialId]=true;availability[`${credential.channelId}.${credential.credentialId}`]=true;availability[credential.sourceInput]=true;availability[`${credential.channelId}.${credential.sourceInput}`]=true;availability[credential.providerEnvKey]=true}return availability}function normalizeMessagingChannelId(channelId){return channelId.trim().toLowerCase()}function enabledPlanChannels(plan){const disabled=new Set((plan.disabledChannels??[]).map(normalizeMessagingChannelId).filter(Boolean));return plan.channels.filter(channel=>{const channelId=normalizeMessagingChannelId(channel.channelId);return channelId.length>0&&channel.active&&!channel.disabled&&!disabled.has(channelId)})}function selectActiveMessagingChannelIds(plan){const seen=new Set;const channels=[];for(const item of enabledPlanChannels(plan)){const channel=normalizeMessagingChannelId(item.channelId);if(!channel||seen.has(channel))continue;seen.add(channel);channels.push(channel)}return channels}function selectEnabledMessagingAgentRender(plan){const active=new Set(selectActiveMessagingChannelIds(plan));return plan.agentRender.filter(render=>render.agent===plan.agent&&active.has(normalizeMessagingChannelId(render.channelId)))}function selectEnabledPostAgentInstallBuildFiles(plan){const active=new Set(selectActiveMessagingChannelIds(plan));const channels=enabledPlanChannels(plan);return plan.buildSteps.filter(step=>{const channelId=normalizeMessagingChannelId(step.channelId);if(!active.has(channelId)||step.kind!=="build-file")return false;if(!step.hookId)return true;const matchingChannels=channels.filter(channel=>normalizeMessagingChannelId(channel.channelId)===channelId);if(matchingChannels.length!==1)return false;const matchedHook=matchingChannels[0]?.hooks?.find(hook=>hook.id===step.hookId);return matchedHook!==void 0&&matchedHook.phase==="post-agent-install"})}function parseSandboxMessagingPlan(value,options={}){if(!isObjectRecord(value)||value.schemaVersion!==1||typeof value.sandboxName!=="string"||typeof value.agent!=="string"||typeof value.workflow!=="string"||!Array.isArray(value.channels)||!Array.isArray(value.disabledChannels)||!isOptionalObjectArray(value,"credentialBindings")||Object.hasOwn(value,"networkPolicy")&&!isObjectRecord(value.networkPolicy)||!isOptionalObjectArray(value,"agentRender")||!isOptionalObjectArray(value,"buildSteps")||!isRuntimeSetup(value.runtimeSetup)||!isOptionalObjectArray(value,"stateUpdates")||!isOptionalObjectArray(value,"healthChecks")){return null}if(options.sandboxName&&value.sandboxName!==options.sandboxName)return null;if(options.agent&&value.agent!==options.agent)return null;const supported=Array.isArray(options.supportedChannelIds)?new Set(options.supportedChannelIds):null;const normalizedChannelIds=new Set;for(const channel of value.channels){if(!isObjectRecord(channel)||typeof channel.channelId!=="string")return null;const normalizedChannelId=normalizeMessagingChannelId(channel.channelId);if(!normalizedChannelId||normalizedChannelId!==channel.channelId||normalizedChannelIds.has(normalizedChannelId)){return null}if(Object.hasOwn(channel,"configured")&&typeof channel.configured!=="boolean"){return null}if(Object.hasOwn(channel,"active")&&typeof channel.active!=="boolean")return null;if(Object.hasOwn(channel,"disabled")&&typeof channel.disabled!=="boolean")return null;if(Object.hasOwn(channel,"inputs")&&!Array.isArray(channel.inputs))return null;if(Object.hasOwn(channel,"hostForward")&&!isHostForward(channel.hostForward))return null;if(Object.hasOwn(channel,"hooks")&&!Array.isArray(channel.hooks))return null;if(Array.isArray(channel.inputs)&&channel.inputs.some(input=>!isObjectRecord(input)||typeof input.inputId!=="string"||Object.hasOwn(input,"channelId")&&input.channelId!==normalizedChannelId)){return null}if(Array.isArray(channel.hooks)&&channel.hooks.some(hook=>!isObjectRecord(hook)||Object.hasOwn(hook,"channelId")&&hook.channelId!==normalizedChannelId)){return null}if(Object.hasOwn(channel,"hostForward")&&isObjectRecord(channel.hostForward)&&channel.hostForward.channelId!==normalizedChannelId){return null}if(supported&&!supported.has(channel.channelId))return null;normalizedChannelIds.add(normalizedChannelId)}if(!value.disabledChannels.every(isCanonicalMessagingChannelId))return null;const disabledChannelIds=new Set(value.disabledChannels);if(disabledChannelIds.size!==value.disabledChannels.length||[...disabledChannelIds].some(channelId=>!normalizedChannelIds.has(channelId))||value.channels.some(channel=>isObjectRecord(channel)&&channel.disabled===true!==disabledChannelIds.has(String(channel.channelId)))){return null}if(!hasCanonicalChannelReferences(value.credentialBindings)||!hasCanonicalChannelReferences(value.agentRender)||!hasCanonicalChannelReferences(value.buildSteps)||!hasCanonicalChannelReferences(value.stateUpdates)||!hasCanonicalChannelReferences(value.healthChecks)||!hasCanonicalNetworkPolicyReferences(value.networkPolicy)||!hasCanonicalRuntimeSetupReferences(value.runtimeSetup)){return null}return cloneSandboxMessagingPlan(normalizePersistedSandboxMessagingPlanShape(value,options.environment))}function cloneSandboxMessagingPlan(plan){return JSON.parse(JSON.stringify(plan))}function isOptionalObjectArray(value,key){if(!Object.hasOwn(value,key))return true;const entries=value[key];return Array.isArray(entries)&&entries.every(isObjectRecord)}function isHostForward(value){return isObjectRecord(value)&&typeof value.channelId==="string"&&typeof value.port==="number"&&Number.isInteger(value.port)&&value.port>=1&&value.port<=65535&&typeof value.label==="string"}function isRuntimeSetup(value){if(value===void 0)return true;return isObjectRecord(value)&&Array.isArray(value.nodePreloads)&&Array.isArray(value.envAliases)&&Array.isArray(value.secretScans)&&value.nodePreloads.every(isObjectRecord)&&value.envAliases.every(isObjectRecord)&&value.secretScans.every(isObjectRecord)}function isCanonicalMessagingChannelId(value){return typeof value==="string"&&value.length>0&&normalizeMessagingChannelId(value)===value}function hasCanonicalChannelReferences(value){return value===void 0||Array.isArray(value)&&value.every(entry=>isObjectRecord(entry)&&isCanonicalMessagingChannelId(entry.channelId))}function hasCanonicalNetworkPolicyReferences(value){if(!isObjectRecord(value)||!Object.hasOwn(value,"entries"))return true;return hasCanonicalChannelReferences(value.entries)}function hasCanonicalRuntimeSetupReferences(value){if(value===void 0)return true;if(!isObjectRecord(value))return false;return["nodePreloads","envAliases","secretScans"].every(field=>hasCanonicalChannelReferences(value[field]))}var import_node_buffer=require("node:buffer");var import_node_crypto2=require("node:crypto");var import_node_util=require("node:util");var MANAGED_STARTUP_PROFILE_SCHEMA_VERSION=1;var MANAGED_STARTUP_PROFILE_MAX_BYTES=64*1024;var MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES=Math.ceil(MANAGED_STARTUP_PROFILE_MAX_BYTES/3)*4;var MAX_IDENTIFIER_BYTES=256;var MAX_MODEL_BYTES=1024;var MAX_URL_BYTES=2048;var MAX_LIST_ITEMS=128;var MAX_JSON_NODES=4096;var MAX_JSON_DEPTH=32;var MAX_TUNING_INTEGER=1e9;var MIN_HERMES_CONTEXT_WINDOW=64e3;var SHA256_RE=/^[a-f0-9]{64}$/;var CONTROL_CHARACTER_RE=/[\u0000-\u001f\u007f-\u009f]/u;var BASE64URL_RE=/^[A-Za-z0-9_-]+$/;var RAW_CA_PEM_RE=/-----BEGIN (?:TRUSTED )?CERTIFICATE-----/iu;var RAW_CA_PEM_BASE64_RE=/^LS0tLS1CRUdJTi(?:BDRVJUSUZJQ0FURS0tLS0t|BUlVTVEVEIENFUlRJRklDQVRFLS0tLS0)/u;var RAW_CA_DER_BASE64_RE=/^MII[A-Za-z0-9+/=\r\n]{253,}$/u;var RAW_CA_DATA_URI_RE=/data:application\/(?:pkix-cert|x-x509-ca-cert);base64,MII[A-Za-z0-9+/=]{253,}/iu;var URL_CANDIDATE_RE=/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;var UTF8_DECODER=new import_node_util.TextDecoder("utf-8",{fatal:true});var CREDENTIAL_SHAPED_NAME_PATTERN=/(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/iu;var CREDENTIAL_COMPOUND_NAME_PATTERN=/^(?:access|refresh|client|bearer|auth|api|private|signing|session|bot|app|resolved)(?:token|key|secret|password)$/iu;var CREDENTIAL_CAMEL_SUFFIX_PATTERN=/(?:apiKey|accessKey|secretKey|authToken|refreshToken|accessToken|clientSecret|privateKey|passcode|password|passwd|passphrase|bearerToken|botToken|appToken|sessionToken|signingKey|secretPublicKey|personalAccessToken|connectionString|webhookUrl)$/iu;var CREDENTIAL_CAMEL_BOUNDARY_PATTERN=/[a-z0-9](?:Token|Key|Secret|Password|Passphrase|Pat)$/u;var CREDENTIAL_ENV_NAME_PATTERN=/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/u;var CREDENTIAL_HEADER_NAME_PATTERN=/^(?:authorization|proxy-authorization|cookie|set-cookie|.+-(?:key|token|secret|password|passphrase|credential|auth)s?)$/iu;var PUBLIC_KEY_NAME_PATTERN=/^public[-_]?keys?$/iu;var PASS_CREDENTIAL_NAME_PATTERN=/(?:^|[-_])pass(?:wd)?$/iu;var NON_SECRET_KEY_METADATA_NAMES=new Set(["envKey","installCacheEnvKey","providerEnvKey","stateKey"]);var MESSAGING_CREDENTIAL_PLACEHOLDER_RE=/^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u;var SECRET_VALUE_PATTERNS=[/nvapi-[A-Za-z0-9_-]{10,}/u,/nvcf-[A-Za-z0-9_-]{10,}/u,/ghp_[A-Za-z0-9_-]{10,}/u,/github_pat_[A-Za-z0-9_]{30,}/u,/sk-(?:proj-|ant-)?[A-Za-z0-9_-]{10,}/u,/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/u,/A(?:K|S)IA[A-Z0-9]{16}/u,/hf_[A-Za-z0-9]{10,}/u,/glpat-[A-Za-z0-9_-]{10,}/u,/gsk_[A-Za-z0-9]{10,}/u,/pypi-[A-Za-z0-9_-]{10,}/u,/tvly-[A-Za-z0-9_-]{10,}/u,/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/u,/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/u,/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/u,/\bBearer\s+[A-Za-z0-9_.+/=-]{10,}/iu,/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----/u];var MANAGED_STARTUP_INFERENCE_APIS=["openai-completions","openai-responses","anthropic-messages"];var MANAGED_STARTUP_REASONING_EFFORTS=["default","low","medium","high"];var MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES=["disabled","thread-opt-in"];var MANAGED_STARTUP_HERMES_TOOL_GATEWAYS=["nous-web","nous-image","nous-audio","nous-browser","nous-code"];var MANAGED_STARTUP_AGENTS=["openclaw","hermes","langchain-deepagents-code"];var MANAGED_STARTUP_MESSAGING_AGENTS=["openclaw","hermes"];function freezeAgentCapabilities(capabilities){return Object.freeze({...capabilities,inferenceApis:Object.freeze([...capabilities.inferenceApis]),dashboardModes:Object.freeze([...capabilities.dashboardModes]),inputModalities:Object.freeze([...capabilities.inputModalities]),webSearchProviders:Object.freeze([...capabilities.webSearchProviders]),toolGateways:Object.freeze([...capabilities.toolGateways]),tuningFields:Object.freeze([...capabilities.tuningFields])})}var PROFILE_CAPABILITIES={openclaw:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["loopback","remote"],inputModalities:["text","image"],webSearchProviders:["brave","tavily"],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning","reasoningEffort"],supportsMessaging:true,supportsInferenceCompatibility:true,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:true,supportsAgentTimeout:true,supportsHeartbeat:true,supportsExtraAgents:true,supportsDeviceAuth:true,observability:"openclaw-otel",supportsMinimalBootstrap:true},hermes:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["disabled","loopback-forwarded"],inputModalities:[],webSearchProviders:["tavily"],toolGateways:[...MANAGED_STARTUP_HERMES_TOOL_GATEWAYS],tuningFields:["contextWindow"],supportsMessaging:true,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false},"langchain-deepagents-code":{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["reasoningEffort"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:true,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"dcode-marker",supportsMinimalBootstrap:false}};for(const agent of MANAGED_STARTUP_AGENTS){Object.defineProperty(PROFILE_CAPABILITIES,agent,{configurable:false,enumerable:true,value:freezeAgentCapabilities(PROFILE_CAPABILITIES[agent]),writable:false})}var MANAGED_STARTUP_PROFILE_CAPABILITIES=Object.freeze(PROFILE_CAPABILITIES);function affordance(input,profilePath,source="docker-arg",representation="value"){return{input,profilePath,source,representation}}var HOST_PROXY_AFFORDANCES=[affordance("HTTP_PROXY","proxy.hostHttpUrl","runtime-env"),affordance("http_proxy","proxy.hostHttpUrl","runtime-env","derived"),affordance("HTTPS_PROXY","proxy.hostHttpsUrl","runtime-env"),affordance("https_proxy","proxy.hostHttpsUrl","runtime-env","derived"),affordance("NO_PROXY","proxy.hostNoProxy","runtime-env"),affordance("no_proxy","proxy.hostNoProxy","runtime-env","derived")];var MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY={openclaw:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_PRIMARY_MODEL_REF","inference.primaryModelRef"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_INFERENCE_COMPAT_B64","inference.compatibility"),affordance("NEMOCLAW_INFERENCE_INPUTS","inference.inputModalities"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_AGENT_TIMEOUT","agentConfig.agentTimeoutSeconds"),affordance("NEMOCLAW_AGENT_HEARTBEAT_EVERY","agentConfig.heartbeatEvery"),affordance("NEMOCLAW_EXTRA_AGENTS_JSON_B64","agentConfig.extraAgents"),affordance("NEMOCLAW_DISABLE_DEVICE_AUTH","agentConfig.deviceAuth.disabled"),affordance("NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE","agentConfig.deviceAuth.optOutSource"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_OPENCLAW_OTEL","agentConfig.otel.enabled"),affordance("NEMOCLAW_OPENCLAW_OTEL_ENDPOINT","agentConfig.otel.endpointUrl"),affordance("NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME","agentConfig.otel.serviceName"),affordance("NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE","agentConfig.otel.sampleRate"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_BIND","dashboard.bindAddress"),affordance("NEMOCLAW_WSL_DASHBOARD_EXPOSURE","dashboard.wslExposure"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.port","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("NEMOCLAW_MINIMAL_BOOTSTRAP","agentConfig.minimalBootstrap","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],hermes:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER","tools.enabledGateways","docker-arg","derived"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64","tools.enabledGateways"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD","dashboard.mode","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT","dashboard.internalPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_TUI","dashboard.tuiEnabled","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost","runtime-env"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],"langchain-deepagents-code":[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_UPSTREAM_ENDPOINT_URL","inference.upstreamEndpointUrl"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_DCODE_AUTO_APPROVAL","agentConfig.autoApprovalMode"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_OBSERVABILITY","agentConfig.observabilityEnabled","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES]};function deferredRuntimeInput(input,owner,reason,admission="managed-launch-forwarded"){return Object.freeze({input,owner,admission,reason})}var MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS=Object.freeze({openclaw:Object.freeze([deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_SHADOW_DIAGNOSTICS","application-environment","operator shadow-diagnostics tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS","application-environment","operator MCP discovery timeout tuning is applied by the application environment transaction"),deferredRuntimeInput("OPENCLAW_HOME","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_STATE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_WORKSPACE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),hermes:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),"langchain-deepagents-code":Object.freeze([deferredRuntimeInput("NEMOCLAW_SANDBOX_NAME","engine-identity","the lifecycle engine owns instance identity outside reusable startup intent"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")])});function runtimeCleanupObligation(input,emittedFor,supportedFor,reason){return Object.freeze({input,emittedFor:Object.freeze([...emittedFor]),supportedFor:Object.freeze([...supportedFor]),owner:"application-environment",reason})}var MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS=Object.freeze([runtimeCleanupObligation("NEMOCLAW_DASHBOARD_BIND",["hermes"],["openclaw"],"generic managed-dashboard construction currently emits the OpenClaw-only bind control for Hermes"),runtimeCleanupObligation("NEMOCLAW_MINIMAL_BOOTSTRAP",["hermes","langchain-deepagents-code"],["openclaw"],"generic host-proxy construction currently emits the OpenClaw-only bootstrap control for other agents")]);var ManagedStartupProfileError=class extends Error{constructor(message){super(`Invalid managed startup profile: ${message}`);this.name="ManagedStartupProfileError"}};var PROFILE_KEYS=new Set(["schemaVersion","agent","agentConfig","inference","proxy","dashboard","tools","messaging","tuning","corporateCa"]);var INFERENCE_KEYS=new Set(["routeProvider","upstreamProvider","model","routedBaseUrl","upstreamEndpointUrl","api","primaryModelRef","compatibility","inputModalities"]);var PROXY_KEYS=new Set(["managedHost","managedPort","hostHttpUrl","hostHttpsUrl","hostNoProxy"]);var OPENCLAW_DASHBOARD_KEYS=new Set(["agent","mode","url","port","bindAddress","wslExposure"]);var HERMES_DASHBOARD_KEYS=new Set(["agent","mode","url","publicPort","internalPort","tuiEnabled"]);var DCODE_DASHBOARD_KEYS=new Set(["agent","mode"]);var TOOLS_KEYS=new Set(["disclosure","enabledGateways"]);var MESSAGING_KEYS=new Set(["plan"]);var TUNING_KEYS=new Set(["contextWindow","maxTokens","reasoning","reasoningEffort"]);var CORPORATE_CA_KEYS=new Set(["bundleSha256"]);var OPENCLAW_CONFIG_KEYS=new Set(["agent","webSearch","otel","agentTimeoutSeconds","heartbeatEvery","extraAgents","deviceAuth","minimalBootstrap"]);var HERMES_CONFIG_KEYS=new Set(["agent","webSearch"]);var DCODE_CONFIG_KEYS=new Set(["agent","autoApprovalMode","observabilityEnabled"]);var WEB_SEARCH_KEYS=new Set(["enabled","provider"]);var OTEL_KEYS=new Set(["enabled","endpointUrl","serviceName","sampleRate"]);var DEVICE_AUTH_KEYS=new Set(["disabled","optOutSource"]);var EXTRA_AGENTS_KEYS=new Set(["agents","defaults","main"]);var MANAGED_STARTUP_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DCODE_AUTO_APPROVAL_MODE_SET=new Set(MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES);var REASONING_EFFORT_SET=new Set(MANAGED_STARTUP_REASONING_EFFORTS);var HERMES_INTERNAL_API_PORT=18642;var HERMES_API_PORT_RANGE_START=8642;var HERMES_API_PORT_RANGE_END=8652;function isHermesApiPort(port){return port>=HERMES_API_PORT_RANGE_START&&port<=HERMES_API_PORT_RANGE_END}function isHermesReservedApiPort(port){return port===HERMES_INTERNAL_API_PORT||isHermesApiPort(port)}var HERMES_RESERVED_API_PORT_LABEL=`${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END} or ${HERMES_INTERNAL_API_PORT}`;function isPlainObject(value){if(typeof value!=="object"||value===null||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function isCredentialShapedName(name){if(PUBLIC_KEY_NAME_PATTERN.test(name)||NON_SECRET_KEY_METADATA_NAMES.has(name))return false;return CREDENTIAL_SHAPED_NAME_PATTERN.test(name)||CREDENTIAL_COMPOUND_NAME_PATTERN.test(name)||CREDENTIAL_CAMEL_SUFFIX_PATTERN.test(name)||CREDENTIAL_CAMEL_BOUNDARY_PATTERN.test(name)||CREDENTIAL_ENV_NAME_PATTERN.test(name)||CREDENTIAL_HEADER_NAME_PATTERN.test(name)||PASS_CREDENTIAL_NAME_PATTERN.test(name)}function valueLooksLikeSecret(value){for(let index=0;index=2&&path5[0]==="messaging"&&path5[1]==="plan"&&typeof value==="string"&&MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)}function containsUrlWithCredentialMaterial(value){const candidates=value.match(URL_CANDIDATE_RE)??[];for(let index=0;index{if(isCredentialShapedName(key))credentialQuery=true});const fragment=url.hash.startsWith("#")?url.hash.slice(1):url.hash;const queryStart=fragment.indexOf("?");const fragmentParameters=new URLSearchParams(queryStart>=0?fragment.slice(queryStart+1):fragment);let credentialFragment=false;fragmentParameters.forEach((_fragmentValue,key)=>{if(isCredentialShapedName(key))credentialFragment=true});if(url.username||url.password||credentialQuery||credentialFragment)return true}catch{}}return false}function invalid(reason){throw new ManagedStartupProfileError(reason)}function payloadPath(path5){return path5.reduce((result,segment)=>segment.startsWith("[")?`${result}${segment}`:`${result}${result?".":""}${segment}`,"")}function mapArrayByIndex(values,mapper){const mapped=[];for(let index=0;index0&&values[insertion-1]>selected){Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:values[insertion-1],writable:true});insertion-=1}Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:selected,writable:true})}return values}function requireRecord(value,where){if(!isPlainObject(value))invalid(`${where} must be an object`);return value}function rejectUnknownKeys(value,allowed,where){const keys=Object.keys(value);for(let index=0;indexmaxBytes||CONTROL_CHARACTER_RE.test(value)){invalid(`${where} must be a bounded, non-empty string without control characters`)}return value}function requireStringEnum(value,allowed,where){const normalized=requireBoundedString(value,where);if(!allowed.has(normalized))invalid(`${where} is not supported`);return normalized}function requireNullablePositiveInteger(value,where){if(value===null)return null;if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>MAX_TUNING_INTEGER){invalid(`${where} must be null or a bounded positive integer`)}return value}function requirePositiveInteger(value,where,maximum=MAX_TUNING_INTEGER){if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>maximum){invalid(`${where} must be a bounded positive integer`)}return value}function requirePort(value,where,minimum=1){if(typeof value!=="number"||!Number.isInteger(value)||value<1||value>65535){invalid(`${where} must be a valid TCP port`)}if(valueMAX_LIST_ITEMS){invalid(`${where} must be a bounded string list`)}const items=mapArrayByIndex(value,item=>requireBoundedString(item,`${where} item`));const unique2=new Set;for(let index=0;index{if(depth>MAX_JSON_DEPTH)invalid(`${where} exceeds the JSON depth limit`);if(current===null||typeof current==="string"||typeof current==="boolean"){return current}if(typeof current==="number"){if(!Number.isFinite(current))invalid(`${where} contains a non-finite number`);return current}if(Array.isArray(current)){return mapArrayByIndex(current,item=>clone(item,depth+1))}if(!isPlainObject(current))invalid(`${where} contains a non-JSON value`);const result=options.nullPrototypeObjects?Object.create(null):{};const keys=Object.getOwnPropertyNames(current);for(let index=0;indexMAX_IDENTIFIER_BYTES||CONTROL_CHARACTER_RE.test(key)){invalid(`${where} contains an invalid object key`)}const descriptor=Object.getOwnPropertyDescriptor(current,key);if(!descriptor||!("value"in descriptor)){invalid(`${where} contains a non-JSON value`)}Object.defineProperty(result,key,{configurable:true,enumerable:true,value:clone(descriptor.value,depth+1),writable:true})}return result};return clone(value,0)}function requireJsonObjectOrNull(value,where){if(value===null)return null;if(!isPlainObject(value))invalid(`${where} must be null or a plain JSON object`);return cloneJsonValue(value,where,{nullPrototypeObjects:true})}function requireJsonObject(value,where){const object=requireJsonObjectOrNull(value,where);if(object===null)invalid(`${where} must be a plain JSON object`);return object}function requireHttpUrl(value,where){const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) URL`)}if(parsed.protocol!=="http:"&&parsed.protocol!=="https:"||parsed.username||parsed.password||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) URL without query or fragment data`)}const pathname=parsed.pathname.replace(/\/+$/u,"");return pathname===""?parsed.origin:`${parsed.origin}${pathname}`}function requireProxyUrl(value,allowedSchemes,where){if(value===null)return null;const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) proxy URL`)}if(!allowedSchemes.has(parsed.protocol)||parsed.username||parsed.password||parsed.pathname!=="/"||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) proxy origin`)}return parsed.origin}function requireManagedProxyHost(value,where){const host=requireBoundedString(value,where);if(!/^[A-Za-z0-9._-]+$/u.test(host)){invalid(`${where} must be a hostname or IPv4 address without a scheme or separators`)}return host}function isLoopbackUrl(value){const hostname=new URL(value).hostname.toLowerCase();return hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="[::1]"}function configuredDashboardPort(value){const explicit=new URL(value).port;return explicit===""?18789:Number(explicit)}function requireSampleRate(value,where){if(typeof value!=="number"||!Number.isFinite(value)||value<0||value>1){invalid(`${where} must be a number between 0 and 1`)}return value}function assertPayloadStructureAndCredentialShapes(root){const pending=[{value:root,depth:0,path:[]}];let discoveredNodes=1;let observedBytes=0;const observeText=value=>{observedBytes+=import_node_buffer.Buffer.byteLength(value,"utf8");if(observedBytes>MANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}};const reserveNode=depth=>{discoveredNodes+=1;if(discoveredNodes>MAX_JSON_NODES||depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}observedBytes+=1};while(pending.length>0){const current=pending.pop();if(!current)break;if(current.depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}if(typeof current.value==="string"){observeText(current.value);if(!isMessagingCredentialPlaceholder(current.path,current.value)&&valueLooksLikeSecret(current.value)){invalid(`payload field ${payloadPath(current.path)} contains credential-shaped string data`)}if(RAW_CA_PEM_RE.test(current.value)||RAW_CA_PEM_BASE64_RE.test(current.value)||RAW_CA_DER_BASE64_RE.test(current.value)||RAW_CA_DATA_URI_RE.test(current.value)){invalid(`payload field ${payloadPath(current.path)} contains raw certificate data; provide only the CA SHA-256 digest`)}if(containsUrlWithCredentialMaterial(current.value)){invalid(`payload field ${payloadPath(current.path)} contains a URL with embedded credentials`)}continue}if(Array.isArray(current.value)){if(Object.getPrototypeOf(current.value)!==Array.prototype){invalid("payload arrays must use the standard JSON prototype")}if("toJSON"in current.value){invalid("payload must not define a custom JSON serializer")}if(Object.getOwnPropertySymbols(current.value).length>0||Object.getOwnPropertyNames(current.value).length!==current.value.length+1){invalid("payload arrays must contain only indexed JSON values")}for(let index=0;index0||discoveredNodes+keys.length>MAX_JSON_NODES){invalid("payload structure exceeds the complexity limit")}for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}}function validateWebSearch(value,agent){const webSearch=requireRecord(value,"agentConfig.webSearch");rejectUnknownKeys(webSearch,WEB_SEARCH_KEYS,"agentConfig.webSearch");const provider=requireStringEnum(webSearch.provider,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].webSearchProviders),"agentConfig.webSearch.provider");return{enabled:requireBoolean(webSearch.enabled,"agentConfig.webSearch.enabled"),provider}}function validateOpenClawOtel(value){const otel=requireRecord(value,"agentConfig.otel");rejectUnknownKeys(otel,OTEL_KEYS,"agentConfig.otel");return{enabled:requireBoolean(otel.enabled,"agentConfig.otel.enabled"),endpointUrl:requireHttpUrl(otel.endpointUrl,"agentConfig.otel.endpointUrl"),serviceName:requireBoundedString(otel.serviceName,"agentConfig.otel.serviceName",MAX_IDENTIFIER_BYTES),sampleRate:requireSampleRate(otel.sampleRate,"agentConfig.otel.sampleRate")}}function validateExtraAgents(value){const extraAgents=requireRecord(value,"agentConfig.extraAgents");rejectUnknownKeys(extraAgents,EXTRA_AGENTS_KEYS,"agentConfig.extraAgents");if(!Array.isArray(extraAgents.agents)||extraAgents.agents.length>MAX_LIST_ITEMS){invalid("agentConfig.extraAgents.agents must be a bounded JSON object list")}return{agents:mapArrayByIndex(extraAgents.agents,(agent,index)=>requireJsonObject(agent,`agentConfig.extraAgents.agents[${String(index)}]`)),defaults:requireJsonObject(extraAgents.defaults,"agentConfig.extraAgents.defaults"),main:requireJsonObject(extraAgents.main,"agentConfig.extraAgents.main")}}function validateDeviceAuth(value){const deviceAuth=requireRecord(value,"agentConfig.deviceAuth");rejectUnknownKeys(deviceAuth,DEVICE_AUTH_KEYS,"agentConfig.deviceAuth");return{disabled:requireBoolean(deviceAuth.disabled,"agentConfig.deviceAuth.disabled"),optOutSource:requireStringEnum(deviceAuth.optOutSource,new Set(["operator","managed-onboard"]),"agentConfig.deviceAuth.optOutSource")}}function validateAgentConfig(value,expectedAgent){const config=requireRecord(value,"agentConfig");const agent=requireStringEnum(config.agent,MANAGED_STARTUP_AGENT_SET,"agentConfig.agent");if(agent!==expectedAgent)invalid("agentConfig.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(config,OPENCLAW_CONFIG_KEYS,"agentConfig");const heartbeatEvery=config.heartbeatEvery===null?null:requireBoundedString(config.heartbeatEvery,"agentConfig.heartbeatEvery",MAX_IDENTIFIER_BYTES);if(heartbeatEvery!==null&&!/^\d+(?:s|m|h)$/u.test(heartbeatEvery)){invalid("agentConfig.heartbeatEvery must be null or a duration ending in s, m, or h")}return{agent,webSearch:validateWebSearch(config.webSearch,agent),otel:validateOpenClawOtel(config.otel),agentTimeoutSeconds:requirePositiveInteger(config.agentTimeoutSeconds,"agentConfig.agentTimeoutSeconds"),heartbeatEvery,extraAgents:validateExtraAgents(config.extraAgents),deviceAuth:validateDeviceAuth(config.deviceAuth),minimalBootstrap:requireBoolean(config.minimalBootstrap,"agentConfig.minimalBootstrap")}}if(agent==="hermes"){rejectUnknownKeys(config,HERMES_CONFIG_KEYS,"agentConfig");return{agent,webSearch:validateWebSearch(config.webSearch,agent)}}rejectUnknownKeys(config,DCODE_CONFIG_KEYS,"agentConfig");return{agent,autoApprovalMode:requireStringEnum(config.autoApprovalMode,DCODE_AUTO_APPROVAL_MODE_SET,"agentConfig.autoApprovalMode"),observabilityEnabled:requireBoolean(config.observabilityEnabled,"agentConfig.observabilityEnabled")}}function validateDashboard(value,expectedAgent){const dashboard=requireRecord(value,"dashboard");const agent=requireStringEnum(dashboard.agent,MANAGED_STARTUP_AGENT_SET,"dashboard.agent");if(agent!==expectedAgent)invalid("dashboard.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(dashboard,OPENCLAW_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");const bindAddress=requireStringEnum(dashboard.bindAddress,new Set(["127.0.0.1","0.0.0.0"]),"dashboard.bindAddress");const wslExposure=requireBoolean(dashboard.wslExposure,"dashboard.wslExposure");const hasRemoteExposure=!isLoopbackUrl(url)||bindAddress==="0.0.0.0"||wslExposure;if(mode==="remote"!==hasRemoteExposure){invalid("OpenClaw dashboard.mode must reflect its URL, bind address, and WSL exposure")}const port=requirePort(dashboard.port,"dashboard.port",1024);if(isHermesApiPort(port))invalid(`OpenClaw dashboard.port must not use a reserved Hermes API port (${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END})`);if(configuredDashboardPort(url)!==port){invalid("OpenClaw dashboard.port must match dashboard.url")}return{agent,mode,url,port,bindAddress,wslExposure}}if(agent==="hermes"){rejectUnknownKeys(dashboard,HERMES_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");if(!isLoopbackUrl(url)){invalid("Hermes dashboard.url must remain loopback; OpenShell owns the host forward")}if(mode==="disabled"){if(dashboard.publicPort!==null||dashboard.internalPort!==null||dashboard.tuiEnabled!==false){invalid("disabled Hermes dashboard must not configure ports or TUI")}return{agent,mode,url,publicPort:null,internalPort:null,tuiEnabled:false}}const publicPort=requirePort(dashboard.publicPort,"dashboard.publicPort",1024);const internalPort=requirePort(dashboard.internalPort,"dashboard.internalPort",1024);if(publicPort===internalPort){invalid("Hermes dashboard publicPort and internalPort must differ")}if(isHermesReservedApiPort(publicPort)||isHermesReservedApiPort(internalPort)){invalid(`Hermes dashboard ports must not use reserved API ports ${HERMES_RESERVED_API_PORT_LABEL}`)}if(configuredDashboardPort(url)!==publicPort){invalid("Hermes dashboard.publicPort must match dashboard.url")}return{agent,mode,url,publicPort,internalPort,tuiEnabled:requireBoolean(dashboard.tuiEnabled,"dashboard.tuiEnabled")}}rejectUnknownKeys(dashboard,DCODE_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled"){invalid("langchain-deepagents-code dashboard.mode must be disabled")}return{agent,mode:"disabled"}}function validateInference(value,agent){const inference=requireRecord(value,"inference");rejectUnknownKeys(inference,INFERENCE_KEYS,"inference");const routeProvider=requireBoundedString(inference.routeProvider,"inference.routeProvider");const model=requireBoundedString(inference.model,"inference.model",MAX_MODEL_BYTES);const api=requireStringEnum(inference.api,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inferenceApis),"inference.api");const upstreamEndpointUrl=inference.upstreamEndpointUrl===null?null:requireHttpUrl(inference.upstreamEndpointUrl,"inference.upstreamEndpointUrl");const primaryModelRef=inference.primaryModelRef===null?null:requireBoundedString(inference.primaryModelRef,"inference.primaryModelRef",MAX_MODEL_BYTES);const compatibility=requireJsonObjectOrNull(inference.compatibility,"inference.compatibility");const inputModalities=inference.inputModalities===null?null:requireEnumList(inference.inputModalities,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inputModalities),"inference.inputModalities",{allowEmpty:false});if(agent==="openclaw"){if(upstreamEndpointUrl!==null){invalid("inference.upstreamEndpointUrl must be null for openclaw")}if(primaryModelRef===null||inputModalities===null){invalid("openclaw requires primaryModelRef and inputModalities")}if(primaryModelRef!==`${routeProvider}/${model}`){invalid("openclaw primaryModelRef must match routeProvider and model")}}else{if(primaryModelRef!==null||compatibility!==null||inputModalities!==null){invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`)}if(agent==="hermes"&&upstreamEndpointUrl!==null){invalid("inference.upstreamEndpointUrl must be null for hermes")}}return{routeProvider,upstreamProvider:requireBoundedString(inference.upstreamProvider,"inference.upstreamProvider"),model,routedBaseUrl:requireHttpUrl(inference.routedBaseUrl,"inference.routedBaseUrl"),upstreamEndpointUrl,api,primaryModelRef,compatibility,inputModalities}}function validateProxy(value,agent){const proxy=requireRecord(value,"proxy");rejectUnknownKeys(proxy,PROXY_KEYS,"proxy");const hostHttpUrl=requireProxyUrl(proxy.hostHttpUrl,new Set(["http:"]),"proxy.hostHttpUrl");const hostHttpsUrl=requireProxyUrl(proxy.hostHttpsUrl,new Set(["http:","https:"]),"proxy.hostHttpsUrl");const hostNoProxy=requireStringList(proxy.hostNoProxy,"proxy.hostNoProxy");if(!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsHostProxyIntent&&(hostHttpUrl!==null||hostHttpsUrl!==null||hostNoProxy.length>0)){invalid(`${agent} rejects host proxy intent and accepts only its root-owned managed route`)}return{managedHost:requireManagedProxyHost(proxy.managedHost,"proxy.managedHost"),managedPort:requirePort(proxy.managedPort,"proxy.managedPort"),hostHttpUrl,hostHttpsUrl,hostNoProxy}}function validateTools(value,agent){const tools=requireRecord(value,"tools");rejectUnknownKeys(tools,TOOLS_KEYS,"tools");const enabledGateways=requireEnumList(tools.enabledGateways,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].toolGateways),"tools.enabledGateways",{allowEmpty:true});return{disclosure:requireStringEnum(tools.disclosure,new Set(["progressive","direct"]),"tools.disclosure"),enabledGateways}}function validateTuning(value,agent){const tuning=requireRecord(value,"tuning");rejectUnknownKeys(tuning,TUNING_KEYS,"tuning");const result={contextWindow:requireNullablePositiveInteger(tuning.contextWindow,"tuning.contextWindow"),maxTokens:requireNullablePositiveInteger(tuning.maxTokens,"tuning.maxTokens"),reasoning:requireNullableBoolean(tuning.reasoning,"tuning.reasoning"),reasoningEffort:tuning.reasoningEffort===null?null:requireStringEnum(tuning.reasoningEffort,REASONING_EFFORT_SET,"tuning.reasoningEffort")};if(agent==="openclaw"){if(result.contextWindow===null||result.maxTokens===null||result.reasoning===null||result.reasoningEffort===null){invalid("openclaw requires contextWindow, maxTokens, reasoning, and reasoningEffort tuning")}}else if(agent==="hermes"){if(result.contextWindow!==null&&result.contextWindowcanonicalizeJson(item));if(!isPlainObject(value))return value;const result={};const keys=sortStrings(Object.keys(value));for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`canonical payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}return serialized}function decodeManagedStartupProfile(encoded){if(typeof encoded!=="string"||encoded.length===0||import_node_buffer.Buffer.byteLength(encoded,"ascii")>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES||!BASE64URL_RE.test(encoded)||encoded.length%4===1){invalid("encoded payload is malformed or exceeds the size limit")}const bytes=import_node_buffer.Buffer.from(encoded,"base64url");if(bytes.length===0||bytes.length>MANAGED_STARTUP_PROFILE_MAX_BYTES||bytes.toString("base64url")!==encoded){invalid("encoded payload is malformed or exceeds the size limit")}let raw;try{raw=UTF8_DECODER.decode(bytes)}catch{invalid("payload is not valid UTF-8")}let parsed;try{parsed=JSON.parse(raw)}catch{invalid("payload is not valid JSON")}const profile=validateManagedStartupProfile(parsed);if(serializeManagedStartupProfile(profile)!==raw){invalid("payload is not in canonical form")}return profile}function fingerprintManagedStartupProfile(profile){return(0,import_node_crypto2.createHash)("sha256").update(serializeManagedStartupProfile(profile),"utf8").digest("hex")}var ManagedStartupAgentEnvironmentError=class extends Error{constructor(message){super(`Cannot map managed startup profile: ${message}`);this.name="ManagedStartupAgentEnvironmentError"}};var EMPTY_APPLICATION_ENVIRONMENT=Object.freeze({});var OPENCLAW_APPLICATION_RUNTIME_INPUTS=Object.freeze([["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","positive-safe-integer"],["NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","positive-finite-seconds"]]);function booleanFlag(value){return value?"1":"0"}function canonicalizeJson2(value){if(Array.isArray(value))return value.map(item=>canonicalizeJson2(item));if(value===null||typeof value!=="object")return value;const record=value;return Object.fromEntries(Object.keys(record).sort().map(key=>[key,canonicalizeJson2(record[key])]))}function encodeCanonicalJson(value){return import_node_buffer2.Buffer.from(JSON.stringify(canonicalizeJson2(value)),"utf8").toString("base64")}function sortedEnvironment(environment){return Object.freeze(Object.fromEntries(Object.entries(environment).sort(([left],[right])=>leftright?1:0)))}function canonicalApplicationRuntimeValue(name,raw,kind){if(raw.includes("\0")||/[\r\n]/u.test(raw)){throw new ManagedStartupAgentEnvironmentError(`${name} must be single-line text`)}const value=Number(raw.trim());const valid=kind==="positive-safe-integer"?Number.isSafeInteger(value)&&value>0:Number.isFinite(value)&&value>0;if(!valid){throw new ManagedStartupAgentEnvironmentError(`${name} must be ${kind==="positive-safe-integer"?"a positive safe integer":"finite positive seconds"}`)}return String(value)}function applicationRuntimePlan(profile,environment){const exportEnvironment={};if(profile.agent==="openclaw"){for(const[name,kind]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){const raw=environment[name];if(raw!==void 0){exportEnvironment[name]=canonicalApplicationRuntimeValue(name,raw,kind)}}}const unsetEnvironment=new Set(MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS.filter(({supportedFor})=>!supportedFor.includes(profile.agent)).map(({input})=>input));if(profile.agent!=="openclaw"){for(const[name]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){unsetEnvironment.add(name)}}return Object.freeze({exportEnvironment:sortedEnvironment(exportEnvironment),unsetEnvironment:Object.freeze([...unsetEnvironment].sort())})}function commonConfigurationEnvironment(profile){return{NEMOCLAW_INFERENCE_API:profile.inference.api,NEMOCLAW_INFERENCE_BASE_URL:profile.inference.routedBaseUrl,NEMOCLAW_INFERENCE_PROVIDER_ID:profile.inference.routeProvider,NEMOCLAW_MODEL:profile.inference.model,NEMOCLAW_TOOL_DISCLOSURE:profile.tools.disclosure,NEMOCLAW_UPSTREAM_PROVIDER:profile.inference.upstreamProvider}}function appendHostProxyEnvironment(environment,profile,options={}){if(options.preserveAmbientWhenAbsent===true&&profile.proxy.hostHttpUrl===null&&profile.proxy.hostHttpsUrl===null&&profile.proxy.hostNoProxy.length===0){return}const httpProxy=profile.proxy.hostHttpUrl??"";const httpsProxy=profile.proxy.hostHttpsUrl??"";const noProxy=profile.proxy.hostNoProxy.join(",");environment.HTTP_PROXY=httpProxy;environment.HTTPS_PROXY=httpsProxy;environment.NO_PROXY=noProxy;environment.http_proxy=httpProxy;environment.https_proxy=httpsProxy;environment.no_proxy=noProxy}function messagingEnvironment(profile,expectedAgent){if(profile.messaging.plan===null)return{};const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:expectedAgent});if(!plan){throw new ManagedStartupAgentEnvironmentError(`messaging.plan must contain a validated ${expectedAgent} messaging plan`)}const{workflow:_workflow,...imageBuildPlan}=plan;return{NEMOCLAW_MESSAGING_PLAN_B64:encodeCanonicalJson(imageBuildPlan)}}function corporateCaMaterial(profile){return Object.freeze({kind:"corporate-ca-handoff",legacyInput:"NEMOCLAW_CORPORATE_CA_B64",expectedSha256:profile.corporateCa.bundleSha256})}function rootOwnedFile(legacyInput,path5,value){return Object.freeze({kind:"root-owned-file",legacyInput,path:path5,contents:`${value} `,owner:"root",group:"root",mode:292})}function dashboardAction(dashboard){return Object.freeze({kind:"configure-dashboard",dashboard:Object.freeze(structuredClone(dashboard))})}function applicationActions(profile,messagingAgent){const actions=[];if(messagingAgent!==null){actions.push(Object.freeze({kind:"apply-messaging-plan",agent:messagingAgent,mode:profile.messaging.plan===null?"clear":"apply",phase:"runtime-setup",runAs:"root"}))}actions.push(Object.freeze({kind:"generate-agent-config",agent:profile.agent,runAs:"sandbox"}));if(messagingAgent!==null){actions.push(Object.freeze({kind:"apply-messaging-plan",agent:messagingAgent,mode:profile.messaging.plan===null?"clear":"apply",phase:"post-agent-install",runAs:"sandbox"}))}actions.push(dashboardAction(profile.dashboard));return Object.freeze(actions)}function mapOpenClawProfile(profile,environment){if(profile.agent!=="openclaw"||profile.agentConfig.agent!=="openclaw"||profile.dashboard.agent!=="openclaw"||profile.inference.primaryModelRef===null||profile.inference.inputModalities===null||profile.tuning.contextWindow===null||profile.tuning.maxTokens===null||profile.tuning.reasoning===null||profile.tuning.reasoningEffort===null){throw new ManagedStartupAgentEnvironmentError("OpenClaw profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),...messagingEnvironment(profile,"openclaw"),CHAT_UI_URL:profile.dashboard.url,NEMOCLAW_AGENT_HEARTBEAT_EVERY:profile.agentConfig.heartbeatEvery??"",NEMOCLAW_AGENT_TIMEOUT:String(profile.agentConfig.agentTimeoutSeconds),NEMOCLAW_CONTEXT_WINDOW:String(profile.tuning.contextWindow),NEMOCLAW_DASHBOARD_BIND:profile.dashboard.bindAddress==="0.0.0.0"?profile.dashboard.bindAddress:"",NEMOCLAW_DISABLE_DEVICE_AUTH:booleanFlag(profile.agentConfig.deviceAuth.disabled),NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE:profile.agentConfig.deviceAuth.optOutSource,NEMOCLAW_EXTRA_AGENTS_JSON_B64:encodeCanonicalJson(profile.agentConfig.extraAgents),NEMOCLAW_INFERENCE_COMPAT_B64:encodeCanonicalJson(profile.inference.compatibility),NEMOCLAW_INFERENCE_INPUTS:profile.inference.inputModalities.join(","),NEMOCLAW_MAX_TOKENS:String(profile.tuning.maxTokens),NEMOCLAW_OPENCLAW_OTEL:booleanFlag(profile.agentConfig.otel.enabled),NEMOCLAW_OPENCLAW_OTEL_ENDPOINT:profile.agentConfig.otel.endpointUrl,NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE:String(profile.agentConfig.otel.sampleRate),NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME:profile.agentConfig.otel.serviceName,NEMOCLAW_PRIMARY_MODEL_REF:profile.inference.primaryModelRef,NEMOCLAW_PROXY_HOST:profile.proxy.managedHost,NEMOCLAW_PROXY_PORT:String(profile.proxy.managedPort),NEMOCLAW_REASONING:String(profile.tuning.reasoning),NEMOCLAW_REASONING_EFFORT:profile.tuning.reasoningEffort,NEMOCLAW_WEB_SEARCH_ENABLED:booleanFlag(profile.agentConfig.webSearch.enabled),NEMOCLAW_WEB_SEARCH_PROVIDER:profile.agentConfig.webSearch.provider,NEMOCLAW_WSL_DASHBOARD_EXPOSURE:booleanFlag(profile.dashboard.wslExposure)};const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64;runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT=String(profile.dashboard.port);runtimeEnvironment.NEMOCLAW_MINIMAL_BOOTSTRAP=booleanFlag(profile.agentConfig.minimalBootstrap);appendHostProxyEnvironment(runtimeEnvironment,profile,{preserveAmbientWhenAbsent:true});return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials:Object.freeze([corporateCaMaterial(profile)]),actions:applicationActions(profile,"openclaw")})}function mapHermesProfile(profile,environment){if(profile.agent!=="hermes"||profile.agentConfig.agent!=="hermes"||profile.dashboard.agent!=="hermes"){throw new ManagedStartupAgentEnvironmentError("Hermes profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),...messagingEnvironment(profile,"hermes"),CHAT_UI_URL:profile.dashboard.url,NEMOCLAW_CONTEXT_WINDOW:profile.tuning.contextWindow===null?"":String(profile.tuning.contextWindow),NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER:booleanFlag(profile.tools.enabledGateways.length>0),NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64:encodeCanonicalJson(profile.tools.enabledGateways),NEMOCLAW_WEB_SEARCH_ENABLED:booleanFlag(profile.agentConfig.webSearch.enabled),NEMOCLAW_WEB_SEARCH_PROVIDER:profile.agentConfig.webSearch.provider};const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64;runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT=profile.dashboard.publicPort===null?"":String(profile.dashboard.publicPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD=profile.dashboard.mode==="loopback-forwarded"?"1":"0";runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT=profile.dashboard.internalPort===null?"":String(profile.dashboard.internalPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_PORT=profile.dashboard.publicPort===null?"":String(profile.dashboard.publicPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_TUI=booleanFlag(profile.dashboard.tuiEnabled);runtimeEnvironment.NEMOCLAW_PROXY_HOST=profile.proxy.managedHost;runtimeEnvironment.NEMOCLAW_PROXY_PORT=String(profile.proxy.managedPort);appendHostProxyEnvironment(runtimeEnvironment,profile,{preserveAmbientWhenAbsent:true});return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials:Object.freeze([corporateCaMaterial(profile)]),actions:applicationActions(profile,"hermes")})}function mapDcodeProfile(profile,environment){if(profile.agent!=="langchain-deepagents-code"||profile.agentConfig.agent!=="langchain-deepagents-code"||profile.dashboard.agent!=="langchain-deepagents-code"||profile.messaging.plan!==null){throw new ManagedStartupAgentEnvironmentError("LangChain Deep Agents Code profile state is inconsistent")}const reasoningEffort=profile.tuning.reasoningEffort===null||profile.tuning.reasoningEffort==="default"?"":profile.tuning.reasoningEffort;const configurationEnvironment={...commonConfigurationEnvironment(profile),NEMOCLAW_REASONING_EFFORT:reasoningEffort,NEMOCLAW_UPSTREAM_ENDPOINT_URL:profile.inference.upstreamEndpointUrl??""};appendHostProxyEnvironment(configurationEnvironment,profile);const runtimeEnvironment={...configurationEnvironment,NEMOCLAW_OBSERVABILITY:booleanFlag(profile.agentConfig.observabilityEnabled)};delete runtimeEnvironment.NEMOCLAW_INFERENCE_BASE_URL;delete runtimeEnvironment.NEMOCLAW_REASONING_EFFORT;for(const name of["HTTP_PROXY","HTTPS_PROXY","NO_PROXY","http_proxy","https_proxy","no_proxy"]){delete runtimeEnvironment[name]}const materials=Object.freeze([corporateCaMaterial(profile),rootOwnedFile("NEMOCLAW_DCODE_AUTO_APPROVAL","/usr/local/share/nemoclaw/dcode-auto-approval",profile.agentConfig.autoApprovalMode),rootOwnedFile("NEMOCLAW_INFERENCE_BASE_URL","/usr/local/share/nemoclaw/dcode-inference-base-url",profile.inference.routedBaseUrl),rootOwnedFile("NEMOCLAW_PROXY_HOST","/usr/local/share/nemoclaw/dcode-proxy-host",profile.proxy.managedHost),rootOwnedFile("NEMOCLAW_PROXY_PORT","/usr/local/share/nemoclaw/dcode-proxy-port",String(profile.proxy.managedPort)),rootOwnedFile("NEMOCLAW_REASONING_EFFORT","/usr/local/share/nemoclaw/dcode-reasoning-effort",reasoningEffort)]);return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials,actions:applicationActions(profile,null)})}function mapManagedStartupProfileToAgentEnvironment(profile,environment=EMPTY_APPLICATION_ENVIRONMENT){const validated=validateManagedStartupProfile(profile);switch(validated.agent){case"openclaw":return mapOpenClawProfile(validated,environment);case"hermes":return mapHermesProfile(validated,environment);case"langchain-deepagents-code":return mapDcodeProfile(validated,environment)}}var import_node_buffer3=require("node:buffer");var import_node_crypto3=require("node:crypto");var import_node_fs=__toESM(require("node:fs"));var import_node_path=__toESM(require("node:path"));var import_node_util2=require("node:util");var MANAGED_STARTUP_APPLICATION_STATE_DIR="/var/lib/nemoclaw/startup-profile";var MANAGED_STARTUP_CA_MAX_BYTES=128*1024;var MANAGED_STARTUP_CA_MAX_CERTIFICATES=24;var STATE_SCHEMA_VERSION=1;var STATE_DIRECTORY_MODE=448;var STATE_FILE_MODE=384;var MAX_CONTROL_FILE_BYTES=512;var MAX_STATE_ENTRIES=32;var SHA256_RE2=/^[a-f0-9]{64}$/u;var GENERATION_RE=/^generation-([a-f0-9]{64})$/u;var PREPARE_TEMP_RE=/^\.prepare-[0-9]+-[a-f0-9]{24}$/u;var CONTROL_TEMP_RE=/^\.(?:committed|pending)\.json-[a-f0-9]{24}\.tmp$/u;var PEM_CERTIFICATE_RE=/-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+?-----END CERTIFICATE-----/gu;var UTF8_DECODER2=new import_node_util2.TextDecoder("utf-8",{fatal:true});var DEFAULT_RUNTIME={rootUid:0,rootGid:0};var ManagedStartupApplicationError=class extends Error{constructor(message){super(`Managed startup application failed: ${message}`);this.name="ManagedStartupApplicationError"}};function fail(message){throw new ManagedStartupApplicationError(message)}function runtimeFor(override){return override??DEFAULT_RUNTIME}function requireContainerRoot(){if(process.geteuid?.()!==0){fail("the image-side applicator must run with effective uid 0")}}function modeOf(stat){return stat.mode&511}function requireOwner(stat,target,runtime){if(stat.uid!==runtime.rootUid||stat.gid!==runtime.rootGid){fail(`${target} must be owned by root:root`)}}function requireSecureDirectory(target,runtime,exactMode){let stat;try{stat=import_node_fs.default.lstatSync(target)}catch{fail(`state directory component is missing or unreadable: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail(`state directory component must be a real directory: ${target}`)}const runtimeOwned=stat.uid===runtime.rootUid&&stat.gid===runtime.rootGid;const systemRootOwned=stat.uid===0&&stat.gid===0;if(exactMode){requireOwner(stat,target,runtime)}else if(!runtimeOwned&&!systemRootOwned){fail(`state directory ancestor is not owned by a trusted identity: ${target}`)}const mode=modeOf(stat);const writableByUntrustedIdentity=(mode&18)!==0;const trustedStickyRoot=(stat.mode&512)!==0&&(runtimeOwned||systemRootOwned);if(exactMode&&mode!==STATE_DIRECTORY_MODE||!exactMode&&writableByUntrustedIdentity&&!trustedStickyRoot){fail(exactMode?`${target} must have mode 0700`:`${target} is a replaceable group- or world-writable ancestor`)}}function requireSecureAncestors(target,runtime){const root=import_node_path.default.parse(target).root;let current=root;requireSecureDirectory(current,runtime,false);for(const segment of import_node_path.default.relative(root,target).split(import_node_path.default.sep).filter(Boolean)){current=import_node_path.default.join(current,segment);let stat;try{stat=import_node_fs.default.lstatSync(current)}catch{fail(`state directory component is missing or unreadable: ${current}`)}if(stat.isSymbolicLink()){const runtimeOwned=stat.uid===runtime.rootUid&&stat.gid===runtime.rootGid;const systemRootOwned=stat.uid===0&&stat.gid===0;if(!runtimeOwned&&!systemRootOwned){fail(`state directory ancestor is a replaceable symlink: ${current}`)}let resolved;try{resolved=import_node_fs.default.realpathSync(current)}catch{fail(`state directory symlink is missing or unreadable: ${current}`)}requireSecureAncestors(resolved,runtime);continue}requireSecureDirectory(current,runtime,false)}}function ensureStateDirectory(rawStateDirectory,runtime){const stateDirectory=rawStateDirectory??MANAGED_STARTUP_APPLICATION_STATE_DIR;if(!import_node_path.default.isAbsolute(stateDirectory)||stateDirectory.includes("\0")){fail("stateDirectory must be an absolute path")}const normalized=import_node_path.default.resolve(stateDirectory);const parent=import_node_path.default.dirname(normalized);requireSecureAncestors(parent,runtime);try{import_node_fs.default.mkdirSync(normalized,{mode:STATE_DIRECTORY_MODE});import_node_fs.default.chownSync(normalized,runtime.rootUid,runtime.rootGid);import_node_fs.default.chmodSync(normalized,STATE_DIRECTORY_MODE)}catch(error){if(error.code!=="EEXIST"){fail(`could not create the managed startup state directory: ${normalized}`)}}requireSecureDirectory(normalized,runtime,true);return normalized}function requireSecureRegularFileStat(stat,target,runtime){if(!stat.isFile()||stat.isSymbolicLink()){fail(`${target} must be a regular file`)}if(stat.nlink!==1){fail(`${target} must not be hardlinked`)}requireOwner(stat,target,runtime);if(modeOf(stat)!==STATE_FILE_MODE){fail(`${target} must have mode 0600`)}}function readSecureFile(target,maxBytes,runtime){let descriptor;try{descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_RDONLY|import_node_fs.default.constants.O_NOFOLLOW)}catch{fail(`state file is missing, unreadable, or a symlink: ${target}`)}try{const stat=import_node_fs.default.fstatSync(descriptor);requireSecureRegularFileStat(stat,target,runtime);if(stat.size<1||stat.size>maxBytes){fail(`${target} is empty or exceeds its size limit`)}const content=import_node_fs.default.readFileSync(descriptor);if(content.length!==stat.size){fail(`${target} changed while it was being read`)}return content}finally{import_node_fs.default.closeSync(descriptor)}}function writeSecureNewFile(target,content,runtime){let descriptor;try{descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_CREAT|import_node_fs.default.constants.O_EXCL|import_node_fs.default.constants.O_WRONLY|import_node_fs.default.constants.O_NOFOLLOW,STATE_FILE_MODE)}catch{fail(`refused to replace an existing state file: ${target}`)}try{import_node_fs.default.fchownSync(descriptor,runtime.rootUid,runtime.rootGid);import_node_fs.default.fchmodSync(descriptor,STATE_FILE_MODE);import_node_fs.default.writeFileSync(descriptor,content);import_node_fs.default.fsyncSync(descriptor)}finally{import_node_fs.default.closeSync(descriptor)}}function syncDirectory(target){const descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_RDONLY);try{import_node_fs.default.fsyncSync(descriptor)}finally{import_node_fs.default.closeSync(descriptor)}}function randomToken(){return(0,import_node_crypto3.randomBytes)(12).toString("hex")}function stateControl(fingerprint){return{schemaVersion:STATE_SCHEMA_VERSION,fingerprint,generation:`generation-${fingerprint}`}}function serializeStateControl(control){return JSON.stringify({fingerprint:control.fingerprint,generation:control.generation,schemaVersion:control.schemaVersion})}function parseStateControl(target,runtime){const bytes=readSecureFile(target,MAX_CONTROL_FILE_BYTES,runtime);let raw;try{raw=UTF8_DECODER2.decode(bytes)}catch{fail(`${target} is not valid UTF-8`)}let parsed;try{parsed=JSON.parse(raw)}catch{fail(`${target} is not valid JSON`)}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail(`${target} does not contain a valid state control`)}const record=parsed;if(Object.keys(record).sort().join(",")!=="fingerprint,generation,schemaVersion"||record.schemaVersion!==STATE_SCHEMA_VERSION||typeof record.fingerprint!=="string"||!SHA256_RE2.test(record.fingerprint)||record.generation!==`generation-${record.fingerprint}`){fail(`${target} does not contain a valid state control`)}const control=stateControl(record.fingerprint);if(serializeStateControl(control)!==raw){fail(`${target} is not in canonical form`)}return control}function publishStateControlIfAbsent(stateDirectory,basename,control,runtime){const target=import_node_path.default.join(stateDirectory,basename);const temporary=import_node_path.default.join(stateDirectory,`.${basename}-${randomToken()}.tmp`);writeSecureNewFile(temporary,serializeStateControl(control),runtime);try{import_node_fs.default.linkSync(temporary,target)}catch(error){try{unlinkSecureControlOrTemp(temporary,runtime)}catch{}if(error.code==="EEXIST"){return{control:parseStateControl(target,runtime),created:false}}fail(`could not atomically publish ${basename}`)}try{import_node_fs.default.unlinkSync(temporary)}catch(error){if(error.code!=="ENOENT"){fail(`could not finalize atomic publication of ${basename}`)}}syncDirectory(stateDirectory);return{control,created:true}}function validateCorporateCaBytes(bytes){if(bytes.length<1||bytes.length>MANAGED_STARTUP_CA_MAX_BYTES){fail(`corporate CA bundle must contain 1-${String(MANAGED_STARTUP_CA_MAX_BYTES)} bytes`)}let pem;try{pem=UTF8_DECODER2.decode(bytes)}catch{fail("corporate CA bundle must be valid UTF-8 PEM")}const matches=[...pem.matchAll(PEM_CERTIFICATE_RE)];if(matches.length<1||matches.length>MANAGED_STARTUP_CA_MAX_CERTIFICATES||matches[0]?.index!==0){fail(`corporate CA bundle must contain 1-${String(MANAGED_STARTUP_CA_MAX_CERTIFICATES)} PEM CA certificates`)}let cursor=0;for(const match of matches){const index=match.index;if(index===void 0||!/^(?:\r?\n)+$/u.test(pem.slice(cursor,index))&&index!==0){fail("corporate CA bundle contains non-PEM material between certificates")}const block=match[0];let certificate;try{certificate=new import_node_crypto3.X509Certificate(block)}catch{fail("corporate CA bundle contains an invalid X.509 certificate")}if(!certificate.ca){fail("corporate CA bundle contains a certificate without basicConstraints CA:TRUE")}cursor=index+block.length}if(!/^(?:\r?\n)?$/u.test(pem.slice(cursor))){fail("corporate CA bundle contains trailing non-PEM material")}}function validateManagedStartupCorporateCaTransport(encoded,profile){const expectedDigest=profile.corporateCa.bundleSha256;if(expectedDigest===null){if(encoded!==void 0){fail("corporate CA transport must be absent when the profile has no CA digest")}return null}if(typeof encoded!=="string"||encoded.length===0||encoded.length>Math.ceil(MANAGED_STARTUP_CA_MAX_BYTES/3)*4||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)){fail("corporate CA transport must be canonical standard base64")}const bytes=import_node_buffer3.Buffer.from(encoded,"base64");if(bytes.toString("base64")!==encoded){fail("corporate CA transport must be canonical standard base64")}validateCorporateCaBytes(bytes);const actualDigest=(0,import_node_crypto3.createHash)("sha256").update(bytes).digest("hex");if(actualDigest!==expectedDigest){fail("corporate CA bundle does not match the profile SHA-256 digest")}return bytes}function readCanonicalProfile(profilePath,runtime){const bytes=readSecureFile(profilePath,MANAGED_STARTUP_PROFILE_MAX_BYTES,runtime);let raw;try{raw=UTF8_DECODER2.decode(bytes)}catch{fail(`${profilePath} is not valid UTF-8`)}let parsed;try{parsed=JSON.parse(raw)}catch{fail(`${profilePath} is not valid JSON`)}let profile;try{profile=validateManagedStartupProfile(parsed)}catch(error){fail(`${profilePath} is invalid: ${error.message}`)}if(serializeManagedStartupProfile(profile)!==raw){fail(`${profilePath} is not a canonical managed startup profile`)}return{profile,fingerprint:fingerprintManagedStartupProfile(profile)}}function validateGeneration(stateDirectory,control,runtime,expectedAgent){if(!GENERATION_RE.test(control.generation)){fail("state control names an invalid generation")}const directory=import_node_path.default.join(stateDirectory,control.generation);requireSecureDirectory(directory,runtime,true);const entries=import_node_fs.default.readdirSync(directory).sort();if(entries.some(entry=>entry!=="profile.json"&&entry!=="corporate-ca.pem")||!entries.includes("profile.json")){fail(`${directory} contains missing or unsupported state files`)}const profilePath=import_node_path.default.join(directory,"profile.json");const{profile,fingerprint}=readCanonicalProfile(profilePath,runtime);if(fingerprint!==control.fingerprint){fail(`${directory} does not match its recorded profile fingerprint`)}if(expectedAgent!==void 0&&profile.agent!==expectedAgent){fail(`managed startup profile targets ${profile.agent}, expected ${expectedAgent}`)}const caPath=import_node_path.default.join(directory,"corporate-ca.pem");let corporateCaPath=null;if(profile.corporateCa.bundleSha256===null){if(entries.includes("corporate-ca.pem")){fail(`${directory} contains a CA bundle that is absent from the profile`)}}else{if(!entries.includes("corporate-ca.pem")){fail(`${directory} is missing the CA bundle recorded by the profile`)}const caBytes=readSecureFile(caPath,MANAGED_STARTUP_CA_MAX_BYTES,runtime);validateCorporateCaBytes(caBytes);if((0,import_node_crypto3.createHash)("sha256").update(caBytes).digest("hex")!==profile.corporateCa.bundleSha256){fail(`${directory} contains a CA bundle with the wrong SHA-256 digest`)}corporateCaPath=caPath}return{directory,profilePath,corporateCaPath,profile,fingerprint}}function validateDisposableDirectory(target,runtime){requireSecureDirectory(target,runtime,true);const entries=import_node_fs.default.readdirSync(target);if(entries.length>2||entries.some(entry=>entry!=="profile.json"&&entry!=="corporate-ca.pem")){fail(`${target} is not a recognized disposable generation`)}for(const entry of entries){const file=import_node_path.default.join(target,entry);const stat=import_node_fs.default.lstatSync(file);requireSecureRegularFileStat(stat,file,runtime)}}function discardDirectory(target,runtime){validateDisposableDirectory(target,runtime);import_node_fs.default.rmSync(target,{recursive:true})}function discardDirectoryIfPresent(target,runtime){try{import_node_fs.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return false;fail(`could not inspect disposable generation ${target}`)}discardDirectory(target,runtime);return true}function unlinkSecureControlOrTemp(target,runtime){const stat=import_node_fs.default.lstatSync(target);requireSecureRegularFileStat(stat,target,runtime);if(stat.size>MAX_CONTROL_FILE_BYTES){fail(`${target} exceeds the state-control size limit`)}import_node_fs.default.unlinkSync(target)}function listStateEntries(stateDirectory){const entries=import_node_fs.default.readdirSync(stateDirectory).sort();if(entries.length>MAX_STATE_ENTRIES){fail(`state directory exceeds ${String(MAX_STATE_ENTRIES)} entries`)}return entries}function unlinkRecoverableControlTemp(stateDirectory,entry,runtime){const temporary=import_node_path.default.join(stateDirectory,entry);const stat=import_node_fs.default.lstatSync(temporary);if(stat.nlink===1){unlinkSecureControlOrTemp(temporary,runtime);return}const basename=entry.startsWith(".committed.json-")?"committed.json":entry.startsWith(".pending.json-")?"pending.json":null;const target=basename===null?null:import_node_path.default.join(stateDirectory,basename);let targetStat=null;try{targetStat=target===null?null:import_node_fs.default.lstatSync(target)}catch{fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`)}if(stat.nlink!==2||targetStat===null||stat.dev!==targetStat.dev||stat.ino!==targetStat.ino||!stat.isFile()||stat.isSymbolicLink()||modeOf(stat)!==STATE_FILE_MODE||stat.size<1||stat.size>MAX_CONTROL_FILE_BYTES){fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`)}requireOwner(stat,temporary,runtime);requireOwner(targetStat,target,runtime);import_node_fs.default.unlinkSync(temporary)}function cleanAtomicTemps(stateDirectory,entries,runtime){let changed=false;for(const entry of entries){const target=import_node_path.default.join(stateDirectory,entry);if(PREPARE_TEMP_RE.test(entry)){discardDirectory(target,runtime);changed=true}else if(CONTROL_TEMP_RE.test(entry)){unlinkRecoverableControlTemp(stateDirectory,entry,runtime);changed=true}}if(changed)syncDirectory(stateDirectory)}function requireKnownStateEntries(stateDirectory,entries){for(const entry of entries){if(entry==="committed.json"||entry==="pending.json"||GENERATION_RE.test(entry)||PREPARE_TEMP_RE.test(entry)||CONTROL_TEMP_RE.test(entry)){continue}fail(`${stateDirectory} contains unsupported state component ${entry}`)}}function discardGenerationsExcept(stateDirectory,keepGeneration,runtime){for(const entry of listStateEntries(stateDirectory)){if(GENERATION_RE.test(entry)&&entry!==keepGeneration){discardDirectoryIfPresent(import_node_path.default.join(stateDirectory,entry),runtime)}}}function optionalStateControl(stateDirectory,basename,runtime){const target=import_node_path.default.join(stateDirectory,basename);try{import_node_fs.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return null;fail(`could not inspect ${target}`)}return parseStateControl(target,runtime)}function removePendingControl(stateDirectory,runtime){try{unlinkSecureControlOrTemp(import_node_path.default.join(stateDirectory,"pending.json"),runtime)}catch(error){if(error.code==="ENOENT")return;throw error}syncDirectory(stateDirectory)}function stateControlsMatch(left,right){return left.fingerprint===right.fingerprint&&left.generation===right.generation}function recoverCommittedState(stateDirectory,committedControl,pendingControl,requested,expectedAgent,runtime){const committed=validateGeneration(stateDirectory,committedControl,runtime,expectedAgent);if(pendingControl)removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,committedControl.generation,runtime);syncDirectory(stateDirectory);if(!stateControlsMatch(committedControl,requested)){fail("a different startup profile is already committed; recreate the sandbox to change it")}return committed}function recoverState(stateDirectory,requested,expectedAgent,runtime){const initialEntries=listStateEntries(stateDirectory);requireKnownStateEntries(stateDirectory,initialEntries);cleanAtomicTemps(stateDirectory,initialEntries,runtime);const initiallyCommittedControl=optionalStateControl(stateDirectory,"committed.json",runtime);const pendingControl=optionalStateControl(stateDirectory,"pending.json",runtime);const committedAfterPendingRead=optionalStateControl(stateDirectory,"committed.json",runtime);const committedControl=committedAfterPendingRead??initiallyCommittedControl;if(committedControl){return{committed:recoverCommittedState(stateDirectory,committedControl,pendingControl,requested,expectedAgent,runtime),pending:null}}if(pendingControl){if(stateControlsMatch(pendingControl,requested)){const pending=validateGeneration(stateDirectory,pendingControl,runtime,expectedAgent);const committedAfterPendingValidation=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedAfterPendingValidation){return{committed:recoverCommittedState(stateDirectory,committedAfterPendingValidation,pendingControl,requested,expectedAgent,runtime),pending:null}}discardGenerationsExcept(stateDirectory,pendingControl.generation,runtime);return{committed:null,pending}}fail("a different startup profile is already pending; wait for it to commit or recreate")}return{committed:null,pending:null}}function createGeneration(stateDirectory,control,profileJson,corporateCa,runtime){const temporaryName=`.prepare-${String(process.pid)}-${randomToken()}`;const temporary=import_node_path.default.join(stateDirectory,temporaryName);const generation=import_node_path.default.join(stateDirectory,control.generation);let renameAttempted=false;try{import_node_fs.default.mkdirSync(temporary,{mode:STATE_DIRECTORY_MODE});import_node_fs.default.chownSync(temporary,runtime.rootUid,runtime.rootGid);import_node_fs.default.chmodSync(temporary,STATE_DIRECTORY_MODE);writeSecureNewFile(import_node_path.default.join(temporary,"profile.json"),profileJson,runtime);if(corporateCa){writeSecureNewFile(import_node_path.default.join(temporary,"corporate-ca.pem"),corporateCa,runtime)}syncDirectory(temporary);renameAttempted=true;import_node_fs.default.renameSync(temporary,generation);syncDirectory(stateDirectory)}catch(error){try{import_node_fs.default.lstatSync(temporary);discardDirectory(temporary,runtime)}catch{}if(error instanceof ManagedStartupApplicationError)throw error;if(renameAttempted&&(error.code==="EEXIST"||error.code==="ENOTEMPTY")){return validateGeneration(stateDirectory,control,runtime)}fail(`could not atomically prepare generation ${control.generation}`)}return validateGeneration(stateDirectory,control,runtime)}function toPrepared(status,stateDirectory,generation,expectedAgent){return{status,stateDirectory,generationDirectory:generation.directory,profilePath:generation.profilePath,corporateCaPath:generation.corporateCaPath,fingerprint:generation.fingerprint,expectedAgent,profile:generation.profile}}function prepareManagedStartupApplication(input,testRuntime){const runtime=runtimeFor(testRuntime);requireContainerRoot();let profile;try{profile=decodeManagedStartupProfile(input.encodedProfile)}catch(error){fail(error.message)}if(profile.agent!==input.expectedAgent){fail(`managed startup profile targets ${profile.agent}, expected ${input.expectedAgent}`)}const corporateCa=validateManagedStartupCorporateCaTransport(input.corporateCaB64,profile);const profileJson=serializeManagedStartupProfile(profile);const control=stateControl(fingerprintManagedStartupProfile(profile));const stateDirectory=ensureStateDirectory(input.stateDirectory,runtime);const recovered=recoverState(stateDirectory,control,input.expectedAgent,runtime);if(recovered.committed){return toPrepared("already-committed",stateDirectory,recovered.committed,input.expectedAgent)}if(recovered.pending){return toPrepared("prepared",stateDirectory,recovered.pending,input.expectedAgent)}const generation=createGeneration(stateDirectory,control,profileJson,corporateCa,runtime);const publication=publishStateControlIfAbsent(stateDirectory,"pending.json",control,runtime);if(publication.control.fingerprint!==control.fingerprint||publication.control.generation!==control.generation){discardDirectoryIfPresent(generation.directory,runtime);syncDirectory(stateDirectory);fail("a different startup profile won the pending-state transaction")}const committedAfterPublication=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedAfterPublication){if(committedAfterPublication.fingerprint!==control.fingerprint||committedAfterPublication.generation!==control.generation){if(publication.created){removePendingControl(stateDirectory,runtime);discardDirectoryIfPresent(generation.directory,runtime);syncDirectory(stateDirectory)}fail("a different startup profile committed during pending-state publication")}const committedGeneration=validateGeneration(stateDirectory,committedAfterPublication,runtime,input.expectedAgent);removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,committedAfterPublication.generation,runtime);return toPrepared("already-committed",stateDirectory,committedGeneration,input.expectedAgent)}const activeGeneration=publication.created?generation:validateGeneration(stateDirectory,publication.control,runtime,input.expectedAgent);return toPrepared("prepared",stateDirectory,activeGeneration,input.expectedAgent)}function validatePreparedHandle(handle){if(!import_node_path.default.isAbsolute(handle.stateDirectory)||!SHA256_RE2.test(handle.fingerprint)||handle.generationDirectory!==import_node_path.default.join(handle.stateDirectory,`generation-${handle.fingerprint}`)||handle.profilePath!==import_node_path.default.join(handle.generationDirectory,"profile.json")||handle.corporateCaPath!==null&&handle.corporateCaPath!==import_node_path.default.join(handle.generationDirectory,"corporate-ca.pem")){fail("prepared startup handle is malformed")}return stateControl(handle.fingerprint)}function commitManagedStartupApplication(prepared,testRuntime){const runtime=runtimeFor(testRuntime);requireContainerRoot();const requested=validatePreparedHandle(prepared);const stateDirectory=ensureStateDirectory(prepared.stateDirectory,runtime);const committedControl=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedControl){if(committedControl.fingerprint!==requested.fingerprint||committedControl.generation!==requested.generation){fail("a different startup profile is already committed")}const generation2=validateGeneration(stateDirectory,committedControl,runtime,prepared.expectedAgent);return{...toPrepared("already-committed",stateDirectory,generation2,prepared.expectedAgent),status:"committed"}}const pendingControl=optionalStateControl(stateDirectory,"pending.json",runtime);if(!pendingControl||pendingControl.fingerprint!==requested.fingerprint||pendingControl.generation!==requested.generation){fail("the prepared startup generation is not the active pending generation")}const generation=validateGeneration(stateDirectory,pendingControl,runtime,prepared.expectedAgent);const publication=publishStateControlIfAbsent(stateDirectory,"committed.json",pendingControl,runtime);if(publication.control.fingerprint!==requested.fingerprint||publication.control.generation!==requested.generation){fail("a different startup profile won the committed-state transaction")}removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,publication.control.generation,runtime);syncDirectory(stateDirectory);return{...toPrepared("already-committed",stateDirectory,generation,prepared.expectedAgent),status:"committed"}}var SHIPPED_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DEFAULT_DEPENDENCIES={prepareApplication:input=>prepareManagedStartupApplication(input),commitApplication:prepared=>commitManagedStartupApplication(prepared)};var ManagedStartupCoordinatorError=class extends Error{constructor(message){super(`Managed startup coordination failed: ${message}`);this.name="ManagedStartupCoordinatorError"}};function fail2(message){throw new ManagedStartupCoordinatorError(message)}function createAdapterRegistry(adapters2){const byAgent=new Map;for(const adapter of adapters2){if(typeof adapter!=="object"||adapter===null||!SHIPPED_AGENT_SET.has(adapter.agent)||typeof adapter.apply!=="function"){fail2("every adapter must identify one shipped agent and provide an apply function")}if(byAgent.has(adapter.agent)){fail2(`duplicate adapter registered for ${adapter.agent}`)}byAgent.set(adapter.agent,adapter)}const missing=MANAGED_STARTUP_AGENTS.filter(agent=>!byAgent.has(agent));if(missing.length>0){fail2(`missing adapter for ${missing.join(", ")}`)}if(byAgent.size!==MANAGED_STARTUP_AGENTS.length){fail2("adapter registry must contain exactly the shipped agents")}return Object.freeze(Object.fromEntries(MANAGED_STARTUP_AGENTS.map(agent=>{const adapter=byAgent.get(agent);if(!adapter)fail2(`missing adapter for ${agent}`);return[agent,adapter]})))}function requirePreparedIdentity(prepared,requestedAgent){if(prepared.expectedAgent!==requestedAgent||prepared.profile.agent!==requestedAgent){fail2(`prepared profile targets ${prepared.profile.agent}, expected ${requestedAgent}`)}}function adapterContext(prepared){return Object.freeze({agent:prepared.profile.agent,profile:prepared.profile,fingerprint:prepared.fingerprint,generationDirectory:prepared.generationDirectory,profilePath:prepared.profilePath,corporateCaPath:prepared.corporateCaPath})}async function coordinateManagedStartupApplication(input,adapters2,dependencies=DEFAULT_DEPENDENCIES){const registry=createAdapterRegistry(adapters2);const prepared=await dependencies.prepareApplication(input);requirePreparedIdentity(prepared,input.expectedAgent);if(prepared.status==="already-committed"){return{adapterApplied:false,application:await dependencies.commitApplication(prepared)}}const adapter=registry[prepared.profile.agent];if(adapter.agent!==prepared.profile.agent){fail2(`adapter registry cross-dispatch detected for ${prepared.profile.agent}`)}await adapter.apply(adapterContext(prepared));return{adapterApplied:true,application:await dependencies.commitApplication(prepared)}}var import_node_crypto4=require("node:crypto");var MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION=1;var MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES=320*1024;var MAX_CORPORATE_CA_ENCODED_BYTES=4*Math.ceil(128*1024/3);var SHA256_RE3=/^[a-f0-9]{64}$/u;var STANDARD_BASE64_RE=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;var MCP_SHADOW_DIAGNOSTICS_ENV="NEMOCLAW_MCP_SHADOW_DIAGNOSTICS";var MANAGED_STARTUP_APPLICATION_RUNTIME_ENV_KEYS=Object.freeze(MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS.openclaw.filter(({admission,owner})=>admission==="managed-launch-forwarded"&&owner==="application-environment").map(({input})=>input));function selectManagedStartupApplicationRuntimeEnvironment(environment){const selected={};for(const name of MANAGED_STARTUP_APPLICATION_RUNTIME_ENV_KEYS){const value=environment[name];if(name===MCP_SHADOW_DIAGNOSTICS_ENV){if(value?.trim()==="1")selected[name]="1";continue}if(value!==void 0)selected[name]=value}return Object.freeze(selected)}function fail3(message){throw new Error(`Managed startup root application request is invalid: ${message}`)}function exactAgent(value){if(typeof value==="string"&&MANAGED_STARTUP_AGENTS.includes(value)){return value}return fail3("agent is unsupported")}function createManagedStartupRootApplyRequest(input){const agent=exactAgent(input.agent);if(input.encodedProfile.length===0||input.encodedProfile.length>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES){fail3("encoded profile exceeds its bounded transport")}const profile=decodeManagedStartupProfile(input.encodedProfile);if(profile.agent!==agent){fail3(`profile targets ${profile.agent}, expected ${agent}`)}const corporateCaB64=input.corporateCaB64??null;if(corporateCaB64!==null&&(corporateCaB64.length===0||corporateCaB64.length>MAX_CORPORATE_CA_ENCODED_BYTES||!STANDARD_BASE64_RE.test(corporateCaB64)||Buffer.from(corporateCaB64,"base64").toString("base64")!==corporateCaB64)){fail3("corporate CA is not canonical bounded base64")}if(profile.corporateCa.bundleSha256!==null!==(corporateCaB64!==null)){fail3("corporate CA transport does not match the profile")}if(corporateCaB64!==null&&(0,import_node_crypto4.createHash)("sha256").update(Buffer.from(corporateCaB64,"base64")).digest("hex")!==profile.corporateCa.bundleSha256){fail3("corporate CA does not match the profile digest")}return Object.freeze({schemaVersion:MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION,agent,encodedProfile:input.encodedProfile,profileFingerprint:fingerprintManagedStartupProfile(profile),corporateCaB64})}function serializeManagedStartupRootApplyRequest(request){const normalized=createManagedStartupRootApplyRequest({agent:request.agent,encodedProfile:request.encodedProfile,...request.corporateCaB64===null?{}:{corporateCaB64:request.corporateCaB64}});if(request.schemaVersion!==MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION||request.profileFingerprint!==normalized.profileFingerprint||!SHA256_RE3.test(request.profileFingerprint)){fail3("schema version or profile fingerprint is invalid")}const serialized=`${JSON.stringify({agent:normalized.agent,corporateCaB64:normalized.corporateCaB64,encodedProfile:normalized.encodedProfile,profileFingerprint:normalized.profileFingerprint,schemaVersion:normalized.schemaVersion})} `;if(Buffer.byteLength(serialized,"utf8")>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail3("serialized request exceeds its bounded transport")}return serialized}function parseManagedStartupRootApplyRequest(text){if(text.length===0||Buffer.byteLength(text,"utf8")>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail3("serialized request is empty or too large")}let parsed;try{parsed=JSON.parse(text)}catch{fail3("serialized request is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail3("serialized request must be an object")}const record=parsed;const expectedKeys=["agent","corporateCaB64","encodedProfile","profileFingerprint","schemaVersion"];if(Object.keys(record).sort().join(",")!==expectedKeys.sort().join(",")||record.schemaVersion!==MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION||typeof record.encodedProfile!=="string"||typeof record.profileFingerprint!=="string"||record.corporateCaB64!==null&&typeof record.corporateCaB64!=="string"){fail3("serialized request has an invalid schema")}const request=createManagedStartupRootApplyRequest({agent:exactAgent(record.agent),encodedProfile:record.encodedProfile,...record.corporateCaB64===null?{}:{corporateCaB64:record.corporateCaB64}});if(record.profileFingerprint!==request.profileFingerprint||!SHA256_RE3.test(record.profileFingerprint)){fail3("profile fingerprint does not match the encoded profile")}if(serializeManagedStartupRootApplyRequest(request)!==text){fail3("serialized request is not canonical")}return request}var import_node_crypto5=require("node:crypto");var import_node_fs2=__toESM(require("node:fs"));var import_node_path2=__toESM(require("node:path"));var TRANSACTION_SCHEMA_VERSION=1;var MAX_TRANSACTION_FILES=128;var MAX_TRANSACTION_FILE_BYTES=8*1024*1024;var MAX_TRANSACTION_TOTAL_BYTES=32*1024*1024;var MAX_MANIFEST_BYTES=256*1024;var MAX_COMMIT_RECEIPT_BYTES=4096;var TRANSACTION_PARENT_DIRECTORY_MODE=493;var TRANSACTION_DIRECTORY_MODE=448;var TRANSACTION_FILE_MODE=256;var ATOMIC_TEMPORARY_FILE_MODE=384;var MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY="/var/lib/nemoclaw/managed-startup-shared-state-transaction-v1";var MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY="/run/nemoclaw/managed-startup-shared-rollback-receipt-v1";var MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY="/var/lib/nemoclaw/managed-startup-shared-state-commit-v1";var MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE="receipt.json";function fail4(message){throw new Error(`Managed startup shared-state transaction failed: ${message}`)}function resolveOptions(options={}){const sandboxRoot=import_node_path2.default.resolve(options.sandboxRoot??"/sandbox");const transactionDirectory=import_node_path2.default.resolve(options.transactionDirectory??MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY);const commitReceiptDirectory=import_node_path2.default.resolve(options.commitReceiptDirectory??(options.transactionDirectory?import_node_path2.default.join(import_node_path2.default.dirname(transactionDirectory),import_node_path2.default.basename(MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY)):MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY));if(transactionDirectory===sandboxRoot||transactionDirectory.startsWith(`${sandboxRoot}${import_node_path2.default.sep}`)||commitReceiptDirectory===sandboxRoot||commitReceiptDirectory.startsWith(`${sandboxRoot}${import_node_path2.default.sep}`)||import_node_path2.default.dirname(commitReceiptDirectory)!==import_node_path2.default.dirname(transactionDirectory)||commitReceiptDirectory===transactionDirectory){fail4("transaction and commit receipts require distinct paths outside sandbox-shared state")}const bootstrapIdentity=options.bootstrapIdentity??null;if(bootstrapIdentity!==null&&!/^[a-f0-9]{64}$/u.test(bootstrapIdentity)){fail4("bootstrap identity must encode 32 lowercase-hex bytes")}return{sandboxRoot,transactionParentDirectory:import_node_path2.default.dirname(transactionDirectory),transactionDirectory,backupDirectory:import_node_path2.default.join(transactionDirectory,"backups"),manifestFile:import_node_path2.default.join(transactionDirectory,"manifest.json"),commitReceiptDirectory,commitReceiptFile:import_node_path2.default.join(commitReceiptDirectory,MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE),trustedUid:options.trustedUid??0,trustedGid:options.trustedGid??0,readOnlyReceipt:options.readOnlyReceipt??false,bootstrapIdentity}}function modeOf2(stat){if(typeof stat.mode==="bigint"){return Number(stat.mode&0o7777n)}return stat.mode&4095}function requireTransactionIdentity(options){const expectedUid=options.readOnlyReceipt?0:options.trustedUid;const expectedGid=options.readOnlyReceipt?0:options.trustedGid;if(process.geteuid?.()!==expectedUid||process.getegid?.()!==expectedGid){fail4("transaction control requires the trusted effective identity")}}function pathExistsNoFollow(target){try{import_node_fs2.default.lstatSync(target);return true}catch(error){if(error.code==="ENOENT")return false;fail4(`could not inspect ${target}`)}}function requireDirectory(target,options,expectedMode=null){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch{fail4(`required directory is missing: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`required directory is unsafe: ${target}`)}if(expectedMode!==null&&(stat.uid!==options.trustedUid||stat.gid!==options.trustedGid||modeOf2(stat)!==expectedMode)){fail4(`${target} must be ${options.trustedUid}:${options.trustedGid} mode ${expectedMode.toString(8)}`)}return stat}function requireTransactionBoundaries(options){requireDirectory(options.sandboxRoot,options);requireDirectory(options.transactionParentDirectory,options,TRANSACTION_PARENT_DIRECTORY_MODE)}function sameStableMetadata(left,right){return left.dev===right.dev&&left.ino===right.ino&&left.mode===right.mode&&left.nlink===right.nlink&&left.uid===right.uid&&left.gid===right.gid&&left.size===right.size&&left.mtimeNs===right.mtimeNs&&left.ctimeNs===right.ctimeNs}function readStableFile(target,maxBytes){const noFollow=import_node_fs2.default.constants.O_NOFOLLOW;if(typeof noFollow!=="number")fail4("O_NOFOLLOW is unavailable");let descriptor;try{descriptor=import_node_fs2.default.openSync(target,import_node_fs2.default.constants.O_RDONLY|noFollow)}catch{fail4(`could not safely open ${target}`)}try{const before=import_node_fs2.default.fstatSync(descriptor,{bigint:true});if(!before.isFile()||before.nlink!==1n||before.size<0n||before.size>BigInt(maxBytes)){fail4(`refusing unsafe or oversized transaction file ${target}`)}const bytes=Buffer.alloc(Number(before.size));let offset=0;while(offset!segment||segment==="."||segment==="..")){fail4(`unsafe transaction path ${JSON.stringify(value)}`)}return segments.join("/")}function absoluteTarget(relativePath,options){const safe=safeRelativePath(relativePath);const target=import_node_path2.default.resolve(options.sandboxRoot,safe);if(!target.startsWith(`${options.sandboxRoot}${import_node_path2.default.sep}`)){fail4(`transaction target escapes the sandbox root: ${relativePath}`)}return target}function relativeTarget(target,options){return safeRelativePath(import_node_path2.default.relative(options.sandboxRoot,target))}function validateExistingAncestors(target,options){const relative=relativeTarget(target,options);const sandboxStat=requireDirectory(options.sandboxRoot,options);let current=options.sandboxRoot;const segments=relative.split("/").slice(0,-1);for(const segment of segments){current=import_node_path2.default.join(current,segment);let stat;try{stat=import_node_fs2.default.lstatSync(current)}catch(error){if(error.code==="ENOENT")return;fail4(`could not inspect transaction path ancestor ${current}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`transaction path ancestor is unsafe: ${current}`)}if(stat.dev!==sandboxStat.dev){fail4(`transaction path crosses a nested filesystem mount: ${current}`)}}}function agentRoot(agent,sandboxRoot){switch(agent){case"openclaw":return import_node_path2.default.join(sandboxRoot,".openclaw");case"hermes":return import_node_path2.default.join(sandboxRoot,".hermes");case"langchain-deepagents-code":return import_node_path2.default.join(sandboxRoot,".deepagents")}}function resolveUnderAgentRoot(root,relativePath){const safe=safeRelativePath(relativePath);const target=import_node_path2.default.resolve(root,safe);if(!target.startsWith(`${root}${import_node_path2.default.sep}`)){fail4(`managed output escapes the agent root: ${relativePath}`)}return target}function renderTarget(root,agent,target){if(agent==="openclaw"&&target==="openclaw.json"){return import_node_path2.default.join(root,"openclaw.json")}const prefix=agent==="openclaw"?"~/.openclaw/":agent==="hermes"?"~/.hermes/":null;if(!prefix||!target.startsWith(prefix)){fail4(`unsupported managed messaging render target ${JSON.stringify(target)}`)}return resolveUnderAgentRoot(root,target.slice(prefix.length))}function managedOutputTargets(profile,options){const root=agentRoot(profile.agent,options.sandboxRoot);const files=new Set;const directories=new Set([root]);switch(profile.agent){case"openclaw":files.add(import_node_path2.default.join(root,"openclaw.json"));files.add(import_node_path2.default.join(root,".config-hash"));break;case"hermes":files.add(import_node_path2.default.join(root,"config.yaml"));files.add(import_node_path2.default.join(root,".env"));files.add(import_node_path2.default.join(root,".config-hash"));break;case"langchain-deepagents-code":files.add(import_node_path2.default.join(root,"config.toml"));directories.add(import_node_path2.default.join(root,".state"));directories.add(import_node_path2.default.join(root,"skills"));break}if(profile.messaging.plan!==null){const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:profile.agent});if(!plan)fail4("managed messaging plan is invalid");for(const render of selectEnabledMessagingAgentRender(plan)){if(typeof render.target!=="string")continue;files.add(renderTarget(root,profile.agent,render.target))}for(const step of selectEnabledPostAgentInstallBuildFiles(plan)){if(typeof step.value!=="object"||step.value===null){continue}const outputPath=step.value.path;if(typeof outputPath==="string"){files.add(resolveUnderAgentRoot(root,outputPath))}}}for(const file of files){let parent=import_node_path2.default.dirname(file);while(parent!==options.sandboxRoot&&parent.startsWith(`${root}${import_node_path2.default.sep}`)){directories.add(parent);if(parent===root)break;parent=import_node_path2.default.dirname(parent)}}return{files:[...files].sort(),directories:[...directories].sort((left,right)=>left.split(import_node_path2.default.sep).length-right.split(import_node_path2.default.sep).length)}}function snapshotFile(target,index,options){validateExistingAncestors(target,options);let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT"){return{receipt:{path:relativeTarget(target,options),state:"absent"},bytes:null}}fail4(`could not inspect managed output ${target}`)}if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1){fail4(`managed output is not a safe regular file: ${target}`)}if(stat.dev!==requireDirectory(options.sandboxRoot,options).dev){fail4(`managed output crosses a nested filesystem mount: ${target}`)}const stable=readStableFile(target,MAX_TRANSACTION_FILE_BYTES);const size=Number(stable.stat.size);const backup=`${String(index).padStart(3,"0")}.bin`;return{receipt:{path:relativeTarget(target,options),state:"file",backup,sha256:(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex"),size,uid:Number(stable.stat.uid),gid:Number(stable.stat.gid),mode:Number(stable.stat.mode&0o7777n)},bytes:stable.bytes}}function snapshotDirectory(target,options){validateExistingAncestors(import_node_path2.default.join(target,".receipt"),options);let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT"){return{path:relativeTarget(target,options),state:"absent"}}fail4(`could not inspect managed output directory ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`managed output directory is unsafe: ${target}`)}if(stat.dev!==requireDirectory(options.sandboxRoot,options).dev){fail4(`managed output directory crosses a nested filesystem mount: ${target}`)}return{path:relativeTarget(target,options),state:"directory",uid:stat.uid,gid:stat.gid,mode:modeOf2(stat)}}function atomicWriteTrustedFile(target,contents,mode,uid,gid){const parent=import_node_path2.default.dirname(target);const temporary=import_node_path2.default.join(parent,`.${import_node_path2.default.basename(target)}.${(0,import_node_crypto5.randomBytes)(12).toString("hex")}`);let descriptor;try{descriptor=import_node_fs2.default.openSync(temporary,import_node_fs2.default.constants.O_CREAT|import_node_fs2.default.constants.O_EXCL|import_node_fs2.default.constants.O_WRONLY|import_node_fs2.default.constants.O_NOFOLLOW,384);import_node_fs2.default.writeFileSync(descriptor,contents);import_node_fs2.default.fchownSync(descriptor,uid,gid);import_node_fs2.default.fchmodSync(descriptor,mode);import_node_fs2.default.fsyncSync(descriptor);import_node_fs2.default.closeSync(descriptor);descriptor=void 0;import_node_fs2.default.renameSync(temporary,target)}catch(error){if(descriptor!==void 0)import_node_fs2.default.closeSync(descriptor);try{import_node_fs2.default.unlinkSync(temporary)}catch{}fail4(`could not atomically write ${target}: ${error.message}`)}}function fsyncDirectory(directory){const descriptor=import_node_fs2.default.openSync(directory,import_node_fs2.default.constants.O_RDONLY);try{import_node_fs2.default.fsyncSync(descriptor)}finally{import_node_fs2.default.closeSync(descriptor)}}function canonicalManifest(manifest){return`${JSON.stringify(manifest,null,2)} `}function canonicalLegacyManifest(manifest){return`${JSON.stringify({schemaVersion:manifest.schemaVersion,agent:manifest.agent,profileFingerprint:manifest.profileFingerprint,files:manifest.files,directories:manifest.directories},null,2)} diff --git a/vitest.config.ts b/vitest.config.ts index 8ef0310fc41..01d800b67cf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -158,6 +158,7 @@ export default defineConfig({ "test/install-station-vllm-continuation.test.ts", "test/install-build-dependency-preflight.test.ts", "test/install-clone-ref.test.ts", + "test/install-hermes-forward-restore.test.ts", "test/install-managed-cli-reuse.test.ts", "test/install-preflight.test.ts", "test/install-preflight-docker-bootstrap.test.ts", @@ -188,6 +189,7 @@ export default defineConfig({ "test/install-station-vllm-continuation.test.ts", "test/install-build-dependency-preflight.test.ts", "test/install-clone-ref.test.ts", + "test/install-hermes-forward-restore.test.ts", "test/install-managed-cli-reuse.test.ts", "test/install-preflight.test.ts", "test/install-preflight-docker-bootstrap.test.ts",