Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e41fba1
fix: preserve messaging egress on reuse
yimoj Sep 1, 2026
4f93912
test(onboard): resolve messaging reuse review findings
prekshivyas Sep 2, 2026
de0b59b
Merge branch 'main' into fix/10667-preserve-channel-egress
prekshivyas Sep 2, 2026
e3da435
Merge branch 'main' into fix/10667-preserve-channel-egress
prekshivyas Sep 2, 2026
4c85269
docs(onboard): correct channel removal guidance
prekshivyas Sep 2, 2026
63e61a9
fix(onboard): abort unavailable provider inspection
prekshivyas Sep 2, 2026
d77d3ba
fix(onboard): preserve gateway-minted channel reuse
prekshivyas Sep 2, 2026
773a203
Merge remote-tracking branch 'origin/main' into fix/10667-preserve-ch…
prekshivyas Sep 2, 2026
16eb03e
fix(onboard): fail closed on invalid profile inspection
prekshivyas Sep 2, 2026
61b5ac3
Merge remote-tracking branch 'origin/main' into fix/10667-preserve-ch…
prekshivyas Sep 2, 2026
9b1d385
Merge remote-tracking branch 'origin/main' into pr10782-final
prekshivyas Sep 2, 2026
2f1001f
test(review): align conflict fixer inference expectation
prekshivyas Sep 2, 2026
6841c87
fix(onboard): validate reusable messaging profiles
prekshivyas Sep 2, 2026
57db67e
Merge remote-tracking branch 'origin/main' into pr10782-final
prekshivyas Sep 2, 2026
3933a4a
fix(onboard): reuse profile import adapter
prekshivyas Sep 2, 2026
ecb884b
Merge remote-tracking branch 'origin/main' into pr10782-final
prekshivyas Sep 2, 2026
55c99d3
test(onboard): satisfy behavior guardrails
prekshivyas Sep 2, 2026
f9aee48
refactor(onboard): use canonical messaging reconciler
prekshivyas Sep 2, 2026
1bb94ca
docs(onboard): use published messaging route
prekshivyas Sep 2, 2026
1aafd21
docs(onboard): name non-interactive trigger
prekshivyas Sep 2, 2026
ca3d933
merge: resolve conflicts with main
github-actions[bot] Sep 2, 2026
9f66158
merge: resolve conflicts with main
github-actions[bot] Sep 2, 2026
53d110e
merge: resolve conflicts with main
github-actions[bot] Sep 3, 2026
fb778dc
merge: resolve conflicts with main
github-actions[bot] Sep 3, 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
17 changes: 9 additions & 8 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 with the channel lifecycle commands. 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, preserving reusable channels, and using lifecycle commands to stop or explicitly remove a channel. 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 @@ -89,9 +89,8 @@ Credential bindings remain OpenShell credential placeholders, so raw messaging c

## Remove a Channel

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.
Use `channels stop` when you want to pause a channel without deleting its credentials or pairing state.
Use `channels remove` for an explicit, durable removal of its selection, OpenShell provider, runtime configuration, and matching network policy preset:

```bash
$$nemoclaw <sandbox> channels remove <channel>
Expand All @@ -100,11 +99,13 @@ $$nemoclaw <sandbox> channels remove <channel>
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.

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.
Clearing a channel's host environment variables is not a removal signal when its recorded OpenShell gateway provider still matches the channel's credential contract.
Onboarding preserves the channel selection and matching network policy preset because interactive inputs normally disappear between runs.
If a required gateway provider is missing or no longer matches the expected credential contract, onboarding disables the channel and drops the matching policy preset.

For an in-sandbox QR-paired channel such as WhatsApp, only `channels remove` clears its session or pairing state before teardown.
Refer to [Manage Messaging Channels](manage-messaging-channels) for channel-specific removal effects and recovery guidance.

## Verify the Result

Expand Down
16 changes: 12 additions & 4 deletions src/lib/adapters/openshell/provider-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,26 @@ export function parseCheckedInProviderProfileContract(
}

/** Compare an exported gateway profile with its checked-in credential boundary. */
export function exportedProviderProfileMatchesContract(
export function compareExportedProviderProfileWithContract(
exported: string,
expected: CheckedInProviderProfileContract,
): boolean {
): boolean | null {
try {
const actual = providerProfileBoundary(JSON.parse(exported) as unknown);
return actual !== null && isDeepStrictEqual(actual, expected.boundary);
return actual === null ? null : isDeepStrictEqual(actual, expected.boundary);
} catch {
return false;
return null;
}
}

/** Compare an exported gateway profile with its checked-in credential boundary. */
export function exportedProviderProfileMatchesContract(
exported: string,
expected: CheckedInProviderProfileContract,
): boolean {
return compareExportedProviderProfileWithContract(exported, expected) === true;
}

export function isMissingProviderProfile(output: string, profileId: string): boolean {
const normalized = output
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "")
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3270,8 +3270,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
loadSession: onboardSession.loadSession,
getActiveSandbox: (name) => registry.getSandbox(name),
mergePolicyMessagingChannels,
detectUnconfiguredMessagingChannels:
messagingChannelSetup.detectUnconfiguredMessagingChannels,
detectUnconfiguredMessagingChannels: messagingChannelSetup.detectUnconfiguredMessagingChannels,
providerMatchesGatewayCredential,
verifyCompatibleEndpointSandboxSmoke: (options) =>
verifyCompatibleEndpointSandboxSmoke({
...options,
Expand Down
49 changes: 47 additions & 2 deletions src/lib/onboard/credential-provider-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,8 @@ function providerMetadata(
return {
status: 0,
stdout: [
`Id: provider-${name}`,
`Name: ${name}`,
`Type: ${type}`,
"Resource version: 1",
`Credential keys: ${credentialKey}`,
"Config keys: <none>",
].join("\n"),
Expand Down Expand Up @@ -358,6 +356,53 @@ describe("credential provider registration", () => {
).toEqual({ kind: "indeterminate" });
});

it("does not classify an unavailable provider inspection as a missing binding", () => {
const session = { stagedCredentialProviders: [] } as unknown as Session;
const runOpenshell = vi.fn((_args: string[]) => ({
status: 1,
stdout: "",
stderr: "gateway unavailable",
}));
const registration = createCredentialProviderRegistration(
registrationDeps(runOpenshell, session),
);

expect(() =>
registration.providerMatchesGatewayCredential(
"alpha-discord-bridge",
"generic",
"DISCORD_BOT_TOKEN",
),
).toThrow("Could not inspect credential provider 'alpha-discord-bridge'");
expect(runOpenshell).toHaveBeenCalledWith(
["provider", "get", "-g", "test-gateway", "alpha-discord-bridge"],
expect.objectContaining({ ignoreError: true, suppressOutput: true }),
);
});

it("does not classify an unavailable static profile inspection as profile drift", () => {
const session = { stagedCredentialProviders: [] } as unknown as Session;
const runOpenshell = vi.fn((_args: string[]) => ({
status: 1,
stdout: "",
stderr: "gateway unavailable",
}));
const deps = registrationDeps(runOpenshell, session);
deps.root = process.cwd();
const registration = createCredentialProviderRegistration(deps);

expect(() =>
registration.providerMatchesGatewayCredential(
"alpha-discord-bridge",
"discord-hermes-static-v1",
"DISCORD_BOT_TOKEN",
),
).toThrow("Could not inspect static provider profile 'discord-hermes-static-v1'");
expect(runOpenshell.mock.calls.map(([args]) => args.join(" "))).toEqual([
"provider profile -g test-gateway export discord-hermes-static-v1 --output json",
]);
});

it("rejects tokenless Hermes Discord profile drift before provider mutation", async () => {
const session = { stagedCredentialProviders: [] } as unknown as Session;
const runOpenshell = vi.fn((args: string[]) =>
Expand Down
72 changes: 34 additions & 38 deletions src/lib/onboard/credential-provider-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ import { createGatewayScopedOpenshellRunner } from "./setup-inference";

const providers = require("./providers");

/** Late-bound provider upsert seam used by live credential fixtures. */
export const credentialProviderRegistrationDependencies = {
upsertMessagingProviders(
tokenDefs: MessagingTokenDef[],
runOpenshell: OpenshellCliHelpers["runOpenshell"],
options: MessagingProviderRegistrationOptions,
): string[] {
return providers.upsertMessagingProviders(tokenDefs, runOpenshell, options) as string[];
},
};

export interface StageSandboxCredentialProvidersInput<Agent> {
sandboxName: string;
enabledChannels: readonly string[];
Expand Down Expand Up @@ -175,17 +186,16 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg
return result;
}

function upsertMessagingProvidersAtGateway(
function upsertMessagingProviders(
tokenDefs: MessagingTokenDef[],
options: MessagingProviderRegistrationOptions,
gatewayName: string,
runOpenshell: OpenshellCliHelpers["runOpenshell"] = gatewayRunner(gatewayName),
options: MessagingProviderRegistrationOptions = {},
runOpenshell: OpenshellCliHelpers["runOpenshell"] = deps.runOpenshell,
): string[] {
const upserted = providers.upsertMessagingProviders(
const upserted = credentialProviderRegistrationDependencies.upsertMessagingProviders(
tokenDefs,
runOpenshell,
{ ...options, gatewayName },
) as string[];
options,
);
recordMigratedLegacyMessagingCredentials(
tokenDefs,
upserted,
Expand All @@ -195,46 +205,35 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg
return upserted;
}

function upsertMessagingProviders(
tokenDefs: MessagingTokenDef[],
options: MessagingProviderRegistrationOptions = {},
): string[] {
const gatewayName = deps.getGatewayName();
return upsertMessagingProvidersAtGateway(tokenDefs, options, gatewayName);
}

function credentialBindingMatchesGateway(
binding: CheckpointProviderBinding,
runOpenshell: OpenshellCliHelpers["runOpenshell"],
): boolean {
return inspectGatewayCredentialBinding(binding, runOpenshell).kind === "exact";
}

function inspectGatewayCredentialBinding(
binding: CheckpointProviderBinding,
runOpenshell: OpenshellCliHelpers["runOpenshell"],
): gatewayProviderMetadata.GatewayCredentialOnlyProviderInspection {
const profileMatches = messagingBridgeProvider.matchesRegisteredMessagingBridgeProfile(
const staticProfile = messagingBridgeProvider.inspectRegisteredStaticMessagingProfile(
binding.type,
{ root: deps.root, runOpenshell },
);
if (profileMatches === false) return { kind: "indeterminate" };
return gatewayProviderMetadata.inspectGatewayCredentialFamilyProviderBinding(
if (staticProfile.kind === "indeterminate") {
throw new Error(
`Could not inspect static provider profile '${binding.type}' on OpenShell gateway '${deps.getGatewayName()}'. Verify the gateway is reachable, then retry the command.`,
);
}
if (staticProfile.kind === "collision") return false;

const provider = gatewayProviderMetadata.inspectGatewayCredentialFamilyProviderBinding(
{
name: binding.name,
type: binding.type,
credentialKey: binding.credentialEnv,
},
runOpenshell,
);
}

function inspectGatewayCredential(
name: string,
type: string,
credentialEnv: string,
): gatewayProviderMetadata.GatewayCredentialOnlyProviderInspection {
return inspectGatewayCredentialBinding({ name, type, credentialEnv }, gatewayRunner());
if (provider.kind === "indeterminate") {
throw new Error(
`Could not inspect credential provider '${binding.name}' on OpenShell gateway '${deps.getGatewayName()}'. Verify the gateway is reachable, then retry the command.`,
);
}
return provider.kind === "exact";
}

function providerMatchesGatewayCredential(
Expand Down Expand Up @@ -283,8 +282,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg
messaging.messagingTokenDefs.map((tokenDef) => [tokenDef.name, tokenDef]),
);
const tokenDefs = messaging.messagingTokenDefs.filter(hasConfiguredMessagingCredential);
const gatewayName = deps.getGatewayName();
const runOpenshell = gatewayRunner(gatewayName);
const runOpenshell = gatewayRunner();
preflightRequiredCredentialProviderBindings(
input.requiredBindings,
plannedTokenDefs,
Expand All @@ -297,14 +295,13 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg
false,
deps,
);
const registered = upsertMessagingProvidersAtGateway(
const registered = upsertMessagingProviders(
tokenDefs,
{
replaceExisting: input.replaceExisting === true,
allowedSandboxes: input.replaceExisting === true ? [input.sandboxName] : undefined,
revalidateSandboxIdentity: input.revalidateSandboxIdentity,
},
gatewayName,
runOpenshell,
);
input.revalidateSandboxIdentity?.("record staged credential provider receipts");
Expand All @@ -317,7 +314,6 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg
}

return {
inspectGatewayCredential,
providerMatchesGatewayCredential,
stageSandboxCredentialProviders,
upsertProvider,
Expand Down
8 changes: 4 additions & 4 deletions src/lib/onboard/machine/handlers/policies-test-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { vi } from "vitest";

import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures";
import { mergePolicyMessagingChannels } from "../../messaging-policy-presets";
import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session";
import type { PoliciesStateOptions } from "./policies";

Expand All @@ -19,13 +20,11 @@ export function createPolicyHandlerDeps(
activeSandbox: vi.fn(() => ({
messaging: { plan: makeMessagingPlan({ channels: ["telegram"] }) },
})),
mergeChannels: vi.fn(
(selected: string[], recorded: string[], active: string[] | null | undefined) =>
selected.length > 0 ? selected : (active ?? recorded),
),
mergeChannels: vi.fn(mergePolicyMessagingChannels),
unconfiguredChannels: vi.fn(
(_planChannels: readonly string[], _selectedChannels: readonly string[]) => [] as string[],
),
providerMatchesGatewayCredential: vi.fn(() => false),
smoke: vi.fn(),
prepareResume: vi.fn(
(
Expand Down Expand Up @@ -61,6 +60,7 @@ export function createPolicyHandlerDeps(
getActiveSandbox: calls.activeSandbox,
mergePolicyMessagingChannels: calls.mergeChannels,
detectUnconfiguredMessagingChannels: calls.unconfiguredChannels,
providerMatchesGatewayCredential: calls.providerMatchesGatewayCredential,
verifyCompatibleEndpointSandboxSmoke: calls.smoke,
preparePolicyPresetResumeSelection: calls.prepareResume,
arePolicyPresetsApplied: calls.appliedCheck,
Expand Down
Loading