Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
27f34d4
fix(messaging): stop rebuild from disabling gateway-backed channels
hunglp6d Aug 31, 2026
a98bee9
Merge branch 'main' into fix/messaging-rebuild-channel-credential-loss
hunglp6d Aug 31, 2026
480a3b0
Merge branch 'main' into fix/messaging-rebuild-channel-credential-loss
hunglp6d Aug 31, 2026
85fde13
merge(main): integrate main for #10707
apurvvkumaria Sep 1, 2026
3c1eded
test(messaging): cover partial Slack credential loss
apurvvkumaria Sep 1, 2026
7729308
fix(messaging): preserve channel state on probe failure
apurvvkumaria Sep 1, 2026
babdb1d
fix(messaging): probe plans before staging
apurvvkumaria Sep 1, 2026
da8b231
merge(main): integrate main for #10707
apurvvkumaria Sep 1, 2026
4b23a42
fix(messaging): normalize legacy Slack bindings
apurvvkumaria Sep 1, 2026
ddfc27a
fix(messaging): preserve provider collision checks
apurvvkumaria Sep 1, 2026
614eeef
merge(main): refresh PR #10707 base
apurvvkumaria Sep 1, 2026
09a80cc
merge(main): refresh PR #10707 base
apurvvkumaria Sep 1, 2026
b126ccc
Merge remote-tracking branch 'refs/remotes/origin/main' into fix/mess…
prekshivyas Sep 2, 2026
84aca3f
fix(messaging): verify gateway-backed lifecycle reuse
prekshivyas Sep 2, 2026
eaaaa12
Merge remote-tracking branch 'refs/remotes/origin/main' into fix/mess…
prekshivyas Sep 2, 2026
bf56e47
test(messaging): split lifecycle reuse coverage
prekshivyas Sep 2, 2026
be23546
Merge remote-tracking branch 'refs/remotes/origin/main' into fix/mess…
prekshivyas Sep 2, 2026
8a3fce2
fix(messaging): address reuse review findings
prekshivyas Sep 2, 2026
f6ebc76
Merge remote-tracking branch 'origin/main' into fix/messaging-rebuild…
prekshivyas Sep 2, 2026
742ae13
chore(ci): remove stale live e2e env allowance
prekshivyas Sep 2, 2026
c4f1997
Merge remote-tracking branch 'origin/main' into fix/messaging-rebuild…
prekshivyas Sep 2, 2026
29a6d29
Merge remote-tracking branch 'origin/main' into fix/messaging-rebuild…
prekshivyas Sep 2, 2026
b46980c
fix(messaging): validate lifecycle credential drift
prekshivyas Sep 2, 2026
16ca4af
test(messaging): scope Google Chat credential fixture
prekshivyas Sep 2, 2026
5f10449
fix(messaging): validate staged credential drift
prekshivyas Sep 2, 2026
194f619
chore: merge latest upstream main
prekshivyas Sep 2, 2026
d1a6c64
merge: resolve conflicts with main
github-actions[bot] Sep 2, 2026
c2ba363
fix(messaging): restore gateway credential inspection
prekshivyas Sep 2, 2026
2a83ba9
fix(messaging): validate provider reuse boundaries
prekshivyas Sep 2, 2026
f418208
refactor(messaging): remove review-only registration seams
prekshivyas Sep 2, 2026
e2e3c16
test(policy): expect fatal OpenShell loss
prekshivyas Sep 2, 2026
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
4 changes: 0 additions & 4 deletions ci/env-var-doc-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,6 @@
"name": "NEMOCLAW_E2E_USE_HOSTED_INFERENCE",
"reason": "Internal E2E-only sentinel that tells CI to route the repository NVIDIA_INFERENCE_API_KEY secret through the hosted inference-api.nvidia.com OpenAI-compatible endpoint. Not user-facing."
},
{
"name": "NEMOCLAW_RUN_LIVE_E2E",
"reason": "Internal Vitest/live-E2E sentinel that permits the exact sandbox-scoped Google Chat fake-mint provider override used by the destructive channel lifecycle fixture. Production users must not set it."
},
{
"name": "NEMOCLAW_COMPAT_MODEL",
"reason": "Internal E2E/test override for the model used by OpenAI-compatible endpoint scenarios. User-facing custom endpoint model selection is collected through onboard prompts or NEMOCLAW_MODEL."
Expand Down
31 changes: 14 additions & 17 deletions docs/manage-sandboxes/enable-channels-during-onboarding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
title: "Enable Channels During Onboarding"
sidebar-title: "Enable Channels During Onboarding"
description: "Select messaging channels and supply their credentials or pairing inputs during NemoClaw onboarding."
description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels, creating OpenShell bridge providers, and removing a channel by clearing its host inputs. Use when enabling or disabling channels during onboarding."
description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels, creating OpenShell bridge providers, and removing a channel with the channel lifecycle commands. Use when enabling or disabling channels during onboarding."
keywords: ["nemoclaw onboard messaging", "messaging channel picker", "channel environment variables", "disable messaging channel"]
content:
type: "how_to"
Expand Down Expand Up @@ -87,27 +87,24 @@ NemoClaw compiles the selected channel configuration into `NEMOCLAW_MESSAGING_PL
The build applies the selected agent configuration, writes reduced runtime metadata to `/usr/local/share/nemoclaw/messaging-runtime-plan.json`, and removes the full build plan from the runtime environment.
Credential bindings remain OpenShell credential placeholders, so raw messaging credentials do not enter the sandbox image or agent configuration.

## Stop Configuring a Channel
## Remove a Channel

Onboarding reads the host inputs on every run, so clearing a channel's inputs and re-onboarding removes it.
Unset the channel's environment variables.
Run onboarding again.
Use `channels remove` when you want to delete a channel's OpenShell provider, runtime configuration, and matching network policy.
This action can also clear stored pairing state.
Use `channels stop` when you want to pause the channel without deleting credentials or pairing state.

NemoClaw reports the removal and drops the channel's network policy preset with it, so the sandbox does not keep the wider egress of a channel it no longer serves.

Expected output:

```text
No host inputs configure discord; disabling the channel and its network egress.
[non-interactive] Applying policy presets: npm, pypi
```bash
$$nemoclaw <sandbox> channels remove <channel>
```

Onboarding uses the current host inputs to determine whether a token-based channel remains configured.
`$$nemoclaw credentials reset` takes an OpenShell provider name and does not change those host inputs.
Accept the rebuild to remove the channel configuration and its network policy preset from the replacement sandbox.
When onboarding runs without a terminal on stdin or with `NEMOCLAW_NON_INTERACTIVE=1`, NemoClaw queues the removal.
Run `$$nemoclaw <sandbox> rebuild` to apply it.
Clearing the channel's host environment variables is not a removal signal when the matching provider remains in OpenShell.
Onboarding reuses that provider and keeps the channel selected, including its network egress.

A QR-paired channel such as WhatsApp is exempt.
The host holds no value that reports whether the pairing is still live, so an absent host input is not evidence that you removed the channel.
Use [`channels remove`](manage-messaging-channels) for those.
For a QR-paired channel such as WhatsApp, only `channels remove` clears the in-sandbox pairing state.
Refer to [Manage Messaging Channels](manage-messaging-channels) for channel-specific removal effects and recovery steps.

## Verify the Result

Expand Down
11 changes: 5 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,8 +892,7 @@ const verifyDirectSandboxGpu = sandboxGpuPreflight.createDirectSandboxGpuVerifie
redact,
});

const registeredCredentialProviders =
credentialProviderRegistration.createCredentialProviderRegistration({
const registration = credentialProviderRegistration.createCredentialProviderRegistration({
root: ROOT,
runOpenshell,
getGatewayName: () => GATEWAY_NAME,
Expand All @@ -902,9 +901,8 @@ const registeredCredentialProviders =
stagedLegacyValues,
migratedLegacyKeys,
persistMigratedLegacyKeys,
});
const { upsertProvider, upsertMessagingProviders, providerMatchesGatewayCredential } =
registeredCredentialProviders;
});
const { upsertProvider, upsertMessagingProviders, providerMatchesGatewayCredential } = registration;
const providerExistsInGateway = (name: string, gatewayName: string = GATEWAY_NAME) =>
onboardProviders.providerExistsInGateway(
name,
Expand Down Expand Up @@ -2520,7 +2518,7 @@ const sandboxCreateIntentResolver = sandboxCreateIntentResolution.createSandboxC
const stageSandboxCredentialProviders = (
input: import("./onboard/credential-provider-registration").StageSandboxCredentialProvidersInput<AgentDefinition | null>,
) =>
registeredCredentialProviders.stageSandboxCredentialProviders(
registration.stageSandboxCredentialProviders(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
input,
sandboxCreateIntentResolver.prepareCredentialProviders,
);
Expand Down Expand Up @@ -3215,6 +3213,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
clearPlanEnv: messagingChannelSetup.clearPlanEnv,
getRegistrySandboxMessagingAuthority:
messagingChannelSetup.getRegistrySandboxMessagingAuthority,
inspectGatewayCredential: registration.inspectGatewayCredential,
providerMatchesGatewayCredential,
stageSandboxCredentialProviders,
promptValidatedSandboxName,
Expand Down
124 changes: 99 additions & 25 deletions src/lib/onboard/checkpoint-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../state/onboard-checkpoint-types";
import {
checkpointSandboxIdentityMatches,
collectRequiredMessagingProviderBindings,
observeProviderEffectFingerprint,
planEffectGroupReplay,
planSandboxCreateReplay,
Expand Down Expand Up @@ -377,6 +378,104 @@ describe("requiredMessagingProviderBindings", () => {
},
]);
});

it("uses every credential and current provider identity for one active channel (#10660)", () => {
const plan: SandboxMessagingPlan = {
schemaVersion: 1,
sandboxName: "alpha",
agent: "openclaw",
workflow: "onboard",
channels: [
{
channelId: "telegram",
displayName: "Telegram",
authMode: "token-paste",
active: true,
selected: true,
configured: true,
disabled: false,
inputs: [],
hooks: [],
},
{
channelId: "slack",
displayName: "Slack",
authMode: "token-paste",
active: true,
selected: true,
configured: true,
disabled: false,
inputs: [],
hooks: [],
},
],
disabledChannels: [],
credentialBindings: [
{
channelId: "telegram",
credentialId: "telegramBotToken",
sourceInput: "botToken",
providerName: "alpha-telegram-bridge",
providerEnvKey: "TELEGRAM_BOT_TOKEN",
placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN",
credentialAvailable: true,
},
{
channelId: "slack",
credentialId: "slackBotToken",
sourceInput: "botToken",
providerName: "alpha-slack-bridge",
providerEnvKey: "SLACK_BOT_TOKEN",
placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN",
credentialAvailable: true,
},
{
channelId: "slack",
credentialId: "slackAppToken",
sourceInput: "appToken",
providerName: "alpha-slack-bridge",
providerEnvKey: "SLACK_APP_TOKEN",
placeholder: "openshell:resolve:env:SLACK_APP_TOKEN",
credentialAvailable: true,
},
],
networkPolicy: { presets: [], entries: [] },
agentRender: [],
buildSteps: [],
stateUpdates: [],
healthChecks: [],
};

expect(collectRequiredMessagingProviderBindings("alpha", plan, new Set(["slack"]))).toEqual([
{
name: "alpha-slack-bridge",
type: "nemoclaw-mcp-v1",
credentialEnv: "SLACK_BOT_TOKEN",
},
{
name: "alpha-slack-bridge",
type: "nemoclaw-mcp-v1",
credentialEnv: "SLACK_APP_TOKEN",
},
]);
expect(requiredMessagingProviderBindings("alpha", plan)).toEqual([
{
name: "alpha-telegram-bridge",
type: "nemoclaw-mcp-v1",
credentialEnv: "TELEGRAM_BOT_TOKEN",
},
{
name: "alpha-slack-bridge",
type: "nemoclaw-mcp-v1",
credentialEnv: "SLACK_BOT_TOKEN",
},
{
name: "alpha-slack-app",
type: "nemoclaw-mcp-v1",
credentialEnv: "SLACK_APP_TOKEN",
},
]);
});
});

describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => {
Expand Down Expand Up @@ -422,31 +521,6 @@ describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => {
});
});

describe("crash-then-resume matrix proves at-most-once destructive create (#6228)", () => {
const states = [
"sandbox",
"openclaw",
"agent_setup",
"policies",
"finalizing",
"post_verify",
] as const;

it.each(
states,
)("crash at %s: reuse a surviving sandbox, recreate under the same identity when it is gone", (state) => {
const cp = checkpoint({
machineState: state,
effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } },
});
expect(planSandboxCreateReplay(cp, { liveSandboxExists: true }).action).toBe("reuse");
expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({
action: "create",
identity: { name: "my-sandbox", agent: "openclaw" },
});
});
});

describe("revalidateCheckpointBindings fails closed without leaking values (#6228)", () => {
it("passes when every binding is currently available", () => {
const cp = checkpoint({
Expand Down
81 changes: 71 additions & 10 deletions src/lib/onboard/checkpoint-replay.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { listMessagingCredentialMetadata } from "../messaging/channels/metadata";
import type { SandboxMessagingPlan } from "../messaging/manifest";
import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../messaging/provider-profile";
import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation";
Expand Down Expand Up @@ -139,17 +140,23 @@ export function requiredWebSearchProviderType(
: provider;
}

export function requiredMessagingProviderBindings(
/** Collect every active credential binding, including multiple keys owned by one provider. */
export function collectRequiredMessagingProviderBindings(
sandboxName: string,
plan: SandboxMessagingPlan | null,
channelIds?: ReadonlySet<string>,
): CheckpointProviderBinding[] {
if (!plan) return [];
const activeChannels = new Set(getActiveChannelIdsFromPlan(plan));
const activeChannels = new Set(
getActiveChannelIdsFromPlan(plan).filter(
(channelId) => channelIds === undefined || channelIds.has(channelId),
),
);
const profiles = messagingBridgeProfilesForAgent(plan.agent, listMessagingBridgeProfiles());
const bindings = new Map<string, CheckpointProviderBinding>();
const bindings: CheckpointProviderBinding[] = [];
for (const binding of plan.credentialBindings) {
if (!activeChannels.has(binding.channelId)) continue;
bindings.set(binding.providerName, {
bindings.push({
name: binding.providerName,
type:
staticMessagingProviderTypeForChannel(binding.channelId, plan.agent, profiles) ??
Expand All @@ -160,13 +167,67 @@ export function requiredMessagingProviderBindings(
for (const profile of profiles) {
if (!activeChannels.has(profile.channelId)) continue;
const name = `${sandboxName}-${profile.channelId}-bridge`;
const existing = bindings.get(name);
bindings.set(
name,
existing
? { ...existing, type: profile.profileId }
: { name, type: profile.profileId, credentialEnv: profile.credentialKey },
if (bindings.some((binding) => binding.name === name)) continue;
bindings.push({ name, type: profile.profileId, credentialEnv: profile.credentialKey });
}
return bindings;
}

/** Replace proven legacy provider names with the current manifest-owned names. */
export function normalizeMessagingProviderBindings(
sandboxName: string,
plan: SandboxMessagingPlan,
): SandboxMessagingPlan {
const providerNamesByCredential = new Map(
listMessagingCredentialMetadata({ agent: plan.agent }).map((credential) => [
`${credential.channelId}\0${credential.providerEnvKey}`,
credential.providerNameTemplate.replaceAll("{sandboxName}", sandboxName),
]),
);
const currentProviderCredentialEnvs = new Map<string, Set<string>>();
for (const binding of plan.credentialBindings) {
const providerName = providerNamesByCredential.get(
`${binding.channelId}\0${binding.providerEnvKey}`,
);
if (providerName !== binding.providerName) continue;
const key = `${binding.channelId}\0${binding.providerName}`;
const credentialEnvs = currentProviderCredentialEnvs.get(key) ?? new Set<string>();
credentialEnvs.add(binding.providerEnvKey);
currentProviderCredentialEnvs.set(key, credentialEnvs);
}
let changed = false;
const credentialBindings = plan.credentialBindings.map((binding) => {
const currentProviderName = providerNamesByCredential.get(
`${binding.channelId}\0${binding.providerEnvKey}`,
);
if (!currentProviderName || currentProviderName === binding.providerName) return binding;
const siblingCredentialEnvs = currentProviderCredentialEnvs.get(
`${binding.channelId}\0${binding.providerName}`,
);
const hasCurrentSibling = [...(siblingCredentialEnvs ?? [])].some(
(providerEnvKey) => providerEnvKey !== binding.providerEnvKey,
);
if (!hasCurrentSibling) return binding;
changed = true;
return { ...binding, providerName: currentProviderName };
});
return changed ? { ...plan, credentialBindings } : plan;
}

export function requiredMessagingProviderBindings(
sandboxName: string,
plan: SandboxMessagingPlan | null,
channelIds?: ReadonlySet<string>,
): CheckpointProviderBinding[] {
if (!plan) return [];
const registrationPlan = normalizeMessagingProviderBindings(sandboxName, plan);
const bindings = new Map<string, CheckpointProviderBinding>();
for (const binding of collectRequiredMessagingProviderBindings(
sandboxName,
registrationPlan,
channelIds,
)) {
bindings.set(binding.name, binding);
}
return [...bindings.values()];
}
Expand Down
Loading
Loading