Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 0 additions & 1 deletion bin/nemoclaw.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/env node
// @ts-nocheck
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

Expand Down
45 changes: 31 additions & 14 deletions nemoclaw-blueprint/scripts/ws-proxy-fix.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
if (!proxyUrl)
return;
if (globalThis[_PATCHED])
const patchedFlag = Reflect.get(globalThis, _PATCHED) === true;
if (patchedFlag)
return;
let proxy;
try {
Expand All @@ -68,7 +69,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
// Override createConnection to route through the proxy's CONNECT tunnel.
// The typing is intentionally loosened because the actual Node.js runtime
// signature is broader than what @types/node declares.
agent.createConnection = function (options, callback) {
Reflect.set(agent, "createConnection", function (options, callback) {
const connectReq = node_http_1.default.request({
host: proxyHost,
port: proxyPort,
Expand All @@ -88,7 +89,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
}
const tlsSocket = node_tls_1.default.connect({
socket,
servername: options.servername || targetHost,
servername: typeof options.servername === "string" ? options.servername : targetHost,
});
callback(null, tlsSocket);
});
Expand All @@ -100,7 +101,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
// createConnection expects a synchronous return; the real socket arrives
// via the callback. Return a placeholder that Node.js will discard.
return new node_net_1.default.Socket();
};
});
return agent;
}
// ---------- Target check -------------------------------------------------
Expand All @@ -123,9 +124,23 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
return false;
}
// ---------- Patch https.request() ---------------------------------------
// Capture the original — typed as a loose callable so we can invoke it
// with the normalised (options, cb) form without fighting overload resolution.
const origRequest = node_https_1.default.request;
// Capture the original so we can call it after normalising arguments.
const requestRef = node_https_1.default.request;
function callOriginalRequest(input, options, callback) {
if (typeof input === "string" || input instanceof node_url_1.URL) {
if (typeof options === "function") {
return requestRef(input, options);
}
if (options) {
return callback ? requestRef(input, options, callback) : requestRef(input, options);
}
return callback ? requestRef(input, {}, callback) : requestRef(input);
}
if (typeof options === "function") {
return requestRef(input, options);
}
return requestRef(input, callback);
}
function wsProxyFixedRequest(input, options, callback) {
// --- Normalise arguments (Node.js accepts multiple call signatures) ---
let opts;
Expand All @@ -136,7 +151,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
opts = {};
}
else {
opts = options || {};
opts = options ?? {};
cb = callback;
}
const url = typeof input === "string" ? new node_url_1.URL(input) : input;
Expand All @@ -159,19 +174,21 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");
host = opts.host.replace(/:\d+$/, "");
}
if (isDiscordWsUpgrade(host, opts.headers)) {
if (!host) {
return callOriginalRequest(input, options, callback);
}
// Discord WebSocket upgrade — inject CONNECT tunnel agent unless the
// caller already provides a custom (non-default) agent.
if (!opts.agent || opts.agent === node_https_1.default.globalAgent) {
const port = parseInt(String(opts.port), 10) || 443;
opts = { ...opts, agent: createTunnelAgent(host, port) };
}
return origRequest.call(node_https_1.default, opts, cb);
return cb ? requestRef(opts, cb) : requestRef(opts);
}
// Non-WebSocket — pass through original arguments unchanged.
// eslint-disable-next-line prefer-rest-params
return origRequest.apply(node_https_1.default, arguments);
// Non-WebSocket — pass through the original arguments unchanged.
return callOriginalRequest(input, options, callback);
}
// Replace https.request with our patched version.
node_https_1.default.request = wsProxyFixedRequest;
globalThis[_PATCHED] = true;
Reflect.set(node_https_1.default, "request", wsProxyFixedRequest);
Reflect.set(globalThis, _PATCHED, true);
})();
64 changes: 44 additions & 20 deletions nemoclaw-blueprint/scripts/ws-proxy-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { URL } from "node:url";
const _PATCHED = Symbol.for("nemoclaw.wsProxyFix");

type RequestCallback = (res: http.IncomingMessage) => void;
type TunnelConnectionOptions = { servername?: string };

/**
* Merged options after normalising the multiple call signatures of
Expand All @@ -54,7 +55,8 @@ interface ReqOpts extends https.RequestOptions {
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
if (!proxyUrl) return;

if ((globalThis as Record<symbol, unknown>)[_PATCHED]) return;
const patchedFlag = Reflect.get(globalThis, _PATCHED) === true;
if (patchedFlag) return;

let proxy: URL;
try {
Expand Down Expand Up @@ -82,10 +84,13 @@ interface ReqOpts extends https.RequestOptions {
// Override createConnection to route through the proxy's CONNECT tunnel.
// The typing is intentionally loosened because the actual Node.js runtime
// signature is broader than what @types/node declares.
(agent as unknown as Record<string, unknown>).createConnection = function (
options: Record<string, unknown>,
callback: (err: Error | null, socket?: tls.TLSSocket) => void,
): net.Socket {
Reflect.set(
agent,
"createConnection",
function (
options: TunnelConnectionOptions,
callback: (err: Error | null, socket?: tls.TLSSocket) => void,
): net.Socket {
const connectReq = http.request({
host: proxyHost,
port: proxyPort,
Expand All @@ -112,7 +117,7 @@ interface ReqOpts extends https.RequestOptions {
}
const tlsSocket = tls.connect({
socket,
servername: (options.servername as string) || targetHost,
servername: typeof options.servername === "string" ? options.servername : targetHost,
});
callback(null, tlsSocket);
},
Expand All @@ -127,7 +132,8 @@ interface ReqOpts extends https.RequestOptions {
// createConnection expects a synchronous return; the real socket arrives
// via the callback. Return a placeholder that Node.js will discard.
return new net.Socket();
};
},
);

return agent;
}
Expand Down Expand Up @@ -158,12 +164,28 @@ interface ReqOpts extends https.RequestOptions {

// ---------- Patch https.request() ---------------------------------------

// Capture the original — typed as a loose callable so we can invoke it
// with the normalised (options, cb) form without fighting overload resolution.
const origRequest = https.request as (
options: ReqOpts,
// Capture the original so we can call it after normalising arguments.
const requestRef = https.request;

function callOriginalRequest(
input: string | URL | ReqOpts,
options?: RequestCallback | ReqOpts,
callback?: RequestCallback,
) => http.ClientRequest;
): http.ClientRequest {
if (typeof input === "string" || input instanceof URL) {
if (typeof options === "function") {
return requestRef(input, options);
}
if (options) {
return callback ? requestRef(input, options, callback) : requestRef(input, options);
}
return callback ? requestRef(input, {}, callback) : requestRef(input);
}
if (typeof options === "function") {
return requestRef(input, options);
}
return requestRef(input, callback);
}

function wsProxyFixedRequest(
input: string | URL | ReqOpts,
Expand All @@ -179,7 +201,7 @@ interface ReqOpts extends https.RequestOptions {
cb = options;
opts = {};
} else {
opts = (options as ReqOpts) || {};
opts = options ?? {};
cb = callback;
}
const url = typeof input === "string" ? new URL(input) : input;
Expand All @@ -202,24 +224,26 @@ interface ReqOpts extends https.RequestOptions {
host = opts.host.replace(/:\d+$/, "");
}
if (isDiscordWsUpgrade(host, opts.headers)) {
if (!host) {
return callOriginalRequest(input, options, callback);
}
// Discord WebSocket upgrade — inject CONNECT tunnel agent unless the
// caller already provides a custom (non-default) agent.
if (!opts.agent || opts.agent === https.globalAgent) {
const port = parseInt(String(opts.port), 10) || 443;
opts = { ...opts, agent: createTunnelAgent(host!, port) };
opts = { ...opts, agent: createTunnelAgent(host, port) };
}
return origRequest.call(https, opts, cb);
return cb ? requestRef(opts, cb) : requestRef(opts);
}

// Non-WebSocket — pass through original arguments unchanged.
// eslint-disable-next-line prefer-rest-params
return (origRequest as unknown as Function).apply(https, arguments);
// Non-WebSocket — pass through the original arguments unchanged.
return callOriginalRequest(input, options, callback);
}

// Replace https.request with our patched version.
(https as unknown as Record<string, unknown>).request = wsProxyFixedRequest;
Reflect.set(https, "request", wsProxyFixedRequest);

(globalThis as Record<symbol, unknown>)[_PATCHED] = true;
Reflect.set(globalThis, _PATCHED, true);
})();

export {};
43 changes: 35 additions & 8 deletions nemoclaw/src/blueprint/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ import { DASHBOARD_PORT } from "../lib/ports.js";

type Action = "plan" | "apply" | "status" | "rollback";

type BlueprintDataScalar = string | number | boolean | null;
type BlueprintDataValue = BlueprintDataScalar | PolicyAdditions | BlueprintDataValue[];
type RollbackPlanSource = { sandbox_name?: string };

function isAction(value: string | undefined): value is Action {
return value === "plan" || value === "apply" || value === "status" || value === "rollback";
}

function isBlueprint(value: unknown): value is Blueprint {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── Logging helpers ─────────────────────────────────────────────

function log(msg: string): void {
Expand All @@ -36,6 +48,10 @@ function progress(pct: number, label: string): void {
process.stdout.write(`PROGRESS:${String(pct)}:${label}\n`);
}

function readRollbackSandboxName(value: RollbackPlanSource | null): string {
return value && typeof value.sandbox_name === "string" ? value.sandbox_name : "openclaw";
}

// ── Utilities ───────────────────────────────────────────────────

export function emitRunId(): string {
Expand All @@ -50,15 +66,18 @@ export function emitRunId(): string {
return rid;
}

type InferenceProfileMap = { [profileName: string]: InferenceProfile };
type PolicyAdditions = { [name: string]: BlueprintDataValue };

interface Blueprint {
version?: string;
components?: {
inference?: {
profiles?: Record<string, InferenceProfile>;
profiles?: InferenceProfileMap;
};
sandbox?: SandboxConfig;
policy?: {
additions?: Record<string, unknown>;
additions?: PolicyAdditions;
};
};
}
Expand Down Expand Up @@ -88,7 +107,11 @@ export function loadBlueprint(): Blueprint {
} catch {
throw new Error(`blueprint.yaml not found at ${bpFile}`);
}
return YAML.parse(content) as Blueprint;
const parsed: unknown = YAML.parse(content);
if (!isBlueprint(parsed)) {
throw new Error(`blueprint.yaml at ${bpFile} must contain a YAML mapping`);
}
return parsed;
}

async function runCmd(
Expand Down Expand Up @@ -121,7 +144,7 @@ async function resolveRunConfig(
blueprint: Blueprint,
endpointUrl?: string,
): Promise<{
inferenceProfiles: Record<string, InferenceProfile>;
inferenceProfiles: InferenceProfileMap;
inferenceCfg: InferenceProfile;
sandboxCfg: SandboxConfig;
}> {
Expand Down Expand Up @@ -169,7 +192,7 @@ export interface RunPlan {
model: string | undefined;
credential_env: string | undefined;
};
policy_additions: Record<string, unknown>;
policy_additions: PolicyAdditions;
dry_run: boolean;
}

Expand Down Expand Up @@ -409,8 +432,12 @@ export async function actionRollback(rid: string): Promise<void> {
const planFile = join(stateDir, "plan.json");
try {
const planData = readFileSync(planFile, "utf-8");
const plan = JSON.parse(planData) as { sandbox_name?: string };
const sandboxName = plan.sandbox_name ?? "openclaw";
const parsedPlan: unknown = JSON.parse(planData);
const rollbackPlan: RollbackPlanSource | null =
typeof parsedPlan === "object" && parsedPlan !== null && !Array.isArray(parsedPlan)
? parsedPlan
: null;
const sandboxName = readRollbackSandboxName(rollbackPlan);

progress(30, `Stopping sandbox ${sandboxName}`);
await runCmd(["openshell", "sandbox", "stop", sandboxName], { reject: false });
Expand All @@ -430,7 +457,7 @@ export async function actionRollback(rid: string): Promise<void> {
// ── CLI ─────────────────────────────────────────────────────────

export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
const action = argv[0] as Action | undefined;
const action = isAction(argv[0]) ? argv[0] : undefined;
let profile = "default";
let planPath: string | undefined;
let runId: string | undefined;
Expand Down
32 changes: 24 additions & 8 deletions nemoclaw/src/blueprint/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,23 @@ export interface BlueprintSnapshotManifest {
path: string;
}

type SnapshotManifestJson = {
timestamp?: string;
source?: string;
file_count?: number;
contents?: Array<string | null>;
};

function isSnapshotManifestJson(value: object | null): value is SnapshotManifestJson {
return value !== null && !Array.isArray(value);
}

function readStringArray(value: SnapshotManifestJson["contents"]): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}

export function listSnapshots(): BlueprintSnapshotManifest[] {
let entries: Dirent[];
try {
Expand All @@ -190,15 +207,14 @@ export function listSnapshots(): BlueprintSnapshotManifest[] {
if (!entry.isDirectory()) continue;
const snapDir = join(SNAPSHOTS_DIR, entry.name);
try {
const raw: unknown = JSON.parse(readFileSync(join(snapDir, "snapshot.json"), "utf-8"));
if (typeof raw !== "object" || raw === null) continue;
const obj = raw as Record<string, unknown>;
if (typeof obj.timestamp !== "string") continue;
const parsed: unknown = JSON.parse(readFileSync(join(snapDir, "snapshot.json"), "utf-8"));
const raw = typeof parsed === "object" && parsed !== null ? parsed : null;
if (!isSnapshotManifestJson(raw) || typeof raw.timestamp !== "string") continue;
snapshots.push({
timestamp: obj.timestamp,
source: typeof obj.source === "string" ? obj.source : "",
file_count: typeof obj.file_count === "number" ? obj.file_count : 0,
contents: Array.isArray(obj.contents) ? (obj.contents as string[]) : [],
timestamp: raw.timestamp,
source: typeof raw.source === "string" ? raw.source : "",
file_count: typeof raw.file_count === "number" ? raw.file_count : 0,
contents: readStringArray(raw.contents),
path: snapDir,
});
} catch {
Expand Down
Loading
Loading