Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
42 changes: 42 additions & 0 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
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.
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.
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 @@ -1948,6 +1987,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 @@ -2039,6 +2079,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 @@ -2187,6 +2228,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
16 changes: 16 additions & 0 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,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 @@ -499,6 +514,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
32 changes: 16 additions & 16 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,11 +333,11 @@ const openshellPinFlow: typeof import("./onboard/openshell-pin") =
const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-failure") =
require("./onboard/sandbox-create-failure");

import type { AgentDefinition } from "./agent/defs";
import type { CurlProbeResult } from "./adapters/http/probe";
import type { GatewayReuseState } from "./state/gateway";
import type { AgentDefinition } from "./agent/defs";
import type { GatewayInference } from "./inference/config";
import type { GpuInfo, ValidationResult } from "./inference/local";
import type { WebSearchConfig } from "./inference/web-search";
import {
hydrateMessagingChannelConfig,
type MessagingChannelConfig,
Expand All @@ -346,31 +346,31 @@ import {
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 type { SelectionDrift } from "./onboard/selection-drift";
import type {
ModelCatalogFetchResult,
ModelValidationResult,
ProbeResult,
ValidationFailureLike,
} from "./onboard/types";
import type { ContainerRuntime } from "./platform";
import type { TierDefinition, TierPreset } from "./policy/tiers";
import { channelHasStaticToken, getChannelTokenKeys, listChannels } from "./sandbox/channels";
import { getMessagingToken } from "./onboard/messaging-token";
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 @@ -5140,7 +5140,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);
}
42 changes: 42 additions & 0 deletions src/lib/state/onboard-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,48 @@ describe("onboard session", () => {
expect(loaded.messagingChannels).toEqual(["telegram", "discord"]);
});

it("persists disabledChannels across save/load roundtrips", () => {
// Regression: `channels stop X` followed by rebuild must carry the paused
// set through the destroy/recreate window. The Session mirror is the only
// place this can survive, because rebuild destroys the registry entry
// before `onboard --resume` reads it back.
const created = session.createSession();
created.disabledChannels = ["telegram"];
session.saveSession(created);

const loaded = requireLoadedSession(session.loadSession());
expect(loaded.disabledChannels).toEqual(["telegram"]);
});

it("filters non-string entries out of persisted disabledChannels", () => {
const created = session.createSession();
fs.mkdirSync(path.dirname(session.SESSION_FILE), { recursive: true });
fs.writeFileSync(
session.SESSION_FILE,
JSON.stringify({
...created,
disabledChannels: ["telegram", 42, null, "discord"],
}),
);

const loaded = requireLoadedSession(session.loadSession());
expect(loaded.disabledChannels).toEqual(["telegram", "discord"]);
});

it("defaults disabledChannels to null for fresh sessions", () => {
const fresh = session.createSession();
expect(fresh.disabledChannels).toBeNull();
});

it("filterSafeUpdates passes through disabledChannels and accepts explicit null clear", () => {
session.saveSession(session.createSession());
session.markStepComplete("provider_selection", { disabledChannels: ["discord"] });
expect(requireLoadedSession(session.loadSession()).disabledChannels).toEqual(["discord"]);

session.markStepComplete("provider_selection", { disabledChannels: null });
expect(requireLoadedSession(session.loadSession()).disabledChannels).toBeNull();
});

it("defaults messagingChannels to null for fresh sessions", () => {
const fresh = session.createSession();
expect(fresh.messagingChannels).toBeNull();
Expand Down
18 changes: 18 additions & 0 deletions src/lib/state/onboard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ export interface Session {
policyPresets: string[] | null;
messagingChannels: string[] | null;
messagingChannelConfig: MessagingChannelConfig | null;
// Channels the operator paused via `nemoclaw <sb> channels stop <ch>`.
// Mirrors `SandboxEntry.disabledChannels` so that `rebuild` — which
// destroys the registry entry before calling `onboard --resume` —
// can carry the paused set across the destroy/recreate window.
// Without this mirror, the disabledChannels filter inside createSandbox
// reads back `[]` from the freshly-empty registry and the channel
// comes back live after rebuild. See #(channels-stop-rebuild bug).
disabledChannels: string[] | null;
// SHA-256 hex digest of every legacy credential value successfully
// written to the OpenShell gateway during this onboard session, keyed by
// env-name. Persisted across process restarts so a `--resume` run that
Expand Down Expand Up @@ -140,6 +148,7 @@ export interface SessionUpdates {
policyPresets?: string[];
messagingChannels?: string[];
messagingChannelConfig?: MessagingChannelConfig | null;
disabledChannels?: string[] | null;
migratedLegacyValueHashes?: Record<string, string>;
gpuPassthrough?: boolean;
telegramConfig?: TelegramConfig | null;
Expand Down Expand Up @@ -329,6 +338,7 @@ export function createSession(overrides: Partial<Session> = {}): Session {
policyPresets: readStringArray(overrides.policyPresets),
messagingChannels: readStringArray(overrides.messagingChannels),
messagingChannelConfig: sanitizeMessagingChannelConfig(overrides.messagingChannelConfig),
disabledChannels: readStringArray(overrides.disabledChannels),
migratedLegacyValueHashes: overrides.migratedLegacyValueHashes
? readStringRecord(overrides.migratedLegacyValueHashes)
: null,
Expand Down Expand Up @@ -368,6 +378,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined):
policyPresets: readStringArray(data.policyPresets),
messagingChannels: readStringArray(data.messagingChannels),
messagingChannelConfig: sanitizeMessagingChannelConfig(data.messagingChannelConfig),
disabledChannels: readStringArray(data.disabledChannels),
migratedLegacyValueHashes: readStringRecord(data.migratedLegacyValueHashes),
gpuPassthrough: data.gpuPassthrough === true,
telegramConfig: parseTelegramConfig(data.telegramConfig),
Expand Down Expand Up @@ -788,6 +799,13 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial<Session> {
const messagingChannelConfig = sanitizeMessagingChannelConfig(updates.messagingChannelConfig);
if (messagingChannelConfig) safe.messagingChannelConfig = messagingChannelConfig;
}
if (updates.disabledChannels === null) {
safe.disabledChannels = null;
} else if (Array.isArray(updates.disabledChannels)) {
safe.disabledChannels = updates.disabledChannels.filter(
(value) => typeof value === "string",
);
}
if (isObject(updates.migratedLegacyValueHashes)) {
const cleaned: Record<string, string> = {};
for (const [k, v] of Object.entries(updates.migratedLegacyValueHashes)) {
Expand Down
Loading
Loading