Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions src/commands/credentials/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default class CredentialsResetCommand extends NemoClawCommand {
provider: Args.string({
name: "PROVIDER",
description: "OpenShell provider name",
ignoreStdin: true,
required: true,
}),
};
Expand Down
16 changes: 14 additions & 2 deletions src/lib/cli/oclif-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,34 @@ describe("runOclifArgv", () => {
});

describe("runOclifCommandById", () => {
let originalArgv: string[];

beforeEach(() => {
executeMock.mockReset();
runCommandMock.mockReset();
loadMock.mockReset();
loadMock.mockResolvedValue(makeConfig());
originalArgv = process.argv;
process.argv = ["/usr/bin/node", "/repo/bin/nemoclaw.js", "alpha", "status"];
process.exitCode = undefined;
});

afterEach(() => {
vi.restoreAllMocks();
process.argv = originalArgv;
process.exitCode = undefined;
});

it("loads the oclif config, applies branded bin metadata, and runs the command", async () => {
const config = makeConfig();
loadMock.mockResolvedValue(config);
runCommandMock.mockResolvedValue(undefined);
runCommandMock.mockImplementation(async () => {
expect(process.argv).toEqual(["/usr/bin/node", "/repo/bin/nemoclaw.js", "list", "--json"]);
});

await runOclifCommandById("list", ["--json"], { rootDir: "/repo" });

expect(process.argv).toEqual(["/usr/bin/node", "/repo/bin/nemoclaw.js", "alpha", "status"]);
expect(loadMock).toHaveBeenCalledWith("/repo");
expect(runCommandMock).toHaveBeenCalledWith("list", ["--json"]);
expect(config.bin).toBe("nemoclaw");
Expand Down Expand Up @@ -228,9 +236,13 @@ describe("runOclifCommandById", () => {

it("rethrows non-parse command failures", async () => {
const error = new Error("boom");
runCommandMock.mockRejectedValue(error);
runCommandMock.mockImplementation(async () => {
expect(process.argv).toEqual(["/usr/bin/node", "/repo/bin/nemoclaw.js", "list"]);
throw error;
});

await expect(runOclifCommandById("list", [], { rootDir: "/repo" })).rejects.toBe(error);
expect(process.argv).toEqual(["/usr/bin/node", "/repo/bin/nemoclaw.js", "alpha", "status"]);
});

it("exits cleanly without rethrowing when oclif Command.exit(code) bubbles up", async () => {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/cli/oclif-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export async function runOclifCommandById(
applyBrandedBin(config);
const errorLine = opts.error ?? console.error;
const exit = opts.exit ?? ((code: number) => process.exit(code));
const originalArgv = process.argv;
const nativeArgv = [...commandId.split(":"), ...args];
process.argv = [originalArgv[0] ?? process.execPath, originalArgv[1] ?? CLI_NAME, ...nativeArgv];

try {
await config.runCommand(commandId, args);
Expand Down Expand Up @@ -122,6 +125,8 @@ export async function runOclifCommandById(
}

throw error;
} finally {
process.argv = originalArgv;
}
}

Expand Down
88 changes: 88 additions & 0 deletions src/lib/onboard/docker-driver-gateway-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,92 @@ describe("docker-driver gateway runtime helpers", () => {
"/opt/openshell/openshell-gateway",
);
});

it("does not match process args that only contain openshell-gateway as a suffix", () => {
const pid = 12_345;
const { helpers, runCapture } = makeHelpers({
runCapture: vi.fn(() => "node /tmp/not-openshell-gateway\n"),
});
const originalExistsSync = fs.existsSync;
vi.spyOn(fs, "existsSync").mockImplementation(((candidate) => {
if (String(candidate) === `/proc/${pid}/cmdline`) return false;
return originalExistsSync(candidate);
}) as typeof fs.existsSync);

expect(
helpers.isDockerDriverGatewayProcess(pid, "/opt/openshell/openshell-gateway", {
requireDockerDriverEnv: false,
}),
).toBe(false);
expect(runCapture).toHaveBeenCalledWith(["ps", "-p", String(pid), "-o", "args="], {
ignoreError: true,
});
});

it("does not match process args that contain openshell-gateway as a later argument", () => {
const pid = 12_346;
const { helpers, runCapture } = makeHelpers({
runCapture: vi.fn(() => "node app.js /tmp/openshell-gateway\n"),
});
const originalExistsSync = fs.existsSync;
vi.spyOn(fs, "existsSync").mockImplementation(((candidate) => {
if (String(candidate) === `/proc/${pid}/cmdline`) return false;
return originalExistsSync(candidate);
}) as typeof fs.existsSync);

expect(
helpers.isDockerDriverGatewayProcess(pid, "/opt/openshell/openshell-gateway", {
requireDockerDriverEnv: false,
}),
).toBe(false);
expect(runCapture).toHaveBeenCalledWith(["ps", "-p", String(pid), "-o", "args="], {
ignoreError: true,
});
});

it("does not match process args that contain the exact gateway path as a later argument", () => {
const pid = 12_347;
const gatewayBin = "/opt/openshell/openshell-gateway";
const { helpers, runCapture } = makeHelpers({
runCapture: vi.fn(() => `python worker.py '${gatewayBin}'\n`),
});
const originalExistsSync = fs.existsSync;
vi.spyOn(fs, "existsSync").mockImplementation(((candidate) => {
if (String(candidate) === `/proc/${pid}/cmdline`) return false;
return originalExistsSync(candidate);
}) as typeof fs.existsSync);

expect(
helpers.isDockerDriverGatewayProcess(pid, gatewayBin, {
requireDockerDriverEnv: false,
}),
).toBe(false);
expect(runCapture).toHaveBeenCalledWith(["ps", "-p", String(pid), "-o", "args="], {
ignoreError: true,
});
});

it("matches the docker compatibility gateway parent process", () => {
const pid = 12_348;
const { helpers, runCapture } = makeHelpers({
runCapture: vi.fn(
() =>
"docker run --rm --name nemoclaw-openshell-gateway image /opt/nemoclaw/openshell-gateway\n",
),
});
const originalExistsSync = fs.existsSync;
vi.spyOn(fs, "existsSync").mockImplementation(((candidate) => {
if (String(candidate) === `/proc/${pid}/cmdline`) return false;
return originalExistsSync(candidate);
}) as typeof fs.existsSync);

expect(
helpers.isDockerDriverGatewayProcess(pid, "/opt/openshell/openshell-gateway", {
requireDockerDriverEnv: false,
}),
).toBe(true);
expect(runCapture).toHaveBeenCalledWith(["ps", "-p", String(pid), "-o", "args="], {
ignoreError: true,
});
});
});
18 changes: 15 additions & 3 deletions src/lib/onboard/docker-driver-gateway-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { resolveOpenshell } from "../adapters/openshell/resolve";
import { isErrnoException } from "../core/errno";
import * as dockerDriverGatewayRuntimeMarker from "./docker-driver-gateway-runtime-marker";
import { isLinuxDockerDriverGatewayEnabled } from "./docker-driver-platform";
import {
gatewayProcessCmdlineMatches,
OPENSHELL_GATEWAY_PROCESS_NAMES,
} from "./gateway-process-identity";
import * as gatewayBinding from "./gateway-binding";
import type { PortProbeResult } from "./preflight";
import * as vmDriverProcess from "./vm-driver-process";
Expand Down Expand Up @@ -236,6 +240,16 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa
}
}

function processIdentityMatchesGatewayBinary(
identity: string,
gatewayBin?: string | null,
): boolean {
return gatewayProcessCmdlineMatches(identity, gatewayBin, {
processNames: OPENSHELL_GATEWAY_PROCESS_NAMES,
resolveExecutablePath: normalizeGatewayExecutablePath,
});
}

function shouldRequireDockerDriverEnv(platform: NodeJS.Platform = process.platform): boolean {
return platform === "linux";
}
Expand Down Expand Up @@ -341,9 +355,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa
identity = captureProcessArgs(pid);
}
if (!identity) return false;
const matchesGatewayBinary =
identity.includes("openshell-gateway") ||
(typeof gatewayBin === "string" && gatewayBin.length > 0 && identity.includes(gatewayBin));
const matchesGatewayBinary = processIdentityMatchesGatewayBinary(identity, gatewayBin);
if (!matchesGatewayBinary) return false;
if (opts.requireDockerDriverEnv && !hasDockerDriverGatewayEnv(pid)) return false;
return true;
Expand Down
68 changes: 68 additions & 0 deletions src/lib/onboard/gateway-process-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import path from "node:path";

export const HOST_GATEWAY_PROCESS_NAMES = new Set(["openshell-gateway", "openclaw-gateway"]);
export const OPENSHELL_GATEWAY_PROCESS_NAMES = new Set(["openshell-gateway"]);

// Container runtimes that can host the compatibility gateway. Limited to the
// ones `docker-driver-gateway-launch` actually invokes so a random user
// command is never mistaken for the parent process of a compat-mode gateway.
export const DOCKER_DRIVER_GATEWAY_CONTAINER_RUNTIME_NAMES = new Set(["docker"]);

// Mount path used by docker-driver-gateway-launch when glibc compat forces the
// gateway to run inside a Docker compatibility container. The parent PID we
// record is the host-side `docker run` process whose argv0 is `docker`, so we
// also accept cmdlines whose argv0 is a known container runtime AND that
// include this mount path as a distinct argv token.
export const DOCKER_DRIVER_GATEWAY_COMPAT_MOUNT_PATH = "/opt/nemoclaw/openshell-gateway";

type ResolveExecutablePath = (value: string) => string | null;

export function cleanGatewayProcessToken(token: string): string {
return token.replace(/^['"]|['"]$/g, "").replace(/ \(deleted\)$/, "");
}

export function gatewayProcessCmdlineMatches(
cmdline: string,
gatewayBin: string | null | undefined,
opts: {
processNames?: ReadonlySet<string>;
resolveExecutablePath?: ResolveExecutablePath;
} = {},
): boolean {
const tokens = cmdline.trim().split(/\s+/).filter(Boolean).map(cleanGatewayProcessToken);
const argv0 = tokens[0] ?? "";
if (!argv0) return false;

const processNames = opts.processNames ?? HOST_GATEWAY_PROCESS_NAMES;
const base = path.basename(argv0);
if (processNames.has(base)) return true;

if (typeof gatewayBin === "string" && gatewayBin.length > 0) {
const normalize = opts.resolveExecutablePath ?? ((value: string) => path.resolve(value));
const actual = normalize(argv0);
const expected = normalize(gatewayBin);
if (actual && expected && actual === expected) return true;
}

// Docker compatibility mode: argv0 basename must be a known container
// runtime AND the mount path appears as a separate argv token. Substring
// matching inside random tokens would over-match, so require both.
if (
DOCKER_DRIVER_GATEWAY_CONTAINER_RUNTIME_NAMES.has(base) &&
tokens.slice(1).includes(DOCKER_DRIVER_GATEWAY_COMPAT_MOUNT_PATH)
) {
return true;
}

return false;
}

export function hostGatewayCmdlineMatches(
cmdline: string,
gatewayBin: string | null | undefined,
): boolean {
return gatewayProcessCmdlineMatches(cmdline, gatewayBin);
}
42 changes: 2 additions & 40 deletions src/lib/onboard/host-gateway-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from "node:path";

import { sleepMs } from "../core/wait";
import { clearDockerDriverGatewayRuntimeMarker } from "./docker-driver-gateway-runtime-marker";
import { hostGatewayCmdlineMatches as sharedHostGatewayCmdlineMatches } from "./gateway-process-identity";

export interface RunResult {
status: number | null;
Expand Down Expand Up @@ -44,18 +45,6 @@ export interface StopHostGatewayResult {
sudoRemediationPids: number[];
}

const HOST_GATEWAY_PROCESS_NAMES = new Set(["openshell-gateway", "openclaw-gateway"]);
// Container runtimes that can host the compatibility gateway. Limited to the
// ones `docker-driver-gateway-launch` actually invokes so a random user
// command (e.g. `vim /opt/nemoclaw/openshell-gateway`) is never mistaken for
// the parent process of a compat-mode gateway.
const DOCKER_DRIVER_GATEWAY_CONTAINER_RUNTIME_NAMES = new Set(["docker"]);
// Mount path used by docker-driver-gateway-launch when glibc compat forces the
// gateway to run inside a Docker compatibility container. The parent PID we
// record is the host-side `docker run` process whose argv0 is `docker`, so we
// also accept cmdlines whose argv0 is a known container runtime AND that
// include this mount path as a distinct argv token.
const DOCKER_DRIVER_GATEWAY_MOUNT_PATH = "/opt/nemoclaw/openshell-gateway";
// pgrep regex anchors on the original openshell-gateway launch shapes. We do
// not extend it to also match the Docker compat parent because pgrep -f only
// sees the cmdline string, not argv0; without an argv0 gate the compat mount
Expand Down Expand Up @@ -165,34 +154,7 @@ function pidOwner(pid: number, deps: HostGatewayProcessDeps): string | null {
return result.stdout.trim() || null;
}

export function hostGatewayCmdlineMatches(
cmdline: string,
gatewayBin: string | null | undefined,
): boolean {
const tokens = cmdline.trim().split(/\s+/);
const argv0 = tokens[0] ?? "";
if (!argv0) return false;
const base = path.basename(argv0);
if (HOST_GATEWAY_PROCESS_NAMES.has(base)) return true;
if (
typeof gatewayBin === "string" &&
gatewayBin.length > 0 &&
path.resolve(argv0) === path.resolve(gatewayBin)
) {
return true;
}
// Docker compatibility mode: argv0 basename must be a known container
// runtime AND the mount path appears as a separate argv token. Substring
// matching inside random tokens (e.g. a log message or `vim
// /opt/nemoclaw/openshell-gateway`) would over-match, so require both.
if (
DOCKER_DRIVER_GATEWAY_CONTAINER_RUNTIME_NAMES.has(base) &&
tokens.includes(DOCKER_DRIVER_GATEWAY_MOUNT_PATH)
) {
return true;
}
return false;
}
export const hostGatewayCmdlineMatches = sharedHostGatewayCmdlineMatches;

function waitForExit(
pid: number,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/sandbox/policy-command-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { dryRunFlag, forceFlag, yesFlag } from "../cli/common-flags";
const sandboxNameArg = Args.string({
name: "sandbox",
description: "Sandbox name",
ignoreStdin: true,
required: true,
});
const presetArg = Args.string({
name: "preset",
description: "Policy preset name",
ignoreStdin: true,
required: false,
});

Expand Down
10 changes: 9 additions & 1 deletion test/cli/credentials-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

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

import { run } from "./helpers";
import { run, runWithInput } from "./helpers";

describe("credentials CLI dispatch", () => {
it("credentials help exits 0 and shows credential subcommands", () => {
Expand All @@ -28,4 +28,12 @@ describe("credentials CLI dispatch", () => {
expect(r.out).toContain("Missing 1 required arg");
expect(r.out).toContain("provider OpenShell provider name");
});

it("credentials reset without provider ignores poisoned stdin", () => {
const r = runWithInput("credentials reset --yes", "/usr/bin/dmesg\n3");
expect(r.code).toBe(2);
expect(r.out).toContain("Missing 1 required arg");
expect(r.out).toContain("provider OpenShell provider name");
expect(r.out).not.toContain("Could not remove provider");
});
});
Loading
Loading