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
31 changes: 23 additions & 8 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -371,26 +371,26 @@ jobs:
path: /tmp/nemoclaw-e2e-messaging-compatible-endpoint-install.log
if-no-files-found: ignore

# ── Channels stop/start lifecycle E2E (#3462 Test 1) ─────────
# Regression coverage for #3453 (channels stop must actually disable the
# channel across rebuild) and #3381 (channels start must re-attach from
# the cached credential). Telegram-only — Discord/Slack walk the same
# disabledChannels filter; telegram is the cheapest regression gate.
# ── Channels stop/start/remove lifecycle E2E (#3462, #3671) ─────────
# Regression coverage for #3453 (stop must disable across rebuild), #3381
# (start must re-attach from cached credentials), and #3671 (remove must
# detach/delete providers and survive rebuild with token env still present).
# Exercises OpenClaw and Hermes across telegram, discord, wechat, and slack.
channels-stop-start-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
(github.event_name != 'workflow_dispatch' ||
inputs.jobs == '' ||
contains(format(',{0},', inputs.jobs), ',channels-stop-start-e2e,'))
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.target_ref || github.ref }}

- name: Run channels stop/start lifecycle E2E test
- name: Run channels stop/start/remove lifecycle E2E test
env:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
NEMOCLAW_NON_INTERACTIVE: "1"
Expand All @@ -400,14 +400,29 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
TELEGRAM_BOT_TOKEN: "test-fake-telegram-token-stop-start-e2e"
TELEGRAM_ALLOWED_IDS: "123456789"
DISCORD_BOT_TOKEN: "test-fake-discord-token-stop-start-e2e"
DISCORD_SERVER_ID: "1491590992753590594"
DISCORD_ALLOWED_IDS: "1005536447329222676"
DISCORD_REQUIRE_MENTION: "0"
SLACK_BOT_TOKEN: "xoxb-fake-slack-token-stop-start-e2e"
SLACK_APP_TOKEN: "xapp-fake-slack-app-token-stop-start-e2e"
SLACK_ALLOWED_USERS: "U0123456789,U09ABCDEFGH"
WECHAT_BOT_TOKEN: "test-fake-wechat-token-stop-start-e2e"
WECHAT_ACCOUNT_ID: "e2e-fake-account-stop-start"
WECHAT_BASE_URL: "https://ilinkai-fake-stop-start.wechat.com"
WECHAT_USER_ID: "wxid_stopstart_operator"
WECHAT_ALLOWED_IDS: "wxid_stopstart_operator"
run: bash test/e2e/test-channels-stop-start.sh

- name: Upload install log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: install-log-channels-stop-start
path: /tmp/nemoclaw-e2e-install.log
path: |
/tmp/nemoclaw-e2e-install.log
/tmp/nemoclaw-e2e-channels-*-install.log
/tmp/nc-channels-*.log
if-no-files-found: ignore

# ── Brave Search E2E (#2687) ─────────────────────────────────
Expand Down
85 changes: 80 additions & 5 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,47 @@ async function applyChannelRemoveToGatewayAndRegistry(
);
process.exit(1);
}

// Detach providers from the sandbox before deletion. openshell rejects
// `provider delete` with FailedPrecondition when the provider is still
// attached to a sandbox; the sandbox image itself only stops referencing
// the bridge after the next rebuild, so without an explicit detach the
// delete will fail on any sandbox that is still alive at remove-time.
// NotFound / NotAttached are treated as success-equivalent because a
// previous run may have already detached, or the channel may have been
// configured for a sandbox that is no longer alive.
const detachFailures: Array<{ name: string; output: string }> = [];
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
const result = runOpenshell(["sandbox", "provider", "detach", sandboxName, name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`;
if (!/\bNotFound\b|not found|not attached/i.test(output)) {
detachFailures.push({ name, output: output.trim() });
}
}
}
if (detachFailures.length > 0) {
console.error(
` Failed to detach bridge provider(s) from sandbox '${sandboxName}': ${detachFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of detachFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
}
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}

// Capture each delete's outcome. If any non-NotFound failure surfaces
// we must NOT update the registry — otherwise NemoClaw would record
// the channel as removed locally while the bridge is still live in
// the gateway, which produces a half-configured sandbox the user
// can't easily recover.
const failed: string[] = [];
// can't easily recover. Surface the underlying openshell output so the
// operator can see exactly why the delete was rejected.
const deleteFailures: Array<{ name: string; output: string }> = [];
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
const result = runOpenshell(["provider", "delete", name], {
Expand All @@ -354,14 +389,17 @@ async function applyChannelRemoveToGatewayAndRegistry(
// Treat "not found" as success-equivalent — a previous run may
// have already deleted the provider.
if (!/\bNotFound\b|not found/i.test(output)) {
failed.push(name);
deleteFailures.push({ name, output: output.trim() });
}
}
}
if (failed.length > 0) {
if (deleteFailures.length > 0) {
console.error(
` Failed to delete bridge provider(s) from the OpenShell gateway: ${failed.join(", ")}.`,
` Failed to delete bridge provider(s) from the OpenShell gateway: ${deleteFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of deleteFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
}
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}
Expand Down Expand Up @@ -631,6 +669,40 @@ function applyChannelPresetIfAvailable(sandboxName: string, channelName: string)
}
}

// Mirror of applyChannelPresetIfAvailable. When the channel-named built-in
// preset is currently applied to the sandbox, un-apply it so `policy-list`
// no longer reports it active and the L7 proxy stops allow-listing the
// channel's upstream API (defense-in-depth: bridge is gone, egress to
// api.telegram.org / discord.com / slack.com should follow). Warns but does
// not abort the remove flow — the bridge teardown has already succeeded;
// the operator can run `policy-remove <channel>` manually if cleanup falters.
function removeChannelPresetIfPresent(sandboxName: string, channelName: string): void {
const builtinPresets = new Set(policies.listPresets().map((p) => p.name));
if (!builtinPresets.has(channelName)) {
return;
}
if (!policies.getAppliedPresets(sandboxName).includes(channelName)) {
return;
}
try {
const removed = policies.removePreset(sandboxName, channelName);
if (!removed) {
console.error(
` ${YW}⚠${R} Channel '${channelName}' bridge removed but its policy preset failed to un-apply.`,
);
console.error(
` Run manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-remove ${channelName}`,
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ${YW}⚠${R} Failed to remove '${channelName}' policy preset: ${msg}`);
console.error(
` Run manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-remove ${channelName}`,
);
}
}

export async function removeSandboxChannel(sandboxName: string, args: string[] = []): Promise<void> {
const dryRun = args.includes("--dry-run");
const rawChannelArg = args.find((arg) => !arg.startsWith("-"));
Expand Down Expand Up @@ -664,6 +736,9 @@ export async function removeSandboxChannel(sandboxName: string, args: string[] =
getChannelTokenKeys(channel),
);
console.log(` ${G}✓${R} Removed ${canonical} bridge from the OpenShell gateway.`);

removeChannelPresetIfPresent(sandboxName, canonical);

await promptAndRebuild(sandboxName, `remove '${canonical}'`);
}

Expand Down
20 changes: 18 additions & 2 deletions src/lib/onboard/messaging-reuse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe("onboard messaging reuse", () => {
expect(reusedChannels).toEqual(["wechat"]);
});

it("normalizes empty resume messaging channels to null", () => {
it("honors an explicit empty resume messaging channel set", () => {
const reusedChannels = getNonInteractiveStoredMessagingChannels(
true,
["unknown"],
Expand All @@ -94,6 +94,22 @@ describe("onboard messaging reuse", () => {
true,
);

expect(reusedChannels).toBeNull();
expect(reusedChannels).toEqual([]);
});

it("does not rediscover token-backed channels when resume recorded none", () => {
const reusedChannels = getNonInteractiveStoredMessagingChannels(
true,
[],
"assistant",
messagingChannels,
() => true,
() => ({ messagingChannels: ["discord"] }),
() => [],
() => true,
true,
);

expect(reusedChannels).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion src/lib/onboard/messaging-reuse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function getNonInteractiveStoredMessagingChannels(
if (!nonInteractive) return null;
if (resume && Array.isArray(sessionChannels)) {
const knownSessionChannels = getKnownMessagingChannels(sessionChannels, messagingChannels);
return knownSessionChannels.length > 0 ? knownSessionChannels : null;
return knownSessionChannels;
}
if (resume || !sandboxName || messagingChannels.some((channel) => hasMessagingToken(channel.envKey))) {
return null;
Expand Down
Loading
Loading