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
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
},
"allowedCycles": [],
"maxRootFiles": {
"src/lib/onboard": 309,
"src/lib/onboard": 308,
"src/lib/actions": 19,
"src/lib/actions/sandbox": 183,
"src/lib/state": 38,
Expand Down
18 changes: 13 additions & 5 deletions scripts/generate-openclaw-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,7 +1431,10 @@ export function buildConfig(env: Env = process.env): JsonObject {
pluginEntries["diagnostics-otel"] = { enabled: true };
}

const plugins: JsonObject = { entries: pluginEntries };
const plugins: JsonObject = {
allow: unique(["nemoclaw", ...openclawPlugins.map((plugin) => plugin.id)]),
entries: pluginEntries,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const pluginLoadPaths: string[] = [];
for (const plugin of openclawPlugins) {
pluginEntries[plugin.id] = { enabled: true };
Expand Down Expand Up @@ -1556,7 +1559,7 @@ export function buildConfig(env: Env = process.env): JsonObject {
return config;
}

function preserveExistingPluginInstalls(config: JsonObject, configPath: string): void {
function preserveExistingPluginState(config: JsonObject, configPath: string): void {
let existing: unknown;
try {
existing = JSON.parse(readFileSync(configPath, "utf-8"));
Expand All @@ -1570,12 +1573,17 @@ function preserveExistingPluginInstalls(config: JsonObject, configPath: string):
if (!isObject(existingPlugins)) {
return;
}
const currentPlugins = config.plugins;
if (Array.isArray(existingPlugins.allow)) {
currentPlugins.allow = unique([
...(Array.isArray(currentPlugins.allow) ? currentPlugins.allow : []),
...existingPlugins.allow.filter((pluginId): pluginId is string => typeof pluginId === "string"),
]);
}
const existingInstalls = existingPlugins.installs;
if (!isObject(existingInstalls) || Object.keys(existingInstalls).length === 0) {
return;
}

const currentPlugins = config.plugins;
if (!isObject(currentPlugins.installs)) {
currentPlugins.installs = {};
}
Expand All @@ -1585,7 +1593,7 @@ function preserveExistingPluginInstalls(config: JsonObject, configPath: string):
export function writeOpenClawConfig(): void {
const config = buildConfig();
const configPath = expandUser("~/.openclaw/openclaw.json");
preserveExistingPluginInstalls(config, configPath);
preserveExistingPluginState(config, configPath);
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(config, null, 2));
chmodSync(configPath, 0o600);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/actions/sandbox/agent/passthrough-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { SpawnSyncOptions } from "node:child_process";

import { describe, expect, it, vi } from "vitest";

import { buildOpenshellExecArgs, wrapExecCommandWithRuntimeEnv } from "../exec";
import { buildOpenshellExecArgs, wrapOpenClawAgentCommandWithRuntimeEnv } from "../exec";
import { runAgentJsonPassthrough } from "./passthrough-json";

describe("runAgentJsonPassthrough", () => {
Expand Down Expand Up @@ -66,7 +66,7 @@ describe("runAgentJsonPassthrough", () => {
"/usr/local/bin/openshell",
buildOpenshellExecArgs(
"alpha",
wrapExecCommandWithRuntimeEnv(["openclaw", "agent", "--json"]),
wrapOpenClawAgentCommandWithRuntimeEnv(["openclaw", "agent", "--json"]),
{ tty: false },
),
expect.objectContaining({
Expand Down
8 changes: 6 additions & 2 deletions src/lib/actions/sandbox/agent/passthrough-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
type OpenClawIncompleteTurnSignal,
openClawAgentJsonProvenanceLines,
} from "../../../openclaw/agent-json-provenance";
import { buildOpenshellExecArgs, computeExitCode, wrapExecCommandWithRuntimeEnv } from "../exec";
import {
buildOpenshellExecArgs,
computeExitCode,
wrapOpenClawAgentCommandWithRuntimeEnv,
} from "../exec";
import { getKnownSandboxTargetGatewayName } from "../gateway-target";
import {
agentDispatchStdio,
Expand Down Expand Up @@ -80,7 +84,7 @@ export function runAgentJsonPassthrough(
binary,
buildOpenshellExecArgs(
sandboxName,
wrapExecCommandWithRuntimeEnv(command),
wrapOpenClawAgentCommandWithRuntimeEnv(command),
{ tty: false },
(deps.getGatewayName ?? getKnownSandboxTargetGatewayName)(sandboxName) ?? undefined,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock("../exec", () => ({
execSandbox: execMock,
buildOpenshellExecArgs: vi.fn((_sb: string, cmd: readonly string[]) => cmd),
wrapExecCommandWithRuntimeEnv: vi.fn((cmd: readonly string[]) => cmd),
wrapOpenClawAgentCommandWithRuntimeEnv: vi.fn((cmd: readonly string[]) => cmd),
computeExitCode: vi.fn((result: { status: number | null }) => ({
code: result.status ?? 1,
errorMessage: null,
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/agent/passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ vi.mock("../exec", () => ({
execSandbox: execMock,
buildOpenshellExecArgs: vi.fn((_sb: string, cmd: readonly string[]) => cmd),
wrapExecCommandWithRuntimeEnv: vi.fn((cmd: readonly string[]) => cmd),
wrapOpenClawAgentCommandWithRuntimeEnv: vi.fn((cmd: readonly string[]) => cmd),
computeExitCode: vi.fn((result: { status: number | null }) => ({
code: result.status ?? 1,
errorMessage: null,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/actions/sandbox/agent/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ import {
buildOpenshellExecArgs,
computeExitCode,
execSandbox,
wrapExecCommandWithRuntimeEnv,
wrapOpenClawAgentCommandWithRuntimeEnv,
} from "../exec";
import { ensureLiveSandboxOrExit } from "../gateway-state";
import { getKnownSandboxTargetGatewayName } from "../gateway-target";
Expand Down Expand Up @@ -212,7 +212,7 @@ export function runAgentNonJsonPassthrough(
binary,
buildOpenshellExecArgs(
sandboxName,
wrapExecCommandWithRuntimeEnv(command),
wrapOpenClawAgentCommandWithRuntimeEnv(command),
{ tty: false },
(deps.getGatewayName ?? getKnownSandboxTargetGatewayName)(sandboxName) ?? undefined,
),
Expand Down
5 changes: 4 additions & 1 deletion src/lib/actions/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import type { GatewaySelectResult } from "./gateway-select";
import { wrapExecCommandWithRuntimeEnv } from "./runtime-env";

export { buildSandboxExecStdio, shouldInheritSandboxExecStdin } from "./exec-stdio";
export { wrapExecCommandWithRuntimeEnv } from "./runtime-env";
export {
wrapExecCommandWithRuntimeEnv,
wrapOpenClawAgentCommandWithRuntimeEnv,
} from "./runtime-env";

export type SandboxExecOptions = {
workdir?: string;
Expand Down
26 changes: 25 additions & 1 deletion src/lib/actions/sandbox/runtime-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import path from "node:path";
import { describe, expect, it } from "vitest";

import { wrapExecCommandWithRuntimeEnv } from "./runtime-env";
import {
wrapExecCommandWithRuntimeEnv,
wrapOpenClawAgentCommandWithRuntimeEnv,
} from "./runtime-env";

describe("wrapExecCommandWithRuntimeEnv", () => {
it("sources the trusted runtime env and preserves each original argv element (#4504)", () => {
Expand Down Expand Up @@ -39,10 +42,10 @@
"node",
"-e",
"process.stdout.write(JSON.stringify(process.argv.slice(1)))",
...payloads,
]);

const result = spawnSync(wrapped[0], wrapped.slice(1), {

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
file name
.
This shell command depends on an uncontrolled
file name
.
encoding: "utf-8",
env: { ...process.env },
});
Expand All @@ -54,10 +57,10 @@
it("removes OPENCLAW_GATEWAY_TOKEN from the executed command environment (#6291)", () => {
const wrapped = wrapExecCommandWithRuntimeEnv([
"/bin/sh",
"-c",
'printf "TOKEN=[%s]" "${OPENCLAW_GATEWAY_TOKEN:-}"',
]);
const result = spawnSync(wrapped[0], wrapped.slice(1), {

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
file name
.
This shell command depends on an uncontrolled
file name
.
encoding: "utf-8",
env: { ...process.env, OPENCLAW_GATEWAY_TOKEN: "super-secret-gateway-token" },
});
Expand All @@ -70,16 +73,16 @@
it("preserves required non-credential proxy and gateway routing metadata", () => {
const wrapped = wrapExecCommandWithRuntimeEnv([
"/bin/sh",
"-c",
'printf "%s|%s|%s" "$HTTP_PROXY" "$NEMOCLAW_OPENCLAW_GATEWAY_URL" "$NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"',
]);
const result = spawnSync(wrapped[0], wrapped.slice(1), {
encoding: "utf-8",
env: {
...process.env,
HTTP_PROXY: "http://10.200.0.1:3128",
NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1",
NEMOCLAW_OPENCLAW_GATEWAY_URL: "ws://10.200.0.2:18789",

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
file name
.
This shell command depends on an uncontrolled
file name
.
OPENCLAW_GATEWAY_TOKEN: "super-secret-gateway-token",
},
});
Expand All @@ -93,10 +96,10 @@
const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-exec-bash-env-"));
const bashEnv = path.join(root, "bash-env.sh");
fs.writeFileSync(bashEnv, 'printf "BASH_ENV_RAN"\n');
const wrapped = wrapExecCommandWithRuntimeEnv(["/usr/bin/printf", "%s", "COMMAND_RAN"]);

try {
const result = spawnSync(wrapped[0], wrapped.slice(1), {

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
file name
.
This shell command depends on an uncontrolled
file name
.
encoding: "utf-8",
env: { ...process.env, BASH_ENV: bashEnv },
});
Expand All @@ -111,7 +114,7 @@
const wrapped = wrapExecCommandWithRuntimeEnv([
"-a",
"spoofed-argv-zero",
"/usr/bin/printf",

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
file name
.
This shell command depends on an uncontrolled
file name
.
"SHOULD_NOT_RUN",
]);
const result = spawnSync(wrapped[0], wrapped.slice(1), { encoding: "utf-8" });
Expand All @@ -119,4 +122,25 @@
expect(result.status).toBe(127);
expect(result.stdout).not.toContain("SHOULD_NOT_RUN");
});

it("suppresses only the UNDICI-EHPA warning for OpenClaw agent commands (#8975)", () => {
const wrapped = wrapOpenClawAgentCommandWithRuntimeEnv([
process.execPath,
"-e",
[
'process.emitWarning("proxy warning", { code: "UNDICI-EHPA" });',
'process.emitWarning("other warning", { code: "NEMOCLAW-TEST" });',
].join(""),
]);
const result = spawnSync(wrapped[0], wrapped.slice(1), {
encoding: "utf-8",
env: { ...process.env },
});
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

expect(result.status, result.stderr).toBe(0);
expect(result.stderr).not.toContain("UNDICI-EHPA");
expect(result.stderr).not.toContain("proxy warning");
expect(result.stderr).toContain("NEMOCLAW-TEST");
expect(result.stderr).toContain("other warning");
});
});
27 changes: 19 additions & 8 deletions src/lib/actions/sandbox/runtime-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,33 @@ const SANDBOX_RUNTIME_ENV_FILE = "/tmp/nemoclaw-proxy-env.sh";
const SANDBOX_RUNTIME_ENV_SENSITIVE_VARS = ["OPENCLAW_GATEWAY_TOKEN"];
const SANDBOX_RUNTIME_ENV_UNSET_SENSITIVE = `builtin unset ${SANDBOX_RUNTIME_ENV_SENSITIVE_VARS.join(" ")}`;
const SANDBOX_RUNTIME_ENV_EXEC_SCRIPT = `if [ -r "${SANDBOX_RUNTIME_ENV_FILE}" ]; then builtin source "${SANDBOX_RUNTIME_ENV_FILE}" || exit $?; fi; ${SANDBOX_RUNTIME_ENV_UNSET_SENSITIVE}; builtin exec -- "$@"`;
const OPENCLAW_AGENT_NODE_OPTIONS =
'builtin export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--disable-warning=UNDICI-EHPA"';
const SANDBOX_RUNTIME_ENV_OPENCLAW_AGENT_EXEC_SCRIPT = `if [ -r "${SANDBOX_RUNTIME_ENV_FILE}" ]; then builtin source "${SANDBOX_RUNTIME_ENV_FILE}" || exit $?; fi; ${SANDBOX_RUNTIME_ENV_UNSET_SENSITIVE}; ${OPENCLAW_AGENT_NODE_OPTIONS}; builtin exec -- "$@"`;

/**
* Source NemoClaw's trusted runtime env without flattening the caller's argv.
* The gateway token is removed after sourcing so ordinary caller argv does not
* inherit it ambiently; owned helpers that need it source the file directly.
* @internal Only NemoClaw-owned exec paths may source the root-generated file.
*/
export function wrapExecCommandWithRuntimeEnv(command: readonly string[]): string[] {
function wrapExecCommand(command: readonly string[], script: string): string[] {
return [
"/bin/bash",
"--noprofile",
"--norc",
"-p",
"-c",
SANDBOX_RUNTIME_ENV_EXEC_SCRIPT,
script,
"nemoclaw-runtime-env",
...command,
];
}

/**
* Source NemoClaw's trusted runtime env without flattening the caller's argv.
* The gateway token is removed after sourcing so ordinary caller argv does not
* inherit it ambiently; owned helpers that need it source the file directly.
* @internal Only NemoClaw-owned exec paths may source the root-generated file.
*/
export function wrapExecCommandWithRuntimeEnv(command: readonly string[]): string[] {
return wrapExecCommand(command, SANDBOX_RUNTIME_ENV_EXEC_SCRIPT);
}

export function wrapOpenClawAgentCommandWithRuntimeEnv(command: readonly string[]): string[] {
return wrapExecCommand(command, SANDBOX_RUNTIME_ENV_OPENCLAW_AGENT_EXEC_SCRIPT);
}
2 changes: 2 additions & 0 deletions src/lib/messaging/applier/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
SandboxMessagingPlan,
} from "../manifest";
import { isProviderPlaceholderForEnvKey } from "../provider-placeholders";
import { allowRenderedOpenClawPlugins } from "./openclaw-plugin-allow";
import { enabledPlanChannels, filterEnabledPlanEntries } from "./plan-filter";
import type {
MessagingHookApplyRequest,
Expand Down Expand Up @@ -215,6 +216,7 @@ function applyJsonFragments(
preserveCredentialPlaceholders(entry.value, getJsonPath(root, entry.path), rules),
);
}
if (plan.agent === "openclaw") allowRenderedOpenClawPlugins(root, render);
return format === "yaml" ? YAML.stringify(root) : JSON.stringify(root, null, 2) + "\n";
}

Expand Down
3 changes: 3 additions & 0 deletions src/lib/messaging/applier/build/messaging-build-applier.mts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { telegramManifest } from "../../channels/telegram/manifest.ts";
import { wechatManifest } from "../../channels/wechat/manifest.ts";
import { whatsappManifest } from "../../channels/whatsapp/manifest.ts";
import type { ChannelAgentPackageRuntimeLockSpec, ChannelManifest } from "../../manifest/types.ts";
import { allowRenderedOpenClawPlugins } from "../openclaw-plugin-allow.ts";
import {
selectActiveMessagingChannelIds,
selectEnabledMessagingAgentRender,
Expand Down Expand Up @@ -294,6 +295,7 @@ export function applyMessagingAgentRenderToObject(
);
setJsonPath(config, render.path, value);
}
if (plan.agent === "openclaw") allowRenderedOpenClawPlugins(config, enabledAgentRender(plan));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

export function applyMessagingAgentRenderToEnvLines(
Expand Down Expand Up @@ -966,6 +968,7 @@ function applyMessagingRenderEntriesToObject(
);
setJsonPath(config, render.path, value);
}
if (plan.agent === "openclaw") allowRenderedOpenClawPlugins(config, renderEntries);
}

function readEnvRenderLines(render: MessagingRenderEntry): readonly string[] {
Expand Down
38 changes: 38 additions & 0 deletions src/lib/messaging/applier/openclaw-plugin-allow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { allowRenderedOpenClawPlugins } from "./openclaw-plugin-allow";

describe("allowRenderedOpenClawPlugins", () => {
it("adds an enabled rendered plugin to the existing allowlist (#8975)", () => {
const config = { plugins: { allow: ["nemoclaw"], entries: {} } };

allowRenderedOpenClawPlugins(config, [
{ path: "plugins.entries.telegram", value: { enabled: true } },
]);

expect(config.plugins.allow).toEqual(["nemoclaw", "telegram"]);
});

it("does not allow a rendered plugin that remains disabled (#8975)", () => {
const config = { plugins: { allow: ["nemoclaw"], entries: {} } };

allowRenderedOpenClawPlugins(config, [
{ path: "plugins.entries.telegram", value: { enabled: false } },
]);

expect(config.plugins.allow).toEqual(["nemoclaw"]);
});

it("rejects a non-array OpenClaw plugin allowlist (#8975)", () => {
const config = { plugins: { allow: "nemoclaw", entries: {} } };

expect(() =>
allowRenderedOpenClawPlugins(config, [
{ path: "plugins.entries.telegram", value: { enabled: true } },
]),
).toThrow("OpenClaw plugins.allow must be an array.");
});
});
51 changes: 51 additions & 0 deletions src/lib/messaging/applier/openclaw-plugin-allow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

type JsonObject = Record<string, unknown>;

type OpenClawPluginRender = {
readonly path?: string;
readonly value?: unknown;
};

function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function enabledPluginId(render: OpenClawPluginRender): string | null {
const segments = render.path?.split(".").filter(Boolean) ?? [];
if (
segments.length !== 3 ||
segments[0] !== "plugins" ||
segments[1] !== "entries" ||
!isObject(render.value) ||
render.value.enabled !== true
) {
return null;
}
return segments[2] ?? null;
}

export function allowRenderedOpenClawPlugins(
config: JsonObject,
renderEntries: readonly OpenClawPluginRender[],
): void {
const renderedPluginIds = renderEntries.flatMap((render) => {
const pluginId = enabledPluginId(render);
return pluginId ? [pluginId] : [];
});
if (renderedPluginIds.length === 0) return;

const plugins = config.plugins;
if (!isObject(plugins)) {
throw new Error("OpenClaw messaging render requires a plugins object.");
}
const existingAllow = plugins.allow;
if (existingAllow !== undefined && !Array.isArray(existingAllow)) {
throw new Error("OpenClaw plugins.allow must be an array.");
}
const allowedPluginIds = (existingAllow ?? []).filter(
(pluginId): pluginId is string => typeof pluginId === "string",
);
plugins.allow = [...new Set([...allowedPluginIds, ...renderedPluginIds])];
}
1 change: 1 addition & 0 deletions src/lib/messaging/applier/setup-applier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ describe("MessagingSetupApplier", () => {
const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}");
expect(openclawConfig.plugins.entries.acpx.enabled).toBe(false);
expect(openclawConfig.plugins.entries["openclaw-weixin"].enabled).toBe(true);
expect(openclawConfig.plugins.allow).toEqual(["openclaw-weixin"]);
expect(openclawConfig.plugins.installs["openclaw-weixin"].spec).toBe(
"@tencent-weixin/openclaw-weixin@2.4.3",
);
Expand Down
1 change: 1 addition & 0 deletions test/generate-openclaw-config-gemini-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("generate-openclaw-config.mts: Gemini 3 managed-route compat", () => {
expect(config.plugins.entries["nemoclaw-gemini-inference-compat"]).toEqual({
enabled: true,
});
expect(config.plugins.allow).toEqual(["nemoclaw", "nemoclaw-gemini-inference-compat"]);
expect(config.plugins.load.paths).toEqual([
"/usr/local/share/nemoclaw/openclaw-plugins/gemini-inference-compat",
]);
Expand Down
Loading
Loading