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
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`, `true`, `yes`, or `on` 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