Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/lib/onboard/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions src/lib/onboard/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -208,6 +209,13 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise<vo
// oclif as a raw stack trace (#7439).
return;
}
// A rejected NEMOCLAW_GATEWAY_MANAGEMENT contract is operator input error,
// not a crash: print the validation reason as a clean single-line CLI error
// and exit nonzero instead of re-throwing it into a Node.js stack trace
// (#7627). `fail` sets exit code 1.
if (error instanceof GatewayManagementDeclarationError) {
fail(deps, ` ${error.message}`);
}
// Stdin EOF at any onboarding prompt is a cancellation, not a failure:
// print a clear message and exit non-zero instead of either crashing with
// a stack trace or — as in the original bug — exiting 0 silently (#5976).
Expand Down
13 changes: 12 additions & 1 deletion src/lib/onboard/gateway-host-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createGatewayHostRuntime, type GatewayHostRuntimeDeps } from "./gateway-host-runtime";
import { GATEWAY_MANAGEMENT_ENV_VAR } from "./gateway-management";
import {
GATEWAY_MANAGEMENT_ENV_VAR,
GatewayManagementDeclarationError,
} from "./gateway-management";
import { evaluateGatewayAttachment } from "./gateway-ownership";
import type { PortProbeResult } from "./preflight";

Expand Down Expand Up @@ -116,6 +119,14 @@ describe("gateway host runtime ownership", () => {
);
});

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,
Expand Down
7 changes: 5 additions & 2 deletions src/lib/onboard/gateway-host-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/lib/onboard/gateway-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/lib/onboard/gateway-teardown-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { gatewayOwnerFromCheckpoint } from "./gateway-authority-checkpoint";
import { resolveGatewayName } from "./gateway-binding";
import {
type GatewayManagementLoadResult,
invalidGatewayManagementDeclarationError,
loadGatewayManagementDeclaration,
} from "./gateway-management";
import {
Expand Down Expand Up @@ -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 &&
Expand Down
Loading