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
3 changes: 3 additions & 0 deletions nemoclaw-blueprint/scripts/ws-proxy-fix.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
host = opts.host.replace(/:\d+$/, "");
}
if (isDiscordWsUpgrade(host, opts.headers)) {
// Guard: if isDiscordWsUpgrade matched but host resolved to
// undefined, we cannot construct a CONNECT tunnel (no target).
// Fall through to the original https.request unchanged.
if (!host) {
return callOriginalRequest(input, options, callback);
}
Expand Down
5 changes: 5 additions & 0 deletions nemoclaw-blueprint/scripts/ws-proxy-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ interface ReqOpts extends https.RequestOptions {
host = opts.host.replace(/:\d+$/, "");
}
if (isDiscordWsUpgrade(host, opts.headers)) {
// Guard: if isDiscordWsUpgrade matched but host resolved to
// undefined, we cannot construct a CONNECT tunnel (no target).
// Fall through to the original https.request unchanged. Before
// PR #2422 this path would have attempted the tunnel with an
// undefined host, which would fail in createTunnelAgent anyway.
if (!host) {
return callOriginalRequest(input, options, callback);
}
Expand Down
5 changes: 1 addition & 4 deletions src/lib/agent-onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ import { loadAgent, resolveAgentName, type AgentDefinition } from "./agent-defs"
import { getProviderSelectionConfig } from "./inference-config";
import * as onboardSession from "./onboard-session";
import { sleepSeconds } from "./wait";

type LooseScalar = string | number | boolean | null | undefined;
type LooseValue = LooseScalar | LooseObject | LooseValue[];
type LooseObject = { [key: string]: LooseValue };
import type { JsonValue as LooseValue, JsonObject as LooseObject } from "./json-types";

export interface OnboardContext {
step: (current: number, total: number, message: string) => void;
Expand Down
12 changes: 3 additions & 9 deletions src/lib/config-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import os from "node:os";
import path from "node:path";

import { shellQuote } from "./shell-quote";
import { isErrnoException, isPermissionError } from "./errno";

type ErrnoLike = Error | { code?: string | number } | null;
// Strict JSON types for file serialization — unlike json-types.ts,
// these exclude undefined since actual JSON cannot contain it.
type JsonScalar = string | number | boolean | null;
type JsonValue = JsonScalar | JsonObject | JsonValue[];
type JsonObject = { [key: string]: JsonValue };
Expand All @@ -19,14 +21,6 @@ function toError(error: Error | string | number | boolean | null | undefined): E
return error instanceof Error ? error : new Error(String(error));
}

function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}

function isPermissionError(error: ErrnoLike): error is NodeJS.ErrnoException {
return isErrnoException(error) && (error.code === "EACCES" || error.code === "EPERM");
}

function parseJson<T>(text: string): T {
return JSON.parse(text);
}
Expand Down
9 changes: 2 additions & 7 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,10 @@ import path from "node:path";
import readline from "node:readline";

import { readConfigFile, writeConfigFile } from "./config-io";
import { isErrnoException } from "./errno";

const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]);

type ErrnoLike = Error | { code?: string | number } | null;

function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException {
return error !== null && typeof error === "object" && "code" in error;
}

type CredentialInput = string | null | undefined;

export function resolveHomeDir(): string {
Expand All @@ -40,7 +35,7 @@ export function resolveHomeDir(): string {
}
} catch (error) {
if (
!(typeof error === "object" && error !== null && isErrnoException(error)) ||
!isErrnoException(error) ||
error.code !== "ENOENT"
) {
throw error;
Expand Down
44 changes: 44 additions & 0 deletions src/lib/errno.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Shared errno helpers for safely narrowing caught errors to
* `NodeJS.ErrnoException`.
*
* Multiple modules (config-io, credentials, http-probe, onboard,
* onboard-session, registry) previously defined their own
* `isErrnoException` + `ErrnoLike` type. This module unifies them
* into a single source of truth.
*
* Usage in catch blocks:
*
* ```ts
* try { … } catch (error) {
* if (isErrnoException(error) && error.code === "ENOENT") { … }
* }
* ```
*/

/**
* Narrow an unknown caught value to `NodeJS.ErrnoException`.
*
* Accepts `unknown` so callers never need a pre-cast. Returns `true`
* when the value is a non-null object carrying a `code` or `errno`
* property — the two fields Node.js sets on filesystem / child-process
* errors.
*/
export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return (
typeof error === "object" &&
error !== null &&
("code" in error || "errno" in error)
);
}

/**
* Convenience: true when the error is an `EACCES` or `EPERM` errno,
* commonly used for permission-denied guards.
*/
export function isPermissionError(error: unknown): error is NodeJS.ErrnoException {
return isErrnoException(error) && (error.code === "EACCES" || error.code === "EPERM");
}
8 changes: 2 additions & 6 deletions src/lib/http-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,9 @@ import type { ProbeResult } from "./onboard-types";
import { ROOT } from "./paths";
import { compactText } from "./url-utils";

export type CurlProbeResult = ProbeResult;

type ErrnoLike = Error | { code?: string | number; errno?: string | number } | null;
import { isErrnoException } from "./errno";

function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException {
return error !== null && typeof error === "object" && ("code" in error || "errno" in error);
}
export type CurlProbeResult = ProbeResult;

export interface CurlProbeOptions {
cwd?: string;
Expand Down
27 changes: 27 additions & 0 deletions src/lib/json-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Shared recursive JSON-like types for loosely-typed data boundaries.
*
* Several modules (onboard, agent-onboard, policies, onboard-session)
* defined their own Scalar / Value / Object triples for the same
* purpose: representing data parsed from JSON, YAML, or environment
* variables before it is validated into a narrower domain type.
*
* This module provides a single canonical set so the pattern is
* consistent across the CLI codebase.
*
* Note: The plugin (`nemoclaw/src/`) has its own parallel types
* (`PluginScalar`, `PluginValue`, `PluginRecord`) because the plugin
* and CLI are compiled separately and cannot share imports.
*/

/** A single JSON-compatible scalar (includes `undefined` for optional fields). */
export type JsonScalar = string | number | boolean | null | undefined;

/** A recursive JSON-compatible value: scalar, object, or array. */
export type JsonValue = JsonScalar | JsonObject | JsonValue[];

/** A JSON-compatible object with string keys and recursive values. */
export type JsonObject = { [key: string]: JsonValue };
47 changes: 14 additions & 33 deletions src/lib/onboard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ import fs from "node:fs";
import path from "node:path";

import { redactSensitiveText, redactUrl } from "./redact";
import { isErrnoException } from "./errno";
import type { WebSearchConfig } from "./web-search";

export const SESSION_VERSION = 1;
export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw");
export const SESSION_FILE = path.join(SESSION_DIR, "onboard-session.json");
export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock");

type SessionJsonPrimitive = string | number | boolean | null;
type SessionJsonValue = SessionJsonPrimitive | UnknownRecord | SessionJsonValue[];
type UnknownRecord = { [key: string]: SessionJsonValue };
import type { JsonValue, JsonObject } from "./json-types";

// Session-specific aliases for the shared JSON types.
type SessionJsonValue = JsonValue;
type UnknownRecord = JsonObject;
type StepStatus = "pending" | "in_progress" | "complete" | "failed" | "skipped";

const STEP_STATES: readonly StepStatus[] = [
Expand Down Expand Up @@ -160,12 +163,6 @@ export function isObject(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

type ErrnoLike = Error | { code?: string | number } | null;

function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException {
return error !== null && error instanceof Error && "code" in error;
}

function readString(value: SessionJsonValue | undefined): string | null {
return typeof value === "string" ? value : null;
}
Expand Down Expand Up @@ -366,7 +363,7 @@ function isProcessAlive(pid: number): boolean {
process.kill(pid, 0);
return true;
} catch (error) {
return error instanceof Error && isErrnoException(error) && error.code === "EPERM";
return isErrnoException(error) && error.code === "EPERM";
}
}

Expand Down Expand Up @@ -406,7 +403,7 @@ export function acquireOnboardLock(command: string | null = null): LockResult {
// resolves to the same file we created (fstat ino vs stat ino).
fd = fs.openSync(LOCK_FILE, "wx", 0o600);
} catch (error) {
if (!(error instanceof Error && isErrnoException(error)) || error.code !== "EEXIST") {
if (!isErrnoException(error) || error.code !== "EEXIST") {
throw error;
}

Expand All @@ -423,11 +420,7 @@ export function acquireOnboardLock(command: string | null = null): LockResult {
staleInode = stat.ino;
existing = parseLockFile(fs.readFileSync(LOCK_FILE, "utf8"));
} catch (readError) {
if (
readError instanceof Error &&
isErrnoException(readError) &&
readError.code === "ENOENT"
) {
if (isErrnoException(readError) && readError.code === "ENOENT") {
continue;
}
throw readError;
Expand Down Expand Up @@ -505,21 +498,15 @@ function unlinkIfInodeMatches(filePath: string, expectedInode: bigint | null): v
return;
}
} catch (statError) {
if (statError instanceof Error && isErrnoException(statError) && statError.code === "ENOENT") {
if (isErrnoException(statError) && statError.code === "ENOENT") {
return;
}
throw statError;
}
try {
fs.unlinkSync(filePath);
} catch (unlinkError) {
if (
!(
unlinkError instanceof Error &&
isErrnoException(unlinkError) &&
unlinkError.code === "ENOENT"
)
) {
if (!isErrnoException(unlinkError) || unlinkError.code !== "ENOENT") {
throw unlinkError;
}
}
Expand All @@ -540,21 +527,15 @@ export function releaseOnboardLock(): void {
const pathStat = fs.statSync(LOCK_FILE, { bigint: true });
pathInode = pathStat.ino;
} catch (error) {
if (!(error instanceof Error && isErrnoException(error) && error.code === "ENOENT")) {
if (!(isErrnoException(error) && error.code === "ENOENT")) {
// Unexpected — fall through to closing the fd.
}
}
if (pathInode !== null && pathInode === fdStat.ino) {
try {
fs.unlinkSync(LOCK_FILE);
} catch (unlinkError) {
if (
!(
unlinkError instanceof Error &&
isErrnoException(unlinkError) &&
unlinkError.code === "ENOENT"
)
) {
if (!(isErrnoException(unlinkError) && unlinkError.code === "ENOENT")) {
// Best effort — surfacing this would mask the real error.
}
}
Expand Down Expand Up @@ -582,7 +563,7 @@ export function releaseOnboardLock(): void {
try {
existing = parseLockFile(fs.readFileSync(LOCK_FILE, "utf8"));
} catch (error) {
if (error instanceof Error && isErrnoException(error) && error.code === "ENOENT") return;
if (isErrnoException(error) && error.code === "ENOENT") return;
throw error;
}
if (!existing) return;
Expand Down
12 changes: 5 additions & 7 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const LOCAL_INFERENCE_TIMEOUT_SECS = envInt("NEMOCLAW_LOCAL_INFERENCE_TIMEOUT",
const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
const runner: typeof import("./runner") = require("./runner");
const { ROOT, SCRIPTS, redact, run, runCapture, runFile, shellQuote, validateName } = runner;
const errnoUtils: typeof import("./errno") = require("./errno");
const { isErrnoException } = errnoUtils;

type RunnerOptions = {
env?: NodeJS.ProcessEnv;
Expand All @@ -37,10 +39,6 @@ type RunnerOptions = {
openshellBinary?: string;
};

function isErrnoException(error: object | null): error is NodeJS.ErrnoException {
return error !== null && "code" in error;
}

function parseJson<T>(text: string): T {
return JSON.parse(text);
}
Expand Down Expand Up @@ -237,9 +235,9 @@ type RemoteProviderConfigEntry = {
skipVerify?: boolean;
};

type LooseScalar = string | number | boolean | null | undefined;
type LooseValue = LooseScalar | LooseObject | LooseValue[];
type LooseObject = { [key: string]: LooseValue };
// Re-export shared JSON types under the names used throughout this module.
// See src/lib/json-types.ts for the canonical definitions.
import type { JsonScalar as LooseScalar, JsonValue as LooseValue, JsonObject as LooseObject } from "./json-types";

type OnboardOptions = {
nonInteractive?: boolean;
Expand Down
8 changes: 5 additions & 3 deletions src/lib/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
//
// Policy preset management — list, load, merge, and apply presets.

import type { JsonValue, JsonObject } from "./json-types";

const fs = require("fs");
const path = require("path");
const os = require("os");
Expand All @@ -20,9 +22,9 @@ type PresetInfo = {
description: string;
};

type PolicyScalar = string | number | boolean | null | undefined;
type PolicyValue = PolicyScalar | PolicyObject | PolicyValue[];
type PolicyObject = { [key: string]: PolicyValue };
// Re-use shared JSON types under policy-domain names.
type PolicyValue = JsonValue;
type PolicyObject = JsonObject;

type PolicyDocument = PolicyObject & {
version?: number;
Expand Down
Loading
Loading