Skip to content
Closed
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1a1e72e
refactor(cli): centralize subprocess execution and make shell use exp…
cv Apr 25, 2026
2971418
merge(main): resolve conflicts for subprocess refactor
cv Apr 25, 2026
d58ee62
fix(env): harden filtered subprocess state
cv Apr 25, 2026
758ae6f
fix(onboard): align Ollama install with WSL flow
cv Apr 25, 2026
9af5dd7
fix(cli): tighten argv-only subprocess helpers
cv Apr 25, 2026
812bb12
fix(preflight): tighten swapfile detection
cv Apr 25, 2026
2199ffc
fix(sandbox): harden restore and create flows
cv Apr 25, 2026
d3c9cca
fix(cli): avoid busy-spin while waiting to connect
cv Apr 25, 2026
c1099c0
test(plugin): reset OpenShell probe mocks per suite
cv Apr 25, 2026
d98ea39
test(cli): reduce brittle command matcher duplication
cv Apr 25, 2026
e2085be
fix(debug): cap transformed command capture
cv Apr 25, 2026
9a48c2f
fix(test): narrow preflight command renderer
cv Apr 25, 2026
5d5c1a4
fix(env): forward fallback proxy variables
cv Apr 25, 2026
2b2a005
fix(deploy): require a non-empty remote home
cv Apr 25, 2026
629e323
fix(onboard): harden local probe and volume cleanup
cv Apr 25, 2026
7301ada
fix(sandbox): scrub env and quote remote state ops
cv Apr 25, 2026
d0d1309
fix(onboard): probe Ollama without a shell
cv Apr 25, 2026
ce39629
fix(sandbox): rollback failed ownership restores
cv Apr 25, 2026
4d3539e
fix(deploy): abort when brev ls fails
cv Apr 25, 2026
f81322d
fix(onboard): preserve Ollama daemon env and port
cv Apr 25, 2026
7ea4eee
fix(sandbox): surface remote state probe failures
cv Apr 25, 2026
e61ce69
fix(onboard): timebox the Ollama installer
cv Apr 25, 2026
64b862c
fix(sandbox): validate snapshot state paths
cv Apr 25, 2026
2ff285b
fix(sandbox): fail state probes on remote errors
cv Apr 25, 2026
0d37942
fix(sandbox): require cd before workspace probes
cv Apr 25, 2026
dfc1771
fix(sandbox): reject null bytes in snapshot paths
cv Apr 25, 2026
63d6887
merge(main): resolve connect probe conflict
cv Apr 25, 2026
ef06bb4
fix(cli): filter gateway volumes by prefix
cv Apr 25, 2026
7c48aff
refactor(cli): add composable remote shell steps
cv Apr 25, 2026
b506679
fix(snapshot): harden tar path arguments
cv Apr 25, 2026
72971ee
refactor(agent): use structured gateway argv
cv Apr 25, 2026
cf292c1
refactor(cli): detach ollama warmup spawns
cv Apr 25, 2026
fbf4eb0
fix(cli): restore HTTP status gateway probes
cv Apr 25, 2026
6681b4b
refactor(preflight): use executable lookup for command checks
cv Apr 25, 2026
5cf7b18
fix(shell): validate assignment variable names
cv Apr 25, 2026
2eed6ab
fix(skill-install): pin sandbox SSH host keys
cv Apr 25, 2026
346d03c
Merge branch 'main' into autoresearch/shellouts-safety-2026-04-24
cv Apr 25, 2026
3376954
merge(main): resolve OpenClaw manifest conflict
cv Apr 25, 2026
91c3a25
test(cli): harden flaky integration timeouts
cv Apr 25, 2026
5e76eef
Merge remote-tracking branch 'origin/main' into autoresearch/shellout…
cv Apr 27, 2026
57f0441
Merge branch 'main' into autoresearch/shellouts-safety-2026-04-24
cv Apr 27, 2026
d88245c
fix(cli): address review feedback on shellouts PR
cv Apr 27, 2026
d9a8ee8
fix(cli): fail fast on empty sandbox ssh config
cv Apr 27, 2026
1f96041
Merge branch 'main' into autoresearch/shellouts-safety-2026-04-24
cv Apr 27, 2026
96c56d4
Merge branch 'main' into autoresearch/shellouts-safety-2026-04-24
cv Apr 28, 2026
0aec13f
Merge branch 'main' into autoresearch/shellouts-safety-2026-04-24
cv Apr 28, 2026
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
9 changes: 4 additions & 5 deletions nemoclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* time.
*/

import { execFileSync } from "node:child_process";
import { execaSync } from "execa";
import { handleSlashCommand } from "./commands/slash.js";
import {
describeOnboardEndpoint,
Expand Down Expand Up @@ -59,12 +59,11 @@ function readBeforeToolCallEvent(
// sandbox). Returns empty strings if the probe fails.
function probeOpenShellInference(): { endpoint: string; provider: string; model: string } {
try {
const raw = execFileSync("openshell", ["inference", "get", "--json"], {
encoding: "utf-8",
const result = execaSync("openshell", ["inference", "get", "--json"], {
timeout: 3000,
stdio: ["pipe", "pipe", "pipe"],
reject: true,
});
const parsed: unknown = JSON.parse(raw);
const parsed: unknown = JSON.parse(result.stdout);
const parsedObject = typeof parsed === "object" && parsed !== null ? parsed : null;
const endpoint = readStringProperty(parsedObject, "endpoint");
const provider = readStringProperty(parsedObject, "provider");
Expand Down
40 changes: 38 additions & 2 deletions nemoclaw/src/lib/subprocess-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,32 @@ const TEMP = ["TMPDIR", "TMP", "TEMP"];

const LOCALE = ["LANG"]; // LC_* handled via prefix

const PROXY = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"];
const PROXY = [
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"all_proxy",
];

const TLS = ["SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"];

const TOOLCHAIN = ["DOCKER_HOST", "KUBECONFIG", "SSH_AUTH_SOCK", "RUST_LOG", "RUST_BACKTRACE"];

const ALLOWED_ENV_NAMES = new Set([...SYSTEM, ...TEMP, ...LOCALE, ...PROXY, ...TLS, ...TOOLCHAIN]);
const NEMOCLAW = ["NEMOCLAW_NON_INTERACTIVE"];

const ALLOWED_ENV_NAMES = new Set([
...SYSTEM,
...TEMP,
...LOCALE,
...PROXY,
...TLS,
...TOOLCHAIN,
...NEMOCLAW,
]);

// ── Allowed prefixes ───────────────────────────────────────────

Expand All @@ -53,3 +72,20 @@ export function buildSubprocessEnv(extra?: Record<string, string>): Record<strin
}
return env;
}

export function buildEnvForSubprocess(
extraEnv: NodeJS.ProcessEnv | undefined,
inheritFullEnv = false,
): NodeJS.ProcessEnv {
if (inheritFullEnv) {
return { ...process.env, ...extraEnv };
}

const normalizedExtraEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(extraEnv ?? {})) {
if (value !== undefined) {
normalizedExtraEnv[key] = value;
}
}
return buildSubprocessEnv(normalizedExtraEnv);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
39 changes: 28 additions & 11 deletions nemoclaw/src/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OpenClawPluginApi } from "./index.js";

vi.mock("node:child_process", () => ({
execFileSync: vi.fn(),
vi.mock("execa", () => ({
execaSync: vi.fn(),
}));

vi.mock("./onboard/config.js", () => ({
Expand All @@ -14,11 +14,11 @@ vi.mock("./onboard/config.js", () => ({
describeOnboardProvider: vi.fn(() => "NVIDIA Endpoint API"),
}));

import { execFileSync } from "node:child_process";
import { execaSync } from "execa";
import register, { getPluginConfig } from "./index.js";
import { loadOnboardConfig } from "./onboard/config.js";

const mockedExecFileSync = vi.mocked(execFileSync);
const mockedExecaSync = vi.mocked(execaSync);
const mockedLoadOnboardConfig = vi.mocked(loadOnboardConfig);

function createMockApi(): OpenClawPluginApi {
Expand All @@ -45,7 +45,7 @@ function createMockApi(): OpenClawPluginApi {
describe("plugin registration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecFileSync.mockReset();
mockedExecaSync.mockReset();
mockedLoadOnboardConfig.mockReturnValue(null);
});

Expand Down Expand Up @@ -86,13 +86,21 @@ describe("plugin registration", () => {
});

it("uses probed OpenShell provider and model when onboard config is unavailable", () => {
mockedExecFileSync.mockReturnValue(
JSON.stringify({
mockedExecaSync.mockReturnValue({
command: "openshell inference get --json",
escapedCommand: "openshell inference get --json",
exitCode: 0,
stdout: JSON.stringify({
provider: "Ollama",
endpoint: "http://host.docker.internal:11434/v1",
model: "llama3.2:latest",
}),
);
stderr: "",
failed: false,
timedOut: false,
isCanceled: false,
killed: false,
} as ReturnType<typeof execaSync>);

const api = createMockApi();
register(api);
Expand All @@ -114,12 +122,20 @@ describe("plugin registration", () => {
});

it("does not treat the provider name as a fallback endpoint", () => {
mockedExecFileSync.mockReturnValue(
JSON.stringify({
mockedExecaSync.mockReturnValue({
command: "openshell inference get --json",
escapedCommand: "openshell inference get --json",
exitCode: 0,
stdout: JSON.stringify({
provider: "Ollama",
model: "llama3.2:latest",
}),
);
stderr: "",
failed: false,
timedOut: false,
isCanceled: false,
killed: false,
} as ReturnType<typeof execaSync>);

const api = createMockApi();
register(api);
Expand All @@ -133,6 +149,7 @@ describe("plugin registration", () => {
describe("before_tool_call secret scanner hook (#1233)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecaSync.mockReset();
mockedLoadOnboardConfig.mockReturnValue(null);
});

Expand Down
4 changes: 2 additions & 2 deletions src/lib/agent-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe("buildRecoveryScript", () => {

it("launches the default gateway command through the validated agent binary", () => {
const script = buildRecoveryScript(minimalAgent, 19000);
expect(script).toContain("command -v 'test-agent'");
expect(script).toContain("command -v test-agent");
expect(script).toContain('nohup "$AGENT_BIN" gateway run --port 19000');
});

Expand All @@ -73,7 +73,7 @@ describe("buildRecoveryScript", () => {
it("validates and launches custom gateway commands explicitly", () => {
const agent = makeAgent({ gateway_command: "custom-launch --mode recovery" });
const script = buildRecoveryScript(agent, 19000);
expect(script).toContain("GATEWAY_CMD_BIN='custom-launch'");
expect(script).toContain("GATEWAY_CMD_BIN=custom-launch");
expect(script).toContain('command -v "$GATEWAY_CMD_BIN" >/dev/null 2>&1');
expect(script).toContain("nohup custom-launch --mode recovery --port 19000");
});
Expand Down
8 changes: 4 additions & 4 deletions src/lib/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as registry from "./registry";
import { DASHBOARD_PORT } from "./ports";
import * as onboardSession from "./onboard-session";
import { loadAgent, type AgentDefinition } from "./agent-defs";
import { shellQuote } from "./runner";
import { buildShellAssignment, formatShellToken } from "./shell-quote";

/**
* Resolve the agent for a sandbox. Checks the per-sandbox registry first
Expand Down Expand Up @@ -65,11 +65,11 @@ export function buildRecoveryScript(agent: AgentDefinition | null, port: number)
const customGatewayExecutable = configuredGatewayCommand.split(/\s+/)[0] ?? binaryName;
const validationSteps = usesValidatedBinary
? [
`AGENT_BIN=${shellQuote(binaryPath)}; if [ ! -x "$AGENT_BIN" ]; then AGENT_BIN="$(command -v ${shellQuote(binaryName)})"; fi;`,
`${buildShellAssignment("AGENT_BIN", binaryPath)}; if [ ! -x "$AGENT_BIN" ]; then AGENT_BIN="$(command -v ${formatShellToken(binaryName)})"; fi;`,
'if [ -z "$AGENT_BIN" ]; then echo AGENT_MISSING; exit 1; fi;',
]
: [
`GATEWAY_CMD_BIN=${shellQuote(customGatewayExecutable)};`,
`${buildShellAssignment("GATEWAY_CMD_BIN", customGatewayExecutable)};`,
'case "$GATEWAY_CMD_BIN" in */*) [ -x "$GATEWAY_CMD_BIN" ] || { echo AGENT_MISSING; exit 1; } ;; *) command -v "$GATEWAY_CMD_BIN" >/dev/null 2>&1 || { echo AGENT_MISSING; exit 1; } ;; esac;',
];
const launchCommand = usesValidatedBinary
Expand All @@ -81,7 +81,7 @@ export function buildRecoveryScript(agent: AgentDefinition | null, port: number)
return [
"[ -f ~/.bashrc ] && . ~/.bashrc 2>/dev/null;",
hermesHome,
`if curl -sf --max-time 3 ${shellQuote(probeUrl)} > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;`,
`if curl -sf --max-time 3 ${formatShellToken(probeUrl)} > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;`,
"rm -f /tmp/gateway.log;",
"touch /tmp/gateway.log; chmod 600 /tmp/gateway.log;",
...validationSteps,
Expand Down
15 changes: 8 additions & 7 deletions src/lib/config-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { shellQuote } from "./shell-quote";
import { buildShellCommand } from "./remote-script";
import { buildShellAssignment, formatShellToken, joinShellWords } from "./shell-quote";
import { isErrnoException, isPermissionError } from "./errno";

// Strict JSON types for file serialization — unlike json-types.ts,
Expand Down Expand Up @@ -46,17 +47,17 @@ function buildRemediation(): string {
" To fix, try one of these recovery paths:",
"",
" # If you can use sudo, repair the existing config directory:",
` sudo chown -R $(whoami) ${shellQuote(nemoclawDir)}`,
` ${buildShellCommand({ command: `sudo chown -R $(whoami) ${formatShellToken(nemoclawDir)}` })}`,
" # or recreate it if it was created by another user:",
` sudo rm -rf ${shellQuote(nemoclawDir)} && nemoclaw onboard`,
` ${buildShellCommand({ command: `${joinShellWords(["sudo", "rm", "-rf", nemoclawDir])} && nemoclaw onboard` })}`,
"",
" # If sudo is unavailable, move the bad config aside from a writable HOME:",
` mv ${shellQuote(nemoclawDir)} ${shellQuote(backupDir)} && nemoclaw onboard`,
` ${buildShellCommand({ command: `${joinShellWords(["mv", nemoclawDir, backupDir])} && nemoclaw onboard` })}`,
" # or, if you already own the directory, remove it without sudo:",
` rm -rf ${shellQuote(nemoclawDir)} && nemoclaw onboard`,
` ${buildShellCommand({ command: `${joinShellWords(["rm", "-rf", nemoclawDir])} && nemoclaw onboard` })}`,
"",
" # If HOME itself is not writable, start NemoClaw with a writable HOME:",
` mkdir -p ${shellQuote(recoveryHome)} && HOME=${shellQuote(recoveryHome)} nemoclaw onboard`,
` ${buildShellCommand({ command: `${joinShellWords(["mkdir", "-p", recoveryHome])} && ${buildShellAssignment("HOME", recoveryHome)} nemoclaw onboard` })}`,
"",
" This usually happens when NemoClaw was first run with sudo",
" or the config directory was created by a different user.",
Expand Down Expand Up @@ -129,7 +130,7 @@ function rejectSymlinksOnPath(dirPath: string): void {
throw new Error(
`Refusing to use config directory: ${current} is a symbolic link ` +
`(target: ${target}). This may indicate a symlink attack. ` +
`Remove the symlink and retry: rm ${shellQuote(current)}`,
`Remove the symlink and retry: ${buildShellCommand({ commandArgs: ["rm", current] })}`,
);
}
} catch (error) {
Expand Down
33 changes: 12 additions & 21 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
Expand All @@ -10,6 +9,10 @@ import readline from "node:readline";
import { readConfigFile, writeConfigFile } from "./config-io";
import { isErrnoException } from "./errno";

// runner.ts still uses CommonJS-style exports — use require here.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runCapture } = require("./runner.js");

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]);

type CredentialInput = string | null | undefined;
Expand Down Expand Up @@ -293,15 +296,10 @@ export async function ensureApiKey(): Promise<void> {
}

export function isRepoPrivate(repo: string): boolean {
try {
const json = execFileSync("gh", ["api", `repos/${repo}`, "--jq", ".private"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return json === "true";
} catch {
return false;
}
const json = runCapture(["gh", "api", `repos/${repo}`, "--jq", ".private"], {
ignoreError: true,
}).trim();
return json === "true";
}

export async function ensureGithubToken(): Promise<void> {
Expand All @@ -311,17 +309,10 @@ export async function ensureGithubToken(): Promise<void> {
return;
}

try {
token = execFileSync("gh", ["auth", "token"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (token) {
process.env.GITHUB_TOKEN = token;
return;
}
} catch {
/* ignored */
token = runCapture(["gh", "auth", "token"], { ignoreError: true }).trim();
if (token) {
process.env.GITHUB_TOKEN = token;
return;
}

console.log("");
Expand Down
Loading
Loading