From 29f08a1fd5e0796bcc78328a990b1d5d2d012e87 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 7 May 2026 10:42:51 +0800 Subject: [PATCH 1/7] feat(onboard): expose agents.defaults.heartbeat.every as build-time env var (#2880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenClaw's periodic heartbeat (default 30m) can flood the dashboard and freeze long-running agent turns. The fix to set `agents.defaults.heartbeat.every "0m"` is documented for OpenClaw, but `openclaw config set` is read-only inside a NemoClaw sandbox and the generated openclaw.json had no env-var passthrough for this key — leaving no supported way to disable the heartbeat. Add NEMOCLAW_AGENT_HEARTBEAT_EVERY mirroring the existing NEMOCLAW_AGENT_TIMEOUT plumbing: Dockerfile ARG → ENV → Python config generator → openclaw.json. Empty/unset preserves the OpenClaw default; "0m" disables; other Go-style durations (e.g. "1h") tune the cadence. Signed-off-by: Dongni Yang Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 7 +++++++ docs/inference/switch-inference-providers.md | 9 +++++++++ scripts/generate-openclaw-config.py | 21 ++++++++++++++++++++ src/lib/onboard.ts | 10 ++++++++++ test/generate-openclaw-config.test.ts | 20 +++++++++++++++++++ 5 files changed, 67 insertions(+) diff --git a/Dockerfile b/Dockerfile index 82c65b00847..0a4e2f252f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -262,6 +262,12 @@ ARG NEMOCLAW_INFERENCE_INPUTS=text # immutable at runtime (Landlock read-only), so this can only be changed by # rebuilding via `nemoclaw onboard`. Ref: issue #2281 ARG NEMOCLAW_AGENT_TIMEOUT=600 +# Cadence for OpenClaw's periodic heartbeat +# (agents.defaults.heartbeat.every). Accepts Go-style durations like "30m", +# "5m", "1h"; "0m" disables heartbeat. Empty default preserves the OpenClaw +# built-in cadence. openclaw.json is immutable at runtime, so this can only +# change at image build time. Ref: issue #2880 +ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY= ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= # Base64-encoded JSON list of messaging channel names to pre-configure # (e.g. ["discord","telegram"]). Channels are added with placeholder tokens @@ -314,6 +320,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_REASONING=${NEMOCLAW_REASONING} \ NEMOCLAW_INFERENCE_INPUTS=${NEMOCLAW_INFERENCE_INPUTS} \ NEMOCLAW_AGENT_TIMEOUT=${NEMOCLAW_AGENT_TIMEOUT} \ + NEMOCLAW_AGENT_HEARTBEAT_EVERY=${NEMOCLAW_AGENT_HEARTBEAT_EVERY} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index b82ea016f97..37cc893bce1 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -146,6 +146,7 @@ To change these values, set the corresponding environment variables before runni | `NEMOCLAW_REASONING` | `true` or `false` | `false` | | `NEMOCLAW_INFERENCE_INPUTS` | `text` or `text,image` | `text` | | `NEMOCLAW_AGENT_TIMEOUT` | Positive integer (seconds) | `600` | +| `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | Go-style duration (`30m`, `1h`, `0m` to disable) | unset (OpenClaw default) | Invalid values are ignored, and the default bakes into the image. Use `NEMOCLAW_INFERENCE_INPUTS=text,image` only for a model that accepts image input through the selected provider. @@ -156,6 +157,7 @@ $ export NEMOCLAW_MAX_TOKENS=8192 $ export NEMOCLAW_REASONING=true $ export NEMOCLAW_INFERENCE_INPUTS=text,image $ export NEMOCLAW_AGENT_TIMEOUT=1800 +$ export NEMOCLAW_AGENT_HEARTBEAT_EVERY=0m $ nemoclaw onboard ``` @@ -165,6 +167,13 @@ example, CPU-only Ollama or vLLM on modest hardware). `openclaw.json` is immutable at runtime, so this value can only be changed by rebuilding the sandbox via `nemoclaw onboard`. +`NEMOCLAW_AGENT_HEARTBEAT_EVERY` sets `agents.defaults.heartbeat.every`. Set it +to `0m` to disable the periodic heartbeat when it disrupts long-running agent +turns; leave it unset to preserve the OpenClaw default cadence. `openclaw.json` +is immutable at runtime, so the in-sandbox `openclaw config set` command cannot +change this — rebuild the sandbox via `nemoclaw onboard --resume` to apply a +new value. + These variables are build-time settings. If you change them on an existing sandbox, recreate the sandbox so the new values bake into the image: diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index a37852f1c38..524ad54b65e 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -22,6 +22,8 @@ NEMOCLAW_MAX_TOKENS Max tokens (default: 4096) NEMOCLAW_REASONING Enable reasoning (default: false) NEMOCLAW_AGENT_TIMEOUT Per-request timeout seconds (default: 600) + NEMOCLAW_AGENT_HEARTBEAT_EVERY OpenClaw agent heartbeat cadence (e.g. "30m", "0m" to + disable). Empty/unset preserves the OpenClaw default. NEMOCLAW_INFERENCE_COMPAT_B64 Base64-encoded inference compat JSON NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded channel list NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 Base64-encoded allowed IDs map @@ -336,6 +338,20 @@ def build_config(env: dict | None = None) -> dict: raise ValueError("NEMOCLAW_AGENT_TIMEOUT must be a positive integer") agent_timeout = int(_raw_agent_timeout) + # NemoClaw#2880: expose OpenClaw's agents.defaults.heartbeat.every so users + # can disable the periodic heartbeat (e.g. "0m") without editing + # openclaw.json by hand. Accept a Go-style duration string (digits with an + # optional s/m/h suffix). Empty/unset preserves the OpenClaw default. + _raw_heartbeat = (env.get("NEMOCLAW_AGENT_HEARTBEAT_EVERY") or "").strip() + if _raw_heartbeat and not re.match(r"^\d+(s|m|h)?$", _raw_heartbeat): + print( + f'[SECURITY] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match ^\\d+(s|m|h)?$, ' + f'got "{_raw_heartbeat}" — skipping override, preserving OpenClaw default', + file=sys.stderr, + ) + _raw_heartbeat = "" + agent_heartbeat = _raw_heartbeat + model_specific_setups = _matching_model_specific_setups( "openclaw", { @@ -534,6 +550,11 @@ def _placeholder(channel: str, env_key: str) -> str: "defaults": { "model": {"primary": primary_model_ref}, "timeoutSeconds": agent_timeout, + **( + {"heartbeat": {"every": agent_heartbeat}} + if agent_heartbeat + else {} + ), # NemoClaw sandboxes are provisioned non-interactively and the # E2E CLI contract expects the first agent turn to answer the # caller's prompt. OpenClaw 2026.4.24 seeds BOOTSTRAP.md by diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 545d72284db..01cc7604705 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2469,6 +2469,16 @@ function patchStagedDockerfile( `ARG NEMOCLAW_AGENT_TIMEOUT=${agentTimeout}`, ); } + // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every + // at build time. Accepts Go-style durations ("30m", "1h"); "0m" disables + // heartbeat. Ref: issue #2880 + const agentHeartbeat = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + if (agentHeartbeat && /^\d+(s|m|h)?$/.test(agentHeartbeat)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, + `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${agentHeartbeat}`, + ); + } // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host // shell so the sandbox-side nemoclaw-start.sh sees them via $ENV at runtime. // Without this, the host export is silently dropped at image build time and diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index d4acfe49716..235249f800a 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -243,6 +243,26 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.agents.defaults.timeoutSeconds).toBe(300); }); + it("omits heartbeat when NEMOCLAW_AGENT_HEARTBEAT_EVERY is unset", () => { + const config = runConfigScript(); + expect(config.agents.defaults.heartbeat).toBeUndefined(); + }); + + it("propagates heartbeat cadence into agents.defaults.heartbeat.every", () => { + const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "30m" }); + expect(config.agents.defaults.heartbeat).toEqual({ every: "30m" }); + }); + + it("disables heartbeat when set to 0m (NemoClaw#2880)", () => { + const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "0m" }); + expect(config.agents.defaults.heartbeat).toEqual({ every: "0m" }); + }); + + it("rejects malformed heartbeat values and preserves OpenClaw default", () => { + const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "5 minutes" }); + expect(config.agents.defaults.heartbeat).toBeUndefined(); + }); + it("disables OpenClaw first-run workspace bootstrap", () => { const config = runConfigScript(); expect(config.agents.defaults.skipBootstrap).toBe(true); From 3b1c4323809a74f9cd5d7a1856257e809dd280da Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 7 May 2026 11:11:00 +0800 Subject: [PATCH 2/7] chore(onboard): address CodeRabbit nits on heartbeat passthrough (#2880) - docs: wrap default value `unset` in inline code per coding guidelines - test: cover empty-string env value, which is what Docker passes when the unset ARG is promoted through ENV (the actual build-time path) Signed-off-by: Dongni Yang Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/inference/switch-inference-providers.md | 2 +- test/generate-openclaw-config.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index 37cc893bce1..59008c69d9d 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -146,7 +146,7 @@ To change these values, set the corresponding environment variables before runni | `NEMOCLAW_REASONING` | `true` or `false` | `false` | | `NEMOCLAW_INFERENCE_INPUTS` | `text` or `text,image` | `text` | | `NEMOCLAW_AGENT_TIMEOUT` | Positive integer (seconds) | `600` | -| `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | Go-style duration (`30m`, `1h`, `0m` to disable) | unset (OpenClaw default) | +| `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | Go-style duration (`30m`, `1h`, `0m` to disable) | `unset` (OpenClaw default) | Invalid values are ignored, and the default bakes into the image. Use `NEMOCLAW_INFERENCE_INPUTS=text,image` only for a model that accepts image input through the selected provider. diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 235249f800a..743eac5e826 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -248,6 +248,13 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.agents.defaults.heartbeat).toBeUndefined(); }); + it("omits heartbeat when NEMOCLAW_AGENT_HEARTBEAT_EVERY is the empty string", () => { + // Docker promotes the unset ARG to an empty ENV value rather than dropping + // the variable, so the build path almost always sees "" rather than undefined. + const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "" }); + expect(config.agents.defaults.heartbeat).toBeUndefined(); + }); + it("propagates heartbeat cadence into agents.defaults.heartbeat.every", () => { const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "30m" }); expect(config.agents.defaults.heartbeat).toEqual({ every: "30m" }); From 7d00843de40f29097e2dbfe4a5a30f5df1a40f9f Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 7 May 2026 11:20:22 +0800 Subject: [PATCH 3/7] test(onboard): assert [SECURITY] stderr warning on malformed heartbeat (#2880) Mirror the existing CONTEXT_WINDOW/MAX_TOKENS validation tests by capturing stderr and asserting that the rejected NEMOCLAW_AGENT_HEARTBEAT_EVERY value emits the documented [SECURITY] warning before falling back to the OpenClaw default. Per CodeRabbit review on PR #3158. Signed-off-by: Dongni Yang Co-Authored-By: Claude Opus 4.7 (1M context) --- test/generate-openclaw-config.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 743eac5e826..01e6cfeb5b3 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -265,9 +265,15 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.agents.defaults.heartbeat).toEqual({ every: "0m" }); }); - it("rejects malformed heartbeat values and preserves OpenClaw default", () => { - const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "5 minutes" }); + it("rejects malformed heartbeat values, preserves OpenClaw default, and warns on stderr", () => { + const result = runConfigScriptRaw({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "5 minutes" }); + expect(result.status).toBe(0); + const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(config.agents.defaults.heartbeat).toBeUndefined(); + expect(result.stderr).toMatch( + /\[SECURITY\] NEMOCLAW_AGENT_HEARTBEAT_EVERY.*"5 minutes"/, + ); }); it("disables OpenClaw first-run workspace bootstrap", () => { From b0f9250f9a9cd27b1d5010e1819fa79cc137a2b6 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 7 May 2026 12:21:45 +0800 Subject: [PATCH 4/7] docs(onboard): one-sentence-per-line for heartbeat env var paragraph (#2880) Per CodeRabbit nit on PR #3158: split the NEMOCLAW_AGENT_HEARTBEAT_EVERY paragraph so each sentence is on its own source line to match the project's docs style guide. Wording unchanged. Signed-off-by: Dongni Yang Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/inference/switch-inference-providers.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index 59008c69d9d..b31018921c0 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -167,12 +167,11 @@ example, CPU-only Ollama or vLLM on modest hardware). `openclaw.json` is immutable at runtime, so this value can only be changed by rebuilding the sandbox via `nemoclaw onboard`. -`NEMOCLAW_AGENT_HEARTBEAT_EVERY` sets `agents.defaults.heartbeat.every`. Set it -to `0m` to disable the periodic heartbeat when it disrupts long-running agent -turns; leave it unset to preserve the OpenClaw default cadence. `openclaw.json` -is immutable at runtime, so the in-sandbox `openclaw config set` command cannot -change this — rebuild the sandbox via `nemoclaw onboard --resume` to apply a -new value. +`NEMOCLAW_AGENT_HEARTBEAT_EVERY` sets `agents.defaults.heartbeat.every`. +Set it to `0m` to disable the periodic heartbeat when it disrupts long-running agent turns. +Leave it unset to preserve the OpenClaw default cadence. +`openclaw.json` is immutable at runtime, so the in-sandbox `openclaw config set` command cannot change this. +Rebuild the sandbox via `nemoclaw onboard --resume` to apply a new value. These variables are build-time settings. If you change them on an existing sandbox, recreate the sandbox so the new values bake into the image: From ef6065d5ef4bc88adf9463572188608cad540c93 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 7 May 2026 13:21:09 +0800 Subject: [PATCH 5/7] docs(onboard): align heartbeat doc to upstream OpenClaw behavior (#2880) Replace the "disable when it disrupts long-running agent turns" framing (which traced to a misread of #2880) with what heartbeat actually does per the OpenClaw heartbeat docs: a scheduled main-session agent turn that reviews follow-ups and reads HEARTBEAT.md from the workspace. Spell out the cost of `0m` (loses periodic supervision and drops HEARTBEAT.md from normal-run context). Signed-off-by: Dongni Yang Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/inference/switch-inference-providers.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index b31018921c0..03923bf7dbc 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -168,8 +168,11 @@ immutable at runtime, so this value can only be changed by rebuilding the sandbox via `nemoclaw onboard`. `NEMOCLAW_AGENT_HEARTBEAT_EVERY` sets `agents.defaults.heartbeat.every`. -Set it to `0m` to disable the periodic heartbeat when it disrupts long-running agent turns. -Leave it unset to preserve the OpenClaw default cadence. +This controls OpenClaw's periodic main-session agent turn. +Each interval, the agent wakes up to review follow-ups and read `HEARTBEAT.md` if present in the workspace. +The OpenClaw default is 30 minutes (1 hour for Anthropic OAuth / Claude CLI reuse). +Tune the cadence with a duration string like `5m` or `2h`, or set `0m` to disable the periodic turns entirely. +Disabling also drops `HEARTBEAT.md` from normal-run bootstrap context per upstream behavior, so the model no longer sees heartbeat-only instructions. `openclaw.json` is immutable at runtime, so the in-sandbox `openclaw config set` command cannot change this. Rebuild the sandbox via `nemoclaw onboard --resume` to apply a new value. From cf35f3fd3d78418cf1dd5b774b430fc6b902a435 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 7 May 2026 13:39:57 +0800 Subject: [PATCH 6/7] fix(onboard): require duration suffix in NEMOCLAW_AGENT_HEARTBEAT_EVERY (#2880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the validation regex from ^\d+(s|m|h)?$ to ^\d+(s|m|h)$ on both sides (Python config generator + TypeScript Dockerfile patcher) so a bare "30" without a unit no longer slips through. OpenClaw's heartbeat docs always show the suffixed form (e.g. 30m, 2h, 0m), and a unit-less number is ambiguous — OpenClaw may parse it as seconds or reject it at startup. Update the existing Python-side stderr-warning test to match the new error message. Signed-off-by: Dongni Yang Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/generate-openclaw-config.py | 9 +++++---- src/lib/onboard.ts | 6 +++--- test/generate-openclaw-config.test.ts | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 524ad54b65e..c1201f04055 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -340,12 +340,13 @@ def build_config(env: dict | None = None) -> dict: # NemoClaw#2880: expose OpenClaw's agents.defaults.heartbeat.every so users # can disable the periodic heartbeat (e.g. "0m") without editing - # openclaw.json by hand. Accept a Go-style duration string (digits with an - # optional s/m/h suffix). Empty/unset preserves the OpenClaw default. + # openclaw.json by hand. Accept a Go-style duration string (digits + a + # required s/m/h suffix — OpenClaw docs always show the suffixed form). + # Empty/unset preserves the OpenClaw default. _raw_heartbeat = (env.get("NEMOCLAW_AGENT_HEARTBEAT_EVERY") or "").strip() - if _raw_heartbeat and not re.match(r"^\d+(s|m|h)?$", _raw_heartbeat): + if _raw_heartbeat and not re.match(r"^\d+(s|m|h)$", _raw_heartbeat): print( - f'[SECURITY] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match ^\\d+(s|m|h)?$, ' + f'[SECURITY] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match ^\\d+(s|m|h)$, ' f'got "{_raw_heartbeat}" — skipping override, preserving OpenClaw default', file=sys.stderr, ) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 01cc7604705..c7467b08953 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2470,10 +2470,10 @@ function patchStagedDockerfile( ); } // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every - // at build time. Accepts Go-style durations ("30m", "1h"); "0m" disables - // heartbeat. Ref: issue #2880 + // at build time. Accepts Go-style durations with a required s/m/h suffix + // ("30m", "1h"); "0m" disables heartbeat. Ref: issue #2880 const agentHeartbeat = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; - if (agentHeartbeat && /^\d+(s|m|h)?$/.test(agentHeartbeat)) { + if (agentHeartbeat && /^\d+(s|m|h)$/.test(agentHeartbeat)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${agentHeartbeat}`, diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 01e6cfeb5b3..d0ec87880bd 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -272,7 +272,7 @@ describe("generate-openclaw-config.py: config generation", () => { const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(config.agents.defaults.heartbeat).toBeUndefined(); expect(result.stderr).toMatch( - /\[SECURITY\] NEMOCLAW_AGENT_HEARTBEAT_EVERY.*"5 minutes"/, + /\[SECURITY\] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match \^\\d\+\(s\|m\|h\)\$, got "5 minutes"/, ); }); From cb3a7f4eda1c5655702435707667c9d4f81df476 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 7 May 2026 13:39:57 +0800 Subject: [PATCH 7/7] test(onboard): add #2880 regression for heartbeat Dockerfile patcher Mirror the existing #2281 (NEMOCLAW_AGENT_TIMEOUT) and #2421 (NEMOCLAW_INFERENCE_INPUTS) regression tests for the new NEMOCLAW_AGENT_HEARTBEAT_EVERY patcher in src/lib/onboard.ts. Covers five valid durations (0m, 30m, 5m, 1h, 30s) plus six rejected forms (undefined, "", "30 minutes", "5", "5x", "fast") to lock in the suffix-required regex behavior. Signed-off-by: Dongni Yang Co-Authored-By: Claude Opus 4.7 (1M context) --- test/onboard.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/test/onboard.test.ts b/test/onboard.test.ts index f44677f200a..1434feb4439 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1352,6 +1352,75 @@ describe("onboard helpers", () => { } }); + it("#2880: bakes NEMOCLAW_AGENT_HEARTBEAT_EVERY env into the staged Dockerfile", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-heartbeat-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + const baseDockerfile = [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_BUILD_ID=default", + "ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=", + ].join("\n"); + + const prior = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + try { + // Valid duration values bake in. + for (const value of ["0m", "30m", "5m", "1h", "30s"]) { + fs.writeFileSync(dockerfilePath, baseDockerfile); + process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY = value; + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + `build-heartbeat-${value}`, + "openai-api", + ); + assert.match( + fs.readFileSync(dockerfilePath, "utf8"), + new RegExp(`^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${value}$`, "m"), + `value="${value}" should bake into the ARG line`, + ); + } + + // Cases that must all leave the empty default untouched (regex rejects + // these so the OpenClaw default cadence is preserved). + const rejectCases = [undefined, "", "30 minutes", "5", "5x", "fast"]; + for (const [index, value] of rejectCases.entries()) { + fs.writeFileSync(dockerfilePath, baseDockerfile); + if (value === undefined) { + delete process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + } else { + process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY = value; + } + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + `build-heartbeat-reject-${index}`, + "openai-api", + ); + assert.match( + fs.readFileSync(dockerfilePath, "utf8"), + /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=$/m, + `value="${String(value)}" should not change the empty ARG default`, + ); + } + } finally { + if (prior === undefined) { + delete process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + } else { + process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY = prior; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("patches the staged Dockerfile with Brave Search config when enabled", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-web-")); const dockerfilePath = path.join(tmpDir, "Dockerfile");