Skip to content
8 changes: 5 additions & 3 deletions docs/reference/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,14 @@ Onboarding validates and reuses a healthy selected service.
It enables or restarts the service when startup or verified runtime drift requires it, then checks gateway health.
The marked service generates the local OpenShell mTLS bundle and reads `$XDG_CONFIG_HOME/openshell/gateway.env`, or `~/.config/openshell/gateway.env` when `XDG_CONFIG_HOME` is not absolute.
NemoClaw preserves unrelated environment entries, accepts `DOCKER_HOST` only for an absolute local `unix://` socket, and refuses foreign or symlinked managed files.
The standalone Linux process is used only when the systemd user manager is unavailable; after a service is selected, startup or health failure stops onboarding.
If the selected systemd user service fails inspection, startup, or its health check, NemoClaw prints its `journalctl` log command and attempts the standalone fallback.

On Apple Silicon macOS, Homebrew makes the official OpenShell formula authoritative.
The installer stages the formula and onboarding starts its `openshell` service.
When Homebrew is present, a missing formula, a formula from another tap, a service-start failure, or a health failure stops onboarding.
Only a host without Homebrew uses the standalone macOS gateway fallback.
If the service fails inspection, startup, or its health check, NemoClaw prints the formula log command and attempts the standalone fallback.
A host without Homebrew, or with no installed OpenShell formula, also uses the standalone macOS gateway.
On both platforms, standalone startup requires exclusive ownership of the gateway port.
Managed-service trust failures and unsafe environment configuration remain hard failures.

NemoClaw-managed gateways on custom ports remain detached and separate from the default service.
An externally supervised gateway can use any matching configured port; its declared supervisor retains lifecycle authority.
Expand Down
32 changes: 30 additions & 2 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ It rejects remote endpoints and relative socket paths before service startup.
### Onboarding reports `invalid_docker_host`

The `invalid_docker_host` advisory means that `DOCKER_HOST` is not an absolute local `unix://` socket path that NemoClaw can write to the managed OpenShell gateway service environment.
NemoClaw does not use the standalone gateway fallback when this validation fails.
Remove the override to use Docker's default local socket:

```bash
Expand Down Expand Up @@ -931,8 +932,14 @@ Follow these steps to reconnect.
brew services restart openshell
```

If Homebrew is present but `openshell` is missing or comes from another tap, rerun the NemoClaw installer.
Onboarding does not use the standalone fallback while Homebrew is present.
During onboarding, NemoClaw uses the standalone fallback when the `openshell` formula is missing.
If the installed service fails inspection, startup, or its health check, NemoClaw prints this log command:

```bash
tail -n 200 "$(brew --prefix)/var/log/openshell/openshell-gateway.out.log" "$(brew --prefix)/var/log/openshell/openshell-gateway.err.log"
```

Rerun the NemoClaw installer to restore the managed service for later onboarding runs.

On Linux package installs, inspect and restart the upstream service.

Expand All @@ -941,15 +948,36 @@ Follow these steps to reconnect.
systemctl --user restart openshell-gateway
```

If the service fails inspection, startup, or its health check, NemoClaw prints this log command:

```bash
journalctl --user --unit openshell-gateway --no-pager --lines=200
```

On Linux tarball installs, inspect and restart the marked NemoClaw service.

```bash
systemctl --user status nemoclaw-openshell-gateway
systemctl --user restart nemoclaw-openshell-gateway
```

If the service fails inspection, startup, or its health check, NemoClaw prints this log command:

```bash
journalctl --user --unit nemoclaw-openshell-gateway --no-pager --lines=200
```

The tarball unit is under `$XDG_CONFIG_HOME/systemd/user`, or `~/.config/systemd/user` when `XDG_CONFIG_HOME` is not absolute.
It starts with your user session; NemoClaw does not enable lingering.
If a managed service fails inspection, startup, or its health check, NemoClaw attempts the standalone fallback on either platform.
The standalone gateway starts only after NemoClaw verifies exclusive ownership of the gateway port.
The fallback does not bypass managed-service trust validation or unsafe environment configuration.
These conditions remain hard failures:

- Homebrew formula identity query, metadata, or official-tap validation errors
- Foreign or symlinked systemd units, or an untrusted systemd executable identity
- An invalid `DOCKER_HOST` or a symlinked service environment file

Without Homebrew on macOS, or without a reachable systemd user manager on Linux, continue with the standalone recovery step below.

1. Check sandbox state.
Expand Down
173 changes: 85 additions & 88 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ const {
getDockerDriverGatewayEnv,
getDockerDriverGatewayPid,
getDockerDriverGatewayPortListenerPid,
getDockerDriverGatewayPortListenerScan,
getDockerDriverGatewayReuseDrift: getGatewayReuseDrift,
getDockerDriverGatewayRuntimeDrift,
getDockerDriverGatewayRuntimeDriftFromSnapshot,
Expand Down Expand Up @@ -1898,15 +1899,7 @@ async function startGatewayWithOptions(
process.env.OPENSHELL_GATEWAY = GATEWAY_NAME;
}

/**
* Reconcile or create the host Docker-driver gateway. The public onboard()
* entrypoint holds acquireOnboardLock()'s atomic cross-process filesystem lock
* (created with openSync("wx")) across this whole call, so separate concurrent
* `nemoclaw onboard` CLI processes cannot race creation.
* The strict post-reap bind check below remains a second boundary against
* recovery commands or external processes that do not participate in that
* lock; the OS then permits only one child to bind the port.
*/
/** Reconcile the host Docker-driver gateway under the onboard lock and strict port checks. */
async function startDockerDriverGateway({
exitOnFailure = true,
skipSandboxBridgeReachability = false,
Expand Down Expand Up @@ -1950,84 +1943,88 @@ async function startDockerDriverGateway({
exitOnFailure,
}),
});
if (
await dockerDriverGatewayEnv.startPackageManagedDockerDriverGatewayWithEnvOverride({
clearDockerDriverGatewayRuntimeFiles,
exitOnFailure,
gatewayEnv: driftGatewayEnv,
gatewayName: GATEWAY_NAME,
isDockerDriverGatewayReady: () =>
isDockerDriverGatewayHttpReady(undefined, undefined, driftGatewayEnv),
registerDockerDriverGatewayEndpoint,
preparePortForOpenShellGatewayUserServiceStart: servicePortOwnership.preparePort,
runCaptureOpenshell,
skipSandboxBridgeReachability,
validatePortOwnerForOpenShellGatewayUserServiceStart: servicePortOwnership.validatePortOwner,
verifySandboxBridgeGatewayReachableOrExit: (fail, options) =>
verifySandboxBridgeGatewayReachableOrExit(fail, {
...options,
port: GATEWAY_PORT,
}),
})
)
return;
const initialHealth = dockerDriverGatewayCutover.readDockerDriverGatewayHealth(
runCaptureOpenshell,
GATEWAY_NAME,
);
const cutover = await dockerDriverGatewayCutover.runDockerDriverGatewayCutover(
{
gatewayBin,
identityGatewayBin,
driftGatewayBin,
driftGatewayEnv,
exitOnFailure,
skipSandboxBridgeReachability,
stateDir,
portListenerScan: servicePortOwnership.portListenerScan,
pidFileGatewayPid: getDockerDriverGatewayPid(),
initialHealth,
},
{
isDockerDriverGatewayProcessAlive,
isGatewayHealthy,
getDockerDriverGatewayRuntimeDrift,
logDockerDriverGatewayRestart,
registerDockerDriverGatewayEndpoint,
isDockerDriverGatewayHttpReady: () =>
isDockerDriverGatewayHttpReady(undefined, undefined, driftGatewayEnv),
verifySandboxBridgeGatewayReachableOrExit: (fail, options) =>
verifySandboxBridgeGatewayReachableOrExit(fail, {
...options,
port: GATEWAY_PORT,
}),
readGatewayHealth: () => ({
status: runCaptureOpenshell(["status"], { ignoreError: true }),
namedInfo: runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], {
ignoreError: true,
}),
activeInfo: runCaptureOpenshell(["gateway", "info"], { ignoreError: true }),
const cutover = await dockerDriverGatewayCutover.runDockerDriverGatewayManagedFallback(
() =>
dockerDriverGatewayEnv.startPackageManagedDockerDriverGatewayWithEnvOverride({
clearDockerDriverGatewayRuntimeFiles,
exitOnFailure,
gatewayEnv: driftGatewayEnv,
gatewayName: GATEWAY_NAME,
isDockerDriverGatewayReady: () =>
isDockerDriverGatewayHttpReady(undefined, undefined, driftGatewayEnv),
registerDockerDriverGatewayEndpoint,
preparePortForOpenShellGatewayUserServiceStart: servicePortOwnership.preparePort,
runCaptureOpenshell,
skipSandboxBridgeReachability,
validatePortOwnerForOpenShellGatewayUserServiceStart:
servicePortOwnership.validatePortOwner,
verifySandboxBridgeGatewayReachableOrExit: (fail, options) =>
verifySandboxBridgeGatewayReachableOrExit(fail, {
...options,
port: GATEWAY_PORT,
}),
}),
rememberDockerDriverGatewayPid,
reapDuplicateHostGatewaysExceptOrFail,
reapHostGatewayBeforeLaunchOrFail,
isGatewayPortAvailable: async () => {
const probe = await checkGatewayPortAvailable();
return probe.ok && !probe.warning;
},
reportUntrustedGatewayPort: servicePortOwnership.reportUntrustedGatewayPort,
reportMissingGatewayBinary: () => {
console.error(" OpenShell Docker-driver gateway binary not found.");
console.error(
` Install OpenShell v${SUPPORTED_OPENSHELL_FALLBACK_VERSION}, or set NEMOCLAW_OPENSHELL_GATEWAY_BIN.`,
);
if (exitOnFailure) process.exit(1);
throw new Error("OpenShell gateway binary not found");
},
log: (message) => console.log(message),
},
async () =>
dockerDriverGatewayCutover.runDockerDriverGatewayCutover(
{
gatewayBin,
identityGatewayBin,
driftGatewayBin,
driftGatewayEnv,
exitOnFailure,
skipSandboxBridgeReachability,
stateDir,
portListenerScan: getDockerDriverGatewayPortListenerScan(
await checkGatewayPortAvailable(),
{ gatewayBin: identityGatewayBin },
),
pidFileGatewayPid: getDockerDriverGatewayPid(),
initialHealth: dockerDriverGatewayCutover.readDockerDriverGatewayHealth(
runCaptureOpenshell,
GATEWAY_NAME,
),
},
{
isDockerDriverGatewayProcessAlive,
isGatewayHealthy,
getDockerDriverGatewayRuntimeDrift,
logDockerDriverGatewayRestart,
registerDockerDriverGatewayEndpoint,
isDockerDriverGatewayHttpReady: () =>
isDockerDriverGatewayHttpReady(undefined, undefined, driftGatewayEnv),
verifySandboxBridgeGatewayReachableOrExit: (fail, options) =>
verifySandboxBridgeGatewayReachableOrExit(fail, {
...options,
port: GATEWAY_PORT,
}),
readGatewayHealth: () => ({
status: runCaptureOpenshell(["status"], { ignoreError: true }),
namedInfo: runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], {
ignoreError: true,
}),
activeInfo: runCaptureOpenshell(["gateway", "info"], { ignoreError: true }),
}),
rememberDockerDriverGatewayPid,
reapDuplicateHostGatewaysExceptOrFail,
reapHostGatewayBeforeLaunchOrFail,
isGatewayPortAvailable: async () => {
const probe = await checkGatewayPortAvailable();
return probe.ok && !probe.warning;
},
reportUntrustedGatewayPort: servicePortOwnership.reportUntrustedGatewayPort,
reportMissingGatewayBinary: () => {
console.error(" OpenShell Docker-driver gateway binary not found.");
console.error(
` Install OpenShell v${SUPPORTED_OPENSHELL_FALLBACK_VERSION}, or set NEMOCLAW_OPENSHELL_GATEWAY_BIN.`,
);
if (exitOnFailure) process.exit(1);
throw new Error("OpenShell gateway binary not found");
},
log: (message) => console.log(message),
},
),
);
if (cutover === "reused") return;
if (cutover !== "launch") return;
if (!gatewayBin || !gatewayLaunch) {
throw new Error("OpenShell gateway launch missing after cutover");
}
Expand All @@ -2037,9 +2034,8 @@ async function startDockerDriverGateway({
const logFd = dockerDriverGatewayLaunch.openDockerDriverGatewayLog(logPath, { exitOnFailure });
console.log(" Starting OpenShell Docker-driver gateway...");
console.log(` Gateway log: ${logPath}`);
const launch = gatewayLaunch;
dockerDriverGatewayLaunch.prepareAndLogDockerDriverGatewayLaunch(launch);
const child = dockerDriverGatewayLaunch.spawnDockerDriverGateway(launch, logFd);
dockerDriverGatewayLaunch.prepareAndLogDockerDriverGatewayLaunch(gatewayLaunch);
const child = dockerDriverGatewayLaunch.spawnDockerDriverGateway(gatewayLaunch, logFd);
const childExit = trackChildExit(child); // #3111 zombie-safe liveness
child.unref();
const childPid = child.pid ?? 0;
Expand Down Expand Up @@ -4706,6 +4702,7 @@ module.exports = {
buildControlUiUrls,

startGateway,
startDockerDriverGateway,
findAvailableDashboardPort,
startGatewayForRecovery,
openshellArgv,
Expand Down
8 changes: 8 additions & 0 deletions src/lib/onboard/docker-driver-gateway-cutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ export interface DockerDriverGatewayCutoverDeps {
log(message: string): void;
}

export async function runDockerDriverGatewayManagedFallback(
startManagedGateway: () => Promise<boolean>,
runStandaloneCutover: () => Promise<"reused" | "launch">,
): Promise<"managed" | "reused" | "launch"> {
if (await startManagedGateway()) return "managed";
return runStandaloneCutover();
}

/**
* Resolve reuse, adoption, or replacement for the host Docker-driver gateway.
* Every reuse path requires a complete listener scan; replacement reaps only
Expand Down
15 changes: 11 additions & 4 deletions src/lib/onboard/docker-driver-gateway-env-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("package-managed Docker-driver gateway env service", () => {
skipSandboxBridgeReachability: false,
startOpenShellGatewayUserService: (opts) => {
opts?.prepareServiceEnv?.();
return { attempted: true, fallbackAllowed: false, started: true };
return { attempted: true, started: true };
},
verifySandboxBridgeGatewayReachableOrExit: async () => undefined,
}),
Expand All @@ -69,7 +69,7 @@ describe("package-managed Docker-driver gateway env service", () => {
const envFile = path.join(tempHome, ".config", "openshell", "gateway.env");
const startService = vi.fn((opts?: { prepareServiceEnv?: () => void }) => {
opts?.prepareServiceEnv?.();
return { attempted: true, fallbackAllowed: false, started: true };
return { attempted: true, started: true };
});

try {
Expand Down Expand Up @@ -126,11 +126,18 @@ describe("package-managed Docker-driver gateway env service", () => {
skipSandboxBridgeReachability: false,
startOpenShellGatewayUserService: (opts) => {
opts?.prepareServiceEnv?.();
return { attempted: true, fallbackAllowed: false, started: true };
return { attempted: true, started: true };
},
verifySandboxBridgeGatewayReachableOrExit: async () => undefined,
}),
).rejects.toThrow("Refusing to write symlinked OpenShell gateway env file");
).rejects.toMatchObject({
name: "OpenShellGatewayServiceEnvironmentError",
cause: expect.objectContaining({
message: expect.stringContaining(
"Refusing to write symlinked OpenShell gateway env file",
),
}),
});

expect(fs.readFileSync(targetFile, "utf-8")).toBe("KEEP_ME=1\n");
} finally {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard/docker-driver-gateway-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ describe("writeDockerGatewayDebEnvOverride", () => {
skipSandboxBridgeReachability: false,
startOpenShellGatewayUserService: (opts) => {
opts?.prepareServiceEnv?.();
return { attempted: true, fallbackAllowed: false, started: true };
return { attempted: true, started: true };
},
verifySandboxBridgeGatewayReachableOrExit: async () => undefined,
}),
Expand Down
28 changes: 20 additions & 8 deletions src/lib/onboard/docker-driver-gateway-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import {
} from "./docker-driver-gateway-config";
import { buildDockerDriverGatewayLocalTlsEnv } from "./docker-driver-gateway-local-tls";
import {
getOpenShellGatewayManagedServiceLogCommand,
getOpenShellUserConfigHome,
hasOpenShellGatewayUserService,
OpenShellGatewayServiceEnvironmentError,
type PackageManagedDockerDriverGatewayOptions,
startPackageManagedDockerDriverGateway,
stopOpenShellGatewayUserService,
} from "./docker-driver-gateway-service";

export { getGatewayHttpsEndpoint, startPackageManagedDockerDriverGateway };
Expand Down Expand Up @@ -348,15 +351,24 @@ export function startPackageManagedDockerDriverGatewayWithEnvOverride(
hasOpenShellGatewayUserService:
options.hasOpenShellGatewayUserService ??
(() => hasOpenShellGatewayUserService({ env, home: effectiveHome })),
managedServiceLogCommand:
options.managedServiceLogCommand ?? getOpenShellGatewayManagedServiceLogCommand(),
prepareOpenShellGatewayUserServiceEnv: () => {
const serviceGatewayEnv = { ...gatewayEnv };
delete serviceGatewayEnv.DOCKER_HOST;
const dockerHost = normalizePackageServiceDockerHost(env.DOCKER_HOST);
if (dockerHost) serviceGatewayEnv.DOCKER_HOST = dockerHost;
writeDockerGatewayDebEnvOverrideFile(() => serviceGatewayEnv, {
env,
home: effectiveHome,
});
try {
const serviceGatewayEnv = { ...gatewayEnv };
delete serviceGatewayEnv.DOCKER_HOST;
const dockerHost = normalizePackageServiceDockerHost(env.DOCKER_HOST);
if (dockerHost) serviceGatewayEnv.DOCKER_HOST = dockerHost;
writeDockerGatewayDebEnvOverrideFile(() => serviceGatewayEnv, {
env,
home: effectiveHome,
});
} catch (error) {
throw new OpenShellGatewayServiceEnvironmentError(error);
}
},
stopOpenShellGatewayUserService:
options.stopOpenShellGatewayUserService ??
(() => stopOpenShellGatewayUserService({ env, home: effectiveHome })),
});
}
Loading
Loading