Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ $ nemoclaw my-assistant snapshot restore 2026-04-21T07-35-55-987Z
$ nemoclaw my-assistant snapshot restore v3 --to my-assistant-clone
```

### `openshell term`
## `openshell term`

Open the OpenShell TUI to monitor sandbox activity and approve network egress requests.
Run this on the host where the sandbox is running.
Expand Down
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.
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
Loading
Loading