Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1cc2d54
fix(uninstall): honour gateway state override
laitingsheng Aug 28, 2026
09b69c0
Merge remote-tracking branch 'origin/main' into fix/uninstall-gateway…
prekshivyas Aug 28, 2026
31b00bc
fix(uninstall): prove external gateway cleanup
prekshivyas Aug 28, 2026
34653db
Merge remote-tracking branch 'origin/main' into fix/uninstall-gateway…
prekshivyas Aug 28, 2026
45ad770
test(uninstall): prove external state preservation
prekshivyas Aug 28, 2026
0d853ba
docs(uninstall): quote gateway state paths
prekshivyas Aug 28, 2026
34c776e
fix(uninstall): validate gateway state ownership
prekshivyas Aug 28, 2026
b325bfe
Merge remote-tracking branch 'origin/main' into fix/uninstall-gateway…
prekshivyas Aug 28, 2026
32b04ab
fix(onboard): preserve trusted gateway template
prekshivyas Aug 28, 2026
6be0d16
fix(onboard): reserve gateway state before TLS
prekshivyas Aug 28, 2026
fad0fbc
fix(gateway): harden configured state lifecycle
prekshivyas Aug 28, 2026
31fbd63
Merge branch 'main' into fix/uninstall-gateway-state-override
prekshivyas Aug 28, 2026
b4ebdc9
fix(uninstall): retire unused gateway reservations
prekshivyas Aug 28, 2026
c8a63ed
Merge branch 'main' into fix/uninstall-gateway-state-override
prekshivyas Aug 28, 2026
0dd5e43
fix(uninstall): harden reserved gateway recovery
prekshivyas Aug 28, 2026
b5a4593
Merge remote-tracking branch 'origin/main' into fix/uninstall-gateway…
prekshivyas Aug 28, 2026
ed0cf37
fix(uninstall): complete stopped gateway recovery
prekshivyas Aug 28, 2026
87301ba
Merge origin/main into fix/uninstall-gateway-state-override
prekshivyas Aug 28, 2026
a440120
fix(uninstall): serialize gateway state reservation cleanup
prekshivyas Aug 29, 2026
3b92202
Merge origin/main into fix/uninstall-gateway-state-override
prekshivyas Aug 29, 2026
0364819
fix(uninstall): harden gateway state coordination
prekshivyas Aug 29, 2026
c317b3f
Merge origin/main into fix/uninstall-gateway-state-override
prekshivyas Aug 29, 2026
2b9537b
Merge branch 'main' into fix/uninstall-gateway-state-override
prekshivyas Aug 29, 2026
a9bd216
test(uninstall): use trusted gateway state fixtures
prekshivyas Aug 29, 2026
48e4c1c
Merge remote feature branch update into fix/uninstall-gateway-state-o…
prekshivyas Aug 29, 2026
0aa7ce9
fix(uninstall): close gateway cleanup review gaps
prekshivyas Aug 29, 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: 5 additions & 0 deletions ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@
"test": "pins the reviewed image runtime artifacts exactly",
"category": "security"
},
{
"file": "test/runtime/gateway/gateway-health-honest.test.ts",
"test": "reports a crashed Docker-driver gateway instead of reporting it healthy (#3111)",
"category": "compatibility"
},
{
"file": "test/runtime/policy/repro-5978-policy-denial-hint.test.ts",
"test": "prints only once when the file is sourced twice in one login shell",
Expand Down
29 changes: 29 additions & 0 deletions docs/manage-sandboxes/uninstall-nemoclaw.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,35 @@ The report includes lines like these:
Remove every gateway port: $$nemoclaw uninstall --all-gateway-ports
```

If onboarding used `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR`, pass its original resolved absolute directory when uninstalling that gateway.

<Warning>
Use the dedicated gateway state directory created for that port, not a shared or parent directory.
Onboarding rejects relative overrides, the shared NemoClaw state root or its parents, and existing nonempty directories without valid NemoClaw-managed gateway configuration.
Let onboarding create the directory when possible. Every existing ancestor, from its containing directory to the filesystem root, must be a real directory owned by the current user or root and must not be group- or world-writable. If the state directory already exists, it must be an owner-controlled, non-symbolic-link directory with mode `0700`.
If onboarding stops immediately after reserving that directory, uninstall removes the marker-only reservation only while the selected gateway port is free. Gateway configuration, a runtime marker, or a PID file moves cleanup to the managed-gateway checks instead. A port-bound marked gateway with valid generated configuration can be retired after the port is free and a complete process scan proves that no live process claims its state; a listener or unproven process preserves the directory. Inspect and stop the listener or matching gateway process, then rerun uninstall after the port is free.
Onboarding holds an exclusive lifecycle lock from reservation through gateway initialization. If uninstall reports that onboarding owns the state directory, wait for onboarding to finish and rerun uninstall; the reservation is preserved.
After successful managed cleanup, uninstall recursively removes that directory and all contents.
Portable cleanup and `--keep-openshell` preserve it.
</Warning>

```bash
NEMOCLAW_GATEWAY_PORT=<port> \
NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="<original-absolute-path>" \
$$nemoclaw uninstall
```

Onboarding records port-bound state-root ownership; uninstall validates that ownership plus generated configuration, sandbox namespace, PID file, runtime marker, and live process identity before cleanup.
Valid gateways created before the state-root marker existed retain the same recovery path through their owner-private generated configuration and live process proof; stopped-gateway recovery requires the port-bound marker.
The whole-host sweep applies an ambient state-directory override only to the port selected by `NEMOCLAW_GATEWAY_PORT`; other port passes use their default per-port directories.
If another port with a custom directory fails during the sweep, rerun:

```bash
NEMOCLAW_GATEWAY_PORT=<failed-port> \
NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="<original-absolute-path>" \
$$nemoclaw uninstall
```

<Warning>
The sweep applies the uninstall plan to every discovered gateway port.
When it finds more than one port, review the port list before you confirm because `--yes` skips this confirmation.
Expand Down
25 changes: 22 additions & 3 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4706,8 +4706,9 @@ That directory contains NemoClaw-owned Docker-driver gateway configuration and S
Uninstall preserves it when the managed or externally supervised gateway process remains because that process depends on the state.
When another gateway-port environment remains, uninstall removes only the selected gateway port's subdirectory of that directory and keeps the other ports' subdirectories.
Run `$$nemoclaw uninstall --all-gateway-ports` to remove every gateway port on the host.
Keep a declared external gateway state directory outside that NemoClaw-owned path.
Uninstall does not otherwise target the declared external directory.
Keep an externally supervised lifecycle authority's declared `stateDir` outside that NemoClaw-owned path.
Uninstall preserves that externally supervised directory.
This differs from a managed `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` override, which successful managed cleanup removes unless `--keep-openshell` or portable cleanup applies.

| Flag | Effect |
|---|---|
Expand All @@ -4734,6 +4735,21 @@ $$nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--destroy-use
`NEMOCLAW_GATEWAY_PORT` selects the gateway instance and state root to uninstall.
Port `8080` selects `nemoclaw` and the shared `~/.nemoclaw/` root; a non-default port selects `nemoclaw-<port>` and `~/.nemoclaw/gateways/<port>/`.
For example, `NEMOCLAW_GATEWAY_PORT=9123 $$nemoclaw uninstall` selects `nemoclaw-9123`.
If onboarding also set `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR`, pass its original resolved absolute directory to uninstall so configuration, namespace, process, and state cleanup target that exact directory.
Use the dedicated gateway state directory created for that port, not a shared or parent directory.
Onboarding rejects relative overrides, the shared NemoClaw state root or its parents, and existing nonempty directories without valid NemoClaw-managed gateway configuration.
Let onboarding create the directory when possible. Every existing ancestor, from its containing directory to the filesystem root, must be a real directory owned by the current user or root and must not be group- or world-writable. If the state directory already exists, it must be an owner-controlled, non-symbolic-link directory with mode `0700`.
If onboarding stops immediately after reserving that directory, uninstall removes the marker-only reservation only while the selected gateway port is free. Gateway configuration, a runtime marker, or a PID file moves cleanup to the managed-gateway checks instead. A port-bound marked gateway with valid generated configuration can be retired after the port is free and a complete process scan proves that no live process claims its state; a listener or unproven process preserves the directory. Inspect and stop the listener or matching gateway process, then rerun uninstall after the port is free.
Onboarding holds an exclusive lifecycle lock from reservation through gateway initialization. If uninstall reports that onboarding owns the state directory, wait for onboarding to finish and rerun uninstall; the reservation is preserved.
Successful managed cleanup recursively removes that directory and all contents; portable cleanup and `--keep-openshell` preserve it.
Before cleanup, uninstall validates port-bound state-root ownership and exact live process identity. A marked, stopped gateway can instead use a free port plus a complete process-absence scan; pre-marker gateways can use their owner-private generated configuration as the legacy ownership proof only with live process identity.

```bash
NEMOCLAW_GATEWAY_PORT=<port> \
NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="<original-absolute-path>" \
$$nemoclaw uninstall
```

The compatibility `--gateway` flag cannot select another instance: when present, it must match the name derived from `NEMOCLAW_GATEWAY_PORT`, or uninstall exits before cleanup.
Default-port uninstall removes NemoClaw-managed entries in `openshell/gateway.env`.
For a NemoClaw-managed authority, it also removes only NemoClaw's marked Linux gateway unit.
Expand Down Expand Up @@ -4808,6 +4824,9 @@ The sweep enumerates the default state root and the non-default roots under `~/.
When the sweep finds more than one port, it confirms once against the resulting port list, then uninstalls each other port before the port `NEMOCLAW_GATEWAY_PORT` selects.
When it finds only the selected port, it uses the standard uninstall confirmation without a port list and runs that port once.
Each port runs as its own uninstall so that every port-scoped value, including the state root, registry file, gateway name, and Docker resource names, resolves from that port rather than from the calling environment.
An ambient `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` applies only to the currently selected port, which runs last.
Child passes for other ports use their default per-port gateway state directories.
If another port with a custom directory fails, rerun `NEMOCLAW_GATEWAY_PORT=<failed-port> NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="<original-absolute-path>" $$nemoclaw uninstall`.
Outside portable cleanup, the selected port runs last so its pass can remove the shared host resources once no other environment remains.
`--delete-models`, `--destroy-user-data`, and `--keep-openshell` apply to every port, subject to the portable preservation contract; `--gateway` remains a check against the selected port only.
A failure to enumerate the gateway state roots safely stops the sweep before any port uninstall begins.
Expand Down Expand Up @@ -5339,7 +5358,7 @@ Set them before running `$$nemoclaw onboard`.
| `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH` | `1` to enable; disabled by default | This setting explicitly opts into the Linux gateway compatibility container for an older host ABI or a diagnostic run; use it only on a trusted local host because it uses host networking and mounts the Docker socket read-only even though the socket still exposes the privileged Docker API; prefer OpenShell 0.0.106's directly supported glibc 2.39+ path; see [Gateway Compatibility Container](/user-guide/openclaw/security/security-controls/gateway-authentication-controls#gateway-compatibility-container) for the container boundary and removal conditions. |
| `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by Linux Docker-driver startup. For the default port, the installer accepts the binary under an absolute `XDG_BIN_HOME` when set, otherwise `~/.local/bin/openshell-gateway`; it also accepts `/usr/local/bin/openshell-gateway` or `/usr/bin/openshell-gateway`. Another path fails service staging. The macOS Homebrew service uses the formula's binary. 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 Linux Docker-driver startup. The macOS Homebrew service uses the formula's driver layout. 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_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway SQLite state directory and standalone-fallback PID file. Use a dedicated absolute directory; onboarding rejects relative paths, the shared NemoClaw state root or its parents, and existing nonempty directories without valid managed gateway configuration. Let onboarding create it when possible; its containing directory must be owned by the current user or root without group or world write access, and a pre-created state directory must be owner-controlled, non-symbolic, and mode `0700`. Pass the original resolved absolute value with `NEMOCLAW_GATEWAY_PORT` when uninstalling that gateway. Port `8080` defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`; non-default ports default to `~/.local/state/nemoclaw/openshell-docker-gateway-<port>`. |
| `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. |
<AgentOnly variant="openclaw,hermes">
| `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
18 changes: 6 additions & 12 deletions src/lib/actions/sandbox/destroy-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { DASHBOARD_PORT } from "../../core/ports";
import { stopOpenShellGatewayUserService } from "../../onboard/docker-driver-gateway-service";
import {
resolveGatewayPortFromName,
resolveGatewayStateDirName,
resolveGatewayStateDirForPort,
} from "../../onboard/gateway-binding";
import { type GatewayOwner, isExternallySupervised } from "../../onboard/gateway-ownership";
import {
Expand Down Expand Up @@ -54,19 +54,13 @@ export interface CleanupGatewayDeps {
function resolvePerGatewayState(gatewayName: string): { port: number; stateDir: string } | null {
const port = resolveGatewayPortFromName(gatewayName);
if (port === null) return null;
const configured = process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR;
if (configured && configured.trim()) {
return { port, stateDir: path.resolve(configured.trim()) };
}
return {
port,
stateDir: path.join(
os.homedir(),
".local",
"state",
"nemoclaw",
resolveGatewayStateDirName(port),
),
stateDir: resolveGatewayStateDirForPort({
configured: process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR,
home: os.homedir(),
port,
}),
};
}

Expand Down
31 changes: 31 additions & 0 deletions src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,37 @@ describe("OpenShell VM DNS monkeypatch", () => {
).toContain('echo "nameserver ${GVPROXY_GATEWAY_IP}" > /etc/resolv.conf');
});

it("uses the sandbox gateway port when resolving VM state", () => {
const homeDir = makeTempDir();
const stateDir = path.join(
homeDir,
".local",
"state",
"nemoclaw",
"openshell-docker-gateway-9123",
);
const rootfs = sandboxRootfs(stateDir);
writeRootfsFiles(rootfs, "nameserver 8.8.8.8\n");

const result = applyOpenShellVmDnsMonkeypatch(
"demo",
{ gatewayPort: 9123, openshellDriver: "vm" },
{
capture: () => ({ status: 0, output: "Id: abc\n" }),
env: {},
homeDir,
platform: "darwin",
},
);

expect(result).toMatchObject({
attempted: true,
changed: true,
ok: true,
rootfs: fs.realpathSync.native(rootfs),
});
});

it("stops VM DNS mutation when authority changes between file writes (#9833)", () => {
const stateDir = makeTempDir();
const rootfs = sandboxRootfs(stateDir);
Expand Down
17 changes: 11 additions & 6 deletions src/lib/actions/sandbox/vm-dns-monkeypatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import path from "node:path";

import { type CaptureOpenshellResult, stripAnsi } from "../../adapters/openshell/client";
import { captureOpenshell } from "../../adapters/openshell/runtime";
import { resolveDockerDriverGatewayStateDir } from "../../onboard/host-gateway-process";
import type { SandboxEntry } from "../../state/registry";

const GVPROXY_DNS = "192.168.127.1";
Expand Down Expand Up @@ -45,10 +46,12 @@ export function shouldApplyVmDnsMonkeypatch(
return platform === "darwin" || env.NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH === "1";
}

function dockerDriverGatewayStateDir(env: NodeJS.ProcessEnv, homeDir: string): string {
const configured = env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR;
if (configured && configured.trim()) return path.resolve(configured.trim());
return path.join(homeDir, ".local", "state", "nemoclaw", "openshell-docker-gateway");
function dockerDriverGatewayStateDir(
env: NodeJS.ProcessEnv,
homeDir: string,
gatewayPort?: number | null,
): string {
return resolveDockerDriverGatewayStateDir(env, homeDir, gatewayPort ?? undefined);
}

export function parseSandboxIdFromGetOutput(output: string): string | null {
Expand Down Expand Up @@ -271,7 +274,7 @@ function buildGuestInitPatch(

export function applyOpenShellVmDnsMonkeypatch(
sandboxName: string,
entry: Pick<SandboxEntry, "openshellDriver"> | null | undefined,
entry: Pick<SandboxEntry, "gatewayPort" | "openshellDriver"> | null | undefined,
deps: {
capture?: CaptureFn;
env?: NodeJS.ProcessEnv;
Expand All @@ -298,7 +301,9 @@ export function applyOpenShellVmDnsMonkeypatch(
return fail("could not resolve OpenShell sandbox id");
}

const stateDir = deps.stateDir ?? dockerDriverGatewayStateDir(env, deps.homeDir ?? os.homedir());
const stateDir =
deps.stateDir ??
dockerDriverGatewayStateDir(env, deps.homeDir ?? os.homedir(), entry?.gatewayPort);
const stateDirPath = path.resolve(stateDir);
const stateDirReal = realpathIfPresent(stateDirPath);
if (!stateDirReal) {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/actions/uninstall/all-gateway-ports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,17 +327,20 @@ describe("uninstall across every gateway port (#7791)", () => {
expect(error).toHaveBeenCalledWith(expect.stringContaining("host fence release failed"));
});

it("binds each child pass to its own gateway port and drops the sweep request", () => {
it("binds each child pass to its own gateway port and drops selected-only state", () => {
const env = {
HOME: "/home/tester",
NEMOCLAW_GATEWAY_PORT: "8080",
NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: "/srv/nemoclaw/selected-gateway",
[ALL_GATEWAY_PORTS_ENV]: "1",
} as NodeJS.ProcessEnv;

const childEnv = uninstallChildEnv(env, 9000);

expect(childEnv.NEMOCLAW_GATEWAY_PORT).toBe("9000");
expect(childEnv.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR).toBeUndefined();
expect(childEnv[ALL_GATEWAY_PORTS_ENV]).toBeUndefined();
expect(env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR).toBe("/srv/nemoclaw/selected-gateway");
});

it.each([
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/uninstall/all-gateway-ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function uninstallChildArgs(options: UninstallRunOptions): string[] {
export function uninstallChildEnv(env: NodeJS.ProcessEnv, port: number): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = { ...env, NEMOCLAW_GATEWAY_PORT: String(port) };
delete next[ALL_GATEWAY_PORTS_ENV];
if (port !== GATEWAY_PORT) delete next.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR;
return next;
}

Expand Down
10 changes: 9 additions & 1 deletion src/lib/actions/uninstall/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe("uninstall plan actions", () => {

it("builds a host uninstall plan with shim classification and env-derived paths", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-plan-"));
const gatewayStateDir = path.join(tmp, "gateway-state");
const shimDir = path.join(tmp, ".local", "bin");
const shim = path.join(shimDir, "nemoclaw");
fs.mkdirSync(shimDir, { recursive: true });
Expand All @@ -41,7 +42,11 @@ describe("uninstall plan actions", () => {
try {
const plan = buildHostUninstallPlan({
deleteModels: false,
env: { HOME: tmp, TMPDIR: path.join(tmp, "tmp") },
env: {
HOME: tmp,
NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: gatewayStateDir,
TMPDIR: path.join(tmp, "tmp"),
},
keepOpenShell: false,
});
const actions = flattenUninstallPlan(plan);
Expand All @@ -54,6 +59,9 @@ describe("uninstall plan actions", () => {
expect(actions).toEqual(
expect.arrayContaining([{ kind: "delete-path", path: path.join(tmp, ".nemoclaw") }]),
);
expect(actions).toEqual(
expect.arrayContaining([{ kind: "delete-path", path: gatewayStateDir }]),
);
expect(actions).toEqual(
expect.arrayContaining([
{ kind: "delete-runtime-glob", pattern: path.join(tmp, "tmp", "nemoclaw-create-*.log") },
Expand Down
Loading
Loading