feat(cli): add daemonless mode for container/cloud environments

Support DENCHCLAW_DAEMONLESS=1 env var and --skip-daemon-install flag
across all commands (bootstrap, update, start, restart, stop) to skip
gateway daemon management and LaunchAgent registration in environments
without systemd/launchd.
This commit is contained in:
kumarabhirup 2026-03-15 21:25:25 -07:00
parent 4436e57d9a
commit 76f8838362
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
13 changed files with 200 additions and 72 deletions

View File

@ -57,6 +57,28 @@ openclaw --profile dench gateway restart
openclaw --profile dench uninstall
```
### Daemonless / Docker
For containers or environments without systemd/launchd, set the environment variable once:
```bash
export DENCHCLAW_DAEMONLESS=1
```
This skips all gateway daemon management (install/start/stop/restart) and launchd LaunchAgent installation across all commands. You must start the gateway yourself as a foreground process:
```bash
openclaw --profile dench gateway --port 19001
```
Alternatively, pass `--skip-daemon-install` to individual commands:
```bash
npx denchclaw --skip-daemon-install
npx denchclaw update --skip-daemon-install
npx denchclaw start --skip-daemon-install
```
---
## Development

View File

@ -1,6 +1,6 @@
{
"name": "denchclaw",
"version": "2.3.4",
"version": "2.3.5",
"description": "Fully Managed OpenClaw Framework for managing your CRM, Sales Automation and Outreach agents. The only local productivity tool you need.",
"keywords": [],
"homepage": "https://github.com/DenchHQ/DenchClaw#readme",

View File

@ -1,6 +1,6 @@
{
"name": "dench",
"version": "2.3.4",
"version": "2.3.5",
"description": "Shorthand alias for denchclaw — AI-powered CRM platform CLI",
"license": "MIT",
"repository": {
@ -16,7 +16,7 @@
],
"type": "module",
"dependencies": {
"denchclaw": "^2.3.4"
"denchclaw": "^2.3.5"
},
"engines": {
"node": ">=22.12.0"

View File

@ -13,6 +13,7 @@ import path from "node:path";
import process from "node:process";
import { confirm, isCancel, select, spinner, text } from "@clack/prompts";
import json5 from "json5";
import { isDaemonlessMode } from "../config/paths.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { readTelemetryConfig, markNoticeShown } from "../telemetry/config.js";
@ -95,6 +96,7 @@ export type BootstrapOptions = {
denchCloudApiKey?: string;
denchCloudModel?: string;
denchGatewayUrl?: string;
skipDaemonInstall?: boolean;
};
type BootstrapSummary = {
@ -2148,6 +2150,7 @@ export async function bootstrapCommand(
runtime.log(theme.warn(appliedProfile.warning));
}
const daemonless = isDaemonlessMode(opts);
const bootstrapStartTime = Date.now();
if (!opts.json) {
@ -2325,7 +2328,7 @@ export async function bootstrapCommand(
"--profile",
profile,
"onboard",
"--install-daemon",
...(daemonless ? [] : ["--install-daemon"]),
"--gateway-bind",
"loopback",
"--gateway-port",
@ -2342,6 +2345,9 @@ export async function bootstrapCommand(
}
onboardArgv.push("--accept-risk", "--skip-ui");
if (daemonless) {
onboardArgv.push("--skip-health");
}
if (nonInteractive) {
await runOpenClawOrThrow({
@ -2410,55 +2416,62 @@ export async function bootstrapCommand(
postOnboardSpinner?.message("Configuring subagent defaults…");
await ensureSubagentDefaults(openclawCommand, profile);
// ── Single post-config gateway restart ──
// All Dench-owned config has been applied. Restart the gateway once so the
// daemon picks up plugin, model, and subagent changes that were written after
// onboard started it. No helper above triggers its own restart.
postOnboardSpinner?.message("Restarting gateway…");
try {
await runOpenClawOrThrow({
openclawCommand,
args: ["--profile", profile, "gateway", "restart"],
timeoutMs: 60_000,
errorMessage: "Failed to restart gateway after config update.",
});
} catch {
// Gateway may not be running (e.g. onboard daemon install failed on this
// platform). The final readiness check below will catch this.
}
// ── Final readiness verification ──
// Give the gateway time to finish starting after the restart, then verify
// readiness. The probe retries here replace the old pattern of probing
// immediately (which raced gateway startup) and jumping straight into a
// destructive stop/install/start auto-fix cycle.
postOnboardSpinner?.message("Waiting for gateway…");
let gatewayProbe = await probeGateway(openclawCommand, profile, gatewayPort);
for (let attempt = 0; attempt < 4 && !gatewayProbe.ok; attempt += 1) {
await sleep(750);
postOnboardSpinner?.message(`Probing gateway health (attempt ${attempt + 2}/5)…`);
gatewayProbe = await probeGateway(openclawCommand, profile, gatewayPort);
}
// Repair is failure-only: only invoked when the retried final verification
// still reports the gateway as unreachable.
// ── Gateway daemon restart + readiness verification ──
// Skipped entirely in daemonless mode — the user manages the gateway process
// externally (e.g. `openclaw gateway --port <port>` as a foreground process).
let gatewayProbe: { ok: boolean; detail?: string };
let gatewayAutoFix: GatewayAutoFixResult | undefined;
if (!gatewayProbe.ok) {
postOnboardSpinner?.message("Gateway unreachable, attempting auto-fix…");
gatewayAutoFix = await attemptGatewayAutoFix({
openclawCommand,
profile,
stateDir,
gatewayPort,
});
gatewayProbe = gatewayAutoFix.finalProbe;
if (!gatewayProbe.ok && gatewayAutoFix.failureSummary) {
gatewayProbe = {
...gatewayProbe,
detail: [gatewayProbe.detail, gatewayAutoFix.failureSummary]
.filter((value, index, self) => value && self.indexOf(value) === index)
.join(" | "),
};
if (daemonless) {
gatewayProbe = { ok: true, detail: "skipped (daemonless)" };
} else {
// All Dench-owned config has been applied. Restart the gateway once so the
// daemon picks up plugin, model, and subagent changes that were written after
// onboard started it. No helper above triggers its own restart.
postOnboardSpinner?.message("Restarting gateway…");
try {
await runOpenClawOrThrow({
openclawCommand,
args: ["--profile", profile, "gateway", "restart"],
timeoutMs: 60_000,
errorMessage: "Failed to restart gateway after config update.",
});
} catch {
// Gateway may not be running (e.g. onboard daemon install failed on this
// platform). The final readiness check below will catch this.
}
// Give the gateway time to finish starting after the restart, then verify
// readiness. The probe retries here replace the old pattern of probing
// immediately (which raced gateway startup) and jumping straight into a
// destructive stop/install/start auto-fix cycle.
postOnboardSpinner?.message("Waiting for gateway…");
gatewayProbe = await probeGateway(openclawCommand, profile, gatewayPort);
for (let attempt = 0; attempt < 4 && !gatewayProbe.ok; attempt += 1) {
await sleep(750);
postOnboardSpinner?.message(`Probing gateway health (attempt ${attempt + 2}/5)…`);
gatewayProbe = await probeGateway(openclawCommand, profile, gatewayPort);
}
// Repair is failure-only: only invoked when the retried final verification
// still reports the gateway as unreachable.
if (!gatewayProbe.ok) {
postOnboardSpinner?.message("Gateway unreachable, attempting auto-fix…");
gatewayAutoFix = await attemptGatewayAutoFix({
openclawCommand,
profile,
stateDir,
gatewayPort,
});
gatewayProbe = gatewayAutoFix.finalProbe;
if (!gatewayProbe.ok && gatewayAutoFix.failureSummary) {
gatewayProbe = {
...gatewayProbe,
detail: [gatewayProbe.detail, gatewayAutoFix.failureSummary]
.filter((value, index, self) => value && self.indexOf(value) === index)
.join(" | "),
};
}
}
}
const gatewayUrl = `ws://127.0.0.1:${gatewayPort}`;

View File

@ -21,6 +21,7 @@ export function registerBootstrapCommand(program: Command) {
.option("--dench-cloud-api-key <key>", "Dench Cloud API key for bootstrap-driven setup")
.option("--dench-cloud-model <id>", "Stable or public Dench Cloud model id to use as default")
.option("--dench-gateway-url <url>", "Override the Dench Cloud gateway base URL")
.option("--skip-daemon-install", "Skip gateway daemon/service installation (for containers or environments without systemd/launchd)", false)
.option("--no-open", "Do not open the browser automatically")
.option("--json", "Output summary as JSON", false)
.addHelpText(
@ -43,6 +44,7 @@ export function registerBootstrapCommand(program: Command) {
denchCloudApiKey: opts.denchCloudApiKey as string | undefined,
denchCloudModel: opts.denchCloudModel as string | undefined,
denchGatewayUrl: opts.denchGatewayUrl as string | undefined,
skipDaemonInstall: Boolean(opts.skipDaemonInstall),
noOpen: Boolean(opts.open === false),
json: Boolean(opts.json),
});

View File

@ -10,6 +10,7 @@ export function registerRestartCommand(program: Command) {
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
.option("--web-port <port>", "Web runtime port override")
.option("--no-open", "Do not open the browser automatically")
.option("--skip-daemon-install", "Skip gateway daemon/service management (for containers or environments without systemd/launchd)", false)
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
@ -17,6 +18,7 @@ export function registerRestartCommand(program: Command) {
profile: opts.profile as string | undefined,
webPort: opts.webPort as string | undefined,
noOpen: Boolean(opts.open === false),
skipDaemonInstall: Boolean(opts.skipDaemonInstall),
json: Boolean(opts.json),
});
});

View File

@ -10,6 +10,7 @@ export function registerStartCommand(program: Command) {
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
.option("--web-port <port>", "Web runtime port override")
.option("--no-open", "Do not open the browser automatically")
.option("--skip-daemon-install", "Skip gateway daemon/service management (for containers or environments without systemd/launchd)", false)
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
@ -17,6 +18,7 @@ export function registerStartCommand(program: Command) {
profile: opts.profile as string | undefined,
webPort: opts.webPort as string | undefined,
noOpen: Boolean(opts.open === false),
skipDaemonInstall: Boolean(opts.skipDaemonInstall),
json: Boolean(opts.json),
});
});

View File

@ -9,12 +9,14 @@ export function registerStopCommand(program: Command) {
.description("Stop Dench managed web runtime on the configured port")
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
.option("--web-port <port>", "Web runtime port override")
.option("--skip-daemon-install", "Skip gateway daemon/service management (for containers or environments without systemd/launchd)", false)
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await stopWebRuntimeCommand({
profile: opts.profile as string | undefined,
webPort: opts.webPort as string | undefined,
skipDaemonInstall: Boolean(opts.skipDaemonInstall),
json: Boolean(opts.json),
});
});

View File

@ -12,6 +12,7 @@ export function registerUpdateCommand(program: Command) {
.option("--non-interactive", "Fail instead of prompting for major-gate approval", false)
.option("--yes", "Approve mandatory major-gate OpenClaw update", false)
.option("--no-open", "Do not open the browser automatically")
.option("--skip-daemon-install", "Skip gateway daemon/service management (for containers or environments without systemd/launchd)", false)
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
@ -21,6 +22,7 @@ export function registerUpdateCommand(program: Command) {
nonInteractive: Boolean(opts.nonInteractive),
yes: Boolean(opts.yes),
noOpen: Boolean(opts.open === false),
skipDaemonInstall: Boolean(opts.skipDaemonInstall),
json: Boolean(opts.json),
});
});

View File

@ -273,6 +273,22 @@ describe("updateWebRuntimeCommand", () => {
});
});
it("skips gateway daemon restart and LaunchAgent in daemonless mode", async () => {
webRuntimeMocks.runOpenClawCommand.mockClear();
launchdMocks.uninstallWebRuntimeLaunchAgent.mockClear();
const runtime = runtimeStub();
const summary = await updateWebRuntimeCommand(
{ nonInteractive: true, skipDaemonInstall: true },
runtime,
);
expect(webRuntimeMocks.runOpenClawCommand).not.toHaveBeenCalled();
expect(launchdMocks.uninstallWebRuntimeLaunchAgent).not.toHaveBeenCalled();
expect(summary.gatewayRestarted).toBe(false);
expect(summary.gatewayError).toBeUndefined();
expect(summary.ready).toBe(true);
});
it("skips OpenClaw update on minor upgrades while still refreshing runtime (avoids unnecessary blocking)", async () => {
webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({
previousMajor: 2,
@ -410,6 +426,30 @@ describe("startWebRuntimeCommand", () => {
expect(summary.started).toBe(true);
});
it("skips gateway daemon restart and LaunchAgent in daemonless mode, uses child process", async () => {
webRuntimeMocks.runOpenClawCommand.mockClear();
launchdMocks.installWebRuntimeLaunchAgent.mockClear();
launchdMocks.uninstallWebRuntimeLaunchAgent.mockClear();
webRuntimeMocks.startManagedWebRuntime.mockClear();
const runtime = runtimeStub();
const summary = await startWebRuntimeCommand(
{ webPort: "3100", skipDaemonInstall: true },
runtime,
);
expect(webRuntimeMocks.runOpenClawCommand).not.toHaveBeenCalled();
expect(launchdMocks.installWebRuntimeLaunchAgent).not.toHaveBeenCalled();
expect(launchdMocks.uninstallWebRuntimeLaunchAgent).not.toHaveBeenCalled();
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({
stateDir: "/tmp/.openclaw-dench",
port: 3100,
gatewayPort: 19001,
});
expect(summary.started).toBe(true);
expect(summary.gatewayRestarted).toBe(false);
expect(summary.gatewayError).toBeUndefined();
});
it("falls back to DenchClaw port 19001 when manifest has no lastGatewayPort (prevents 18789 hijack)", async () => {
webRuntimeMocks.readManagedWebRuntimeManifest.mockReturnValue({
schemaVersion: 1,

View File

@ -6,7 +6,7 @@ import { confirm, isCancel, spinner } from "@clack/prompts";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { stylePromptMessage } from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js";
import { DENCHCLAW_DEFAULT_GATEWAY_PORT } from "../config/paths.js";
import { DENCHCLAW_DEFAULT_GATEWAY_PORT, isDaemonlessMode } from "../config/paths.js";
import { VERSION } from "../version.js";
import { applyCliProfileEnv } from "./profile.js";
import {
@ -43,12 +43,14 @@ export type UpdateWebRuntimeOptions = {
yes?: boolean;
noOpen?: boolean;
json?: boolean;
skipDaemonInstall?: boolean;
};
export type StopWebRuntimeOptions = {
profile?: string;
webPort?: string | number;
json?: boolean;
skipDaemonInstall?: boolean;
};
export type StartWebRuntimeOptions = {
@ -56,6 +58,7 @@ export type StartWebRuntimeOptions = {
webPort?: string | number;
noOpen?: boolean;
json?: boolean;
skipDaemonInstall?: boolean;
};
export type UpdateWebRuntimeSummary = {
@ -397,6 +400,7 @@ export async function updateWebRuntimeCommand(
await runOpenClawUpdateWithProgress(openclawCommand);
}
const daemonless = isDaemonlessMode(opts);
const selectedPort =
parseOptionalPort(opts.webPort) ??
parseOptionalPort(previousManifest?.lastPort) ??
@ -404,7 +408,7 @@ export async function updateWebRuntimeCommand(
DEFAULT_WEB_APP_PORT;
const gatewayPort = resolveGatewayPort(stateDir);
if (process.platform === "darwin") {
if (!daemonless && process.platform === "darwin") {
uninstallWebRuntimeLaunchAgent();
}
@ -414,11 +418,9 @@ export async function updateWebRuntimeCommand(
includeLegacyStandalone: true,
});
const gatewayResult = await restartGatewayDaemon({
profile,
gatewayPort,
json: Boolean(opts.json),
});
const gatewayResult: { restarted: boolean; error?: string } = daemonless
? { restarted: false, error: "skipped (daemonless)" }
: await restartGatewayDaemon({ profile, gatewayPort, json: Boolean(opts.json) });
const workspaceDirs = discoverWorkspaceDirs(stateDir);
const skillSyncResult = syncManagedSkills({ workspaceDirs, packageRoot });
@ -430,7 +432,7 @@ export async function updateWebRuntimeCommand(
port: selectedPort,
gatewayPort,
startFn:
process.platform === "darwin"
!daemonless && process.platform === "darwin"
? (p) => installWebRuntimeLaunchAgent(p)
: undefined,
});
@ -449,7 +451,7 @@ export async function updateWebRuntimeCommand(
ready: ensureResult.ready,
reason: ensureResult.reason,
gatewayRestarted: gatewayResult.restarted,
gatewayError: gatewayResult.error,
gatewayError: daemonless ? undefined : gatewayResult.error,
skillSync: skillSyncResult,
};
@ -459,7 +461,11 @@ export async function updateWebRuntimeCommand(
runtime.log(`Profile: ${profile}`);
runtime.log(`Version: ${VERSION}`);
runtime.log(`Web port: ${selectedPort}`);
runtime.log(`Gateway: ${summary.gatewayRestarted ? "restarted" : "restart failed"}`);
if (daemonless) {
runtime.log(`Gateway: skipped (daemonless mode)`);
} else {
runtime.log(`Gateway: ${summary.gatewayRestarted ? "restarted" : "restart failed"}`);
}
if (summary.gatewayError) {
runtime.log(theme.warn(`Gateway error: ${summary.gatewayError}`));
}
@ -508,7 +514,7 @@ export async function stopWebRuntimeCommand(
const stateDir = resolveProfileStateDir(profile);
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
if (process.platform === "darwin") {
if (!isDaemonlessMode(opts) && process.platform === "darwin") {
uninstallWebRuntimeLaunchAgent();
}
@ -570,11 +576,12 @@ export async function startWebRuntimeCommand(
runtime.log(theme.warn(appliedProfile.warning));
}
const daemonless = isDaemonlessMode(opts);
const stateDir = resolveProfileStateDir(profile);
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
const gatewayPort = resolveGatewayPort(stateDir);
if (process.platform === "darwin") {
if (!daemonless && process.platform === "darwin") {
uninstallWebRuntimeLaunchAgent();
}
@ -590,14 +597,12 @@ export async function startWebRuntimeCommand(
);
}
const gatewayResult = await restartGatewayDaemon({
profile,
gatewayPort,
json: Boolean(opts.json),
});
const gatewayResult: { restarted: boolean; error?: string } = daemonless
? { restarted: false, error: "skipped (daemonless)" }
: await restartGatewayDaemon({ profile, gatewayPort, json: Boolean(opts.json) });
let startResult;
if (process.platform === "darwin") {
if (!daemonless && process.platform === "darwin") {
startResult = installWebRuntimeLaunchAgent({ stateDir, port: selectedPort, gatewayPort });
if (!startResult.started && startResult.reason !== "runtime-missing") {
startResult = startManagedWebRuntime({ stateDir, port: selectedPort, gatewayPort });
@ -625,7 +630,7 @@ export async function startWebRuntimeCommand(
started: probe.ok,
reason: probe.reason,
gatewayRestarted: gatewayResult.restarted,
gatewayError: gatewayResult.error,
gatewayError: daemonless ? undefined : gatewayResult.error,
};
if (opts.json) {
@ -637,7 +642,11 @@ export async function startWebRuntimeCommand(
runtime.log(theme.heading(`Dench web ${label}`));
runtime.log(`Profile: ${profile}`);
runtime.log(`Web port: ${selectedPort}`);
runtime.log(`Gateway: ${summary.gatewayRestarted ? "restarted" : "restart failed"}`);
if (daemonless) {
runtime.log(`Gateway: skipped (daemonless mode)`);
} else {
runtime.log(`Gateway: ${summary.gatewayRestarted ? "restarted" : "restart failed"}`);
}
if (summary.gatewayError) {
runtime.log(theme.warn(`Gateway error: ${summary.gatewayError}`));
}

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
isDaemonlessMode,
resolveGatewayPort,
DEFAULT_GATEWAY_PORT,
DENCHCLAW_DEFAULT_GATEWAY_PORT,
@ -85,6 +86,32 @@ describe("resolveGatewayPort", () => {
});
});
describe("isDaemonlessMode", () => {
it("returns false when no opt and no env var", () => {
expect(isDaemonlessMode(undefined, {})).toBe(false);
expect(isDaemonlessMode({}, {})).toBe(false);
});
it("returns true when opts.skipDaemonInstall is set", () => {
expect(isDaemonlessMode({ skipDaemonInstall: true }, {})).toBe(true);
});
it("returns true when DENCHCLAW_DAEMONLESS=1 env var is set", () => {
expect(isDaemonlessMode(undefined, { DENCHCLAW_DAEMONLESS: "1" })).toBe(true);
expect(isDaemonlessMode({}, { DENCHCLAW_DAEMONLESS: "1" })).toBe(true);
});
it("returns false for non-'1' env values", () => {
expect(isDaemonlessMode(undefined, { DENCHCLAW_DAEMONLESS: "true" })).toBe(false);
expect(isDaemonlessMode(undefined, { DENCHCLAW_DAEMONLESS: "yes" })).toBe(false);
expect(isDaemonlessMode(undefined, { DENCHCLAW_DAEMONLESS: "0" })).toBe(false);
});
it("opts.skipDaemonInstall=true wins even without env var", () => {
expect(isDaemonlessMode({ skipDaemonInstall: true }, { DENCHCLAW_DAEMONLESS: "0" })).toBe(true);
});
});
describe("port constants", () => {
it("DenchClaw default port is distinct from OpenClaw default (prevents port collision)", () => {
expect(DENCHCLAW_DEFAULT_GATEWAY_PORT).not.toBe(DEFAULT_GATEWAY_PORT);

View File

@ -22,6 +22,13 @@ export function resolveIsNixMode(env: NodeJS.ProcessEnv = process.env): boolean
export const isNixMode = resolveIsNixMode();
export function isDaemonlessMode(
opts?: { skipDaemonInstall?: boolean },
env: NodeJS.ProcessEnv = process.env,
): boolean {
return Boolean(opts?.skipDaemonInstall) || env.DENCHCLAW_DAEMONLESS === "1";
}
// Support historical (and occasionally misspelled) legacy state dirs.
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"] as const;
const NEW_STATE_DIRNAME = ".openclaw-dench";