Skip to content

fix(cli): add tunnel stop + per-channel stop/start (#2103)#2210

Merged
ericksoa merged 5 commits into
mainfrom
fix/2103-tunnel-stop-and-channels-stop
Apr 22, 2026
Merged

fix(cli): add tunnel stop + per-channel stop/start (#2103)#2210
ericksoa merged 5 commits into
mainfrom
fix/2103-tunnel-stop-and-channels-stop

Conversation

@cjagwani

@cjagwani cjagwani commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #2103 where nemoclaw stop lied — it only stopped the cloudflared tunnel, not Telegram/Discord bridges, which moved inside the sandbox in #1081 and couldn't be paused without destroying the sandbox.

  • Rename nemoclaw stop/startnemoclaw tunnel stop/start. Legacy stop/start kept as deprecated aliases that print a warning and delegate.
  • New nemoclaw <sandbox> channels stop <channel> and channels start <channel>. Marks the channel disabled in the per-sandbox registry, rebuilds, and the onboard code skips registering that bridge with the gateway. Credentials stay in the keychain — non-destructive, unlike channels remove.

Why rebuild and not openshell sandbox exec

Aaron floated the shields-down exec-into-sandbox pattern in the #2103 thread. On closer look, that path doesn't work cleanly for this:

  • openclaw.json is read-only at runtime (Landlock + root:root 444), and NemoClaw's sandbox entrypoint explicitly intercepts openclaw channels add/remove from inside with a host-side-only error.
  • openclaw gateway stop is too coarse — kills every channel plus agent comms.
  • SIGTERM on the bridge process works but doesn't persist across a gateway restart.
  • No per-channel runtime CLI exists in OpenClaw today.

So this PR mirrors channels remove's config-patch + rebuild plumbing. A rebuild takes a few minutes and preserves workspace data, which is strictly better than the current "delete the sandbox" workaround.

Note on #2103's policy-remove claim

#2103's issue body said "No remove/delete/revoke logic exists anywhere in policies.ts." That's stale — removePreset() at src/lib/policies.ts:278 and the nemoclaw <sandbox> policy-remove <preset> CLI dispatch at src/nemoclaw.ts:3088 already exist. No code change needed for that line item.

Test plan

  • npx vitest run --project cli — 1740 passed, 7 skipped (3 new registry tests for setChannelDisabled/getDisabledChannels)
  • npm run typecheck:cli clean
  • Manual: help text renders new commands, nemoclaw stop prints deprecation + still works, nemoclaw tunnel stop works directly, nemoclaw tunnel bogus prints usage
  • Manual: nemoclaw <sandbox> channels stop telegram with fake registry → registry gains disabledChannels: ["telegram"]; channels start telegram removes the entry; dry-run honored; unknown channel rejected
  • Not yet validated against a live sandbox rebuild (the onboard-side filter that excludes disabledChannels from messagingTokenDefs is a one-liner, but hasn't been E2E-tested end-to-end)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added tunnel start|stop command for service management
    • Added channels start|stop commands to enable/disable specific messaging channels in sandboxes
  • Deprecations

    • Marked nemoclaw start|stop as deprecated; use tunnel start|stop instead
  • Tests

    • Added test coverage for channel enable/disable functionality

Issue #2103: `nemoclaw stop` claimed to stop all services but only
stopped the cloudflared tunnel. Bridges moved inside the sandbox in
PR #1081 and couldn't be paused without destroying the sandbox.

Rename:
- `nemoclaw tunnel start|stop` — cloudflared tunnel lifecycle
- `nemoclaw start|stop` kept as deprecated aliases w/ warning

Per-channel lifecycle (new):
- `nemoclaw <sandbox> channels stop <channel>` — marks channel as
  disabled in the sandbox registry, triggers a rebuild, credentials stay
  in the keychain
- `nemoclaw <sandbox> channels start <channel>` — unmarks and rebuilds
- onboard.ts filters `messagingTokenDefs` by `disabledChannels` so
  disabled bridges aren't registered with the gateway on rebuild

registry.ts:
- `SandboxEntry.disabledChannels?: string[]`
- `getDisabledChannels(name)` + `setChannelDisabled(name, channel, bool)`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds per-sandbox disabled-channel state persisted in the registry; onboarding now omits credentials for disabled channels when creating/rebuilding sandboxes; CLI gains tunnel start|stop and channels start|stop commands to toggle channel state and trigger rebuilds, with deprecation messages for old aliases.

Changes

Cohort / File(s) Summary
Registry Layer
src/lib/registry.ts, test/registry.test.ts
Add disabledChannels?: string[] to SandboxEntry. Implement getDisabledChannels(name) and setChannelDisabled(name, channel, disabled) with atomic updates and persistence rules. Tests cover enable/disable toggles, persistence cleanup, and non-existent sandbox behavior.
Onboarding Layer
src/lib/onboard.ts
createSandbox() derives disabledChannels via registry.getDisabledChannels() and builds a disabledEnvKeys set to exclude messaging token definitions whose env keys correspond to disabled channels when assembling providers/tokens. Persist disabledChannels on sandbox registration when non-empty.
CLI Layer
src/nemoclaw.ts
Add global tunnel command (`start

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as "nemoclaw (CLI)"
    participant Registry
    participant Onboard as "createSandbox()"
    participant Messaging

    User->>CLI: channels stop <channel>
    CLI->>Registry: setChannelDisabled(sandbox, channel, true)
    Registry->>Registry: update disabledChannels (atomic)
    Registry-->>CLI: success
    CLI->>CLI: promptAndRebuild(sandbox, "stop")
    CLI->>Onboard: rebuild sandbox
    Onboard->>Registry: getDisabledChannels(sandbox)
    Registry-->>Onboard: [disabled channels]
    Onboard->>Onboard: compute disabledEnvKeys
    Onboard->>Onboard: filter messagingTokenDefs (exclude disabledEnvKeys)
    Onboard-->>Messaging: register providers (omitting disabled)
    Onboard-->>CLI: sandbox rebuilt
    CLI-->>User: Channel disabled, sandbox rebuilt
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I hopped through tunnels, soft and quick,
I told some channels: "Stop — be slick."
The registry logged each gentle ban,
Onboard trimmed keys with a careful plan.
Sandbox hushes while carrots tick.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix(cli): add tunnel stop + per-channel stop/start (#2103)' accurately summarizes the main changes: adding tunnel stop functionality and per-channel stop/start commands to the CLI.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/2103-tunnel-stop-and-channels-stop

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/onboard.ts (1)

3385-3417: ⚠️ Potential issue | 🟠 Major

Persist disabled channel state across rebuilds (currently lost after recreate).

You correctly filter out disabled channels here, but the recreate path later deletes the registry entry and re-registers it without disabledChannels, so stopped channels can silently re-enable on subsequent rebuilds.

🔧 Proposed fix
-  const disabledEnvKeys = new Set(
-    MESSAGING_CHANNELS.filter((c) => registry.getDisabledChannels(sandboxName).includes(c.name))
+  const disabledChannels = registry.getDisabledChannels(sandboxName);
+  const disabledEnvKeys = new Set(
+    MESSAGING_CHANNELS.filter((c) => disabledChannels.includes(c.name))
       .flatMap((c) => (c.appTokenEnvKey ? [c.envKey, c.appTokenEnvKey] : [c.envKey])),
   );
   registry.registerSandbox({
     name: sandboxName,
     model: model || null,
     provider: provider || null,
@@
     providerCredentialHashes:
       Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined,
     messagingChannels: activeMessagingChannels,
+    disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined,
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard.ts` around lines 3385 - 3417, The disabled-channel state
filtered into disabledEnvKeys is lost because the recreate path deletes and
re-registers the registry entry without preserving
registry.getDisabledChannels(sandboxName); modify the recreate/registration
logic (the code that currently removes and re-adds the sandbox registry entry)
to read the existing disabled list via registry.getDisabledChannels(sandboxName)
and pass it through when re-creating the registry entry (i.e., include
disabledChannels: registry.getDisabledChannels(sandboxName) or copy the array
into the object you pass to registry.create/update), so the disabled channels
remain honored by the messagingTokenDefs/disabledEnvKeys logic after a recreate.
🧹 Nitpick comments (1)
src/nemoclaw.ts (1)

1850-1882: Avoid rebuilds for no-op channel state changes

This flow rebuilds even when the channel is already in the requested state (e.g., stopping an already stopped channel). That adds unnecessary churn and wait time.

♻️ Suggested refinement
 async function sandboxChannelsSetEnabled(sandboxName, args, disabled) {
   const verb = disabled ? "stop" : "start";
   const dryRun = args.includes("--dry-run");
   const channelArg = args.find((arg) => !arg.startsWith("-"));
@@
   const normalized = channelArg.trim().toLowerCase();
+  const currentDisabled = new Set(registry.getDisabledChannels(sandboxName) || []).has(normalized);
+  if (currentDisabled === disabled) {
+    console.log(
+      `  Channel '${normalized}' is already ${disabled ? "disabled" : "enabled"} for '${sandboxName}'.`,
+    );
+    return;
+  }
+
   if (dryRun) {
     console.log(
       `  --dry-run: would ${verb} channel '${normalized}' for '${sandboxName}'.`,
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 1850 - 1882, The command always triggers
promptAndRebuild even when the channel is already in the requested state; update
sandboxChannelsSetEnabled to skip the rebuild for no-op changes: before calling
promptAndRebuild, check the current disabled/enabled state for the channel
(e.g., via registry.getSandbox(...) or registry.getChannel(...) or similar) and
if currentState === disabled then log a message like "channel already
{disabled|enabled} for '{sandboxName}'" and return; if such a read method
doesn't exist, change registry.setChannelDisabled to return a tri-state (e.g.,
false = sandbox not found, "no-change" = already in requested state, true =
state changed) or add a separate registry method that returns whether the call
actually changed state, then only call promptAndRebuild when a real state change
occurred.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/nemoclaw.ts`:
- Around line 2992-2994: The "tunnel" case in the global command switch is
unreachable because GLOBAL_COMMANDS does not include "tunnel"; add "tunnel" to
the GLOBAL_COMMANDS set (or remove the GLOBAL_COMMANDS gate depending on intent)
so the switch can dispatch to the tunnel(args) handler; ensure any tests or
command registration that rely on GLOBAL_COMMANDS are updated accordingly so the
case "tunnel": await tunnel(args); break; is reachable.

---

Outside diff comments:
In `@src/lib/onboard.ts`:
- Around line 3385-3417: The disabled-channel state filtered into
disabledEnvKeys is lost because the recreate path deletes and re-registers the
registry entry without preserving registry.getDisabledChannels(sandboxName);
modify the recreate/registration logic (the code that currently removes and
re-adds the sandbox registry entry) to read the existing disabled list via
registry.getDisabledChannels(sandboxName) and pass it through when re-creating
the registry entry (i.e., include disabledChannels:
registry.getDisabledChannels(sandboxName) or copy the array into the object you
pass to registry.create/update), so the disabled channels remain honored by the
messagingTokenDefs/disabledEnvKeys logic after a recreate.

---

Nitpick comments:
In `@src/nemoclaw.ts`:
- Around line 1850-1882: The command always triggers promptAndRebuild even when
the channel is already in the requested state; update sandboxChannelsSetEnabled
to skip the rebuild for no-op changes: before calling promptAndRebuild, check
the current disabled/enabled state for the channel (e.g., via
registry.getSandbox(...) or registry.getChannel(...) or similar) and if
currentState === disabled then log a message like "channel already
{disabled|enabled} for '{sandboxName}'" and return; if such a read method
doesn't exist, change registry.setChannelDisabled to return a tri-state (e.g.,
false = sandbox not found, "no-change" = already in requested state, true =
state changed) or add a separate registry method that returns whether the call
actually changed state, then only call promptAndRebuild when a real state change
occurred.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ed008fb-f224-46c2-a7bc-08046938a35f

📥 Commits

Reviewing files that changed from the base of the PR and between b2ef7c6 and 4f26ea0.

📒 Files selected for processing (4)
  • src/lib/onboard.ts
  • src/lib/registry.ts
  • src/nemoclaw.ts
  • test/registry.test.ts

Comment thread src/nemoclaw.ts
… case

Without this the switch never matched — 'nemoclaw tunnel stop' fell through
to 'Unknown command: tunnel'. Caught while manually exercising the PR.
@cjagwani cjagwani self-assigned this Apr 22, 2026
@cjagwani cjagwani added security Potential vulnerability, unsafe behavior, or access risk priority: high labels Apr 22, 2026

@ericksoa ericksoa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work on this PR — the registry layer is clean and well-tested, the deprecation path is sensible, and the PR description is thorough.

One issue needs fixing before merge:

Disabled channels are lost on rebuild

createSandbox() in onboard.ts reads disabledChannels to filter out bridges, but when registerSandbox() is called later it overwrites the registry entry without preserving the disabledChannels field. This means a stopped channel silently re-enables on the next rebuild — defeating the purpose of the feature.

Suggested fix: read the disabled list before the rebuild and pass it through:

const disabledChannels = registry.getDisabledChannels(sandboxName);
// ... later, in registerSandbox():
registry.registerSandbox({
  ...entry,
  disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined,
});

Additionally (nice-to-have): sandboxChannelsSetEnabled triggers a full rebuild even when the channel is already in the requested state. Since rebuilds take a few minutes, a short-circuit for no-ops would be a good UX improvement:

const alreadyDisabled = registry.getDisabledChannels(sandboxName).includes(normalized);
if (alreadyDisabled === disabled) {
  console.log(`  Channel '${normalized}' is already ${disabled ? "disabled" : "enabled"}.`);
  return;
}

@cjagwani cjagwani requested a review from ericksoa April 22, 2026 04:39
cjagwani and others added 2 commits April 21, 2026 21:43
1. (blocker) Preserve disabledChannels across rebuilds. createSandbox()
   filtered disabledChannels into disabledEnvKeys but the later
   registerSandbox() overwrote the registry entry without the field, so
   next rebuild silently re-enabled the channel. Capture once, pass
   through registerSandbox, teach registerSandbox to carry it.

2. (nice-to-have) Short-circuit no-op channels stop|start — skip the
   rebuild prompt when the channel is already in the requested state.

New test proves disabledChannels survives re-registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@ericksoa ericksoa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up commit 8982710b cleanly addresses both issues from my earlier review:

1. Disabled channels preserved on rebuilddisabledChannels is now read before the rebuild and passed through to registerSandbox(). The registry layer also persists the field on re-registration. New test covers the round-trip.

2. No-op rebuild guardsandboxChannelsSetEnabled now short-circuits when the channel is already in the requested state, avoiding unnecessary multi-minute rebuilds.

Both fixes are minimal and correct. Approving.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/nemoclaw.ts (1)

1851-1900: Validate and reject extra args for channels start|stop.

channels start|stop currently consumes only the first positional channel and ignores other unexpected args/flags. Consider strict validation to avoid silent operator mistakes in scripts.

Proposed diff
 async function sandboxChannelsSetEnabled(sandboxName, args, disabled) {
   const verb = disabled ? "stop" : "start";
   const dryRun = args.includes("--dry-run");
-  const channelArg = args.find((arg) => !arg.startsWith("-"));
+  const channelArgs = args.filter((arg) => !arg.startsWith("-"));
+  const unknownFlags = args.filter((arg) => arg.startsWith("-") && arg !== "--dry-run");
+  if (unknownFlags.length > 0) {
+    console.error(`  Unknown argument(s): ${unknownFlags.join(", ")}`);
+    console.error(`  Usage: nemoclaw <sandbox> channels ${verb} <channel> [--dry-run]`);
+    process.exit(1);
+  }
+  const channelArg = channelArgs[0];
+  if (channelArgs.length > 1) {
+    console.error(`  Unexpected extra argument(s): ${channelArgs.slice(1).join(", ")}`);
+    console.error(`  Usage: nemoclaw <sandbox> channels ${verb} <channel> [--dry-run]`);
+    process.exit(1);
+  }
   if (!channelArg) {
     console.error(`  Usage: nemoclaw <sandbox> channels ${verb} <channel> [--dry-run]`);
     console.error(`  Valid channels: ${knownChannelNames().join(", ")}`);
     process.exit(1);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 1851 - 1900, The sandboxChannelsSetEnabled
handler currently picks the first positional arg and ignores any extra
args/flags which can hide mistakes; update sandboxChannelsSetEnabled to strictly
validate args: accept exactly one positional channel name and optionally a
single flag --dry-run, reject any other flags or additional positional values,
printing the same usage/valid channels messages and exiting non-zero; reference
the existing symbols sandboxChannelsSetEnabled (entry point),
sandboxChannelsStart/sandboxChannelsStop (callers), knownChannelNames() (for
usage output), and registry.setChannelDisabled/promptAndRebuild (unchanged
success path) so you only add validation before getChannelDef and dry-run logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/nemoclaw.ts`:
- Around line 1851-1900: The sandboxChannelsSetEnabled handler currently picks
the first positional arg and ignores any extra args/flags which can hide
mistakes; update sandboxChannelsSetEnabled to strictly validate args: accept
exactly one positional channel name and optionally a single flag --dry-run,
reject any other flags or additional positional values, printing the same
usage/valid channels messages and exiting non-zero; reference the existing
symbols sandboxChannelsSetEnabled (entry point),
sandboxChannelsStart/sandboxChannelsStop (callers), knownChannelNames() (for
usage output), and registry.setChannelDisabled/promptAndRebuild (unchanged
success path) so you only add validation before getChannelDef and dry-run logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 56043554-2064-46c1-a81a-59dc942052a5

📥 Commits

Reviewing files that changed from the base of the PR and between ece79bb and bfbe0e5.

📒 Files selected for processing (4)
  • src/lib/onboard.ts
  • src/lib/registry.ts
  • src/nemoclaw.ts
  • test/registry.test.ts
✅ Files skipped from review due to trivial changes (2)
  • test/registry.test.ts
  • src/lib/registry.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/onboard.ts

@ericksoa ericksoa merged commit 946387a into main Apr 22, 2026
17 checks passed
@cv cv added the v0.0.23 label Apr 22, 2026
@wscurran wscurran added bug-fix PR fixes a bug or regression and removed priority: high labels Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix PR fixes a bug or regression security Potential vulnerability, unsafe behavior, or access risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[All Platforms]The stop command only manages host-side cloudflared

4 participants