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
5 changes: 3 additions & 2 deletions docs/manage-sandboxes/manage-messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ The next rebuild reuses the bridge provider without requiring the service-accoun
Hermes Google Chat does not use the dedicated webhook endpoint or `$$nemoclaw tunnel` commands.
</AgentOnly>

When `channels start` re-enables a channel, NemoClaw reapplies the matching built-in policy preset before rebuild.
If policy restoration fails, the command keeps the channel disabled and exits without rebuilding into a partially active state.
When `channels start` re-enables a channel, NemoClaw records the channel as enabled in the messaging plan.
The rebuild attaches the existing bridge provider before applying its matching built-in policy preset to the replacement sandbox.
If the command queues the change without rebuilding, the running sandbox keeps its existing bridge and network policy until you rebuild it.

## Avoid Cross-Sandbox Conflicts

Expand Down
9 changes: 5 additions & 4 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2975,12 +2975,13 @@ Use `channels stop` instead of `channels remove` when you want to pause a bridge

### `$$nemoclaw <name> channels start <channel>`

Re-enable a channel previously paused with `channels stop`. The channel is removed from the disabled list, the sandbox is rebuilt, and the bridge registers with the gateway again using the stored credentials.
Re-enable a channel previously paused with `channels stop`.
The command verifies that the sandbox's agent runtime supports the channel before reading configured or disabled channel state.
It then requires the channel to be configured for the sandbox.
Before the rebuild, NemoClaw reapplies the matching built-in network policy preset so the restored bridge has egress to its upstream API.
Before updating the disabled list or applying the policy, NemoClaw prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective.
If policy restoration fails, NemoClaw rolls the channel back to disabled and exits without rebuilding into a partially active state.
NemoClaw removes the channel from the disabled list and records it as enabled in the messaging plan.
The rebuild uses that plan to attach the existing bridge provider before applying its matching built-in network policy preset to the replacement sandbox.
Before updating the disabled list, NemoClaw prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective.
If the command queues the change without rebuilding, the running sandbox keeps its existing bridge and network policy until you run `$$nemoclaw <name> rebuild`.

```bash
$$nemoclaw my-assistant channels start telegram
Expand Down
139 changes: 87 additions & 52 deletions src/lib/actions/sandbox/policy-channel-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,53 @@ function makeTeamsEntry(
} as unknown as SandboxEntry;
}

function makeHermesDiscordEntry(name: string): SandboxEntry {
return {
name,
agent: "hermes",
policies: [],
messaging: {
schemaVersion: 1,
plan: {
schemaVersion: 1,
sandboxName: name,
agent: "hermes",
workflow: "stop-channel",
channels: [
{
channelId: "discord",
displayName: "Discord",
authMode: "token-paste",
active: false,
selected: true,
configured: true,
disabled: true,
inputs: [],
hooks: [],
},
],
disabledChannels: ["discord"],
credentialBindings: [
{
channelId: "discord",
credentialId: "botToken",
sourceInput: "botToken",
providerName: `${name}-discord-bridge`,
providerEnvKey: "DISCORD_BOT_TOKEN",
placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN",
credentialAvailable: true,
},
],
networkPolicy: { presets: [], entries: [] },
agentRender: [],
buildSteps: [],
stateUpdates: [],
healthChecks: [],
},
},
} as unknown as SandboxEntry;
}

let spies: MockInstance[];
let logSpy: MockInstance;
let errSpy: MockInstance;
Expand Down Expand Up @@ -1200,13 +1247,8 @@ describe("Teams host-forward lifecycle (PRA-2)", () => {

await startSandboxChannel("alpha", { channel: "teams" });

expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", {
disclosedPresetState: "absent",
});
expect(applyPresetMock).not.toHaveBeenCalled();
expect(rebuildSandboxMock).toHaveBeenCalledWith("alpha", ["--yes"]);
expect(applyPresetMock.mock.invocationCallOrder[0]).toBeLessThan(
rebuildSandboxMock.mock.invocationCallOrder[0],
);
expect(ensureMessagingHostForwardAfterRebuildMock).toHaveBeenCalledWith(
"alpha",
expect.any(Object),
Expand All @@ -1221,16 +1263,50 @@ describe("Teams host-forward lifecycle (PRA-2)", () => {
});
});

it("channels start reapplies its policy before a non-interactive rebuild is queued", async () => {
it("rebuilds before a credential-bound Hermes Discord policy reaches the replacement sandbox", async () => {
const current = makeHermesDiscordEntry("alpha");
arrangeRegistry({ current });
vi.mocked(defs.loadAgent).mockReturnValue(agentFixture("hermes"));
getDisabledChannelsMock.mockImplementation(
() => current.messaging?.plan.disabledChannels ?? [],
);
updateSandboxMock.mockImplementation((_name: string, updates: Partial<SandboxEntry>) => {
Object.assign(current, updates);
return true;
});
applyPresetMock.mockImplementation(() => {
throw new Error(
"credential_binding references provider 'alpha-discord-bridge', but that provider is not attached to the sandbox",
);
});
rebuildSandboxMock.mockImplementation(async () => {
expect(current.messaging?.plan.disabledChannels).toEqual([]);
expect(current.messaging?.plan.networkPolicy.presets).toEqual(["discord"]);
expect(current.messaging?.plan.credentialBindings).toContainEqual(
expect.objectContaining({
providerName: "alpha-discord-bridge",
providerEnvKey: "DISCORD_BOT_TOKEN",
}),
);
});

await startSandboxChannel("alpha", { channel: "discord" });

expect(applyPresetMock).not.toHaveBeenCalled();
expect(rebuildSandboxMock).toHaveBeenCalledWith("alpha", ["--yes"]);
expect(updateSandboxMock.mock.invocationCallOrder[0]).toBeLessThan(
rebuildSandboxMock.mock.invocationCallOrder[0],
);
});

it("channels start defers policy application when a non-interactive rebuild is queued", async () => {
process.env.NEMOCLAW_NON_INTERACTIVE = "1";
arrangeRegistry({ current: makeTeamsEntry("alpha", { disabled: true }) });
getDisabledChannelsMock.mockReturnValue(["teams"]);

await startSandboxChannel("alpha", { channel: "teams" });

expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", {
disclosedPresetState: "absent",
});
expect(applyPresetMock).not.toHaveBeenCalled();
expect(rebuildSandboxMock).not.toHaveBeenCalled();
expect(loggedText()).toContain("Change queued");
});
Expand All @@ -1253,48 +1329,7 @@ describe("Teams host-forward lifecycle (PRA-2)", () => {
expect(scopeDisclosureMock.mock.invocationCallOrder[0]).toBeLessThan(
updateSandboxMock.mock.invocationCallOrder[0],
);
expect(scopeDisclosureMock.mock.invocationCallOrder[0]).toBeLessThan(
applyPresetMock.mock.invocationCallOrder[0],
);
});

it("channels start restores the disabled plan and skips rebuild when its policy preset fails", async () => {
const current = makeTeamsEntry("alpha", { disabled: true });
arrangeRegistry({ current });
getDisabledChannelsMock.mockImplementation(
() => current.messaging?.plan.disabledChannels ?? [],
);
updateSandboxMock.mockImplementation((_name: string, updates: Partial<SandboxEntry>) => {
Object.assign(current, updates);
return true;
});
applyPresetMock.mockReturnValue(false);

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

expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", {
disclosedPresetState: "absent",
});
expect(registry.getDisabledChannels("alpha")).toContain("teams");
expect(rebuildSandboxMock).not.toHaveBeenCalled();
expect(loggedText()).toContain("channels start teams");
});

it("channels start prints recovery guidance when policy and disabled-plan rollback both fail", async () => {
arrangeRegistry({ current: makeTeamsEntry("alpha", { disabled: true }) });
getDisabledChannelsMock.mockReturnValue(["teams"]);
applyPresetMock.mockReturnValue(false);
updateSandboxMock.mockReturnValueOnce(true).mockReturnValueOnce(false);

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

expect(rebuildSandboxMock).not.toHaveBeenCalled();
expect(loggedText()).toContain("Could not restore 'teams' to disabled state");
expect(loggedText()).toContain("nemoclaw alpha channels stop teams");
expect(applyPresetMock).not.toHaveBeenCalled();
});
});

Expand Down
30 changes: 7 additions & 23 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,9 +1916,9 @@ async function sandboxChannelsSetEnabled(
return;
}

const disclosedPresetState = disabled
? undefined
: loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "start");
if (!disabled) {
loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "start");
}

if (dryRun) {
console.log(` --dry-run: would ${verb} channel '${canonical}' for '${sandboxName}'.`);
Expand All @@ -1930,26 +1930,10 @@ async function sandboxChannelsSetEnabled(
console.error(` Could not persist messaging plan for '${sandboxName}'.`);
process.exit(1);
}
// Rebuild persists only the presets it actually restores. Re-apply a
// restarted channel's preset before a queued or immediate rebuild so the
// registry and backup manifest carry the enabled plan's policy intent.
// If policy application fails, put the plan back in its disabled state so
// runtime configuration cannot later be rebuilt without the required egress.
if (
!disabled &&
!applyChannelPresetIfAvailable(sandboxName, canonical, "start", {
disclosedPresetState,
})
) {
const rolledBack = await persistManifestChannelDisabledPlan(sandboxName, canonical, true);
if (!rolledBack) {
console.error(
` ${YW}⚠${R} Could not restore '${canonical}' to disabled state after its policy preset failed to apply.`,
);
console.error(` Re-run: ${CLI_NAME} ${sandboxName} channels stop ${canonical}`);
}
process.exit(1);
}
// A rebuild that disabled every channel can leave its providers on the
// gateway but detached from the current sandbox. The enabled plan carries
// the preset into rebuild, where sandbox creation attaches each provider
// before OpenShell accepts its credential-bound policy.
const state = disabled ? "disabled" : "enabled";
console.log(` ${G}✓${R} Marked ${canonical} ${state} for '${sandboxName}'.`);
const rebuilt = await promptAndRebuild(sandboxName, `${verb} '${canonical}'`);
Expand Down
5 changes: 2 additions & 3 deletions test/channels-add-bridge-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,8 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => {
expect.objectContaining({ channelId: "googlechat", active: true, disabled: false }),
]),
);
expect(policies.applyPreset).toHaveBeenCalledWith("test-sb", "googlechat", {
disclosedPresetState: null,
});
expect(startedPlan?.networkPolicy.presets).toContain("googlechat");
expect(policies.applyPreset).not.toHaveBeenCalled();
expect(appliedPresets).toContain("googlechat");
expect(session.policyPresets).toContain("googlechat");
expect(providerSpy).not.toHaveBeenCalled();
Expand Down
Loading