Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,7 @@ These flags toggle optional behaviors during onboarding; set them before running
| `NEMOCLAW_DISABLE_OVERLAY_FIX` | `1` to enable | Skips the Docker overlay-fix step during sandbox build. For environments where the fix is incompatible. |
| `NEMOCLAW_OVERLAY_SNAPSHOTTER` | snapshotter name | Selects the containerd overlay snapshotter for sandbox builds. Empty (default) preserves containerd's choice. |
| `NEMOCLAW_SKIP_TELEGRAM_REACHABILITY` | `1` to enable | Skips the Telegram bot reachability probe during onboard (useful in restricted networks). |
| `NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION` | `1` to enable | Skips the live Slack `auth.test` and `apps.connections.open` credential probes during onboard and `channels add slack`. Use only in restricted networks or hermetic test environments; Slack token format checks still apply. |
| `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | `1` to enable | Accepts a new sandbox config path without an interactive prompt when the stored path differs from the discovered one. |
| `NEMOCLAW_RESOURCE_PROFILE` | profile name or `default` | Selects a sandbox CPU/RAM resource profile from the blueprint during onboarding. `default` means no resource preference, so NemoClaw passes no OpenShell CPU or memory flags. Unknown names fail fast. |
| `NEMOCLAW_CPU` | percentage or Kubernetes CPU quantity | Overrides the selected profile's CPU size passed to OpenShell `--cpu`. Percentages resolve against detected capacity. |
Expand Down
12 changes: 12 additions & 0 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { runOpenshell } from "../../adapters/openshell/runtime";
import { shellQuote } from "../../runner";
import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery";
import { rebuildSandbox } from "./rebuild";
import { validateSlackChannelCredentials } from "./slack-channel-validation";
import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification";
import {
type ChannelDef,
Expand Down Expand Up @@ -824,6 +825,17 @@ export async function addSandboxChannel(
await acquirePasteTokens(canonical, channel, acquired);
}

if (canonical === "slack") {
const validation = validateSlackChannelCredentials(channel, acquired);
if (!validation.ok) {
console.error(` ${validation.message}`);
process.exit(1);
}
if (validation.message) {
console.log(` ${YW}⚠${R} ${validation.message}`);
}
}

persistChannelTokens(acquired);
// Push to the gateway and update the registry NOW so that answering
// "rebuild later" (or running non-interactively) does not silently
Expand Down
57 changes: 57 additions & 0 deletions src/lib/actions/sandbox/slack-channel-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { ChannelDef } from "../../sandbox/channels";
import {
formatSlackValidationFailure,
validateSlackCredentials,
} from "../../onboard/slack-validation";

export type SlackChannelCredentialValidationResult =
| { ok: true; message?: string }
| { ok: false; message: string };

function isAcquiredTokenFormatValid(channel: ChannelDef, envKey: string, token: string): boolean {
if (envKey === channel.envKey) return !channel.tokenFormat || channel.tokenFormat.test(token);
if (envKey === channel.appTokenEnvKey) {
return !channel.appTokenFormat || channel.appTokenFormat.test(token);
}
return false;
}

export function validateSlackChannelCredentials(
channel: ChannelDef,
acquired: Record<string, string>,
): SlackChannelCredentialValidationResult {
if (!channel.envKey || !channel.appTokenEnvKey) {
return { ok: false, message: "Slack channel definition is missing required token keys." };
}

const botToken = acquired[channel.envKey];
const appToken = acquired[channel.appTokenEnvKey];
if (!botToken || !appToken) {
return { ok: false, message: "Slack requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN." };
}

for (const [envKey, token] of Object.entries(acquired)) {
if (!isAcquiredTokenFormatValid(channel, envKey, token)) {
const hint =
envKey === channel.appTokenEnvKey
? channel.appTokenFormatHint || "Check the token and try again."
: channel.tokenFormatHint || "Check the token and try again.";
return { ok: false, message: `Invalid ${envKey} format. ${hint}` };
}
}

const validation = validateSlackCredentials({ botToken, appToken });
if (validation.ok) {
return validation.skipped && validation.message
? { ok: true, message: validation.message }
: { ok: true };
}

return {
ok: false,
message: `Slack credential validation failed. ${formatSlackValidationFailure(validation)}`,
};
}
7 changes: 3 additions & 4 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,12 +552,10 @@ import {
type SandboxGpuConfig,
type SandboxGpuFlag,
} from "./onboard/sandbox-gpu-mode";
import { filterSlackSelectionByValidation } from "./onboard/slack-validation";
import type { SelectionDrift } from "./onboard/selection-drift";
import { formatOnboardConfigSummary, formatSandboxBuildEstimateNote } from "./onboard/summary";
import type {
ModelValidationResult,
ValidationFailureLike,
} from "./onboard/types";
import type { ModelValidationResult, ValidationFailureLike } from "./onboard/types";
import type { ContainerRuntime } from "./platform";
import { getChannelTokenKeys, listChannels } from "./sandbox/channels";
import type { GatewayReuseState } from "./state/gateway";
Expand Down Expand Up @@ -5611,6 +5609,7 @@ async function setupMessagingChannels(
if (reachability.skipped) found = found.filter((c) => c !== "telegram");
}
}
found = filterSlackSelectionByValidation(found, MESSAGING_CHANNELS);
} else {
note(" [non-interactive] No messaging tokens configured. Skipping.");
}
Expand Down
109 changes: 109 additions & 0 deletions src/lib/onboard/messaging-channel-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prompt, saveCredential } from "../credentials/store";
import { KNOWN_CHANNELS } from "../sandbox/channels";
import { setupSelectedMessagingChannels } from "./messaging-channel-setup";
import { validateSlackCredentials } from "./slack-validation";

vi.mock("../credentials/store", () => ({
getCredential: vi.fn(() => null),
Expand All @@ -20,11 +21,18 @@ vi.mock("./host-qr-dispatch", () => ({
dispatchHostQrLogin: vi.fn(),
}));

vi.mock("./slack-validation", () => ({
formatSlackValidationFailure: vi.fn((result: { message: string }) => result.message),
validateSlackCredentials: vi.fn(() => ({ ok: true })),
}));

const ORIGINAL_ENV = { ...process.env };

describe("setupSelectedMessagingChannels", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.clearAllMocks();
vi.mocked(validateSlackCredentials).mockReturnValue({ ok: true });
});

afterEach(() => {
Expand Down Expand Up @@ -121,4 +129,105 @@ describe("setupSelectedMessagingChannels", () => {
expect(output).toContain("Slack channel IDs");
expect(output).toContain("channel IDs saved");
});

it("does not save prompted Slack credentials when Slack API rejects them", async () => {
delete process.env.SLACK_BOT_TOKEN;
delete process.env.SLACK_APP_TOKEN;
vi.mocked(prompt)
.mockResolvedValueOnce("xoxb-fake-bot-token")
.mockResolvedValueOnce("xapp-fake-app-token");
vi.mocked(validateSlackCredentials).mockReturnValueOnce({
ok: false,
kind: "rejected",
tokenKind: "app",
credential: "app",
error: "invalid_auth",
httpStatus: 200,
curlStatus: 0,
message: "Slack app token was rejected by Slack API: invalid_auth.",
});
const enabled = new Set(["slack"]);
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((message = "") => {
logs.push(String(message));
});

await setupSelectedMessagingChannels(
["slack"],
enabled,
[{ name: "slack", ...KNOWN_CHANNELS.slack }],
);

expect(enabled.has("slack")).toBe(false);
expect(saveCredential).not.toHaveBeenCalled();
expect(process.env.SLACK_BOT_TOKEN).toBeUndefined();
expect(process.env.SLACK_APP_TOKEN).toBeUndefined();
const output = logs.join("\n");
expect(output).toContain("Slack app token was rejected by Slack API");
expect(output).not.toContain("xoxb-fake-bot-token");
expect(output).not.toContain("xapp-fake-app-token");
});

it("does not save prompted Slack credentials when Slack API validation is indeterminate", async () => {
delete process.env.SLACK_BOT_TOKEN;
delete process.env.SLACK_APP_TOKEN;
vi.mocked(prompt)
.mockResolvedValueOnce("xoxb-timeout-bot-token")
.mockResolvedValueOnce("xapp-timeout-app-token");
vi.mocked(validateSlackCredentials).mockReturnValueOnce({
ok: false,
kind: "indeterminate",
tokenKind: "bot",
credential: "bot",
httpStatus: 0,
curlStatus: 28,
message: "Slack bot token could not be validated because Slack API was unreachable.",
});
const enabled = new Set(["slack"]);

await setupSelectedMessagingChannels(
["slack"],
enabled,
[{ name: "slack", ...KNOWN_CHANNELS.slack }],
);

expect(enabled.has("slack")).toBe(false);
expect(saveCredential).not.toHaveBeenCalled();
expect(process.env.SLACK_BOT_TOKEN).toBeUndefined();
expect(process.env.SLACK_APP_TOKEN).toBeUndefined();
});

it("ignores existing Slack tokens that pass format but fail Slack API validation", async () => {
process.env.SLACK_BOT_TOKEN = "xoxb-existing-invalid";
process.env.SLACK_APP_TOKEN = "xapp-existing-valid";
vi.mocked(validateSlackCredentials).mockReturnValueOnce({
ok: false,
kind: "rejected",
tokenKind: "bot",
credential: "bot",
error: "token_revoked",
httpStatus: 200,
curlStatus: 0,
message: "Slack bot token was rejected by Slack API: token_revoked.",
});
const enabled = new Set(["slack"]);
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((message = "") => {
logs.push(String(message));
});

await setupSelectedMessagingChannels(
["slack"],
enabled,
[{ name: "slack", ...KNOWN_CHANNELS.slack }],
);

expect(enabled.has("slack")).toBe(false);
expect(prompt).not.toHaveBeenCalled();
expect(saveCredential).not.toHaveBeenCalled();
const output = logs.join("\n");
expect(output).toContain("Invalid existing slack token ignored");
expect(output).toContain("token_revoked");
expect(output).not.toContain("slack — already configured");
});
});
Loading
Loading