diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index bd3cc0de4fb..88a11b11c35 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -9,6 +9,8 @@ # skip/05-network-policy.sh, then cleanup.sh --verify with if: always()). # messaging-providers-e2e Validates messaging credential provider/placeholder/L7-proxy chain # for Telegram + Discord. Uses fake tokens. See PR #1081. +# token-rotation-e2e Validates that rotating a messaging token and re-running onboard +# propagates the new credential to the sandbox. See issue #1903. # sandbox-survival-e2e Sandbox survival across gateway restarts (onboard, inference, # gateway stop/start, verify sandbox + workspace + inference). # hermes-e2e Hermes Agent E2E — install → onboard --agent hermes → health @@ -203,6 +205,38 @@ jobs: path: /tmp/nemoclaw-e2e-install.log if-no-files-found: ignore + # ── Token rotation (credential propagation to L7 proxy) ───── + # Validates that rotating a messaging token and re-running onboard + # propagates the new credential to the sandbox. Uses two fake tokens + # to prove the sandbox is rebuilt on rotation and reused when unchanged. + # See: issue #1903 + token-rotation-e2e: + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run token rotation E2E test + env: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_POLICY_TIER: "open" + GITHUB_TOKEN: ${{ github.token }} + TELEGRAM_BOT_TOKEN_A: "test-fake-token-A-rotation-e2e" + TELEGRAM_BOT_TOKEN_B: "test-fake-token-B-rotation-e2e" + run: bash test/e2e/test-token-rotation.sh + + - name: Upload install log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: install-log-token-rotation + path: /tmp/nemoclaw-e2e-install.log + if-no-files-found: ignore + # ── Sandbox survival (gateway restart recovery) ────────────── sandbox-survival-e2e: if: github.repository == 'NVIDIA/NemoClaw' @@ -550,8 +584,8 @@ jobs: notify-on-failure: runs-on: ubuntu-latest - needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, sandbox-survival-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, inference-routing-e2e, snapshot-commands-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e] - if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.hermes-e2e.result == 'failure' || needs.skip-permissions-e2e.result == 'failure' || needs.sandbox-operations-e2e.result == 'failure' || needs.inference-routing-e2e.result == 'failure' || needs.snapshot-commands-e2e.result == 'failure' || needs.rebuild-openclaw-e2e.result == 'failure' || needs.upgrade-stale-sandbox-e2e.result == 'failure' || needs.rebuild-hermes-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} + needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, inference-routing-e2e, snapshot-commands-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e] + if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.token-rotation-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.hermes-e2e.result == 'failure' || needs.skip-permissions-e2e.result == 'failure' || needs.sandbox-operations-e2e.result == 'failure' || needs.inference-routing-e2e.result == 'failure' || needs.snapshot-commands-e2e.result == 'failure' || needs.rebuild-openclaw-e2e.result == 'failure' || needs.upgrade-stale-sandbox-e2e.result == 'failure' || needs.rebuild-hermes-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} permissions: issues: write steps: diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 56400137d14..0e9d6e6aad1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6,6 +6,7 @@ // Supports non-interactive mode via --non-interactive flag or // NEMOCLAW_NON_INTERACTIVE=1 env var for CI/CD pipelines. +const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -71,6 +72,7 @@ const agentOnboard = require("./agent-onboard"); const agentDefs = require("./agent-defs"); const gatewayState = require("./gateway-state"); +const sandboxState = require("./sandbox-state"); const validation = require("./validation"); const urlUtils = require("./url-utils"); const buildContext = require("./build-context"); @@ -792,6 +794,45 @@ function providerExistsInGateway(name) { return result.status === 0; } +/** + * Compute a SHA-256 hash of a credential value for change detection. + * Stored in the sandbox registry so we can detect rotation on reuse + * without needing to read the credential back from OpenShell. + * @param {string} value - Credential value to hash. + * @returns {string|null} Hex-encoded SHA-256 hash, or null if value is falsy. + */ +function hashCredential(value) { + if (!value) return null; + return crypto.createHash("sha256").update(String(value).trim()).digest("hex"); +} + +/** + * Detect whether any messaging provider credential has been rotated since + * the sandbox was created, by comparing SHA-256 hashes of the current + * token values against hashes stored in the sandbox registry. + * + * Returns `changed: false` for legacy sandboxes that have no stored hashes + * (conservative — avoids unnecessary rebuilds after upgrade). + * + * @param {string} sandboxName - Name of the sandbox to check. + * @param {Array<{name: string, envKey: string, token: string|null}>} tokenDefs + * @returns {{ changed: boolean, changedProviders: string[] }} + */ +function detectMessagingCredentialRotation(sandboxName, tokenDefs) { + const sb = registry.getSandbox(sandboxName); + const storedHashes = sb?.providerCredentialHashes || {}; + const changedProviders = []; + for (const { name, envKey, token } of tokenDefs) { + if (!token) continue; + const storedHash = storedHashes[envKey]; + if (!storedHash) continue; + if (storedHash !== hashCredential(token)) { + changedProviders.push(name); + } + } + return { changed: changedProviders.length > 0, changedProviders }; +} + // Tri-state probe factory for messaging-conflict backfill. An upfront liveness // check is necessary because `openshell provider get` exits non-zero for both // "provider not attached" and "gateway unreachable"; without the liveness @@ -2821,6 +2862,10 @@ async function createSandbox( // Reconcile local registry state with the live OpenShell gateway state. const liveExists = pruneStaleSandboxEntry(sandboxName); + // Declared outside the liveExists block so it is accessible during + // post-creation restore (the sandbox create path runs after the block). + let pendingStateRestore = null; + if (liveExists) { const existingSandboxState = getSandboxReuseState(sandboxName); @@ -2831,7 +2876,14 @@ async function createSandbox( hasMessagingTokens && messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); - if (!isRecreateSandbox() && !needsProviderMigration) { + // Detect whether any messaging credential has been rotated since the + // sandbox was created. Provider credentials are resolved once at sandbox + // startup, so a rotated token requires a rebuild to take effect. + const credentialRotation = hasMessagingTokens + ? detectMessagingCredentialRotation(sandboxName, messagingTokenDefs) + : { changed: false, changedProviders: [] }; + + if (!isRecreateSandbox() && !needsProviderMigration && !credentialRotation.changed) { if (isNonInteractive()) { if (existingSandboxState === "ready") { // Upsert messaging providers even on reuse so credential changes take @@ -2873,9 +2925,53 @@ async function createSandbox( } } + // Back up workspace state before destroying the sandbox when triggered + // by credential rotation, so files can be restored after recreation. + if (credentialRotation.changed && existingSandboxState === "ready") { + const rotatedNames = credentialRotation.changedProviders.join(", "); + console.log(` Messaging credential(s) rotated: ${rotatedNames}`); + console.log(" Rebuilding sandbox to propagate new credentials to the L7 proxy..."); + try { + const backup = sandboxState.backupSandboxState(sandboxName); + if (backup.success) { + note(` ✓ State backed up (${backup.backedUpDirs.length} directories)`); + pendingStateRestore = backup; + } else { + console.error(" State backup failed — aborting rebuild to prevent data loss."); + console.error(" Pass --recreate-sandbox to force recreation without backup."); + upsertMessagingProviders(messagingTokenDefs); + // Update stored hashes so the next onboard doesn't re-detect rotation. + const abortHashes = {}; + for (const { envKey, token } of messagingTokenDefs) { + if (token) abortHashes[envKey] = hashCredential(token); + } + if (Object.keys(abortHashes).length > 0) { + registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); + } + ensureDashboardForward(sandboxName, chatUiUrl); + return sandboxName; + } + } catch (err) { + console.error(` State backup threw: ${err.message} — aborting rebuild.`); + console.error(" Pass --recreate-sandbox to force recreation without backup."); + upsertMessagingProviders(messagingTokenDefs); + const abortHashes = {}; + for (const { envKey, token } of messagingTokenDefs) { + if (token) abortHashes[envKey] = hashCredential(token); + } + if (Object.keys(abortHashes).length > 0) { + registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); + } + ensureDashboardForward(sandboxName, chatUiUrl); + return sandboxName; + } + } + if (needsProviderMigration) { console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); console.log(" Recreating to ensure credentials flow through the provider pipeline."); + } else if (credentialRotation.changed) { + // Message already printed above during backup. } else if (existingSandboxState === "ready") { note(` Sandbox '${sandboxName}' exists and is ready — recreating by explicit request.`); } else { @@ -3231,6 +3327,12 @@ async function createSandbox( // Register only after confirmed ready — prevents phantom entries const effectiveAgent = agent || agentDefs.loadAgent("openclaw"); + const providerCredentialHashes = {}; + for (const { envKey, token } of messagingTokenDefs) { + if (token) { + providerCredentialHashes[envKey] = hashCredential(token); + } + } registry.registerSandbox({ name: sandboxName, model: model || null, @@ -3239,9 +3341,27 @@ async function createSandbox( agent: agent ? agent.name : null, agentVersion: fromDockerfile ? null : effectiveAgent.expectedVersion || null, dangerouslySkipPermissions: dangerouslySkipPermissions || undefined, + providerCredentialHashes: + Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, messagingChannels: activeMessagingChannels, }); + // Restore workspace state if we backed it up during credential rotation. + if (pendingStateRestore?.success) { + note(" Restoring workspace state after credential rotation..."); + const restore = sandboxState.restoreSandboxState( + sandboxName, + pendingStateRestore.manifest.backupPath, + ); + if (restore.success) { + note(` ✓ State restored (${restore.restoredDirs.length} directories)`); + } else { + console.error( + ` Warning: partial restore. Manual recovery: ${pendingStateRestore.manifest.backupPath}`, + ); + } + } + // DNS proxy — run a forwarder in the sandbox pod so the isolated // sandbox namespace can resolve hostnames (fixes #626). console.log(" Setting up sandbox DNS proxy..."); @@ -5931,6 +6051,8 @@ module.exports = { summarizeProbeFailure, hasResponsesToolCall, upsertProvider, + hashCredential, + detectMessagingCredentialRotation, hydrateCredentialEnv, pruneKnownHostsEntries, shouldIncludeBuildContextPath, diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 3ed8be8a2c3..6db3d6103f9 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -18,6 +18,7 @@ export interface SandboxEntry { agent?: string | null; dangerouslySkipPermissions?: boolean; agentVersion?: string | null; + providerCredentialHashes?: Record; messagingChannels?: string[]; } @@ -166,6 +167,7 @@ export function registerSandbox(entry: SandboxEntry): void { dangerouslySkipPermissions: entry.dangerouslySkipPermissions === true ? true : undefined, agentVersion: entry.agentVersion || null, + providerCredentialHashes: entry.providerCredentialHashes || undefined, messagingChannels: entry.messagingChannels || [], }; if (!data.defaultSandbox) { diff --git a/test/credential-rotation.test.ts b/test/credential-rotation.test.ts new file mode 100644 index 00000000000..c61c1851d9e --- /dev/null +++ b/test/credential-rotation.test.ts @@ -0,0 +1,149 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +describe("credential rotation detection", () => { + let hashCredential; + let detectMessagingCredentialRotation; + let registry; + + beforeEach(() => { + // Fresh imports to avoid cross-test contamination + ({ hashCredential, detectMessagingCredentialRotation } = require("../dist/lib/onboard.js")); + registry = require("../dist/lib/registry.js"); + }); + + describe("hashCredential", () => { + it("returns null for falsy values", () => { + expect(hashCredential(null)).toBeNull(); + expect(hashCredential("")).toBeNull(); + expect(hashCredential(undefined)).toBeNull(); + }); + + it("returns a 64-char hex SHA-256 hash for valid input", () => { + const hash = hashCredential("my-secret-token"); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("produces consistent hashes for the same input", () => { + const a = hashCredential("token-abc"); + const b = hashCredential("token-abc"); + expect(a).toBe(b); + }); + + it("produces different hashes for different inputs", () => { + const a = hashCredential("token-A"); + const b = hashCredential("token-B"); + expect(a).not.toBe(b); + }); + + it("trims whitespace before hashing", () => { + const a = hashCredential(" token "); + const b = hashCredential("token"); + expect(a).toBe(b); + }); + }); + + describe("detectMessagingCredentialRotation", () => { + it("returns changed: false when no hashes are stored (legacy sandbox)", () => { + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "test-sandbox", + // no providerCredentialHashes + }); + + const result = detectMessagingCredentialRotation("test-sandbox", [ + { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "new-token" }, + ]); + + expect(result.changed).toBe(false); + expect(result.changedProviders).toEqual([]); + vi.restoreAllMocks(); + }); + + it("returns changed: false when hashes match", () => { + const tokenHash = hashCredential("same-token"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "test-sandbox", + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: tokenHash }, + }); + + const result = detectMessagingCredentialRotation("test-sandbox", [ + { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "same-token" }, + ]); + + expect(result.changed).toBe(false); + expect(result.changedProviders).toEqual([]); + vi.restoreAllMocks(); + }); + + it("returns changed: true with correct provider names when hashes differ", () => { + const oldHash = hashCredential("old-token"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "test-sandbox", + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: oldHash }, + }); + + const result = detectMessagingCredentialRotation("test-sandbox", [ + { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "new-token" }, + ]); + + expect(result.changed).toBe(true); + expect(result.changedProviders).toEqual(["test-telegram-bridge"]); + vi.restoreAllMocks(); + }); + + it("detects rotation across multiple providers", () => { + const telegramHash = hashCredential("tg-old"); + const discordHash = hashCredential("dc-same"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "test-sandbox", + providerCredentialHashes: { + TELEGRAM_BOT_TOKEN: telegramHash, + DISCORD_BOT_TOKEN: discordHash, + }, + }); + + const result = detectMessagingCredentialRotation("test-sandbox", [ + { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "tg-new" }, + { name: "test-discord-bridge", envKey: "DISCORD_BOT_TOKEN", token: "dc-same" }, + ]); + + expect(result.changed).toBe(true); + expect(result.changedProviders).toEqual(["test-telegram-bridge"]); + vi.restoreAllMocks(); + }); + + it("skips providers with null tokens", () => { + const hash = hashCredential("old-token"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "test-sandbox", + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: hash }, + }); + + const result = detectMessagingCredentialRotation("test-sandbox", [ + { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: null }, + ]); + + expect(result.changed).toBe(false); + expect(result.changedProviders).toEqual([]); + vi.restoreAllMocks(); + }); + + it("returns changed: false when sandbox is not found", () => { + vi.spyOn(registry, "getSandbox").mockReturnValue(null); + + const result = detectMessagingCredentialRotation("nonexistent", [ + { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "token" }, + ]); + + expect(result.changed).toBe(false); + expect(result.changedProviders).toEqual([]); + vi.restoreAllMocks(); + }); + }); +}); diff --git a/test/e2e/test-token-rotation.sh b/test/e2e/test-token-rotation.sh new file mode 100755 index 00000000000..6e3aef2624b --- /dev/null +++ b/test/e2e/test-token-rotation.sh @@ -0,0 +1,242 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Token rotation E2E test (issue #1903): +# - prove that rotating a messaging token and re-running onboard propagates +# the new credential to the sandbox (sandbox is rebuilt automatically) +# - prove that re-running onboard with the same token reuses the sandbox +# +# Uses two distinct fake tokens. The test validates that NemoClaw detects the +# rotation and triggers a sandbox rebuild, not the Telegram API response. +# +# Prerequisites: +# - Docker running +# - NVIDIA_API_KEY set (or fake OpenAI endpoint) +# - TELEGRAM_BOT_TOKEN_A and TELEGRAM_BOT_TOKEN_B set (can be fake) +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ +# NVIDIA_API_KEY=nvapi-... \ +# TELEGRAM_BOT_TOKEN_A=fake-a TELEGRAM_BOT_TOKEN_B=fake-b \ +# bash test/e2e/test-token-rotation.sh + +set -uo pipefail + +if [ -z "${NEMOCLAW_E2E_NO_TIMEOUT:-}" ]; then + export NEMOCLAW_E2E_NO_TIMEOUT=1 + TIMEOUT_SECONDS="${NEMOCLAW_E2E_TIMEOUT_SECONDS:-900}" + exec timeout -s TERM "$TIMEOUT_SECONDS" "$0" "$@" +fi + +PASS=0 +FAIL=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# Determine repo root +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-token-rotation}" +REGISTRY="$HOME/.nemoclaw/sandboxes.json" +INSTALL_LOG="/tmp/nemoclaw-e2e-install.log" + +# ── Prerequisite checks ────────────────────────────────────────── + +if [ -z "${TELEGRAM_BOT_TOKEN_A:-}" ] || [ -z "${TELEGRAM_BOT_TOKEN_B:-}" ]; then + echo "SKIP: TELEGRAM_BOT_TOKEN_A and TELEGRAM_BOT_TOKEN_B must both be set" + exit 0 +fi + +if [ "$TELEGRAM_BOT_TOKEN_A" = "$TELEGRAM_BOT_TOKEN_B" ]; then + echo "SKIP: TELEGRAM_BOT_TOKEN_A and TELEGRAM_BOT_TOKEN_B must be different" + exit 0 +fi + +# ── Helpers ─────────────────────────────────────────────────────── + +cleanup() { + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +} +trap cleanup EXIT + +# ── Phase 0: Install NemoClaw with token A ──────────────────────── + +section "Phase 0: Install NemoClaw and first onboard with token A" + +# Pre-clean +openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +openshell gateway destroy -g nemoclaw 2>/dev/null || true + +export TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN_A" +export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" +export NEMOCLAW_POLICY_TIER="open" +export NEMOCLAW_RECREATE_SANDBOX=1 + +info "Running install.sh --non-interactive (includes first onboard)..." +cd "$REPO" || exit 1 +touch "$INSTALL_LOG" +bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait $install_pid +install_exit=$? +kill $tail_pid 2>/dev/null || true +wait $tail_pid 2>/dev/null || true + +# Source shell profile to pick up nvm/PATH changes from install.sh +if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true +fi +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" +fi +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi + +if [ $install_exit -eq 0 ]; then + pass "install.sh completed (exit 0)" +else + fail "install.sh failed (exit $install_exit)" + info "Last 30 lines of install log:" + tail -30 "$INSTALL_LOG" 2>/dev/null || true + exit 1 +fi + +# Verify tools are on PATH +if ! command -v openshell >/dev/null 2>&1; then + fail "openshell not found on PATH after install" + exit 1 +fi +pass "openshell installed ($(openshell --version 2>&1 || echo unknown))" + +if ! command -v nemoclaw >/dev/null 2>&1; then + fail "nemoclaw not found on PATH after install" + exit 1 +fi +pass "nemoclaw installed at $(command -v nemoclaw)" + +# ── Phase 1: Verify first onboard with token A ────────────────── + +section "Phase 1: Verify first onboard results" + +if openshell sandbox list 2>/dev/null | grep -q "$SANDBOX_NAME"; then + pass "Sandbox $SANDBOX_NAME created and running" +else + fail "Sandbox $SANDBOX_NAME not running after first onboard" +fi + +if openshell provider get "${SANDBOX_NAME}-telegram-bridge" >/dev/null 2>&1; then + pass "Provider ${SANDBOX_NAME}-telegram-bridge exists" +else + fail "Provider ${SANDBOX_NAME}-telegram-bridge not found" +fi + +# Verify credential hashes are stored for this sandbox in the registry +if [ -f "$REGISTRY" ] && node -e " +const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); +const h = (r.sandboxes || {})[process.argv[2]]?.providerCredentialHashes || {}; +process.exit('TELEGRAM_BOT_TOKEN' in h ? 0 : 1); +" "$REGISTRY" "$SANDBOX_NAME" 2>/dev/null; then + pass "Credential hash stored for $SANDBOX_NAME" +else + fail "Credential hash not found for $SANDBOX_NAME in registry" +fi + +# ── Phase 2: Rotate token (re-onboard with token B) ────────────── + +section "Phase 2: Re-onboard with rotated TELEGRAM_BOT_TOKEN_B" + +export TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN_B" +unset NEMOCLAW_RECREATE_SANDBOX + +ONBOARD_OUTPUT=$(nemoclaw onboard --non-interactive 2>&1) +onboard_exit=$? + +if [ $onboard_exit -ne 0 ]; then + fail "Phase 2 onboard failed (exit $onboard_exit)" + echo "$ONBOARD_OUTPUT" | tail -30 + exit 1 +fi + +if echo "$ONBOARD_OUTPUT" | grep -q "credential(s) rotated"; then + pass "Credential rotation detected" +else + fail "Credential rotation not detected in onboard output" + info "Onboard output:" + echo "$ONBOARD_OUTPUT" | tail -20 +fi + +if echo "$ONBOARD_OUTPUT" | grep -q "Rebuilding sandbox"; then + pass "Sandbox rebuild triggered by rotation" +else + fail "Sandbox rebuild not triggered" + info "Onboard output:" + echo "$ONBOARD_OUTPUT" | tail -20 +fi + +if openshell sandbox list 2>/dev/null | grep -q "$SANDBOX_NAME"; then + pass "Sandbox running after rotation" +else + fail "Sandbox not running after rotation" +fi + +# ── Phase 3: Re-onboard with same token B (no change) ──────────── + +section "Phase 3: Re-onboard with same token (no rotation expected)" + +ONBOARD_OUTPUT=$(nemoclaw onboard --non-interactive 2>&1) +onboard_exit=$? + +if [ $onboard_exit -ne 0 ]; then + fail "Phase 3 onboard failed (exit $onboard_exit)" + echo "$ONBOARD_OUTPUT" | tail -30 + exit 1 +fi + +if echo "$ONBOARD_OUTPUT" | grep -q "reusing it"; then + pass "Sandbox reused when token unchanged" +else + fail "Sandbox was not reused (unexpected rebuild)" + info "Onboard output:" + echo "$ONBOARD_OUTPUT" | tail -20 +fi + +# ── Summary ─────────────────────────────────────────────────────── + +section "Summary" +echo " Total: $TOTAL Pass: $PASS Fail: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "" + echo "FAILED" + exit 1 +fi +echo "" +echo "ALL PASSED"