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
7 changes: 5 additions & 2 deletions docs/reference/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ graph LR
The logical diagram above shows how components relate.
This section shows what actually runs where on the host.
NemoClaw's default Docker-driver topology does not place the sandbox in an embedded k3s cluster.
On Linux and Apple Silicon macOS, NemoClaw starts the OpenShell Docker-driver gateway and creates the sandbox as a Docker container.
The gateway normally runs as a host process; Linux hosts that need the gateway compatibility patch may run the same gateway binary inside a small container.
On Linux, NemoClaw configures and restarts the package-managed OpenShell gateway user service when it is installed, then creates the sandbox as a Docker container.
NemoClaw treats that service as authoritative only when `systemctl --user show openshell-gateway` reports a package/vendor unit path and an `openshell-gateway` `ExecStart`.
Per-user units, partial units, and user-manager or bus outages do not take over gateway ownership; NemoClaw falls back to the standalone gateway process used by earlier installs.
That compatibility fallback remains until supported upgrade paths no longer include pre-service OpenShell installs and the package-managed handoff has direct nightly coverage.
On Apple Silicon macOS, NemoClaw starts the OpenShell Docker-driver gateway and creates the sandbox as a Docker container.
In both Docker-driver modes, the sandbox is a Docker container, not a Kubernetes pod.
Legacy non-Docker-driver installs still use the k3s-based gateway path; the diagram below shows the standard Docker-driver topology.

Expand Down
8 changes: 4 additions & 4 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1395,7 +1395,7 @@ Earlier releases only stopped `openshell forward` processes, so those orphans ac

For Local Ollama setups, uninstall also stops matching Ollama auth proxy processes before deleting `~/.nemoclaw` state so stale proxy listeners do not block a later reinstall.

On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-driver gateway PID files, SQLite data, audit logs, and VM-driver state.
On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-driver gateway SQLite data, audit logs, VM-driver state, and standalone-fallback gateway PID files.

| Flag | Effect |
|---|---|
Expand Down Expand Up @@ -1604,9 +1604,9 @@ These flags toggle optional behaviors during onboarding; set them before running
| `NEMOCLAW_SANDBOX_GPU` | `auto`, `1`, or `0` | Controls sandbox GPU passthrough during onboarding. `auto` enables GPU passthrough when an NVIDIA GPU is detected, `1` requires GPU passthrough, and `0` forces CPU-only sandbox creation. |
| `NEMOCLAW_SANDBOX_GPU_DEVICE` | OpenShell GPU device selector | Selects the GPU device passed with `openshell sandbox create --gpu-device`. Requires explicit sandbox GPU enablement with `NEMOCLAW_SANDBOX_GPU=1` (or `--sandbox-gpu` for CLI-driven onboarding); otherwise onboarding rejects the selector instead of treating it as an implicit opt-in. |
| `NEMOCLAW_DOCKER_GPU_PATCH` | `0` to disable, anything else to keep the default | Controls the Linux Docker-driver GPU sandbox compatibility patch. Set to `0` only as an escape hatch when the patch fails and you need onboarding to continue without patching the GPU sandbox container. |
| `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by the Linux Docker-driver gateway. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary passed to the Linux Docker-driver gateway supervisor. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway pid file and SQLite state directory. Defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`. |
| `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway SQLite state directory and standalone-fallback PID file. Defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`. |
| `NEMOCLAW_AUTO_FIX_FIREWALL` | `1` to enable | Opts in to automatic UFW remediation when Linux Docker-driver sandbox containers cannot reach the host gateway after a proven TCP failure. NemoClaw runs `sudo -n` only, validates the narrow Docker bridge subnet → gateway IP:port rule before invoking UFW, re-probes after applying it, and otherwise falls back to the printed manual command. |
| `NEMOCLAW_WECHAT_QUIET` | `1` to enable | Silences the `[wechat]` diagnostic lines printed during the host-side WeChat QR login (poll status, IDC redirects, swallowed gateway errors), which are visible by default while the experimental WeChat path stabilizes; set `1` once the flow is reliable in your environment. |

Expand Down
8 changes: 4 additions & 4 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1611,7 +1611,7 @@ Earlier releases only stopped `openshell forward` processes, so those orphans ac

For Local Ollama setups, uninstall also stops matching Ollama auth proxy processes before deleting `~/.nemoclaw` state so stale proxy listeners do not block a later reinstall.

On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-driver gateway PID files, SQLite data, audit logs, and VM-driver state.
On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-driver gateway SQLite data, audit logs, VM-driver state, and standalone-fallback gateway PID files.

| Flag | Effect |
|---|---|
Expand Down Expand Up @@ -1893,9 +1893,9 @@ These flags toggle optional behaviors during onboarding; set them before running
| `NEMOCLAW_SANDBOX_GPU` | `auto`, `1`, or `0` | Controls sandbox GPU passthrough during onboarding. `auto` enables GPU passthrough when an NVIDIA GPU is detected, `1` requires GPU passthrough, and `0` forces CPU-only sandbox creation. |
| `NEMOCLAW_SANDBOX_GPU_DEVICE` | OpenShell GPU device selector | Selects the GPU device passed with `openshell sandbox create --gpu-device`. Requires explicit sandbox GPU enablement with `NEMOCLAW_SANDBOX_GPU=1` (or `--sandbox-gpu` for CLI-driven onboarding); otherwise onboarding rejects the selector instead of treating it as an implicit opt-in. |
| `NEMOCLAW_DOCKER_GPU_PATCH` | `0` to disable, anything else to keep the default | Controls the Linux Docker-driver GPU sandbox compatibility patch. Set to `0` only as an escape hatch when the patch fails and you need onboarding to continue without patching the GPU sandbox container. |
| `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by the Linux Docker-driver gateway. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary passed to the Linux Docker-driver gateway supervisor. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway pid file and SQLite state directory. Defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`. |
| `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. |
| `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway SQLite state directory and standalone-fallback PID file. Defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`. |
| `NEMOCLAW_AUTO_FIX_FIREWALL` | `1` to enable | Opts in to automatic UFW remediation when Linux Docker-driver sandbox containers cannot reach the host gateway after a proven TCP failure. NemoClaw runs `sudo -n` only, validates the narrow Docker bridge subnet → gateway IP:port rule before invoking UFW, re-probes after applying it, and otherwise falls back to the printed manual command. |
| `NEMOCLAW_WECHAT_QUIET` | `1` to enable | Silences the `[wechat]` diagnostic lines printed during the host-side WeChat QR login (poll status, IDC redirects, swallowed gateway errors), which are visible by default while the experimental WeChat path stabilizes; set `1` once the flow is reliable in your environment. |

Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2389,7 +2389,6 @@ async function startGatewayWithOptions(
}

async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridgeReachability = false }: { exitOnFailure?: boolean; skipSandboxBridgeReachability?: boolean } = {}): Promise<void> {
dockerDriverGatewayEnv.writeDockerGatewayDebEnvOverride(() => getDockerDriverGatewayEnv());
const gatewayBin = resolveOpenShellGatewayBinary();
const openshellVersionOutput = runCaptureOpenshell(["--version"], {
ignoreError: true,
Expand All @@ -2403,6 +2402,7 @@ async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridg
const identityGatewayBin = runtimeIdentity?.identityGatewayBin ?? gatewayBin;
const { verifySandboxBridgeGatewayReachableOrExit } =
require("./onboard/gateway-sandbox-reachability") as typeof import("./onboard/gateway-sandbox-reachability");
if (await dockerDriverGatewayEnv.startPackageManagedDockerDriverGatewayWithEnvOverride({ clearDockerDriverGatewayRuntimeFiles, exitOnFailure, gatewayEnv, gatewayName: GATEWAY_NAME, registerDockerDriverGatewayEndpoint, runCaptureOpenshell, skipSandboxBridgeReachability, verifySandboxBridgeGatewayReachableOrExit })) return;

const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true });
const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], {
Expand Down
75 changes: 72 additions & 3 deletions src/lib/onboard/docker-driver-gateway-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from "vitest";
import {
buildDockerDriverGatewayEnv,
buildDockerGatewayDebEnvFile,
startPackageManagedDockerDriverGatewayWithEnvOverride,
writeDockerGatewayDebEnvOverride,
} from "./docker-driver-gateway-env";

Expand Down Expand Up @@ -127,15 +128,16 @@ describe("writeDockerGatewayDebEnvOverride", () => {

const existsSpy = vi
.spyOn(fs, "existsSync")
.mockImplementation((candidate) => candidate === "/usr/bin/openshell-gateway");
.mockImplementation((candidate) => candidate === "/usr/lib/systemd/user/openshell-gateway.service");
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome);

try {
writeDockerGatewayDebEnvOverride(() => ({
const wrote = writeDockerGatewayDebEnvOverride(() => ({
OPENSHELL_BIND_ADDRESS: "127.0.0.1",
}));
}), { platform: "linux" });

const envFileContent = fs.readFileSync(envFile, "utf-8");
expect(wrote).toBe(true);
expect(fs.statSync(envDir).mode & 0o777).toBe(0o700);
expect(fs.statSync(envFile).mode & 0o777).toBe(0o600);
expect(envFileContent).toContain("KEEP_ME=1\n");
Expand All @@ -146,4 +148,71 @@ describe("writeDockerGatewayDebEnvOverride", () => {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});

it("does not write service env for standalone gateway binaries", () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-"));
const existsSpy = vi
.spyOn(fs, "existsSync")
.mockImplementation((candidate) => candidate === "/usr/bin/openshell-gateway");
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome);

try {
const wrote = writeDockerGatewayDebEnvOverride(
() => ({
OPENSHELL_BIND_ADDRESS: "127.0.0.1",
}),
{ platform: "linux" },
);

expect(wrote).toBe(false);
expect(fs.existsSync(path.join(tempHome, ".config", "openshell", "gateway.env"))).toBe(false);
} finally {
existsSpy.mockRestore();
homedirSpy.mockRestore();
fs.rmSync(tempHome, { recursive: true, force: true });
}
});

it("writes the service env only when package-managed startup prepares the service", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-"));
const envFile = path.join(tempHome, ".config", "openshell", "gateway.env");
const existsSpy = vi
.spyOn(fs, "existsSync")
.mockImplementation(
(candidate) => candidate === "/usr/lib/systemd/user/openshell-gateway.service",
);
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome);

try {
await expect(
startPackageManagedDockerDriverGatewayWithEnvOverride({
clearDockerDriverGatewayRuntimeFiles: vi.fn(),
exitOnFailure: false,
gatewayEnv: { OPENSHELL_BIND_ADDRESS: "127.0.0.1" },
gatewayName: "nemoclaw",
hasOpenShellGatewayUserService: () => true,
isDockerDriverGatewayReady: async () => true,
registerDockerDriverGatewayEndpoint: () => true,
runCaptureOpenshell: (args) =>
args[0] === "status"
? "Gateway: nemoclaw\nConnected"
: "Gateway: nemoclaw\nGateway endpoint: https://127.0.0.1:8080/",
skipSandboxBridgeReachability: false,
startOpenShellGatewayUserService: (opts) => {
opts?.prepareServiceEnv?.();
return { attempted: true, fallbackAllowed: false, started: true };
},
verifySandboxBridgeGatewayReachableOrExit: async () => undefined,
}),
).resolves.toBe(true);

expect(fs.readFileSync(envFile, "utf-8")).toContain(
"OPENSHELL_BIND_ADDRESS=127.0.0.1\n",
);
} finally {
existsSpy.mockRestore();
homedirSpy.mockRestore();
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
});
44 changes: 37 additions & 7 deletions src/lib/onboard/docker-driver-gateway-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ import {
getGatewayHttpsEndpoint,
} from "../core/gateway-address";
import { GATEWAY_PORT } from "../core/ports";
import {
hasOpenShellGatewayUserService,
startPackageManagedDockerDriverGateway,
type PackageManagedDockerDriverGatewayOptions,
} from "./docker-driver-gateway-service";

export { getGatewayHttpsEndpoint };
export { startPackageManagedDockerDriverGateway };

export const DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS = [
"OPENSHELL_DRIVERS",
Expand All @@ -41,6 +47,13 @@ export interface BuildDockerDriverGatewayEnvOptions {
resolveSandboxBin: () => string | null;
}

export type PackageManagedDockerDriverGatewayWithEnvOverrideOptions = Omit<
PackageManagedDockerDriverGatewayOptions,
"prepareOpenShellGatewayUserServiceEnv"
> & {
gatewayEnv: Record<string, string>;
};

export function getGatewayPortCheckOptions(): { host: string } {
return { host: GATEWAY_BIND_ADDRESS };
}
Expand Down Expand Up @@ -133,13 +146,9 @@ function readTextFileIfPresent(filePath: string): string {

export function writeDockerGatewayDebEnvOverride(
getOverride: () => Record<string, string>,
): void {
const servicePaths = [
"/usr/bin/openshell-gateway",
"/usr/lib/systemd/user/openshell-gateway.service",
"/lib/systemd/user/openshell-gateway.service",
];
if (!servicePaths.some((candidate) => fs.existsSync(candidate))) return;
opts: Parameters<typeof hasOpenShellGatewayUserService>[0] = {},
): boolean {
if (!hasOpenShellGatewayUserService(opts)) return false;
const override = getOverride();
const envDir = path.join(os.homedir(), ".config", "openshell");
const envFile = path.join(envDir, "gateway.env");
Expand All @@ -151,4 +160,25 @@ export function writeDockerGatewayDebEnvOverride(
mode: 0o600,
});
fs.chmodSync(envFile, 0o600);
return true;
}

export function writeDockerGatewayDebEnvOverrideOrThrow(
getOverride: () => Record<string, string>,
opts: Parameters<typeof hasOpenShellGatewayUserService>[0] = {},
): void {
if (!writeDockerGatewayDebEnvOverride(getOverride, opts)) {
throw new Error("OpenShell gateway user service env file is not available");
}
}

export function startPackageManagedDockerDriverGatewayWithEnvOverride({
gatewayEnv,
...options
}: PackageManagedDockerDriverGatewayWithEnvOverrideOptions): Promise<boolean> {
return startPackageManagedDockerDriverGateway({
...options,
prepareOpenShellGatewayUserServiceEnv: () =>
writeDockerGatewayDebEnvOverrideOrThrow(() => gatewayEnv),
});
}
Loading
Loading