Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ install_method: curl # curl install.sh | bash
binary_path: /usr/local/bin/hermes
version_command: "hermes --version"
expected_version: "2026.4.8"
gateway_command: "hermes gateway run"
gateway_argv:
- hermes
- gateway
- run

# ── Health probe ────────────────────────────────────────────────
# The API server adapter listens on 8642 by default and exposes
Expand Down
7 changes: 5 additions & 2 deletions agents/openclaw/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ install_method: npm # npm install -g openclaw@<version>
binary_path: /usr/local/bin/openclaw
version_command: "openclaw --version"
expected_version: "2026.4.9"
gateway_command: "openclaw gateway run"
gateway_argv:
- openclaw
- gateway
- run

# ── Health probe ────────────────────────────────────────────────
health_probe:
url: "http://localhost:18789/"
url: "http://localhost:18789/health"
port: 18789
timeout_seconds: 30

Expand Down
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,7 +26,16 @@ 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",
Expand All @@ -40,7 +49,17 @@ const TLS = [

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 @@ -61,3 +80,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.
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
17 changes: 17 additions & 0 deletions src/lib/agent-defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ describe("agent definitions", () => {

expect(openclaw.name).toBe("openclaw");
expect(openclaw.displayName).toBe("OpenClaw");
expect(openclaw.healthProbe.url).toBe("http://localhost:18789/health");
expect(openclaw.healthProbe.port).toBe(18789);
expect(openclaw.forwardPort).toBe(18789);
expect(openclaw.gatewayArgv).toEqual(["openclaw", "gateway", "run"]);
expect(openclaw.configPaths).toEqual({
immutableDir: "/sandbox/.openclaw",
writableDir: "/sandbox/.openclaw-data",
Expand All @@ -66,6 +68,7 @@ describe("agent definitions", () => {
format: "yaml",
});
expect(hermes.healthProbe.url).toBe("http://localhost:8642/health");
expect(hermes.gatewayArgv).toEqual(["hermes", "gateway", "run"]);
expect(hermes.messagingPlatforms).toEqual(["telegram", "discord", "slack"]);
});

Expand Down Expand Up @@ -119,4 +122,18 @@ describe("agent definitions", () => {

expect(() => loadAgent(agentName)).toThrow(/health_probe\.port/);
});

it("rejects shell-style gateway_command values and requires gateway_argv", () => {
const agentName = `invalid-gateway-command-${String(Date.now())}`;
writeTempAgentManifest(
agentName,
[
`name: ${agentName}`,
"display_name: Broken Gateway Command",
'gateway_command: "python -m agent-launcher; echo pwned"',
].join("\n"),
);

expect(() => loadAgent(agentName)).toThrow(/Use 'gateway_argv'/);
});
});
50 changes: 49 additions & 1 deletion src/lib/agent-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import path from "node:path";

import { ROOT } from "./runner";
import { DASHBOARD_PORT } from "./ports";
import { joinShellWords } from "./shell-quote";

export const AGENTS_DIR = path.join(ROOT, "agents");

Expand Down Expand Up @@ -58,6 +59,7 @@ export interface AgentDefinition {
version_command?: string;
expected_version?: string;
gateway_command?: string;
gateway_argv?: string[];
device_pairing?: boolean;
phone_home_hosts?: string[];
forward_ports?: number[];
Expand All @@ -71,6 +73,7 @@ export interface AgentDefinition {
readonly displayName: string;
readonly healthProbe: AgentHealthProbe;
readonly forwardPort: number;
readonly gatewayArgv: string[];
readonly dashboard: AgentDashboard;
readonly configPaths: AgentConfigPaths;
readonly stateDirs: string[];
Expand Down Expand Up @@ -141,6 +144,25 @@ function readStringArray(record: ManifestRecord, key: string): string[] | undefi
return value.filter((entry): entry is string => typeof entry === "string");
}

function readCommandArray(record: ManifestRecord, key: string): string[] | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (!Array.isArray(value)) {
throw new Error(`Agent manifest field '${key}' must be an array of command arguments`);
}

const args = value.map((entry, index) => {
if (typeof entry !== "string" || entry.trim() === "") {
throw new Error(
`Agent manifest field '${key}[${String(index)}]' must be a non-empty string command argument`,
);
}
return entry;
});

return args.length > 0 ? args : undefined;
}

function isValidPort(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
}
Expand Down Expand Up @@ -215,6 +237,25 @@ function readMessagingPlatforms(record: ManifestRecord): { supported?: string[]
return supported ? { supported } : {};
}

const SAFE_GATEWAY_COMMAND_TOKEN_RE = /^[A-Za-z0-9_@%+=:,./-]+$/;

function parseLegacyGatewayCommand(gatewayCommand: string): string[] {
const trimmed = gatewayCommand.trim();
if (!trimmed) {
throw new Error("Agent manifest field 'gateway_command' must not be empty");
}

const args = trimmed.split(/\s+/).filter(Boolean);
if (args.length === 0 || args.some((arg) => !SAFE_GATEWAY_COMMAND_TOKEN_RE.test(arg))) {
throw new Error(
"Agent manifest field 'gateway_command' only supports simple shell-free tokens. " +
"Use 'gateway_argv' for quoted or complex commands.",
);
}

return args;
}

function loadManifestRecord(manifestPath: string): ManifestRecord {
const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8"));
if (!isManifestRecord(parsed)) {
Expand Down Expand Up @@ -258,6 +299,8 @@ export function loadAgent(name: string): AgentDefinition {
const versionCommand = readString(raw, "version_command");
const expectedVersion = readString(raw, "expected_version");
const gatewayCommand = readString(raw, "gateway_command");
const gatewayArgv = readCommandArray(raw, "gateway_argv") ??
(gatewayCommand ? parseLegacyGatewayCommand(gatewayCommand) : undefined);
const forwardPorts = readPortArray(raw, "forward_ports");
const healthProbe = readHealthProbe(raw);
const config = readObject(raw, "config");
Expand All @@ -274,7 +317,8 @@ export function loadAgent(name: string): AgentDefinition {
binary_path: binaryPath,
version_command: versionCommand,
expected_version: expectedVersion,
gateway_command: gatewayCommand,
gateway_command: gatewayCommand ?? (gatewayArgv ? joinShellWords(gatewayArgv) : undefined),
gateway_argv: gatewayArgv,
device_pairing: readBoolean(raw, "device_pairing"),
phone_home_hosts: phoneHomeHosts,
forward_ports: forwardPorts,
Expand Down Expand Up @@ -304,6 +348,10 @@ export function loadAgent(name: string): AgentDefinition {
return forwardPorts?.[0] ?? DASHBOARD_PORT;
},

get gatewayArgv(): string[] {
return gatewayArgv ?? [binaryPath ?? "openclaw", "gateway", "run"];
},

get dashboard(): AgentDashboard {
const d = readObject(raw, "dashboard") ?? {};
const kind: AgentDashboardKind = d.kind === "api" ? "api" : "ui";
Expand Down
4 changes: 4 additions & 0 deletions src/lib/agent-onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import { printDashboardUi } from "../../dist/lib/agent-onboard";
import type { AgentDefinition } from "./agent-defs";

function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
const gatewayArgv = overrides.gatewayArgv ?? overrides.gateway_argv ?? ["agent", "gateway", "run"];

return {
name: "agent",
displayName: "Agent",
gateway_argv: gatewayArgv,
gatewayArgv,
healthProbe: { url: "http://127.0.0.1:19000/", port: 19000, timeout_seconds: 5 },
forwardPort: 19000,
dashboard: { kind: "ui", label: "UI", path: "/" },
Expand Down
Loading
Loading