diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 0f9f166f298..82257f55204 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveOnboardOptions, runOnboardCommand } from "./command"; import type { OnboardFlags } from "./command-support"; +import { invalidGatewayManagementDeclarationError } from "./gateway-management"; function exitWithCode(code: number): never { throw new Error(`exit:${code}`); @@ -261,6 +262,68 @@ describe("onboard command options", () => { expect(errors.join("\n")).toContain("Installation cancelled"); }); + it("prints a clean CLI error for an invalid gateway management contract (#7627)", async () => { + const errors: string[] = []; + await expect( + runOnboardCommand({ + flags: {}, + env: {}, + runOnboard: async () => { + throw invalidGatewayManagementDeclarationError( + "unsupported gateway-management contract version; this NemoClaw build supports version 1", + ); + }, + error: (message = "") => errors.push(message), + exit: exitWithCode, + }), + ).rejects.toThrow("exit:1"); + const output = errors.join("\n"); + expect(output).toContain("Invalid gateway management declaration"); + expect(output).toContain("unsupported gateway-management contract version"); + // No stack frames leaked into the user-facing output. + expect(output).not.toContain(".js:"); + expect(output).not.toContain(" at "); + }); + + it("escapes terminal controls in gateway declaration errors before printing (#7627)", async () => { + const errors: string[] = []; + await expect( + runOnboardCommand({ + flags: {}, + env: {}, + runOnboard: async () => { + throw invalidGatewayManagementDeclarationError( + "unknown declaration field(s): forged\n\u001b[31mError: \u202efake failure", + ); + }, + error: (message = "") => errors.push(message), + exit: exitWithCode, + }), + ).rejects.toThrow("exit:1"); + + expect(errors).toEqual([ + " Invalid gateway management declaration: unknown declaration field(s): forged\\u000a\\u001b[31mError: \\u202efake failure", + ]); + expect(errors[0]?.split(/\r?\n/u)).toHaveLength(1); + expect(errors[0]).not.toMatch( + /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u, + ); + }); + + it("re-throws a non-cancellation, non-gateway error so genuine bugs still surface (#7627)", async () => { + await expect( + runOnboardCommand({ + flags: {}, + env: {}, + runOnboard: async () => { + throw new Error("unexpected boom"); + }, + error: () => {}, + exit: exitWithCode, + }), + ).rejects.toThrow("unexpected boom"); + }); + it("returns without rethrowing when a prompt rejects with SIGINT (#7439)", async () => { const exit = vi.fn<(code: number) => never>(); await expect( diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index dcc7c62b143..cba531b5573 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -12,6 +12,7 @@ import { } from "../tool-disclosure"; import { applyAgentsManifestEnv } from "./agents-manifest"; import type { OnboardFlags } from "./command-support"; +import { GatewayManagementDeclarationError } from "./gateway-management"; import { managedSandboxFeatureIssue } from "./managed-sandbox-feature"; import { DCODE_OBSERVABILITY_FEATURE } from "./observability-policy-presets"; import { isOpenclawAgent } from "./openclaw-otel-policy-presets"; @@ -208,6 +209,13 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise { ); }); + it("throws a recognized GatewayManagementDeclarationError so the CLI can present it cleanly (#7627)", () => { + declareExternalSupervision({ ...DECLARATION, version: 99 }); + + expect(() => createGatewayHostRuntime(createDeps()).getGatewayOwner()).toThrow( + GatewayManagementDeclarationError, + ); + }); + it("fails closed on unsupported capabilities before probing an external listener (#6576)", () => { declareExternalSupervision({ ...DECLARATION, diff --git a/src/lib/onboard/gateway-host-runtime.ts b/src/lib/onboard/gateway-host-runtime.ts index 92035564e1c..3418a90db9d 100644 --- a/src/lib/onboard/gateway-host-runtime.ts +++ b/src/lib/onboard/gateway-host-runtime.ts @@ -23,7 +23,10 @@ import { isGatewayHttpReady, waitForGatewayHttpReady, } from "./gateway-http-readiness"; -import { loadGatewayManagementDeclaration } from "./gateway-management"; +import { + invalidGatewayManagementDeclarationError, + loadGatewayManagementDeclaration, +} from "./gateway-management"; import { assertGatewayEffectAllowed, cgroupBelongsToUnit, @@ -113,7 +116,7 @@ export function createGatewayHostRuntime(deps: GatewayHostRuntimeDeps): GatewayH function resolveCurrentGatewayOwner(gatewayName: string, gatewayPort: number): GatewayOwner { const loaded = loadGatewayManagementDeclaration(); if (!loaded.ok) { - throw new Error(`Invalid gateway management declaration: ${loaded.reason}`); + throw invalidGatewayManagementDeclarationError(loaded.reason); } return resolveGatewayOwner({ gatewayName, diff --git a/src/lib/onboard/gateway-management.ts b/src/lib/onboard/gateway-management.ts index 703cc266656..8e845e95e70 100644 --- a/src/lib/onboard/gateway-management.ts +++ b/src/lib/onboard/gateway-management.ts @@ -76,6 +76,37 @@ export type GatewayManagementParseResult = | { ok: true; declaration: GatewayManagementDeclaration } | { ok: false; reason: string }; +/** + * A rejected NEMOCLAW_GATEWAY_MANAGEMENT contract is user-input error, not a + * NemoClaw bug: the operator pointed the env var at a declaration file that + * fails contract validation (bad version, unknown field, non-loopback / DNS + * endpoint, embedded credentials, query string, path, …). Command boundaries + * recognize this class to print a clean single-line CLI error and exit nonzero + * instead of leaking a Node.js stack trace (#7627). Every rejection reason + * funnels through `reason`, so this one class covers the whole class of + * contract-validation failures. + */ +export class GatewayManagementDeclarationError extends Error {} + +const GATEWAY_MANAGEMENT_ERROR_CONTROL_RE = + /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/gu; + +function escapeGatewayManagementErrorReason(reason: string): string { + return reason.replace( + GATEWAY_MANAGEMENT_ERROR_CONTROL_RE, + (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} + +/** Build the user-facing error for an invalid gateway management declaration. */ +export function invalidGatewayManagementDeclarationError( + reason: string, +): GatewayManagementDeclarationError { + return new GatewayManagementDeclarationError( + `Invalid gateway management declaration: ${escapeGatewayManagementErrorReason(reason)}`, + ); +} + const DECLARATION_KEYS = new Set([ "version", "mode", diff --git a/src/lib/onboard/gateway-teardown-authority.ts b/src/lib/onboard/gateway-teardown-authority.ts index ebc8b4c5792..1db805ca6d4 100644 --- a/src/lib/onboard/gateway-teardown-authority.ts +++ b/src/lib/onboard/gateway-teardown-authority.ts @@ -21,6 +21,7 @@ import { gatewayOwnerFromCheckpoint } from "./gateway-authority-checkpoint"; import { resolveGatewayName } from "./gateway-binding"; import { type GatewayManagementLoadResult, + invalidGatewayManagementDeclarationError, loadGatewayManagementDeclaration, } from "./gateway-management"; import { @@ -87,7 +88,7 @@ function resolveGatewayEffectAuthority( ? deps.loadDeclaration(env) : loadGatewayManagementDeclaration({ env }); if (!loaded.ok) { - throw new Error(`Invalid gateway management declaration: ${loaded.reason}`); + throw invalidGatewayManagementDeclarationError(loaded.reason); } const hasPackagedService = loaded.declaration === null &&