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
3e41d49
feat(onboard): persist messaging plan in session for resume
sandl99 Jun 7, 2026
461c399
refactor(onboard): move messaging plan env helpers to messaging-chann…
sandl99 Jun 7, 2026
9a4ed8e
fix(onboard): add plan identity guard and test coverage for resume pl…
sandl99 Jun 7, 2026
2d05201
fix(onboard): prefer env-staged plan over session plan on non-interac…
sandl99 Jun 7, 2026
673d441
refactor(onboard): establish messagingPlan as session source of truth
sandl99 Jun 7, 2026
6913199
refactor(messaging): migrate conflict detection to manifest-plan arch…
sandl99 Jun 7, 2026
9e57fc0
fix(messaging): address CodeRabbit review on conflict detection
sandl99 Jun 7, 2026
aa028bd
fix(messaging): address Advisor review findings on conflict detection
sandl99 Jun 7, 2026
b294a4f
Merge remote-tracking branch 'origin/main' into feat/phase-4a-messagi…
sandl99 Jun 7, 2026
d12db3c
fix(messaging): decouple channel registry from conflict-detection cor…
sandl99 Jun 7, 2026
50bbc4e
Merge branch 'main' into feat/phase-4a-messaging-conflict-plan-driven
sandl99 Jun 7, 2026
b2117c8
fix(messaging): address PR Review Advisor findings on conflict detection
sandl99 Jun 7, 2026
b545d0a
fix(messaging): validate env plan sandbox identity and update stale c…
sandl99 Jun 7, 2026
7bee701
fix(messaging): restore legacy hash fallback and fix disabled-channel…
sandl99 Jun 7, 2026
40142b9
fix(messaging): scope legacy hashes by channel and intersect plan bin…
sandl99 Jun 7, 2026
50633fe
fix(messaging): use manifest keys for multi-credential conflict compa…
sandl99 Jun 7, 2026
2e6f1e3
fix(messaging): close plan coverage gap and migrate add-channel to ma…
sandl99 Jun 7, 2026
5e3977a
refactor(messaging): remove providerCredentialHashes from conflict de…
sandl99 Jun 7, 2026
0fe3685
refactor(messaging): remove providerCredentialHashes from all write p…
sandl99 Jun 7, 2026
a2c6510
chore(ci): lower test size budget for channels-add-preset and onboard…
sandl99 Jun 7, 2026
08a563d
merge: resolve test-file-size-budget conflict with origin/main
sandl99 Jun 7, 2026
34e9762
test(messaging): update token rotation plan hash check
sandl99 Jun 8, 2026
ce76283
Merge branch 'main' into feat/phase-4a-messaging-conflict-plan-driven
sandl99 Jun 8, 2026
1d66456
refactor(messaging): remove conflict adapter
sandl99 Jun 8, 2026
3634df4
Merge branch 'main' into feat/phase-4a-messaging-conflict-plan-driven
sandl99 Jun 8, 2026
50f459a
fix(messaging): fail closed on channel conflict errors
sandl99 Jun 8, 2026
b2031d9
refactor(messaging): split conflict detection modules
sandl99 Jun 8, 2026
fab8c69
fix(messaging): keep legacy backfill pre-plan only
sandl99 Jun 8, 2026
be2bbe0
Merge branch 'main' into feat/phase-4a-messaging-conflict-plan-driven
sandl99 Jun 8, 2026
bd847fc
fix(messaging): ignore credentialless conflict comparisons
Jun 8, 2026
f5839b2
refactor(messaging): split conflict detection modules
sandl99 Jun 8, 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: 2 additions & 2 deletions ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"nemoclaw/src/commands/migration-state.test.ts": 1566,
"src/lib/inference/nim.test.ts": 2079,
"src/lib/onboard/preflight.test.ts": 1905,
"test/channels-add-preset.test.ts": 1915,
"test/channels-add-preset.test.ts": 1901,
"test/generate-openclaw-config.test.ts": 2106,
"test/install-preflight.test.ts": 4397,
"test/nemoclaw-start.test.ts": 5300,
"test/onboard-messaging.test.ts": 2122,
"test/onboard-messaging.test.ts": 2110,
"test/onboard-selection.test.ts": 7757,
"test/onboard.test.ts": 4887,
"test/policies.test.ts": 2763
Expand Down
202 changes: 104 additions & 98 deletions src/lib/actions/sandbox/policy-channel-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// isNonInteractive is destructured at module load (`const { isNonInteractive }
// = require("../../onboard")`), so it cannot be spied after load; it reads
// process.env.NEMOCLAW_NON_INTERACTIVE === "1" at call time, which we drive
// directly. The real messaging-conflict, sandbox/channels, and credential-hash
// directly. The real messaging/applier, sandbox/channels, and credential-hash
// modules run unmocked so the genuine hash + conflict logic is exercised.

import { createRequire } from "node:module";
Expand Down Expand Up @@ -54,6 +54,56 @@ const { addSandboxChannel } = D("actions/sandbox/policy-channel.js") as {
const TELEGRAM_TOKEN = "123456:AAH-secret-bot-token-value";
const TELEGRAM_HASH = hashCredential(TELEGRAM_TOKEN) as string;

// Build a minimal plan-backed SandboxEntry for conflict-detection fixtures.
// Callers supply credential bindings as { providerEnvKey, credentialHash? }.
function makePlanEntry(
name: string,
channelId: "telegram" | "slack" | "discord" | "wechat" | "whatsapp",
bindings: Array<{ providerEnvKey: string; credentialHash?: string }>,
): SandboxEntry {
return {
name,
messaging: {
schemaVersion: 1,
plan: {
schemaVersion: 1,
sandboxName: name,
agent: "openclaw",
workflow: "onboard",
channels: [
{
channelId,
displayName: channelId,
authMode: "token-paste",
active: true,
selected: true,
configured: true,
disabled: false,
inputs: [],
hooks: [],
},
],
disabledChannels: [],
credentialBindings: bindings.map((b) => ({
channelId,
credentialId: b.providerEnvKey.toLowerCase(),
sourceInput: b.providerEnvKey.toLowerCase(),
providerName: `${name}-${channelId}-bridge`,
providerEnvKey: b.providerEnvKey,
placeholder: `openshell:resolve:env:${b.providerEnvKey}`,
credentialAvailable: true,
...(b.credentialHash ? { credentialHash: b.credentialHash } : {}),
})),
networkPolicy: { presets: [], entries: [] },
agentRender: [],
buildSteps: [],
stateUpdates: [],
healthChecks: [],
},
},
} as unknown as SandboxEntry;
}

let spies: MockInstance[];
let logSpy: MockInstance;
let errSpy: MockInstance;
Expand Down Expand Up @@ -195,13 +245,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("interactive matching-token conflict: warns, user continues, add proceeds", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue("y");
Expand All @@ -219,13 +263,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("interactive matching-token conflict: user aborts, nothing is mutated", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue("n");
Expand All @@ -241,13 +279,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("interactive matching-token conflict: empty answer (default N) aborts", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue(""); // bare Enter -> default No
Expand All @@ -262,13 +294,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("non-interactive matching-token conflict: aborts with exit(1) and guidance", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
process.env.NEMOCLAW_NON_INTERACTIVE = "1";
Expand All @@ -292,13 +318,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("--force bypasses the conflict even in non-interactive mode", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
process.env.NEMOCLAW_NON_INTERACTIVE = "1";
Expand All @@ -318,7 +338,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("unknown-token wording when the other sandbox has the channel but no hash", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [{ name: "bob", messagingChannels: ["telegram"] }], // no providerCredentialHashes
others: [{ name: "bob", messagingChannels: ["telegram"] }], // no plan — legacy entry, unknown-token
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue("y");
Expand All @@ -334,15 +354,9 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("different hash on the other sandbox is NOT a conflict (no warning, add proceeds)", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: {
TELEGRAM_BOT_TOKEN: hashCredential("a-completely-different-token") as string,
},
},
],
others: [makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: hashCredential("a-completely-different-token") as string },
])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue("n"); // would abort IF prompted; proves no prompt happens
Expand All @@ -360,11 +374,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 6
it("idempotent same-sandbox re-add does not self-conflict", async () => {
arrangeRegistry({
current: {
name: "alpha",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
current: makePlanEntry("alpha", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }]),
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue("n"); // would abort IF prompted
Expand All @@ -383,13 +393,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("--dry-run never runs the conflict check or touches credentials", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});

await addSandboxChannel("alpha", { channel: "telegram", dryRun: true });
Expand All @@ -411,13 +415,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
const wechatHash = hashCredential(wechatToken) as string;
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["wechat"],
providerCredentialHashes: { WECHAT_BOT_TOKEN: wechatHash },
},
],
others: [makePlanEntry("bob", "wechat", [{ providerEnvKey: "WECHAT_BOT_TOKEN", credentialHash: wechatHash }])],
});
// The hook planner skips non-interactive host-QR enrollment, but the
// conflict guard should still see a cached WeChat credential.
Expand All @@ -439,13 +437,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("in-sandbox-qr whatsapp skips the credential conflict check", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["whatsapp"],
providerCredentialHashes: { WHATSAPP_TOKEN: "irrelevant" },
},
],
others: [{ name: "bob", messagingChannels: ["whatsapp"] }],
});
process.env.NEMOCLAW_NON_INTERACTIVE = "1";

Expand All @@ -463,11 +455,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }]),
// Legacy entry with NO messagingChannels field — backfill probes the
// (alive) gateway, gets "absent" for every provider, then writes
// messagingChannels:[] for it. We make THAT write throw to genuinely
Expand Down Expand Up @@ -511,17 +499,45 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
expect(updateSandboxMock).toHaveBeenCalledWith("alpha", expect.any(Object));
});

it("non-interactive add aborts when the conflict check throws", async () => {
arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] });
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
listSandboxesMock.mockImplementation(() => {
throw new Error("malformed messaging plan");
});
process.env.NEMOCLAW_NON_INTERACTIVE = "1";

await expect(addSandboxChannel("alpha", { channel: "telegram" })).rejects.toThrow(
"process.exit(1)",
);

const text = loggedText();
expect(text).toContain("Could not verify messaging channel conflicts");
expect(text).toContain("rerun with --force");
expect(upsertMock).not.toHaveBeenCalled();
});

it("--force proceeds when the conflict check throws", async () => {
arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] });
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
listSandboxesMock.mockImplementation(() => {
throw new Error("malformed messaging plan");
});
process.env.NEMOCLAW_NON_INTERACTIVE = "1";

await addSandboxChannel("alpha", { channel: "telegram", force: true });

const text = loggedText();
expect(text).toContain("proceeding without a completed messaging channel conflict check");
expect(exitMock).not.toHaveBeenCalled();
expect(upsertMock).toHaveBeenCalledTimes(1);
});

// Scenario 10
it("never prints the raw token value in any conflict output (proceed path)", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue("y");
Expand All @@ -537,13 +553,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("non-interactive abort path also keeps the raw token out of output", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["telegram"],
providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH },
},
],
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
process.env.NEMOCLAW_NON_INTERACTIVE = "1";
Expand All @@ -562,13 +572,9 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
const slackBotHash = hashCredential(slackBot) as string;
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [
{
name: "bob",
messagingChannels: ["slack"],
providerCredentialHashes: { SLACK_BOT_TOKEN: slackBotHash }, // only bot token matches
},
],
// only bot token stored — app token unknown → conservative unknown-token OR
// matching-token if bot token matches; test verifies the conflict is surfaced.
others: [makePlanEntry("bob", "slack", [{ providerEnvKey: "SLACK_BOT_TOKEN", credentialHash: slackBotHash }])],
});
getCredentialMock.mockImplementation((key: string) => {
if (key === "SLACK_BOT_TOKEN") return slackBot;
Expand Down
Loading
Loading