fix(cli): add tunnel stop + per-channel stop/start (#2103)#2210
Conversation
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>
📝 WalkthroughWalkthroughAdds per-sandbox disabled-channel state persisted in the registry; onboarding now omits credentials for disabled channels when creating/rebuilding sandboxes; CLI gains Changes
Sequence DiagramsequenceDiagram
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
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~22 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 | 🟠 MajorPersist 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 changesThis 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
📒 Files selected for processing (4)
src/lib/onboard.tssrc/lib/registry.tssrc/nemoclaw.tstest/registry.test.ts
… case Without this the switch never matched — 'nemoclaw tunnel stop' fell through to 'Unknown command: tunnel'. Caught while manually exercising the PR.
ericksoa
left a comment
There was a problem hiding this comment.
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;
}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
left a comment
There was a problem hiding this comment.
Follow-up commit 8982710b cleanly addresses both issues from my earlier review:
1. Disabled channels preserved on rebuild — disabledChannels 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 guard — sandboxChannelsSetEnabled now short-circuits when the channel is already in the requested state, avoiding unnecessary multi-minute rebuilds.
Both fixes are minimal and correct. Approving.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/nemoclaw.ts (1)
1851-1900: Validate and reject extra args forchannels start|stop.
channels start|stopcurrently 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
📒 Files selected for processing (4)
src/lib/onboard.tssrc/lib/registry.tssrc/nemoclaw.tstest/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
Summary
Fixes #2103 where
nemoclaw stoplied — 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.nemoclaw stop/start→nemoclaw tunnel stop/start. Legacystop/startkept as deprecated aliases that print a warning and delegate.nemoclaw <sandbox> channels stop <channel>andchannels 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, unlikechannels remove.Why rebuild and not
openshell sandbox execAaron floated the shields-down exec-into-sandbox pattern in the #2103 thread. On closer look, that path doesn't work cleanly for this:
openclaw.jsonis read-only at runtime (Landlock +root:root 444), and NemoClaw's sandbox entrypoint explicitly interceptsopenclaw channels add/removefrom inside with a host-side-only error.openclaw gateway stopis too coarse — kills every channel plus agent comms.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()atsrc/lib/policies.ts:278and thenemoclaw <sandbox> policy-remove <preset>CLI dispatch atsrc/nemoclaw.ts:3088already exist. No code change needed for that line item.Test plan
npx vitest run --project cli— 1740 passed, 7 skipped (3 new registry tests forsetChannelDisabled/getDisabledChannels)npm run typecheck:clicleannemoclaw stopprints deprecation + still works,nemoclaw tunnel stopworks directly,nemoclaw tunnel bogusprints usagenemoclaw <sandbox> channels stop telegramwith fake registry → registry gainsdisabledChannels: ["telegram"];channels start telegramremoves the entry; dry-run honored; unknown channel rejecteddisabledChannelsfrommessagingTokenDefsis a one-liner, but hasn't been E2E-tested end-to-end)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
tunnel start|stopcommand for service managementchannels start|stopcommands to enable/disable specific messaging channels in sandboxesDeprecations
nemoclaw start|stopas deprecated; usetunnel start|stopinsteadTests