Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ reviews:
- `cloud-e2e` — full onboard + cloud inference
- `sandbox-operations-e2e` — multi-sandbox lifecycle
- `rebuild-openclaw-e2e` — workspace state survives rebuild
- `channels-stop-start-e2e` — channel disable/enable lifecycle across
rebuild with cached Telegram credentials
- `messaging-compatible-endpoint-e2e` — Telegram + compatible endpoint
inference.local regression
- `hermes-discord-e2e` — Hermes Discord config schema + placeholder
Expand All @@ -193,9 +195,37 @@ reviews:

To run selectively:
```
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=cloud-e2e,sandbox-operations-e2e,rebuild-openclaw-e2e,messaging-compatible-endpoint-e2e,hermes-discord-e2e,hermes-slack-e2e,openshell-gateway-upgrade-e2e
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=cloud-e2e,sandbox-operations-e2e,rebuild-openclaw-e2e,channels-stop-start-e2e,messaging-compatible-endpoint-e2e,hermes-discord-e2e,hermes-slack-e2e,openshell-gateway-upgrade-e2e
```

- path: "src/lib/onboard/channel-state.ts"
instructions: &e2e-channel-stop-start |
This file controls disabled channel resolution used during onboard
and rebuild. Changes affect whether `channels stop` persists across
sandbox destroy/recreate and whether `channels start` reattaches
cached credentials.

**E2E test recommendation:**
- `channels-stop-start-e2e` — Telegram channel stop/start lifecycle
across rebuild with cached credentials

To run selectively:
```
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=channels-stop-start-e2e
```

- path: "src/lib/state/onboard-session.ts"
instructions: *e2e-channel-stop-start

- path: "src/lib/actions/sandbox/rebuild.ts"
instructions: *e2e-channel-stop-start

- path: "src/lib/commands/sandbox/channels/**"
instructions: *e2e-channel-stop-start

- path: "test/e2e/test-channels-stop-start.sh"
instructions: *e2e-channel-stop-start

- path: "src/lib/actions/inference-set.ts"
instructions: |
This file switches the OpenShell inference route and patches the
Expand Down
44 changes: 43 additions & 1 deletion .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ on:
credential-sanitization-e2e, telegram-injection-e2e,
overlayfs-autofix-e2e, device-auth-health-e2e,
launchable-smoke-e2e, gpu-e2e, gpu-double-onboard-e2e,
brave-search-e2e
channels-stop-start-e2e, brave-search-e2e
required: false
type: string
default: ""
Expand Down Expand Up @@ -371,6 +371,45 @@ jobs:
path: /tmp/nemoclaw-e2e-messaging-compatible-endpoint-install.log
if-no-files-found: ignore

# ── Channels stop/start lifecycle E2E (#3462 Test 1) ─────────
# Regression coverage for #3453 (channels stop must actually disable the
# channel across rebuild) and #3381 (channels start must re-attach from
# the cached credential). Telegram-only — Discord/Slack walk the same
# disabledChannels filter; telegram is the cheapest regression gate.
channels-stop-start-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
(github.event_name != 'workflow_dispatch' ||
inputs.jobs == '' ||
contains(format(',{0},', inputs.jobs), ',channels-stop-start-e2e,'))
runs-on: ubuntu-latest
Comment thread
coderabbitai[bot] marked this conversation as resolved.
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.ref }}

- name: Run channels stop/start lifecycle E2E test
env:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_POLICY_TIER: "open"
NEMOCLAW_SANDBOX_NAME: "e2e-channels-stop-start"
GITHUB_TOKEN: ${{ github.token }}
TELEGRAM_BOT_TOKEN: "test-fake-telegram-token-stop-start-e2e"
TELEGRAM_ALLOWED_IDS: "123456789"
run: bash test/e2e/test-channels-stop-start.sh

- name: Upload install log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: install-log-channels-stop-start
path: /tmp/nemoclaw-e2e-install.log
if-no-files-found: ignore

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# ── Brave Search E2E (#2687) ─────────────────────────────────
# Validates the full Brave Search path with a real BRAVE_API_KEY:
# non-interactive onboard auto-enables web search, the brave network
Expand Down Expand Up @@ -1976,6 +2015,7 @@ jobs:
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
channels-stop-start-e2e,
brave-search-e2e,
kimi-inference-compat-e2e,
token-rotation-e2e,
Expand Down Expand Up @@ -2068,6 +2108,7 @@ jobs:
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
channels-stop-start-e2e,
brave-search-e2e,
kimi-inference-compat-e2e,
token-rotation-e2e,
Expand Down Expand Up @@ -2217,6 +2258,7 @@ jobs:
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
channels-stop-start-e2e,
brave-search-e2e,
kimi-inference-compat-e2e,
token-rotation-e2e,
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/inference-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function baseSession(overrides: Partial<Session> = {}): Session {
policyPresets: null,
messagingChannels: null,
messagingChannelConfig: null,
disabledChannels: null,
migratedLegacyValueHashes: null,
gpuPassthrough: false,
telegramConfig: null,
Expand Down
49 changes: 32 additions & 17 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,31 @@ const { LOCAL_INFERENCE_PROVIDERS, REMOTE_PROVIDER_CONFIG } = require("../../onb
REMOTE_PROVIDER_CONFIG: Record<string, { providerName: string; credentialEnv: string | null }>;
};

import { loadAgent } from "../../agent/defs";
import { ensureAgentBaseImage } from "../../agent/onboard";
import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy";
import * as nim from "../../inference/nim";
import type { Session } from "../../state/onboard-session";
import * as onboardSession from "../../state/onboard-session";
import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime";
import {
detectOpenShellStateRpcPreflightIssue,
detectOpenShellStateRpcResultIssue,
printOpenShellStateRpcIssue,
} from "../../adapters/openshell/gateway-drift";
import * as policies from "../../policy";
import * as registry from "../../state/registry";
import { resolveOpenshell } from "../../adapters/openshell/resolve";
import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime";
import { loadAgent } from "../../agent/defs";
import { ensureAgentBaseImage } from "../../agent/onboard";
import { RD as _RD, B, D, G, R, YW } from "../../cli/terminal-style";
import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy";
import * as nim from "../../inference/nim";
import * as policies from "../../policy";
import { parseLiveSandboxNames } from "../../runtime-recovery";
import { removeSandboxRegistryEntry } from "./destroy";
import { executeSandboxCommand } from "./process-recovery";
import * as sandboxVersion from "../../sandbox/version";
import type { Session } from "../../state/onboard-session";
import * as onboardSession from "../../state/onboard-session";
import * as registry from "../../state/registry";
import * as sandboxState from "../../state/sandbox";
import {
createSystemDeps as createSessionDeps,
getActiveSandboxSessions,
} from "../../state/sandbox-session";
import * as sandboxState from "../../state/sandbox";
import * as sandboxVersion from "../../sandbox/version";
import { B, D, G, R, RD as _RD, YW } from "../../cli/terminal-style";
import { removeSandboxRegistryEntry } from "./destroy";
import { executeSandboxCommand } from "./process-recovery";

const agentRuntime = require("../../../../bin/lib/agent-runtime");

Expand Down Expand Up @@ -543,6 +543,21 @@ export async function rebuildSandbox(
sb.messagingChannelConfig ?? sessionMessagingChannelConfig ?? null;
const hasRebuildMessagingChannels =
registryMessagingChannels !== null || sessionMessagingChannels !== null;
// Snapshot the operator's paused channel set BEFORE `removeSandboxRegistryEntry`
// wipes the registry entry. Otherwise the `disabledChannels` filter inside
// `createSandbox` (onboard.ts) reads back `[]` from the freshly-empty registry
// and the stopped channel comes back live in the rebuilt image. The session
// mirror is the only place this list can survive the destroy/recreate window.
//
// Always re-stash from `sb` — do NOT fall back to a prior session value.
// `sb` is loaded fresh from the registry at the top of rebuildSandbox, so it
// already reflects the latest `channels stop|start` write. The session mirror
// is downstream of the registry; re-stashing on every rebuild keeps a stale
// ["telegram"] from a prior stop/rebuild cycle from leaking into the next
// start/rebuild and filtering the channel back out.
const rebuildDisabledChannels = Array.isArray(sb.disabledChannels)
? sb.disabledChannels.filter((value: unknown): value is string => typeof value === "string")
: [];
log(
`Session before update: sandboxName=${sessionBefore?.sandboxName}, status=${sessionBefore?.status}, resumable=${sessionBefore?.resumable}, provider=${sessionBefore?.provider}, model=${sessionBefore?.model}, sessionMatch=${sessionMatchesSandbox}`,
);
Expand All @@ -558,6 +573,7 @@ export async function rebuildSandbox(
s.agent = rebuildAgent;
s.messagingChannels = rebuildMessagingChannels;
s.messagingChannelConfig = rebuildMessagingChannelConfig;
s.disabledChannels = rebuildDisabledChannels;
// Persist inference selection from the about-to-be-removed registry entry
// so onboard --resume can recreate with the same provider/model in
// non-interactive mode. Without this the registry is gone by the time
Expand Down Expand Up @@ -683,9 +699,8 @@ export async function rebuildSandbox(

const preservedRegistryFields = {
...(hasRebuildMessagingChannels ? { messagingChannels: [...rebuildMessagingChannels] } : {}),
...(Array.isArray(sb.disabledChannels) && sb.disabledChannels.length > 0
? { disabledChannels: [...sb.disabledChannels] }
: {}),
disabledChannels:
rebuildDisabledChannels.length > 0 ? [...rebuildDisabledChannels] : undefined,
...(sb.providerCredentialHashes ? { providerCredentialHashes: sb.providerCredentialHashes } : {}),
};
if (Object.keys(preservedRegistryFields).length > 0) {
Expand Down
32 changes: 16 additions & 16 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,42 +345,42 @@ const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-f

import type { AgentDefinition } from "./agent/defs";
import type { CurlProbeResult } from "./adapters/http/probe";
import type { GatewayReuseState } from "./state/gateway";
import type { GatewayInference } from "./inference/config";
import type { GpuInfo, ValidationResult } from "./inference/local";
import type { WebSearchConfig } from "./inference/web-search";
import {
hydrateMessagingChannelConfig,
type MessagingChannelConfig,
mergeMessagingChannelConfigs,
readMessagingChannelConfigFromEnv,
sanitizeMessagingChannelConfig,
} from "./messaging-channel-config";
import type { ContainerRuntime } from "./platform";
import type { Session, SessionUpdates } from "./state/onboard-session";
import { streamGatewayStart } from "./onboard/gateway";
import { reportGpuPassthroughRecovery } from "./onboard/gpu-recovery";
import { getMessagingToken } from "./onboard/messaging-token";
import type {
DockerDriverBinaryOverrides,
OpenShellInstallDeps,
OpenShellInstallResult,
} from "./onboard/openshell-install";
import { decidePolicyCarryForward } from "./onboard/policy-carryforward";
import type { SelectionDrift } from "./onboard/selection-drift";
import type {
ModelCatalogFetchResult,
ModelValidationResult,
ProbeResult,
ValidationFailureLike,
} from "./onboard/types";
import { getMessagingToken } from "./onboard/messaging-token";
import { decidePolicyCarryForward } from "./onboard/policy-carryforward";
import type { ContainerRuntime } from "./platform";
import type { TierDefinition, TierPreset } from "./policy/tiers";
import { channelHasStaticToken, getChannelTokenKeys, listChannels } from "./sandbox/channels";
import { streamGatewayStart } from "./onboard/gateway";
import { reportGpuPassthroughRecovery } from "./onboard/gpu-recovery";
import type { StreamSandboxCreateResult } from "./sandbox/create-stream";
import type { GatewayReuseState } from "./state/gateway";
import type { Session, SessionUpdates } from "./state/onboard-session";
import type { SandboxEntry } from "./state/registry";
import type { BackupResult } from "./state/sandbox";
import type { TierDefinition, TierPreset } from "./policy/tiers";
import type { SandboxCreateFailure, ValidationClassification } from "./validation";
import type { ProbeRecovery } from "./validation-recovery";
import type { WebSearchConfig } from "./inference/web-search";
import type {
DockerDriverBinaryOverrides,
OpenShellInstallDeps,
OpenShellInstallResult,
} from "./onboard/openshell-install";
import type { SelectionDrift } from "./onboard/selection-drift";

const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1";
const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY;
Expand Down Expand Up @@ -5120,7 +5120,7 @@ async function createSandbox(
// Credentials stay in the keychain; the bridge simply isn't registered with
// the gateway on the next rebuild. `channels start` removes the entry and
// the bridge comes back.
const disabledChannels = registry.getDisabledChannels(sandboxName);
const disabledChannels = require("./onboard/channel-state").resolveDisabledChannels(sandboxName);
const disabledEnvKeys = new Set(
MESSAGING_CHANNELS.filter((c) => disabledChannels.includes(c.name)).flatMap((c) =>
getChannelTokenKeys(c),
Expand Down
42 changes: 42 additions & 0 deletions src/lib/onboard/channel-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import { resolveDisabledChannels } from "./channel-state";

describe("onboard channel state helpers", () => {
it("prefers disabledChannels from the onboard session mirror", () => {
const getRegistryDisabledChannels = vi.fn(() => ["discord"]);

expect(
resolveDisabledChannels("alpha", {
loadSession: () => ({ disabledChannels: ["telegram"] }),
getRegistryDisabledChannels,
}),
).toEqual(["telegram"]);
expect(getRegistryDisabledChannels).not.toHaveBeenCalled();
});

it("falls back to the registry when the session has no mirror", () => {
expect(
resolveDisabledChannels("alpha", {
loadSession: () => ({ disabledChannels: null }),
getRegistryDisabledChannels: (sandboxName) =>
sandboxName === "alpha" ? ["discord"] : [],
}),
).toEqual(["discord"]);
});

it("treats an empty session mirror as authoritative", () => {
const getRegistryDisabledChannels = vi.fn(() => ["telegram"]);

expect(
resolveDisabledChannels("alpha", {
loadSession: () => ({ disabledChannels: [] }),
getRegistryDisabledChannels,
}),
).toEqual([]);
expect(getRegistryDisabledChannels).not.toHaveBeenCalled();
});
});
26 changes: 26 additions & 0 deletions src/lib/onboard/channel-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import * as onboardSession from "../state/onboard-session";
import * as registry from "../state/registry";

type DisabledChannelsSession = Pick<onboardSession.Session, "disabledChannels">;

export type DisabledChannelsDeps = {
loadSession: () => DisabledChannelsSession | null;
getRegistryDisabledChannels: (sandboxName: string) => string[];
};

export function resolveDisabledChannels(
sandboxName: string,
deps?: DisabledChannelsDeps,
): string[] {
// `rebuild` destroys the registry entry before `onboard --resume` reaches
// createSandbox, so the session mirror is authoritative when present.
const sessionDisabledChannels = (deps?.loadSession ?? onboardSession.loadSession)()
?.disabledChannels;
if (Array.isArray(sessionDisabledChannels)) {
return sessionDisabledChannels;
}
return (deps?.getRegistryDisabledChannels ?? registry.getDisabledChannels)(sandboxName);
}
Loading
Loading