import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, symlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';

// Mock the core module at the module boundary so every `runSetup` invocation
// in this suite gets a controllable `requestFaucetFunding` spy. `vi.mock` is
// hoisted by vitest, so the mock intercepts `setup.ts`'s top-level import
// regardless of where this line appears. Other `@origintrail-official/dkg-core`
// exports are passed through unchanged via `importActual` so existing tests
// that rely on core semantics (transitive imports) stay intact.
//
// We hoist the same `requestFaucetFunding` spy and inject it into TWO mock
// surfaces: (1) the dkg-core barrel (so any direct caller in this package
// gets the spy via the public surface), and (2) the dkg-core `dist/faucet.js`
// module path (so `fundWalletsBestEffort` inside dkg-core's own
// `faucet-orchestration.ts` — which calls `requestFaucetFunding` via an
// in-package import — also routes through the spy). The barrel-level mock
// alone wouldn't intercept dkg-core's intra-package call after the S1 of
// issue #386 extracted the orchestrator into core (`fundWalletsBestEffort`
// reaches `requestFaucetFunding` via `./faucet.js`, not via the barrel).
const requestFaucetFundingSpy = vi.hoisted(() =>
  vi.fn(async () => ({ success: true, funded: ['0.01 ETH', '1000 TRAC'] })),
);
vi.mock('@origintrail-official/dkg-core', async () => {
  const actual = await vi.importActual<typeof import('@origintrail-official/dkg-core')>(
    '@origintrail-official/dkg-core',
  );
  return {
    ...actual,
    requestFaucetFunding: requestFaucetFundingSpy,
    fundWalletsBestEffort: vi.fn(async ({ network, idempotencySeed, callerId, didStartDaemon }) => {
      const faucetUrl = network?.faucet?.url;
      const faucetMode = network?.faucet?.mode;
      if (!faucetUrl || !faucetMode) return;

      const walletAddresses = didStartDaemon
        ? await actual.readWalletsWithRetry()
        : actual.readWallets();
      if (!walletAddresses.length) return;

      try {
        const result = await requestFaucetFundingSpy(
          faucetUrl,
          faucetMode,
          walletAddresses,
          idempotencySeed ?? callerId,
        );
        if (!result.success || result.error) {
          actual.logManualFundingInstructions(
            result.failedWallets?.length ? result.failedWallets : walletAddresses,
            faucetUrl,
            faucetMode,
          );
        }
      } catch {
        actual.logManualFundingInstructions(walletAddresses, faucetUrl, faucetMode);
      }
    }),
  };
});
import { requestFaucetFunding } from '@origintrail-official/dkg-core';

import {
  discoverWorkspace,
  discoverAgentName,
  writeDkgConfig,
  mergeOpenClawConfig,
  unmergeOpenClawConfig,
  verifyUnmergeInvariants,
  verifySkillRemoved,
  installCanonicalNodeSkill,
  removeCanonicalNodeSkill,
  resolveWorkspaceDirFromConfig,
  openclawConfigPath,
  loadNetworkConfig,
  readWallets,
  readWalletsWithRetry,
  logManualFundingInstructions,
  runSetup,
  type AdapterEntryConfig,
} from '../src/setup.js';

// Default entryConfig fixture used by most mergeOpenClawConfig call sites
// (the new third positional arg after D2). Cases that assert specific
// entry.config values seed their own.
const defaultEntryConfig: AdapterEntryConfig = {
  daemonUrl: 'http://127.0.0.1:9200',
  memory: { enabled: true },
  channel: { enabled: true },
};

// Default install workspace fixture for `mergeOpenClawConfig`'s fourth
// positional arg (Codex PR #234 R2-1). Cases that assert `installedWorkspace`
// semantics seed their own path. The value doesn't need to exist on disk —
// it's a string stored verbatim on the entry.
const defaultInstalledWorkspace = '/tmp/dkg-test-workspace';
import { homedir } from 'node:os';

const __dirname = dirname(fileURLToPath(import.meta.url));

// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------

let testDir: string;

function makeTestDir(): string {
  const dir = join(tmpdir(), `dkg-setup-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
  mkdirSync(dir, { recursive: true });
  return dir;
}

beforeEach(() => {
  testDir = makeTestDir();
});

afterEach(() => {
  rmSync(testDir, { recursive: true, force: true });
});

// ---------------------------------------------------------------------------
// discoverAgentName
// ---------------------------------------------------------------------------

describe('discoverAgentName', () => {
  // Point DKG_HOME at a fresh tmp dir per test so the persisted-name
  // branch (C8) sees no ~/.dkg/config.json unless the test explicitly
  // seeds one. Otherwise, a dev-machine `~/.dkg/config.json.name`
  // would leak into these tests and break the fallback assertions.
  let originalDkg: string | undefined;
  let dkgHome: string;

  beforeEach(() => {
    originalDkg = process.env.DKG_HOME;
    dkgHome = join(testDir, '.dkg');
    mkdirSync(dkgHome, { recursive: true });
    process.env.DKG_HOME = dkgHome;
  });

  afterEach(() => {
    process.env.DKG_HOME = originalDkg;
  });

  it('returns override when provided', () => {
    expect(discoverAgentName('/nonexistent', 'my-agent')).toBe('my-agent');
  });

  it('parses name from IDENTITY.md with Name field', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(ws, 'IDENTITY.md'), '# Identity\n- **Name**: Alice\n- **Role**: Assistant\n');
    expect(discoverAgentName(ws)).toBe('Alice');
  });

  it('parses name from plain Name: format', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(ws, 'IDENTITY.md'), '# My Agent\nName: Bob\n');
    expect(discoverAgentName(ws)).toBe('Bob');
  });

  it('falls back to generated name when IDENTITY.md has no Name field and no persisted config', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(ws, 'IDENTITY.md'), '# Identity\nJust some text\n');
    expect(discoverAgentName(ws)).toMatch(/^openclaw-agent-[a-z0-9]+$/);
  });

  it('falls back to generated name when IDENTITY.md is missing and no persisted config', () => {
    const ws = join(testDir, 'my-workspace');
    mkdirSync(ws, { recursive: true });
    const name = discoverAgentName(ws);
    expect(name).toMatch(/^openclaw-agent-[a-z0-9]+$/);
  });

  // C8: persisted name stability. On re-runs where IDENTITY.md is absent
  // (or has no Name: field), the faucet Idempotency-Key must stay stable
  // across invocations to avoid duplicate requests. Honoring
  // `~/.dkg/config.json.name` (written by a prior setup run via
  // writeDkgConfig's first-wins semantics) achieves this without
  // introducing a new source of truth.
  it('returns the persisted name from ~/.dkg/config.json when IDENTITY.md is missing', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({ name: 'persisted-agent' }));
    expect(discoverAgentName(ws)).toBe('persisted-agent');
  });

  it('returns the persisted name when IDENTITY.md has no Name: field', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(ws, 'IDENTITY.md'), '# Identity\nJust some text\n');
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({ name: 'persisted-agent' }));
    expect(discoverAgentName(ws)).toBe('persisted-agent');
  });

  it('prefers IDENTITY.md over persisted name when both are present', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(ws, 'IDENTITY.md'), '# Identity\nName: Alice\n');
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({ name: 'persisted-agent' }));
    expect(discoverAgentName(ws)).toBe('Alice');
  });

  it('prefers the override arg over both IDENTITY.md and persisted name', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(ws, 'IDENTITY.md'), '# Identity\nName: Alice\n');
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({ name: 'persisted-agent' }));
    expect(discoverAgentName(ws, 'override-agent')).toBe('override-agent');
  });

  it('falls through to random when persisted config.json exists but lacks a name field', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({ apiPort: 9200 }));
    expect(discoverAgentName(ws)).toMatch(/^openclaw-agent-[a-z0-9]+$/);
  });

  it('falls through to random when persisted config.json is unparseable', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), '{not-json');
    expect(discoverAgentName(ws)).toMatch(/^openclaw-agent-[a-z0-9]+$/);
  });

  it('falls through to random when persisted config.json has a non-string name', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({ name: 42 }));
    expect(discoverAgentName(ws)).toMatch(/^openclaw-agent-[a-z0-9]+$/);
  });

  it('falls through to random when persisted config.json has an empty-string name', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({ name: '   ' }));
    expect(discoverAgentName(ws)).toMatch(/^openclaw-agent-[a-z0-9]+$/);
  });
});

// ---------------------------------------------------------------------------
// writeDkgConfig
// ---------------------------------------------------------------------------

const fakeNetwork = {
  networkName: 'Test Network',
  relays: ['/ip4/1.2.3.4/tcp/9090/p2p/12D3test'],
  defaultContextGraphs: ['testing'],
  defaultNodeRole: 'edge' as const,
  chain: {
    type: 'evm' as const,
    rpcUrl: 'https://rpc.test',
    hubAddress: '0xTEST',
    chainId: 'test:1',
  },
};

describe('writeDkgConfig', () => {
  it('creates a new config from network defaults', () => {
    const dkgHome = join(testDir, '.dkg');
    const original = process.env.DKG_HOME;
    process.env.DKG_HOME = dkgHome;

    try {
      writeDkgConfig('test-agent', fakeNetwork, 9200);

      const config = JSON.parse(readFileSync(join(dkgHome, 'config.json'), 'utf-8'));
      expect(config.name).toBe('test-agent');
      expect(config.apiPort).toBe(9200);
      expect(config.nodeRole).toBe('edge');
      expect(config.contextGraphs).toEqual(['testing']);
      // Chain is intentionally NOT persisted on a fresh setup — the daemon
      // resolves it at runtime from network/<env>.json via resolveChainConfig
      // (cli/src/config.ts), so future hub/RPC rotations propagate without
      // a config rewrite. autoUpdate likewise omits repo/branch/etc. but
      // keeps the `enabled` flag mirrored from the network default because
      // several consumers (/api/status, /api/info, telemetry log pusher,
      // resolveAutoUpdateEnabled) read `config.autoUpdate?.enabled` directly
      // without a network fallback. fakeNetwork has no autoUpdate, so
      // nothing should be written at all here.
      expect(config.chain).toBeUndefined();
      expect(config.autoUpdate).toBeUndefined();
      expect(config.relay).toBeUndefined();
    } finally {
      process.env.DKG_HOME = original;
    }
  });

  it('merges with existing config without overwriting', () => {
    const dkgHome = join(testDir, '.dkg');
    mkdirSync(dkgHome, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({
      name: 'existing-node',
      apiPort: 9300,
      nodeRole: 'core',
      contextGraphs: ['custom'],
      relay: '/ip4/5.6.7.8/tcp/9090/p2p/existing',
      chain: { type: 'evm', rpcUrl: 'https://custom.rpc', hubAddress: '0xCUSTOM', chainId: 'custom:2' },
    }));

    const original = process.env.DKG_HOME;
    process.env.DKG_HOME = dkgHome;

    try {
      writeDkgConfig('new-agent', fakeNetwork, 9200);

      const config = JSON.parse(readFileSync(join(dkgHome, 'config.json'), 'utf-8'));
      // Existing values preserved
      expect(config.name).toBe('existing-node');
      expect(config.apiPort).toBe(9300);
      expect(config.nodeRole).toBe('core');
      expect(config.contextGraphs).toEqual(['custom']);
      expect(config.relay).toBe('/ip4/5.6.7.8/tcp/9090/p2p/existing');
      expect(config.chain.rpcUrl).toBe('https://custom.rpc');
      expect(config.openclawAdapter).toBeUndefined();
      expect(config.openclawChannel).toBeUndefined();
    } finally {
      process.env.DKG_HOME = original;
    }
  });

  it('mirrors only autoUpdate.enabled from network default and preserves existing pins', () => {
    // Regression: previously `writeDkgConfig` copied the entire
    // `network.autoUpdate` block into the user's config when absent,
    // which froze repo/branch/checkInterval at first-run values and broke
    // future rotations (main -> release/v10) shipped via
    // `network/<env>.json#autoUpdate`. The daemon's
    // `resolveAutoUpdateConfig` already does field-level fall-through, so
    // we drop everything *except* `enabled` — that one flag stays because
    // /api/status, /api/info, the telemetry log pusher in lifecycle.ts,
    // and `resolveAutoUpdateEnabled` itself read
    // `config.autoUpdate?.enabled` directly without a network fallback.
    //
    // (1) Fresh setup, network has autoUpdate.enabled=true with extra
    //     pins -> persist ONLY { enabled: true }, no repo/branch.
    const fresh = join(testDir, '.dkg-fresh');
    const original = process.env.DKG_HOME;
    process.env.DKG_HOME = fresh;
    try {
      writeDkgConfig('test-agent', {
        ...fakeNetwork,
        autoUpdate: { enabled: true, repo: 'OriginTrail/dkg', branch: 'main' },
      } as any, 9200);
      const cfg = JSON.parse(readFileSync(join(fresh, 'config.json'), 'utf-8'));
      expect(cfg.autoUpdate).toEqual({ enabled: true });
      expect(cfg.chain).toBeUndefined();
    } finally {
      process.env.DKG_HOME = original;
    }

    // (2) Fresh setup, network explicitly disables autoUpdate -> mirror it.
    const disabled = join(testDir, '.dkg-disabled');
    process.env.DKG_HOME = disabled;
    try {
      writeDkgConfig('test-agent', {
        ...fakeNetwork,
        autoUpdate: { enabled: false, repo: 'OriginTrail/dkg', branch: 'main' },
      } as any, 9200);
      const cfg = JSON.parse(readFileSync(join(disabled, 'config.json'), 'utf-8'));
      expect(cfg.autoUpdate).toEqual({ enabled: false });
    } finally {
      process.env.DKG_HOME = original;
    }

    // (3) Existing config with an operator-pinned autoUpdate. The heal-legacy
    //     pass (`pruneNetworkPinnedDefaults`) operates per-field: any field
    //     whose value equals the current network default is treated as a
    //     stale auto-copy from a pre-PR-322 setup run and dropped, while
    //     fields that differ from the network default are preserved as
    //     genuine operator overrides. Here `repo` matches the network value
    //     so it gets dropped (the resolver will re-derive it at runtime),
    //     `branch` differs ('release/v10' vs 'main') so it's preserved as a
    //     real override, and `enabled` is kept regardless. This matches the
    //     companion expectation in the "heals legacy auto-pinned" test
    //     below — see case (2) at line ~432 which asserts the same
    //     per-field semantics directly.
    const persisted = join(testDir, '.dkg-persisted');
    mkdirSync(persisted, { recursive: true });
    writeFileSync(join(persisted, 'config.json'), JSON.stringify({
      name: 'pinned-node',
      apiPort: 9300,
      autoUpdate: { enabled: true, repo: 'OriginTrail/dkg', branch: 'release/v10' },
    }));
    process.env.DKG_HOME = persisted;
    try {
      writeDkgConfig('pinned-node', {
        ...fakeNetwork,
        autoUpdate: { enabled: true, repo: 'OriginTrail/dkg', branch: 'main' },
      } as any, 9300);
      const cfg = JSON.parse(readFileSync(join(persisted, 'config.json'), 'utf-8'));
      expect(cfg.autoUpdate).toEqual({
        enabled: true,
        branch: 'release/v10',
      });
    } finally {
      process.env.DKG_HOME = original;
    }
  });

  it('heals legacy auto-pinned chain/autoUpdate copies on rerun (PR #322 follow-up)', () => {
    // Earlier `dkg openclaw setup` runs blindly copied the entire `chain` and
    // `autoUpdate` blocks from `network/<env>.json` into ~/.dkg/config.json.
    // After PR #322 fresh installs no longer do that, but operators who
    // already ran the buggy version were stuck on stale snapshots — a hub or
    // RPC rotation in network config would NOT propagate, exactly the failure
    // mode that broke the testnet relays. The heal pass strips fields that
    // still equal the current network default (= clearly auto-copies, never
    // deliberate overrides) while leaving real customisations alone.
    const original = process.env.DKG_HOME;
    const networkV2 = {
      ...fakeNetwork,
      chain: {
        type: 'evm',
        rpcUrl: 'https://sepolia.base.org',
        hubAddress: '0xNEW_HUB_AFTER_ROTATION',
        chainId: 'base:84532',
      },
      autoUpdate: {
        enabled: true,
        repo: 'OriginTrail/dkg',
        branch: 'main',
        checkIntervalMinutes: 5,
      },
    } as any;

    // (1) Pure auto-copy of an OLD network snapshot: every field happens to
    //     match the *previous* defaults (old hub address, old branch, …) but
    //     is now stale. We mimic that by writing a config whose chain/auto
    //     blocks are byte-identical to the *current* network — i.e. the
    //     operator was on the same defaults before. Heal must strip both
    //     blocks so the resolver re-derives them from network at runtime.
    const legacy = join(testDir, '.dkg-legacy-autopin');
    mkdirSync(legacy, { recursive: true });
    writeFileSync(join(legacy, 'config.json'), JSON.stringify({
      name: 'legacy-node',
      apiPort: 9300,
      chain: { ...networkV2.chain },
      autoUpdate: { ...networkV2.autoUpdate },
    }));
    process.env.DKG_HOME = legacy;
    try {
      writeDkgConfig('legacy-node', networkV2, 9300);
      const cfg = JSON.parse(readFileSync(join(legacy, 'config.json'), 'utf-8'));
      expect(cfg.chain).toBeUndefined();
      expect(cfg.autoUpdate).toEqual({ enabled: true });
    } finally {
      process.env.DKG_HOME = original;
    }

    // (2) Operator pinned a private RPC and a release-branch override. The
    //     hubAddress and chainId in their config still match the network
    //     default (auto-copied), so those should be stripped, but the
    //     overridden rpcUrl + branch + repo (matches default) ride along
    //     correctly: rpcUrl differs -> kept; branch differs -> kept; repo
    //     matches -> dropped; hubAddress matches -> dropped.
    const mixed = join(testDir, '.dkg-mixed-overrides');
    mkdirSync(mixed, { recursive: true });
    writeFileSync(join(mixed, 'config.json'), JSON.stringify({
      name: 'mixed-node',
      apiPort: 9301,
      chain: {
        type: networkV2.chain.type,
        rpcUrl: 'https://my-private.rpc.example',
        hubAddress: networkV2.chain.hubAddress,
        chainId: networkV2.chain.chainId,
      },
      autoUpdate: {
        enabled: true,
        repo: networkV2.autoUpdate.repo,
        branch: 'release/v10',
        checkIntervalMinutes: networkV2.autoUpdate.checkIntervalMinutes,
      },
    }));
    process.env.DKG_HOME = mixed;
    try {
      writeDkgConfig('mixed-node', networkV2, 9301);
      const cfg = JSON.parse(readFileSync(join(mixed, 'config.json'), 'utf-8'));
      expect(cfg.chain).toEqual({ rpcUrl: 'https://my-private.rpc.example' });
      expect(cfg.autoUpdate).toEqual({ enabled: true, branch: 'release/v10' });
    } finally {
      process.env.DKG_HOME = original;
    }

    // (3) Operator deliberately disabled auto-update while the network has it
    //     enabled. `enabled` differs from network -> the whole autoUpdate
    //     block must survive the heal (we keep the disagreeing `enabled`
    //     even though all other fields would otherwise be pruned).
    const disabledOverride = join(testDir, '.dkg-disabled-override');
    mkdirSync(disabledOverride, { recursive: true });
    writeFileSync(join(disabledOverride, 'config.json'), JSON.stringify({
      name: 'opt-out-node',
      apiPort: 9302,
      autoUpdate: {
        enabled: false,
        repo: networkV2.autoUpdate.repo,
        branch: networkV2.autoUpdate.branch,
      },
    }));
    process.env.DKG_HOME = disabledOverride;
    try {
      writeDkgConfig('opt-out-node', networkV2, 9302);
      const cfg = JSON.parse(readFileSync(join(disabledOverride, 'config.json'), 'utf-8'));
      expect(cfg.autoUpdate).toEqual({ enabled: false });
    } finally {
      process.env.DKG_HOME = original;
    }
  });

  it('removes stale legacy OpenClaw flags from an existing DKG config', () => {
    const dkgHome = join(testDir, '.dkg');
    mkdirSync(dkgHome, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({
      name: 'existing-node',
      apiPort: 9300,
      openclawAdapter: true,
      openclawChannel: {
        bridgeUrl: 'http://127.0.0.1:9201',
      },
    }));

    const original = process.env.DKG_HOME;
    process.env.DKG_HOME = dkgHome;

    try {
      writeDkgConfig('existing-node', fakeNetwork, 9200);

      const config = JSON.parse(readFileSync(join(dkgHome, 'config.json'), 'utf-8'));
      expect(config.openclawAdapter).toBeUndefined();
      expect(config.openclawChannel).toBeUndefined();
    } finally {
      process.env.DKG_HOME = original;
    }
  });

  it('migrates legacy OpenClaw transport hints into localAgentIntegrations before removing the old key', () => {
    const dkgHome = join(testDir, '.dkg');
    mkdirSync(dkgHome, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({
      name: 'existing-node',
      apiPort: 9300,
      openclawChannel: {
        bridgeUrl: 'http://127.0.0.1:9301',
        gatewayUrl: 'http://127.0.0.1:9300',
      },
      localAgentIntegrations: {
        openclaw: {
          enabled: true,
          transport: {
            kind: 'openclaw-channel',
          },
        },
      },
    }));

    const original = process.env.DKG_HOME;
    process.env.DKG_HOME = dkgHome;

    try {
      writeDkgConfig('existing-node', fakeNetwork, 9200);

      const config = JSON.parse(readFileSync(join(dkgHome, 'config.json'), 'utf-8'));
      expect(config.openclawChannel).toBeUndefined();
      expect(config.localAgentIntegrations.openclaw.transport).toMatchObject({
        kind: 'openclaw-channel',
        bridgeUrl: 'http://127.0.0.1:9301',
        gatewayUrl: 'http://127.0.0.1:9300',
      });
    } finally {
      process.env.DKG_HOME = original;
    }
  });

  // S1 step 4 ordering-invariant regression guard (issue #386 / execution-plan.md
  // §3.S1 step 4). After the agent-agnostic field-level merge moved to
  // dkg-core's `ensureDkgNodeConfig`, OpenClaw's `writeDkgConfig` MUST keep
  // running `migrateLegacyOpenClawTransport` + the `openclawAdapter`/
  // `openclawChannel` deletes + `pruneNetworkPinnedDefaults` BEFORE delegating
  // to `ensureDkgNodeConfig`. If a future refactor flipped the order:
  //   - Migration after merge: the `...existing` spread inside
  //     `ensureDkgNodeConfig` would already have copied `openclawChannel`
  //     into the output; the post-merge migration would then have to mutate
  //     the *output* of `ensureDkgNodeConfig`, but the helper writes the
  //     file synchronously, so the on-disk JSON would still contain
  //     `openclawChannel` — and `localAgentIntegrations.openclaw.transport`
  //     would be missing the migrated bridgeUrl/gatewayUrl.
  //   - Delete after merge: same shape — `openclawChannel` would survive
  //     to disk.
  // This test asserts the union of both: bridge/gateway hints land under
  // `localAgentIntegrations.openclaw.transport` AND the legacy key is gone
  // AND the post-migration `name`/`apiPort` field-level merge respects the
  // overrides — three signals from one fixture so a future refactor that
  // breaks any one is caught with a precise stack trace.
  it('ordering invariant: legacy migration + prune run before ensureDkgNodeConfig field merge', () => {
    const dkgHome = join(testDir, '.dkg-ordering-invariant');
    mkdirSync(dkgHome, { recursive: true });
    writeFileSync(join(dkgHome, 'config.json'), JSON.stringify({
      // Fields the migration must consume + delete:
      openclawChannel: {
        bridgeUrl: 'http://127.0.0.1:9999',
        gatewayUrl: 'http://127.0.0.1:8888',
      },
      openclawAdapter: { stale: true },
      // Field the prune must strip (matches network default below):
      autoUpdate: { enabled: true, repo: 'OriginTrail/dkg', branch: 'main', checkIntervalMinutes: 30 },
      // Pre-existing localAgentIntegrations the migration extends in-place
      // (proves the migration ran on `existing` BEFORE field-level merge —
      // if the order flipped, `localAgentIntegrations` would still be the
      // pre-migration shape and bridgeUrl/gatewayUrl would be missing):
      localAgentIntegrations: { openclaw: { enabled: true, transport: { kind: 'openclaw-channel' } } },
      // Field the merge must preserve over the explicit override (proves
      // ensureDkgNodeConfig saw post-migration existing with name intact):
      name: 'preserved-from-existing',
      apiPort: 9400,
    }));

    const original = process.env.DKG_HOME;
    process.env.DKG_HOME = dkgHome;
    try {
      writeDkgConfig('discovered-name', {
        ...fakeNetwork,
        autoUpdate: { enabled: true, repo: 'OriginTrail/dkg', branch: 'main', checkIntervalMinutes: 30 },
      }, 9200);

      const config = JSON.parse(readFileSync(join(dkgHome, 'config.json'), 'utf-8'));

      // (1) Migration ran: bridge/gateway hints ended up under
      // localAgentIntegrations.openclaw.transport.
      expect(config.localAgentIntegrations.openclaw.transport).toMatchObject({
        kind: 'openclaw-channel',
        bridgeUrl: 'http://127.0.0.1:9999',
        gatewayUrl: 'http://127.0.0.1:8888',
      });
      // (2) Delete ran: legacy keys gone from on-disk config.
      expect(config.openclawChannel).toBeUndefined();
      expect(config.openclawAdapter).toBeUndefined();
      // (3) Prune ran: stale auto-pinned autoUpdate fields stripped, only
      // `enabled` mirrored back via ensureDkgNodeConfig.
      expect(config.autoUpdate).toEqual({ enabled: true });
      // (4) Field-level merge respected post-migration existing: name/apiPort
      // preserved (no explicit overrides, so first-wins on `existing`).
      expect(config.name).toBe('preserved-from-existing');
      expect(config.apiPort).toBe(9400);
    } finally {
      process.env.DKG_HOME = original;
    }
  });
});

// ---------------------------------------------------------------------------
// mergeOpenClawConfig
// ---------------------------------------------------------------------------

describe('mergeOpenClawConfig', () => {
  it('adds adapter to a minimal openclaw.json', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.allow).toContain('adapter-openclaw');
    expect(config.plugins.load.paths).toContain('/path/to/adapter');
    const entry = config.plugins.entries['adapter-openclaw'];
    expect(entry.enabled).toBe(true);
    expect(entry.config).toEqual({
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true },
      installedWorkspace: defaultInstalledWorkspace,
    });
  });

  it('is idempotent — no duplicates on second run', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.allow.filter((x: string) => x === 'adapter-openclaw')).toHaveLength(1);
    expect(config.plugins.load.paths.filter((x: string) => x === '/path/to/adapter')).toHaveLength(1);
  });

  it('preserves existing plugin config', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        allow: ['other-plugin'],
        load: { paths: ['/other'] },
        entries: { 'other-plugin': { enabled: true, foo: 'bar' } },
      },
      someOtherKey: 123,
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.allow).toContain('other-plugin');
    expect(config.plugins.allow).toContain('adapter-openclaw');
    expect(config.plugins.load.paths).toContain('/other');
    expect(config.plugins.entries['other-plugin']).toEqual({ enabled: true, foo: 'bar' });
    expect(config.someOtherKey).toBe(123);
  });

  it('creates a backup file', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const files = readdirSync(testDir);
    const backups = files.filter((f: string) => f.startsWith('openclaw.json.bak.'));
    expect(backups.length).toBeGreaterThanOrEqual(1);
  });

  it('normalizes Windows backslashes in adapter path', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, 'C:\\Users\\test\\adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.load.paths[0]).toBe('C:/Users/test/adapter');
  });

  it('replaces stale cached adapter-openclaw load paths with the current adapter path', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        allow: ['adapter-openclaw'],
        load: {
          paths: [
            'C:/Users/test/AppData/Local/npm-cache/_npx/123/node_modules/@origintrail-official/dkg-adapter-openclaw',
            '/other/plugin',
          ],
        },
        entries: {
          'adapter-openclaw': { enabled: true },
        },
      },
    }));

    mergeOpenClawConfig(configPath, 'C:\\Projects\\dkg-v9\\packages\\adapter-openclaw', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.load.paths).toEqual([
      '/other/plugin',
      'C:/Projects/dkg-v9/packages/adapter-openclaw',
    ]);
  });

  it('writes plugins.slots.memory = "adapter-openclaw" to elect the adapter into the memory slot', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.slots).toBeDefined();
    expect(config.plugins.slots.memory).toBe('adapter-openclaw');
  });

  it('preserves an existing plugins.slots object when adding the memory slot', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        slots: {
          contextEngine: 'some-context-engine',
          other: 'other-value',
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.slots.memory).toBe('adapter-openclaw');
    expect(config.plugins.slots.contextEngine).toBe('some-context-engine');
    expect(config.plugins.slots.other).toBe('other-value');
  });

  it('refuses to merge when plugins.slots.contextEngine === "adapter-openclaw" (wrong-slot guard)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        slots: { contextEngine: 'adapter-openclaw' },
      },
    }));

    expect(() => mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace)).toThrow(/contextEngine/);
  });

  it('overwrites a different plugins.slots.memory value with a log line', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        slots: { memory: 'memory-core' },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.slots.memory).toBe('adapter-openclaw');
  });

  it('is idempotent on plugins.slots.memory re-runs — byte-identical output', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const firstRun = readFileSync(configPath, 'utf-8');
    const firstBackupCount = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const secondRun = readFileSync(configPath, 'utf-8');
    const secondBackupCount = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    expect(secondRun).toBe(firstRun);
    expect(secondBackupCount).toBe(firstBackupCount);
  });

  // PR #228 Codex N2 — persist prior slot owner so disconnect can restore it.
  it('captures a prior non-adapter plugins.slots.memory owner into the adapter entry', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: { slots: { memory: 'memory-core' } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.slots.memory).toBe('adapter-openclaw');
    expect(config.plugins.entries['adapter-openclaw'].previousMemorySlotOwner).toBe('memory-core');
  });

  it('on a second merge, does NOT overwrite previousMemorySlotOwner with the adapter id (first-wins)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: { slots: { memory: 'memory-core' } },
    }));

    // First merge captures "memory-core" into the entry.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterFirst = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterFirst.plugins.entries['adapter-openclaw'].previousMemorySlotOwner).toBe('memory-core');

    // Second merge: slot is already the adapter, so the capture branch won't
    // fire — and even if it did, the first-wins guard keeps the original.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterSecond = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterSecond.plugins.entries['adapter-openclaw'].previousMemorySlotOwner).toBe('memory-core');
  });

  // D2 — entry.config is the single source of truth for DkgNodePlugin runtime
  // config. Fresh merge populates it; re-merge preserves user-customized values
  // (first-wins), matching the previousMemorySlotOwner pattern.
  it('writes entry.config with daemonUrl/memory/channel on fresh merge', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true },
    }, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    const entryConfig = config.plugins.entries['adapter-openclaw'].config;
    expect(entryConfig.daemonUrl).toBe('http://127.0.0.1:9200');
    expect(entryConfig.memory).toEqual({ enabled: true });
    expect(entryConfig.channel).toEqual({ enabled: true });
  });

  it('writes entry.config.stateDir when setup provides a workspace-scoped default', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));
    const stateDir = join(defaultInstalledWorkspace, '.dkg-adapter');

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir,
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDir).toBe(stateDir);
    expect(config.plugins.entries['adapter-openclaw'].config.stateDirSource).toBe('setup-default');
  });

  it('does not mark an incoming stateDir as setup-owned without the setup marker', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));
    const stateDir = join(defaultInstalledWorkspace, '.dkg-adapter');

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir,
      memory: { enabled: true },
      channel: { enabled: true },
    }, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDir).toBe(stateDir);
    expect(config.plugins.entries['adapter-openclaw'].config.stateDirSource).toBeUndefined();
  });

  it('preserves an existing setup-owned stateDir marker when entryConfig omits stateDir', () => {
    const configPath = join(testDir, 'openclaw.json');
    const stateDir = join(defaultInstalledWorkspace, '.openclaw');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              installedWorkspace: defaultInstalledWorkspace,
              stateDir,
              stateDirSource: 'setup-default',
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true },
    }, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    const entryConfig = config.plugins.entries['adapter-openclaw'].config;
    expect(entryConfig.stateDir).toBe(stateDir);
    expect(entryConfig.stateDirSource).toBe('setup-default');
  });

  it('preserves existing entry.config values on re-merge (first-wins semantics)', () => {
    const configPath = join(testDir, 'openclaw.json');
    // Seed: user has a prior merge with a custom daemonUrl and a memory
    // block with enabled:false. The channel block is absent — re-merge
    // should fill it in from defaults without touching existing keys.
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              daemonUrl: 'http://custom:9300',
              memory: { enabled: false },
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true },
    }, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    const entryConfig = config.plugins.entries['adapter-openclaw'].config;
    // User-customized values survive.
    expect(entryConfig.daemonUrl).toBe('http://custom:9300');
    expect(entryConfig.memory.enabled).toBe(false);
    // Missing sub-object gets filled in from defaults.
    expect(entryConfig.channel).toEqual({ enabled: true });
  });

  it('preserves a user-owned stateDir on re-merge', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              stateDir: '/user/custom/openclaw-state',
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir: join(defaultInstalledWorkspace, '.dkg-adapter'),
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDir).toBe('/user/custom/openclaw-state');
    expect(config.plugins.entries['adapter-openclaw'].config.stateDirSource).toBeUndefined();
  });

  it('preserves a user-owned stateDir that happens to equal the prior workspace default', () => {
    const configPath = join(testDir, 'openclaw.json');
    const firstWs = join(testDir, 'workspace-user-default-a');
    const secondWs = join(testDir, 'workspace-user-default-b');
    const userPinnedStateDir = join(firstWs, '.openclaw');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              installedWorkspace: firstWs,
              stateDir: userPinnedStateDir,
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir: join(secondWs, '.dkg-adapter'),
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, secondWs);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    const entryConfig = config.plugins.entries['adapter-openclaw'].config;
    expect(entryConfig.stateDir).toBe(userPinnedStateDir);
    expect(entryConfig.stateDirSource).toBeUndefined();
    expect(entryConfig.installedWorkspace).toBe(secondWs);
  });

  it('updates setup-owned stateDir when installedWorkspace changes', () => {
    const configPath = join(testDir, 'openclaw.json');
    const firstWs = join(testDir, 'workspace-a');
    const secondWs = join(testDir, 'workspace-b');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir: join(firstWs, '.dkg-adapter'),
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, firstWs);

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir: join(secondWs, '.dkg-adapter'),
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, secondWs);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDir).toBe(join(secondWs, '.dkg-adapter'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDirSource).toBe('setup-default');
    expect(config.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(secondWs);
  });

  it('rewrites the legacy setup-owned .openclaw default to .dkg-adapter on re-merge', () => {
    const configPath = join(testDir, 'openclaw.json');
    const ws = join(testDir, 'workspace-legacy-default');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              installedWorkspace: ws,
              stateDir: join(ws, '.openclaw'),
              stateDirSource: 'setup-default',
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir: join(ws, '.dkg-adapter'),
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, ws);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDir).toBe(join(ws, '.dkg-adapter'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDirSource).toBe('setup-default');
    expect(config.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(ws);
  });

  it('updates setup-owned stateDir when existing installedWorkspace and stateDir have surrounding whitespace', () => {
    const configPath = join(testDir, 'openclaw.json');
    const firstWs = join(testDir, 'workspace-whitespace-a');
    const secondWs = join(testDir, 'workspace-whitespace-b');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              installedWorkspace: `  ${firstWs}  `,
              stateDir: `  ${join(firstWs, '.openclaw')}  `,
              stateDirSource: 'setup-default',
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir: join(secondWs, '.dkg-adapter'),
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, secondWs);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDir).toBe(join(secondWs, '.dkg-adapter'));
    expect(config.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(secondWs);
  });

  it('updates setup-owned stateDir when the existing value uses a symlink alias', () => {
    const configPath = join(testDir, 'openclaw.json');
    const realWs = join(testDir, 'workspace-real');
    const aliasWs = join(testDir, 'workspace-alias');
    const secondWs = join(testDir, 'workspace-next');
    mkdirSync(realWs, { recursive: true });
    mkdirSync(secondWs, { recursive: true });
    try {
      symlinkSync(realWs, aliasWs, 'dir');
    } catch {
      return;
    }
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              installedWorkspace: realWs,
              stateDir: join(aliasWs, '.openclaw'),
              stateDirSource: 'setup-default',
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      stateDir: join(secondWs, '.dkg-adapter'),
      stateDirSource: 'setup-default',
      memory: { enabled: true },
      channel: { enabled: true },
    }, secondWs);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDir).toBe(join(secondWs, '.dkg-adapter'));
    expect(config.plugins.entries['adapter-openclaw'].config.stateDirSource).toBe('setup-default');
    expect(config.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(secondWs);
  });

  it('overrideDaemonUrl option replaces existing daemonUrl (used when --port is explicit)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              daemonUrl: 'http://custom:9300',
              memory: { enabled: true },
              channel: { enabled: true },
            },
          },
        },
      },
    }));

    mergeOpenClawConfig(
      configPath,
      '/path/to/adapter',
      {
        daemonUrl: 'http://127.0.0.1:9400',
        memory: { enabled: true },
        channel: { enabled: true },
      },
      defaultInstalledWorkspace,
      { overrideDaemonUrl: true },
    );

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.daemonUrl).toBe('http://127.0.0.1:9400');
  });

  // PR A — tools.profile patch. Ensures plugin-registered `dkg_*` tools are
  // visible to the agent by upgrading the common default `"coding"` profile
  // (whose allowlist filters out plugin tools) to `"full"`, while respecting
  // explicit restrictive profiles ("minimal", "messaging").
  it('tools.profile: upgrades "coding" → "full"', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'coding' },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.tools.profile).toBe('full');
  });

  it('tools.profile: respects explicit "minimal" (no change)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'minimal' },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.tools.profile).toBe('minimal');
  });

  it('tools.profile: respects explicit "messaging" (no change)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'messaging' },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.tools.profile).toBe('messaging');
  });

  it('tools.profile: sets "full" when absent', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.tools.profile).toBe('full');
  });

  // PR A — channels.dkg-ui patch. Without at least one non-`enabled` key on
  // the channel entry, OpenClaw's loader demotes the plugin to setup-runtime
  // mode where `api.registerTool` is a noop. A port pin is the cheapest
  // non-`enabled` key that satisfies `hasMeaningfulChannelConfigShallow`.
  it('channels.dkg-ui: creates { enabled: true, port: 9201 } when missing', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });
  });

  it('channels.dkg-ui: adds port to degenerate { enabled: true }', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      channels: { 'dkg-ui': { enabled: true } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });
  });

  it('channels.dkg-ui: preserves existing user port (no change)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      channels: { 'dkg-ui': { enabled: true, port: 9300 } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.channels['dkg-ui']).toEqual({ enabled: true, port: 9300 });
  });

  it('is idempotent on tools.profile + channels.dkg-ui — byte-identical output on second run', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const firstRun = readFileSync(configPath, 'utf-8');
    const firstBackupCount = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const secondRun = readFileSync(configPath, 'utf-8');
    const secondBackupCount = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    expect(secondRun).toBe(firstRun);
    expect(secondBackupCount).toBe(firstBackupCount);
  });

  // PR #250 review comment 2 — keep top-level channels.dkg-ui.port in sync with
  // the adapter entry's own config.channel.port so the plugin doesn't end up
  // looking at two different ports in two different places.
  it('channels.dkg-ui.port: derives from entryConfig.channel.port when provided', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true, port: 9300 },
    } as AdapterEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.channels['dkg-ui']).toEqual({ enabled: true, port: 9300 });
  });

  // PR #250 review comment 6 — on re-runs, the adapter entry's existing port
  // wins (first-wins) over the incoming default. The top-level channel must
  // match the preserved entry port, not the incoming fallback 9201.
  it('channels.dkg-ui.port: uses preserved entry.config.channel.port on re-merge even when incoming entryConfig has no port', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: {
              daemonUrl: 'http://127.0.0.1:9200',
              memory: { enabled: true },
              channel: { enabled: true, port: 9300 },
            },
          },
        },
      },
    }));

    // Incoming entryConfig has no port — first-wins preserves the existing 9300
    // on the adapter entry, and the top-level channel should inherit it.
    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true },
    } as AdapterEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.channel.port).toBe(9300);
    expect(config.channels['dkg-ui']).toEqual({ enabled: true, port: 9300 });
  });

  // PR #250 review comment 7 — after a first merge, the channel is strictly
  // adapter-owned (byte-identical to mergedChannelsDkgUi). A re-run with a
  // different port must refresh the top-level channel, not leave the stale
  // port in place. The strict user-edit guard from comment 3 shouldn't
  // prevent adapter-owned refresh.
  it('re-merge refreshes channels.dkg-ui.port when the channel still matches last merge output', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    // First merge with default entryConfig → creates { enabled: true, port: 9201 }.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterFirst = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterFirst.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });
    expect(afterFirst.plugins.entries['adapter-openclaw'].mergedChannelsDkgUi).toEqual({ enabled: true, port: 9201 });

    // Simulate the user (or a later setup run) updating the adapter entry's
    // channel port to 9300 directly on the entry config.
    afterFirst.plugins.entries['adapter-openclaw'].config.channel.port = 9300;
    writeFileSync(configPath, JSON.stringify(afterFirst, null, 2) + '\n');

    // Re-merge with default entryConfig (no port) — first-wins preserves 9300
    // on the entry, and the adapter-owned channel should refresh to match.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const afterSecond = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterSecond.plugins.entries['adapter-openclaw'].config.channel.port).toBe(9300);
    expect(afterSecond.channels['dkg-ui']).toEqual({ enabled: true, port: 9300 });
    // mergedChannelsDkgUi tracks the latest adapter output.
    expect(afterSecond.plugins.entries['adapter-openclaw'].mergedChannelsDkgUi).toEqual({ enabled: true, port: 9300 });
  });

  it('re-merge idempotency: unchanged adapter-owned channel produces byte-identical JSON', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const firstRun = readFileSync(configPath, 'utf-8');
    const firstBackups = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    // Re-run with identical state — the refresh branch must be a no-op.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const secondRun = readFileSync(configPath, 'utf-8');
    const secondBackups = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    expect(secondRun).toBe(firstRun);
    expect(secondBackups).toBe(firstBackups);
  });

  // PR #250 review comment 9 — `mergedChannelsDkgUi` must refresh on EVERY
  // channel write, not just the first-wins capture path. Re-creation or
  // re-upgrade at a different port on a subsequent merge needs the snapshot
  // to follow, otherwise unmerge's deep-equal ownership check fails and the
  // adapter-owned channel is left behind on disconnect.
  it('re-create after user deletion: mergedChannelsDkgUi tracks the NEW port, previousChannelsDkgUi stays first-wins', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    // First merge at port 9201.
    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true, port: 9201 },
    } as AdapterEntryConfig, defaultInstalledWorkspace);

    const afterFirst = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterFirst.plugins.entries['adapter-openclaw'].previousChannelsDkgUi).toBeNull();
    expect(afterFirst.plugins.entries['adapter-openclaw'].mergedChannelsDkgUi).toEqual({ enabled: true, port: 9201 });

    // Simulate user deleting channels.dkg-ui. Also reset entry port so
    // post-merge resolution picks 9300.
    delete afterFirst.channels['dkg-ui'];
    afterFirst.plugins.entries['adapter-openclaw'].config.channel.port = 9300;
    writeFileSync(configPath, JSON.stringify(afterFirst, null, 2) + '\n');

    // Re-merge: must re-create at 9300 and refresh mergedChannelsDkgUi.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const afterSecond = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterSecond.channels['dkg-ui']).toEqual({ enabled: true, port: 9300 });
    // `previousChannelsDkgUi` stays first-wins (original absent → `null`).
    expect(afterSecond.plugins.entries['adapter-openclaw'].previousChannelsDkgUi).toBeNull();
    // `mergedChannelsDkgUi` tracks the LATEST output (9300, not stale 9201).
    expect(afterSecond.plugins.entries['adapter-openclaw'].mergedChannelsDkgUi).toEqual({ enabled: true, port: 9300 });
  });

  it('re-upgrade degenerate channel: mergedChannelsDkgUi tracks the NEW port, previousChannelsDkgUi stays first-wins', () => {
    const configPath = join(testDir, 'openclaw.json');
    // Seed: degenerate channel so the first merge captures
    // previousChannelsDkgUi = { enabled: true } (first-wins).
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      channels: { 'dkg-ui': { enabled: true } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', {
      daemonUrl: 'http://127.0.0.1:9200',
      memory: { enabled: true },
      channel: { enabled: true, port: 9201 },
    } as AdapterEntryConfig, defaultInstalledWorkspace);

    const afterFirst = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterFirst.plugins.entries['adapter-openclaw'].previousChannelsDkgUi).toEqual({ enabled: true });
    expect(afterFirst.plugins.entries['adapter-openclaw'].mergedChannelsDkgUi).toEqual({ enabled: true, port: 9201 });

    // Simulate user stripping channel back to degenerate + bumping entry port.
    afterFirst.channels['dkg-ui'] = { enabled: true };
    afterFirst.plugins.entries['adapter-openclaw'].config.channel.port = 9300;
    writeFileSync(configPath, JSON.stringify(afterFirst, null, 2) + '\n');

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const afterSecond = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterSecond.channels['dkg-ui']).toEqual({ enabled: true, port: 9300 });
    // first-wins preserves the original degenerate shape.
    expect(afterSecond.plugins.entries['adapter-openclaw'].previousChannelsDkgUi).toEqual({ enabled: true });
    // Latest-output snapshot follows the new port.
    expect(afterSecond.plugins.entries['adapter-openclaw'].mergedChannelsDkgUi).toEqual({ enabled: true, port: 9300 });
  });
});

// ---------------------------------------------------------------------------
// unmergeOpenClawConfig (PR #228 Codex N2 — restore prior memory-slot owner)
// ---------------------------------------------------------------------------

describe('unmergeOpenClawConfig', () => {
  it('restores plugins.slots.memory to the previous owner when the merge persisted one', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: { slots: { memory: 'memory-core' } },
    }));

    // Merge → captures "memory-core" as previousMemorySlotOwner.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.plugins.slots.memory).toBe('adapter-openclaw');
    expect(afterMerge.plugins.entries['adapter-openclaw'].previousMemorySlotOwner).toBe('memory-core');

    // Unmerge → restores "memory-core" and removes the entry entirely.
    unmergeOpenClawConfig(configPath);
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.plugins.slots.memory).toBe('memory-core');
    expect(afterUnmerge.plugins.entries['adapter-openclaw']).toBeUndefined();
  });

  it('clears plugins.slots.memory when no prior owner was persisted', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    // Merge on a clean config — no previousMemorySlotOwner is captured.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.plugins.slots.memory).toBe('adapter-openclaw');
    expect(afterMerge.plugins.entries['adapter-openclaw'].previousMemorySlotOwner).toBeUndefined();

    unmergeOpenClawConfig(configPath);
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.plugins.slots.memory).toBeUndefined();
  });

  it('merge→unmerge round-trip from a clean openclaw.json restores the original memory-slot state', () => {
    const configPath = join(testDir, 'openclaw.json');
    // "clean" here means: plugins object exists but no slot is set; mimics a
    // fresh install that hasn't configured a memory provider yet.
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    unmergeOpenClawConfig(configPath);

    const final = JSON.parse(readFileSync(configPath, 'utf-8'));
    // plugins.slots.memory is unset again — same as before the merge/unmerge cycle.
    expect(final.plugins.slots?.memory).toBeUndefined();
  });

  it('is idempotent — a second unmerge on an already-disconnected config writes nothing', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: { slots: { memory: 'memory-core' } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    unmergeOpenClawConfig(configPath);
    const firstBackupCount = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;
    const firstContent = readFileSync(configPath, 'utf-8');

    unmergeOpenClawConfig(configPath);
    const secondBackupCount = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;
    const secondContent = readFileSync(configPath, 'utf-8');

    expect(secondContent).toBe(firstContent);
    expect(secondBackupCount).toBe(firstBackupCount);
  });

  it('leaves plugins.slots.memory alone when the user has externally re-owned the slot between merge and unmerge', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: { slots: { memory: 'memory-core' } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    // Simulate external modification: user swaps in a different memory plugin.
    const intermediate = JSON.parse(readFileSync(configPath, 'utf-8'));
    intermediate.plugins.slots.memory = 'some-other-memory-plugin';
    writeFileSync(configPath, JSON.stringify(intermediate, null, 2) + '\n');

    unmergeOpenClawConfig(configPath);

    const final = JSON.parse(readFileSync(configPath, 'utf-8'));
    // We don't clobber the user's new choice, and the adapter entry is gone.
    expect(final.plugins.slots.memory).toBe('some-other-memory-plugin');
    expect(final.plugins.entries['adapter-openclaw']).toBeUndefined();
  });

  // PR #228 Codex N4 — a missing openclaw.json is treated as already-
  // disconnected so the Disconnect UI flow doesn't strand users who removed
  // or relocated OpenClaw. No throw, no `.bak`, no file created.
  it('is a no-op when openclaw.json is missing', () => {
    const configPath = join(testDir, 'does-not-exist.json');
    const countBefore = readdirSync(testDir).length;

    expect(() => unmergeOpenClawConfig(configPath)).not.toThrow();

    expect(existsSync(configPath)).toBe(false);
    expect(readdirSync(testDir).length).toBe(countBefore);
    expect(
      readdirSync(testDir).some((f: string) => f.includes('.bak.')),
    ).toBe(false);
  });

  it('is a no-op when openclaw.json exists but is not valid JSON', () => {
    const configPath = join(testDir, 'openclaw.json');
    const original = '{ not-valid-json';
    writeFileSync(configPath, original);
    const countBefore = readdirSync(testDir).length;

    expect(() => unmergeOpenClawConfig(configPath)).not.toThrow();

    // File untouched (not rewritten), no `.bak` sibling written.
    expect(readFileSync(configPath, 'utf-8')).toBe(original);
    expect(readdirSync(testDir).length).toBe(countBefore);
    expect(
      readdirSync(testDir).some((f: string) => f.startsWith('openclaw.json.bak.')),
    ).toBe(false);
  });

  // PR #228 Codex R4-N1 — when a caller supplies an explicit openclaw.json
  // path that doesn't exist, we must NOT silently fall back to the default
  // `~/.openclaw/openclaw.json`. Doing so would unmerge the wrong config for
  // users who relocated OpenClaw (data-corruption path).
  it('does NOT fall back to the default home when an explicit missing path is supplied', () => {
    // The explicit path the caller passes: a directory that doesn't contain openclaw.json.
    const relocated = join(testDir, 'relocated-openclaw');
    mkdirSync(relocated, { recursive: true });
    const explicitMissingPath = join(relocated, 'openclaw.json');

    // The default home we want left untouched — a fully-merged config that
    // would be visibly mutated if unmerge fell through to it.
    const defaultHome = join(testDir, 'default-openclaw');
    mkdirSync(defaultHome, { recursive: true });
    const defaultConfigPath = join(defaultHome, 'openclaw.json');
    writeFileSync(defaultConfigPath, JSON.stringify({ plugins: {} }, null, 2) + '\n');
    mergeOpenClawConfig(defaultConfigPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const defaultContentBefore = readFileSync(defaultConfigPath, 'utf-8');
    const defaultBackupsBefore = readdirSync(defaultHome).filter(
      (f: string) => f.startsWith('openclaw.json.bak.'),
    ).length;

    // Point OPENCLAW_HOME at `defaultHome` — this is what setup.ts's
    // `openclawDir()` would consult if the explicit-path guard fell through.
    const originalEnv = process.env.OPENCLAW_HOME;
    process.env.OPENCLAW_HOME = defaultHome;
    try {
      expect(() => unmergeOpenClawConfig(explicitMissingPath)).not.toThrow();
    } finally {
      if (originalEnv === undefined) {
        delete process.env.OPENCLAW_HOME;
      } else {
        process.env.OPENCLAW_HOME = originalEnv;
      }
    }

    // The default home's config must be byte-identical and no new `.bak`.
    expect(readFileSync(defaultConfigPath, 'utf-8')).toBe(defaultContentBefore);
    const defaultBackupsAfter = readdirSync(defaultHome).filter(
      (f: string) => f.startsWith('openclaw.json.bak.'),
    ).length;
    expect(defaultBackupsAfter).toBe(defaultBackupsBefore);
    // And the explicit path didn't get a freshly-created file either.
    expect(existsSync(explicitMissingPath)).toBe(false);
  });

  // D1 — unmerge deletes the adapter entry entirely (including its config
  // sub-object). The adapter owns every field on this entry post-D2, so there
  // is no user-customizable state to preserve.
  it('removes the adapter entry entirely on unmerge (including entry.config)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    // Merge populates entry + entry.config.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.plugins.entries['adapter-openclaw'].config).toBeDefined();

    unmergeOpenClawConfig(configPath);

    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.plugins.entries['adapter-openclaw']).toBeUndefined();
  });

  // Codex PR #234 R2-1 (as refined by R3-2) — unmerge returns the prior
  // memory-slot owner for slot restoration. `installedWorkspace` is NOT
  // returned post-R3-2: the daemon reads it off openclaw.json BEFORE calling
  // unmerge, so the skill cleanup runs before the entry is deleted.
  it('returns previousMemorySlotOwner read before entry deletion', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: { slots: { memory: 'memory-core' } },
    }));
    const ws = join(testDir, 'workspace');
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, ws);

    const result = unmergeOpenClawConfig(configPath);

    expect(result).toEqual({ previousMemorySlotOwner: 'memory-core' });
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.plugins.entries['adapter-openclaw']).toBeUndefined();
  });

  it('returns an empty object when openclaw.json is absent', () => {
    const missingPath = join(testDir, 'never-existed.json');
    expect(unmergeOpenClawConfig(missingPath)).toEqual({});
  });

  it('returns an empty object when openclaw.json is unparseable JSON', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, '{this is not: json');
    expect(unmergeOpenClawConfig(configPath)).toEqual({});
  });

  it('omits previousMemorySlotOwner when the merge did not capture one (clean install)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const result = unmergeOpenClawConfig(configPath);

    expect(result.previousMemorySlotOwner).toBeUndefined();
  });

  // PR #250 review comment 1 — round-trip restoration of tools.profile +
  // channels.dkg-ui. Without these, a connect→disconnect cycle would leave
  // openclaw.json permanently widened.
  it('round-trip: absent tools.profile + absent channels.dkg-ui → merge → unmerge restores absent', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    // After merge: both keys are now present.
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.tools.profile).toBe('full');
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });

    unmergeOpenClawConfig(configPath);

    // After unmerge: both keys are gone, and the channels container is also
    // removed (not left as `channels: {}`) so the round-trip returns the
    // config to its pre-merge shape.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.tools?.profile).toBeUndefined();
    expect(afterUnmerge.channels).toBeUndefined();
  });

  it('round-trip: pre-existing sibling channel preserved when channels.dkg-ui is removed on unmerge', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      channels: { telegram: { enabled: true, botToken: 'abc' } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    // After merge: telegram still present, dkg-ui added.
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.channels.telegram).toEqual({ enabled: true, botToken: 'abc' });
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });

    unmergeOpenClawConfig(configPath);

    // After unmerge: dkg-ui gone, telegram preserved, channels container retained.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.channels?.['dkg-ui']).toBeUndefined();
    expect(afterUnmerge.channels?.telegram).toEqual({ enabled: true, botToken: 'abc' });
  });

  it('merge: respects user-disabled channel on adapter entry (does not silently re-enable)', () => {
    const configPath = join(testDir, 'openclaw.json');
    // User has explicitly disabled the channel on the adapter entry.
    writeFileSync(configPath, JSON.stringify({
      plugins: {
        entries: {
          'adapter-openclaw': {
            enabled: true,
            config: { channel: { enabled: false, port: 9201 } },
          },
        },
      },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    // Entry-level enabled=false is preserved by first-wins merge.
    expect(afterMerge.plugins.entries['adapter-openclaw'].config.channel.enabled).toBe(false);
    // Top-level channels.dkg-ui MUST honor the user's disable — we only add the
    // `port` key here so OpenClaw's meaningful-config check still fires.
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: false, port: 9201 });
  });

  it('round-trip: "coding" profile + degenerate { enabled: true } channel → merge → unmerge restores prior values', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'coding' },
      channels: { 'dkg-ui': { enabled: true } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    // After merge: profile upgraded, channel port added.
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.tools.profile).toBe('full');
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });

    unmergeOpenClawConfig(configPath);

    // After unmerge: both restored to pre-merge shape.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.tools.profile).toBe('coding');
    expect(afterUnmerge.channels['dkg-ui']).toEqual({ enabled: true });
  });

  it('round-trip: explicit "minimal" profile preserved through merge + unmerge', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'minimal' },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    // Merge leaves "minimal" alone — no capture, no mutation.
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.tools.profile).toBe('minimal');

    unmergeOpenClawConfig(configPath);

    // Unmerge still leaves "minimal" alone — nothing to restore, we never captured.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.tools.profile).toBe('minimal');
  });

  it('round-trip: user-customized channels.dkg-ui (non-enabled key) preserved through merge + unmerge', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      channels: { 'dkg-ui': { enabled: true, port: 9999, customField: 'user-owned' } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    // Merge leaves the user channel alone — no capture, no mutation.
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9999, customField: 'user-owned' });

    unmergeOpenClawConfig(configPath);

    // Unmerge still leaves it alone.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9999, customField: 'user-owned' });
  });

  // PR #250 review comment 3 — post-merge user edits must be preserved by
  // unmerge. The ownership check is strict deep-equal against the exact shape
  // merge wrote; any divergence means the user now owns the channel.
  it('round-trip: user adds field to merge-created channel → unmerge preserves user edit', () => {
    const configPath = join(testDir, 'openclaw.json');
    // Seed: no channels.dkg-ui at all.
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });

    // Simulate the user adding an auth block after merge.
    const userEdited = {
      ...afterMerge,
      channels: {
        ...afterMerge.channels,
        'dkg-ui': { ...afterMerge.channels['dkg-ui'], auth: { token: 'xyz' } },
      },
    };
    writeFileSync(configPath, JSON.stringify(userEdited, null, 2) + '\n');

    unmergeOpenClawConfig(configPath);

    // User's edit survives — disconnect saw that the channel no longer matches
    // what merge wrote, so it left the channel entirely alone.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.channels['dkg-ui']).toEqual({
      enabled: true,
      port: 9201,
      auth: { token: 'xyz' },
    });
  });

  it('round-trip: user changes port on merge-created channel → unmerge preserves user port', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });

    // User changes the port.
    const userEdited = {
      ...afterMerge,
      channels: { ...afterMerge.channels, 'dkg-ui': { enabled: true, port: 9500 } },
    };
    writeFileSync(configPath, JSON.stringify(userEdited, null, 2) + '\n');

    unmergeOpenClawConfig(configPath);

    // Port 9500 survives — it's not the shape merge wrote.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9500 });
  });

  it('round-trip: user edits degenerate-upgraded channel → unmerge preserves user edit', () => {
    const configPath = join(testDir, 'openclaw.json');
    // Seed: degenerate channel that merge upgrades by adding a port.
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      channels: { 'dkg-ui': { enabled: true } },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9201 });

    // User edits post-merge — changes port and adds a field.
    const userEdited = {
      ...afterMerge,
      channels: {
        ...afterMerge.channels,
        'dkg-ui': { enabled: true, port: 9500, foo: 'bar' },
      },
    };
    writeFileSync(configPath, JSON.stringify(userEdited, null, 2) + '\n');

    unmergeOpenClawConfig(configPath);

    // The user's shape survives — disconnect did NOT restore the original
    // `{ enabled: true }` because the current channel diverges from the merge
    // output.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.channels['dkg-ui']).toEqual({ enabled: true, port: 9500, foo: 'bar' });
  });

  // PR #250 review comment 8 — mergedToolsShape snapshot gates tools.profile
  // revert on the full `tools` section being untouched. User edits anywhere
  // under `tools` mean the whole section is user-owned; we leave it alone.
  it('round-trip: user adds unrelated tools field post-merge → unmerge preserves profile', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.tools.profile).toBe('full');

    // Simulate user adding an unrelated tools field — profile stays "full".
    const userEdited = {
      ...afterMerge,
      tools: { ...afterMerge.tools, web: { enabled: false } },
    };
    writeFileSync(configPath, JSON.stringify(userEdited, null, 2) + '\n');

    unmergeOpenClawConfig(configPath);

    // Profile stays "full" — the tools section diverges from mergedToolsShape
    // (it has a new `web` field), so we treat the whole section as user-owned.
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.tools.profile).toBe('full');
    expect(afterUnmerge.tools.web).toEqual({ enabled: false });
  });

  it('round-trip: unchanged tools section → unmerge reverts "coding" profile', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'coding' },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterMerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterMerge.tools.profile).toBe('full');

    // No post-merge edits — tools matches mergedToolsShape → revert runs.
    unmergeOpenClawConfig(configPath);

    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.tools.profile).toBe('coding');
  });

  // PR #250 review comment 10 — mergedToolsShape must refresh ONLY when this
  // merge pass actually mutated `config.tools`. Otherwise a re-merge that
  // respects a later user choice (e.g. profile changed to "minimal") would
  // overwrite the snapshot with the user's current shape, causing unmerge's
  // deep-equal to match and silently revert `previousToolsProfile`.
  it('re-merge after user switches to "minimal": mergedToolsShape stays at prior output, unmerge preserves "minimal"', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'coding' },
    }));

    // First merge: upgrades "coding" → "full" and captures mergedToolsShape.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterFirst = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterFirst.tools.profile).toBe('full');
    expect(afterFirst.plugins.entries['adapter-openclaw'].mergedToolsShape)
      .toEqual({ alsoAllow: ['group:plugins'], profile: 'full' });
    expect(afterFirst.plugins.entries['adapter-openclaw'].previousToolsProfile).toBe('coding');

    // User switches to "minimal" and adds a post-merge field.
    afterFirst.tools = { profile: 'minimal', alsoAllow: ['group:plugins'], web: { enabled: false } };
    writeFileSync(configPath, JSON.stringify(afterFirst, null, 2) + '\n');

    // Re-merge: "minimal" is respected (no profile mutation), alsoAllow already
    // present (no push). mutatedTools stays false, so the snapshot is NOT
    // overwritten with the current user shape.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const afterSecond = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterSecond.tools.profile).toBe('minimal');
    // Snapshot still reflects the FIRST merge's output, not the user's current shape.
    expect(afterSecond.plugins.entries['adapter-openclaw'].mergedToolsShape)
      .toEqual({ alsoAllow: ['group:plugins'], profile: 'full' });

    // Unmerge: current tools (`minimal`, with `web` field) ≠ snapshot (`full`)
    // → deep-equal fails → profile revert is skipped → user's "minimal" survives.
    unmergeOpenClawConfig(configPath);
    const afterUnmerge = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(afterUnmerge.tools.profile).toBe('minimal');
    expect(afterUnmerge.tools.web).toEqual({ enabled: false });
  });

  it('first-merge no-op on settled tools: mergedToolsShape is NOT captured when nothing was mutated', () => {
    const configPath = join(testDir, 'openclaw.json');
    // Config already has profile: "full" and alsoAllow includes "group:plugins".
    // Merge has nothing to do under `config.tools` — should not capture a snapshot.
    writeFileSync(configPath, JSON.stringify({
      plugins: {},
      tools: { profile: 'full', alsoAllow: ['group:plugins'] },
    }));

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.tools.profile).toBe('full');
    // No mutation ⇒ no capture. previousToolsProfile also stays absent.
    expect(config.plugins.entries['adapter-openclaw'].mergedToolsShape).toBeUndefined();
    expect('previousToolsProfile' in config.plugins.entries['adapter-openclaw']).toBe(false);
  });

  it('re-merge idempotency on settled tools: snapshot once captured, not re-written on no-op re-merge', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));

    // First merge creates the settled state + captures the snapshot.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const firstRun = readFileSync(configPath, 'utf-8');
    const firstBackups = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    // Second merge: tools is already settled — no mutation should occur.
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, defaultInstalledWorkspace);
    const secondRun = readFileSync(configPath, 'utf-8');
    const secondBackups = readdirSync(testDir).filter((f: string) => f.startsWith('openclaw.json.bak.')).length;

    // Byte-identical output proves mergedToolsShape wasn't re-written with a
    // (possibly differently-ordered) new structuredClone.
    expect(secondRun).toBe(firstRun);
    expect(secondBackups).toBe(firstBackups);
  });
});

// ---------------------------------------------------------------------------
// mergeOpenClawConfig installedWorkspace persistence (Codex PR #234 R2-1)
// ---------------------------------------------------------------------------

describe('mergeOpenClawConfig installedWorkspace', () => {
  it('persists installedWorkspace verbatim on the adapter entry', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));
    const ws = join(testDir, 'workspace');

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, ws);

    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
    expect(config.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(ws);
  });

  it('overwrites installedWorkspace on re-merge (latest-wins)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));
    const firstWs = join(testDir, 'first-workspace');
    const secondWs = join(testDir, 'second-workspace');

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, firstWs);
    expect(JSON.parse(readFileSync(configPath, 'utf-8'))
      .plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(firstWs);

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, secondWs);

    // Latest-wins: re-install updates the authoritative pointer (matches the
    // same semantics as `entry.enabled` and `entry.config.daemonUrl` with
    // overrideDaemonUrl — reinstalls reflect current reality).
    expect(JSON.parse(readFileSync(configPath, 'utf-8'))
      .plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(secondWs);
  });

  it('is idempotent when the same workspace is re-supplied', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, JSON.stringify({ plugins: {} }));
    const ws = join(testDir, 'workspace');

    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, ws);
    const first = readFileSync(configPath, 'utf-8');
    mergeOpenClawConfig(configPath, '/path/to/adapter', defaultEntryConfig, ws);
    const second = readFileSync(configPath, 'utf-8');

    expect(second).toBe(first);
    expect(JSON.parse(second).plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(ws);
  });
});

// ---------------------------------------------------------------------------
// verifySkillRemoved (Codex PR #234 R2-2)
// ---------------------------------------------------------------------------

describe('verifySkillRemoved', () => {
  it('returns null when the canonical node skill file is absent', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    expect(verifySkillRemoved(ws)).toBeNull();
  });

  it('returns a descriptive failure string when the skill file still exists', () => {
    const ws = join(testDir, 'workspace');
    const skillDir = join(ws, 'skills', 'dkg-node');
    mkdirSync(skillDir, { recursive: true });
    const skillPath = join(skillDir, 'SKILL.md');
    writeFileSync(skillPath, '# Canonical DKG Node Skill\n');

    const failure = verifySkillRemoved(ws);

    expect(failure).not.toBeNull();
    expect(failure).toContain(skillPath);
    expect(failure).toMatch(/still present/);
  });

  it('treats a dangling dkg-node/ directory with no SKILL.md as clean (directory alone does not fail)', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(join(ws, 'skills', 'dkg-node'), { recursive: true });
    // No SKILL.md inside the directory. Verify targets the file, not the
    // parent — matches `removeCanonicalNodeSkill`'s unlink target.
    expect(verifySkillRemoved(ws)).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// verifyUnmergeInvariants (PR #228 Codex N3 — full reverse-merge check)
// ---------------------------------------------------------------------------

describe('verifyUnmergeInvariants', () => {
  it('returns null when every field `mergeOpenClawConfig` writes has been unwound (entry absent)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(
      configPath,
      JSON.stringify(
        {
          plugins: {
            allow: [],
            load: { paths: [] },
            entries: {},
            slots: {},
          },
        },
        null,
        2,
      ) + '\n',
    );

    expect(verifyUnmergeInvariants(configPath)).toBeNull();
  });

  it('returns null when entry exists but is disabled (defensive — absent is the normal post-unmerge state)', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(
      configPath,
      JSON.stringify(
        {
          plugins: {
            allow: [],
            load: { paths: [] },
            entries: { 'adapter-openclaw': { enabled: false } },
            slots: {},
          },
        },
        null,
        2,
      ) + '\n',
    );

    expect(verifyUnmergeInvariants(configPath)).toBeNull();
  });

  it('returns a descriptive string when plugins.slots.memory is still elected', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(
      configPath,
      JSON.stringify({
        plugins: {
          allow: [],
          load: { paths: [] },
          entries: { 'adapter-openclaw': { enabled: false } },
          slots: { memory: 'adapter-openclaw' },
        },
      }),
    );

    expect(verifyUnmergeInvariants(configPath)).toMatch(/plugins\.slots\.memory is still "adapter-openclaw"/);
  });

  it('returns a descriptive string when plugins.allow still contains the adapter', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(
      configPath,
      JSON.stringify({
        plugins: {
          allow: ['adapter-openclaw'],
          load: { paths: [] },
          entries: { 'adapter-openclaw': { enabled: false } },
          slots: {},
        },
      }),
    );

    expect(verifyUnmergeInvariants(configPath)).toMatch(/plugins\.allow still contains "adapter-openclaw"/);
  });

  it('returns a descriptive string when plugins.load.paths still contains an adapter load path', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(
      configPath,
      JSON.stringify({
        plugins: {
          allow: [],
          load: { paths: ['/home/me/packages/adapter-openclaw'] },
          entries: { 'adapter-openclaw': { enabled: false } },
          slots: {},
        },
      }),
    );

    const result = verifyUnmergeInvariants(configPath);
    expect(result).toMatch(/plugins\.load\.paths still contains adapter path/);
    expect(result).toContain('/home/me/packages/adapter-openclaw');
  });

  it('returns a descriptive string when plugins.entries["adapter-openclaw"] is still present with enabled=true', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(
      configPath,
      JSON.stringify({
        plugins: {
          allow: [],
          load: { paths: [] },
          entries: { 'adapter-openclaw': { enabled: true } },
          slots: {},
        },
      }),
    );

    expect(verifyUnmergeInvariants(configPath)).toMatch(
      /plugins\.entries\["adapter-openclaw"\] is still present with enabled=true/,
    );
  });

  // PR #228 Codex N4 — missing file is treated as already-disconnected so
  // the Disconnect UI flow doesn't strand users who removed or relocated
  // OpenClaw. The invariants hold trivially when the config doesn't exist.
  it('returns null on a missing config file (treated as already-disconnected)', () => {
    const configPath = join(testDir, 'does-not-exist.json');

    expect(verifyUnmergeInvariants(configPath)).toBeNull();
  });

  it('does not throw on an unparseable config file — returns a descriptive string', () => {
    const configPath = join(testDir, 'openclaw.json');
    writeFileSync(configPath, '{ not-valid-json');

    const result = verifyUnmergeInvariants(configPath);
    expect(result).toMatch(/Could not parse/);
  });

  // PR #228 Codex R4-N1 — `verifyUnmergeInvariants` must never read the
  // default `~/.openclaw/openclaw.json` when the caller supplied an explicit
  // path. Even when the default home holds a dirty/still-merged config, an
  // explicit missing path should be reported as already-disconnected.
  it('does NOT read the default home when an explicit missing path is supplied', () => {
    const explicitMissingPath = join(testDir, 'relocated', 'openclaw.json');

    // Seed OPENCLAW_HOME with a config whose invariants would FAIL if it
    // were accidentally consulted — slot still elected, adapter still in allow.
    const defaultHome = join(testDir, 'default-openclaw');
    mkdirSync(defaultHome, { recursive: true });
    writeFileSync(
      join(defaultHome, 'openclaw.json'),
      JSON.stringify(
        {
          plugins: {
            allow: ['adapter-openclaw'],
            slots: { memory: 'adapter-openclaw' },
            load: { paths: [] },
            entries: { 'adapter-openclaw': { enabled: true } },
          },
        },
        null,
        2,
      ) + '\n',
    );

    const originalEnv = process.env.OPENCLAW_HOME;
    process.env.OPENCLAW_HOME = defaultHome;
    try {
      // Returns null because the explicit path is missing; invariants hold
      // trivially. If the fn fell through to the default, it would return a
      // descriptive failure string for one of the three dirty invariants.
      expect(verifyUnmergeInvariants(explicitMissingPath)).toBeNull();
    } finally {
      if (originalEnv === undefined) {
        delete process.env.OPENCLAW_HOME;
      } else {
        process.env.OPENCLAW_HOME = originalEnv;
      }
    }
  });
});

// ---------------------------------------------------------------------------
// openclaw.plugin.json — manifest kind declaration
// ---------------------------------------------------------------------------

describe('openclaw.plugin.json manifest', () => {
  it('declares kind: "memory" so the adapter is eligible for memory-slot election', () => {
    const manifestPath = join(__dirname, '..', 'openclaw.plugin.json');
    const packagePath = join(__dirname, '..', 'package.json');
    const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
    const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
    expect(manifest.kind).toBe('memory');
    // the manifest `id` and the published npm `name` are
    // intentionally DIFFERENT identifiers. The `id` is the plugin slot
    // key used by OpenClaw's slot resolution (`plugins.slots.memory`,
    // `plugins.entries`, `plugins.allow`) — this must stay short and
    // stable (`adapter-openclaw`) because it is hard-coded across
    // `setup.ts`, `DkgMemoryPlugin.ts`, and `openclaw-entry.mjs`. The
    // `pkg.name` is the scoped npm package name used for installation.
    // A previous iteration of this PR renamed `manifest.id` to
    // `pkg.name` in the manifest alone, which split the plugin identity
    // in two and silently broke slot election; the rename has been
    // reverted and the slot id is once again the short `adapter-openclaw`.
    expect(manifest.id).toBe('adapter-openclaw');
    // Sanity check that both the adapter code AND this test agree on
    // the slot identifier — any future rename must update every call
    // site that matches on `plugins.slots.memory`.
    expect(pkg.name).toBe('@origintrail-official/dkg-adapter-openclaw');
  });
});

// ---------------------------------------------------------------------------
// installCanonicalNodeSkill
// ---------------------------------------------------------------------------

describe('installCanonicalNodeSkill', () => {
  it('resolves the canonical skill from the current CLI package layout by default', () => {
    const ws = join(testDir, 'workspace');

    const targetPath = installCanonicalNodeSkill(ws);

    expect(targetPath).toBe(join(ws, 'skills', 'dkg-node', 'SKILL.md'));
    const copied = readFileSync(targetPath, 'utf-8');
    expect(copied).toContain('name: dkg-node');
    expect(copied).toContain('# DKG V10 Node Skill');
    expect(copied).toContain('OriginTrail');
  });

  it('copies the canonical CLI skill into the OpenClaw workspace', () => {
    const ws = join(testDir, 'workspace');
    const sourceDir = join(testDir, 'cli-skill');
    const sourcePath = join(sourceDir, 'SKILL.md');
    mkdirSync(sourceDir, { recursive: true });
    writeFileSync(sourcePath, '# Canonical DKG Node Skill\n');

    const targetPath = installCanonicalNodeSkill(ws, sourcePath);

    expect(targetPath).toBe(join(ws, 'skills', 'dkg-node', 'SKILL.md'));
    expect(readFileSync(targetPath, 'utf-8')).toBe('# Canonical DKG Node Skill\n');
  });

  it('overwrites an existing workspace dkg-node skill with the canonical CLI copy', () => {
    const ws = join(testDir, 'workspace');
    const sourceDir = join(testDir, 'cli-skill');
    const sourcePath = join(sourceDir, 'SKILL.md');
    mkdirSync(join(ws, 'skills', 'dkg-node'), { recursive: true });
    mkdirSync(sourceDir, { recursive: true });
    writeFileSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'), '# Old Adapter Skill\n');
    writeFileSync(sourcePath, '# Canonical DKG Node Skill\n');

    installCanonicalNodeSkill(ws, sourcePath);

    expect(readFileSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'), 'utf-8')).toBe('# Canonical DKG Node Skill\n');
  });
});

// ---------------------------------------------------------------------------
// removeCanonicalNodeSkill — symmetric counterpart used by the daemon-side
// Disconnect path to retire the agent-facing skill alongside the config entry.
// ---------------------------------------------------------------------------

describe('removeCanonicalNodeSkill', () => {
  it('removes the canonical node skill and cleans up the empty dkg-node directory', () => {
    const ws = join(testDir, 'workspace');
    const sourceDir = join(testDir, 'cli-skill');
    const sourcePath = join(sourceDir, 'SKILL.md');
    mkdirSync(sourceDir, { recursive: true });
    writeFileSync(sourcePath, '# Canonical DKG Node Skill\n');
    installCanonicalNodeSkill(ws, sourcePath);
    const skillPath = join(ws, 'skills', 'dkg-node', 'SKILL.md');
    expect(existsSync(skillPath)).toBe(true);

    removeCanonicalNodeSkill(ws);

    expect(existsSync(skillPath)).toBe(false);
    expect(existsSync(join(ws, 'skills', 'dkg-node'))).toBe(false);
    // Outer skills/ parent is adapter-agnostic and must never be touched.
    expect(existsSync(join(ws, 'skills'))).toBe(true);
  });

  it('is idempotent when the skill is absent', () => {
    const ws = join(testDir, 'workspace');
    // No seed — workspace exists but nothing under skills/.
    mkdirSync(ws, { recursive: true });

    expect(() => removeCanonicalNodeSkill(ws)).not.toThrow();
    expect(() => removeCanonicalNodeSkill(ws)).not.toThrow();

    expect(existsSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
    expect(existsSync(join(ws, 'skills', 'dkg-node'))).toBe(false);
  });

  it('leaves unrelated files in skills/dkg-node/ intact', () => {
    const ws = join(testDir, 'workspace');
    const sourceDir = join(testDir, 'cli-skill');
    const sourcePath = join(sourceDir, 'SKILL.md');
    mkdirSync(sourceDir, { recursive: true });
    writeFileSync(sourcePath, '# Canonical DKG Node Skill\n');
    installCanonicalNodeSkill(ws, sourcePath);
    const siblingPath = join(ws, 'skills', 'dkg-node', 'custom-note.md');
    writeFileSync(siblingPath, '# User note alongside the adapter skill\n');

    removeCanonicalNodeSkill(ws);

    expect(existsSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
    expect(existsSync(siblingPath)).toBe(true);
    // Sibling keeps the dir non-empty, so rmdirSync(ENOTEMPTY) was swallowed.
    expect(existsSync(join(ws, 'skills', 'dkg-node'))).toBe(true);
    expect(readFileSync(siblingPath, 'utf-8')).toBe('# User note alongside the adapter skill\n');
  });

  it('leaves other skills under skills/ intact', () => {
    const ws = join(testDir, 'workspace');
    const sourceDir = join(testDir, 'cli-skill');
    const sourcePath = join(sourceDir, 'SKILL.md');
    mkdirSync(sourceDir, { recursive: true });
    writeFileSync(sourcePath, '# Canonical DKG Node Skill\n');
    installCanonicalNodeSkill(ws, sourcePath);
    const otherSkillPath = join(ws, 'skills', 'other-skill', 'notes.md');
    mkdirSync(dirname(otherSkillPath), { recursive: true });
    writeFileSync(otherSkillPath, '# Unrelated sibling skill\n');

    removeCanonicalNodeSkill(ws);

    expect(existsSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
    expect(existsSync(join(ws, 'skills', 'dkg-node'))).toBe(false);
    expect(existsSync(otherSkillPath)).toBe(true);
    expect(existsSync(join(ws, 'skills'))).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// resolveWorkspaceDirFromConfig — shared resolver between setup install and
// the daemon-side Disconnect path (Codex PR #234 R1-1).
// ---------------------------------------------------------------------------

describe('resolveWorkspaceDirFromConfig', () => {
  // Use a deterministic openclaw.json path inside testDir so relative-path
  // resolution is independent of cwd. OPENCLAW_HOME is scoped per-test only
  // for the default-fallback cases.
  let openclawConfigFilePath: string;

  beforeEach(() => {
    const openclawHome = join(testDir, '.openclaw');
    mkdirSync(openclawHome, { recursive: true });
    openclawConfigFilePath = join(openclawHome, 'openclaw.json');
  });

  it('prefers agents.defaults.workspace over other key variants', () => {
    const wanted = join(testDir, 'wanted-ws');
    const result = resolveWorkspaceDirFromConfig(
      {
        agents: { defaults: { workspace: wanted } },
        workspace: join(testDir, 'ignored-ws'),
        workspaceDir: join(testDir, 'also-ignored'),
      },
      openclawConfigFilePath,
    );
    expect(result).toBe(wanted);
  });

  it('falls back to top-level workspace when agents.defaults.workspace is absent', () => {
    const wanted = join(testDir, 'top-level-ws');
    const result = resolveWorkspaceDirFromConfig(
      { workspace: wanted, workspaceDir: join(testDir, 'ignored') },
      openclawConfigFilePath,
    );
    expect(result).toBe(wanted);
  });

  it('falls back to workspaceDir when the first two keys are absent', () => {
    const wanted = join(testDir, 'legacy-key-ws');
    const result = resolveWorkspaceDirFromConfig(
      { workspaceDir: wanted },
      openclawConfigFilePath,
    );
    expect(result).toBe(wanted);
  });

  it('expands a leading ~ to homedir()', () => {
    const result = resolveWorkspaceDirFromConfig(
      { agents: { defaults: { workspace: '~/foo' } } },
      openclawConfigFilePath,
    );
    expect(result).toBe(join(homedir(), 'foo'));
  });

  it('resolves relative paths against dirname(openclawConfigPath) — not cwd', () => {
    const result = resolveWorkspaceDirFromConfig(
      { workspace: './workspace' },
      openclawConfigFilePath,
    );
    expect(result).toBe(join(dirname(openclawConfigFilePath), 'workspace'));
  });

  it('passes absolute paths through unchanged', () => {
    const absolute = join(testDir, 'already', 'absolute');
    const result = resolveWorkspaceDirFromConfig(
      { workspace: absolute },
      openclawConfigFilePath,
    );
    expect(result).toBe(absolute);
  });

  it('returns default $OPENCLAW_HOME/workspace when no key is set but the dir exists', () => {
    const openclawHome = join(testDir, 'default-home');
    const defaultWs = join(openclawHome, 'workspace');
    mkdirSync(defaultWs, { recursive: true });

    const original = process.env.OPENCLAW_HOME;
    process.env.OPENCLAW_HOME = openclawHome;
    try {
      const result = resolveWorkspaceDirFromConfig(
        { plugins: {} },
        join(openclawHome, 'openclaw.json'),
      );
      expect(result).toBe(defaultWs);
    } finally {
      if (original === undefined) delete process.env.OPENCLAW_HOME;
      else process.env.OPENCLAW_HOME = original;
    }
  });

  it('returns null when no key is set and the default $OPENCLAW_HOME/workspace does not exist', () => {
    const openclawHome = join(testDir, 'empty-home');
    mkdirSync(openclawHome, { recursive: true });

    const original = process.env.OPENCLAW_HOME;
    process.env.OPENCLAW_HOME = openclawHome;
    try {
      const result = resolveWorkspaceDirFromConfig(
        { plugins: {} },
        join(openclawHome, 'openclaw.json'),
      );
      expect(result).toBeNull();
    } finally {
      if (original === undefined) delete process.env.OPENCLAW_HOME;
      else process.env.OPENCLAW_HOME = original;
    }
  });

  // the default-fallback must derive from `dirname(openclawConfigPath)`
  // rather than the process-wide `$OPENCLAW_HOME`. A legacy install whose
  // openclaw.json lives at a non-default path (e.g. a user-specified
  // `--config-path`-style location in scripts, or a `OPENCLAW_HOME`-shadowed
  // directory from a prior version) would otherwise resolve to the default
  // `~/.openclaw/workspace` on Disconnect — cleaning the wrong SKILL.md or
  // missing the real one.
  it('derives the default fallback from dirname(openclawConfigPath), not $OPENCLAW_HOME (R9-1)', () => {
    // Set `OPENCLAW_HOME` to one place; the openclaw.json lives somewhere
    // else entirely. The fallback must target the config-adjacent workspace,
    // NOT `$OPENCLAW_HOME/workspace`.
    const shadowHome = join(testDir, 'shadow-openclaw-home');
    const shadowWs = join(shadowHome, 'workspace');
    mkdirSync(shadowWs, { recursive: true });

    const configHome = join(testDir, 'legacy-install-dir');
    const configWs = join(configHome, 'workspace');
    mkdirSync(configWs, { recursive: true });
    const legacyConfigPath = join(configHome, 'openclaw.json');

    const original = process.env.OPENCLAW_HOME;
    process.env.OPENCLAW_HOME = shadowHome;
    try {
      const result = resolveWorkspaceDirFromConfig(
        { plugins: {} },
        legacyConfigPath,
      );
      // Correct answer: co-located with the config file.
      expect(result).toBe(configWs);
      // Pre-R9-1 regression guard — the shadow path must NOT win.
      expect(result).not.toBe(shadowWs);
    } finally {
      if (original === undefined) delete process.env.OPENCLAW_HOME;
      else process.env.OPENCLAW_HOME = original;
    }
  });

  it('returns null when the winning key is present but not a non-empty string (no fallback cascade across keys)', () => {
    // Matches discoverWorkspace semantics: `??` only skips null/undefined, so
    // a present-but-empty-string / non-string value does NOT cascade to the
    // next key. With no default $OPENCLAW_HOME/workspace on disk the resolver
    // returns null, matching the existing install-path throw conditions.
    const openclawHome = join(testDir, 'empty-home-2');
    mkdirSync(openclawHome, { recursive: true });

    const original = process.env.OPENCLAW_HOME;
    process.env.OPENCLAW_HOME = openclawHome;
    try {
      const result = resolveWorkspaceDirFromConfig(
        {
          agents: { defaults: { workspace: '' } },
          workspace: 'would-have-been-picked-if-cascading',
        },
        join(openclawHome, 'openclaw.json'),
      );
      expect(result).toBeNull();
    } finally {
      if (original === undefined) delete process.env.OPENCLAW_HOME;
      else process.env.OPENCLAW_HOME = original;
    }
  });
});

// ---------------------------------------------------------------------------
// discoverWorkspace
// ---------------------------------------------------------------------------

describe('discoverWorkspace', () => {
  it('uses override path when provided', () => {
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });

    const result = discoverWorkspace(ws);
    expect(result.workspaceDir).toBe(ws);
  });

  it('throws when override path does not exist', () => {
    expect(() => discoverWorkspace('/nonexistent/path/xyz')).toThrow('does not exist');
  });
});

// ---------------------------------------------------------------------------
// openclawConfigPath — honors OPENCLAW_HOME (Codex PR #228 review #6)
// ---------------------------------------------------------------------------

describe('openclawConfigPath', () => {
  it('honors OPENCLAW_HOME when set', () => {
    const fakeHome = join(testDir, 'custom-openclaw-home');
    const original = process.env.OPENCLAW_HOME;
    process.env.OPENCLAW_HOME = fakeHome;

    try {
      expect(openclawConfigPath()).toBe(join(fakeHome, 'openclaw.json'));
    } finally {
      if (original === undefined) {
        delete process.env.OPENCLAW_HOME;
      } else {
        process.env.OPENCLAW_HOME = original;
      }
    }
  });

  it('falls back to ~/.openclaw/openclaw.json when OPENCLAW_HOME is unset', () => {
    const original = process.env.OPENCLAW_HOME;
    delete process.env.OPENCLAW_HOME;

    try {
      expect(openclawConfigPath()).toBe(join(homedir(), '.openclaw', 'openclaw.json'));
    } finally {
      if (original !== undefined) {
        process.env.OPENCLAW_HOME = original;
      }
    }
  });
});

// ---------------------------------------------------------------------------
// runSetup — AbortSignal threading (Codex PR #228 review #1)
// ---------------------------------------------------------------------------

describe('runSetup abort signal', () => {
  it('throws before any filesystem writes when the signal is already aborted', async () => {
    // Point DKG/OpenClaw home at the empty tmp dir so a stray write would be
    // observable, and give runSetup a valid workspace so Step 1's discovery
    // check would otherwise succeed.
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const workspace = join(testDir, 'workspace');
    mkdirSync(workspace, { recursive: true });

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      const controller = new AbortController();
      controller.abort();

      await expect(
        runSetup({
          workspace,
          start: false,
          verify: false,
          signal: controller.signal,
        }),
      ).rejects.toThrow(/Setup aborted/);

      // No state should have been written.
      expect(existsSync(dkgHome)).toBe(false);
      expect(existsSync(openclawHome)).toBe(false);
      expect(existsSync(join(workspace, 'config.json'))).toBe(false);
      expect(existsSync(join(workspace, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  it('stops cooperatively mid-flow when the signal aborts between steps', async () => {
    // Pre-seed a valid workspace + openclaw.json so Steps 1, 2, and 3 complete,
    // then abort before Step 5 (merge). Assert the merge did not run.
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const workspace = join(testDir, 'workspace');
    mkdirSync(workspace, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    // Minimal openclaw.json setup won't merge because we abort beforehand.
    const openclawConfigPath = join(openclawHome, 'openclaw.json');
    writeFileSync(openclawConfigPath, JSON.stringify({ plugins: {} }, null, 2) + '\n');

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      const controller = new AbortController();
      // Abort immediately — Step 1's `throwIfAborted()` fires first, so merge
      // + workspace-config + skill-install never run. The observable guarantee
      // we care about: openclaw.json is byte-identical to what we wrote.
      controller.abort();

      const before = readFileSync(openclawConfigPath, 'utf-8');
      await expect(
        runSetup({
          workspace,
          start: false,
          verify: false,
          signal: controller.signal,
        }),
      ).rejects.toThrow(/Setup aborted/);
      const after = readFileSync(openclawConfigPath, 'utf-8');
      expect(after).toBe(before);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  it('completes normally when signal is undefined (backwards compatibility)', async () => {
    // Smoke test that the abort gate is a no-op without a signal — guards
    // against the check accidentally rejecting the `undefined` case.
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const workspace = join(testDir, 'workspace');
    mkdirSync(workspace, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({ plugins: {} }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await expect(
        runSetup({
          workspace,
          start: false,
          verify: false,
          // no signal
        }),
      ).resolves.toBeUndefined();
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });
});

// ---------------------------------------------------------------------------
// runSetup workspace migration (Codex PR #234 R3-3)
// Re-running setup with a different workspace must retire the prior install's
// SKILL.md — otherwise the old `/dir-a/skills/dkg-node/SKILL.md` is orphaned
// and Disconnect will only ever retire whatever `installedWorkspace` points
// at (latest merge wins).
// ---------------------------------------------------------------------------

describe('runSetup workspace migration', () => {
  it('removes the prior install\'s SKILL.md when the workspace changes between setups (cleanup runs AFTER new install lands)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const dirA = join(testDir, 'workspace-a');
    const dirB = join(testDir, 'workspace-b');
    mkdirSync(dirA, { recursive: true });
    mkdirSync(dirB, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({ plugins: {} }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      // First install targets dirA.
      await runSetup({ workspace: dirA, start: false, verify: false });
      const skillA = join(dirA, 'skills', 'dkg-node', 'SKILL.md');
      expect(existsSync(skillA)).toBe(true);
      const afterA = JSON.parse(readFileSync(join(openclawHome, 'openclaw.json'), 'utf-8'));
      expect(afterA.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(dirA);

      // Second install targets dirB — old install's skill at dirA must be retired.
      await runSetup({ workspace: dirB, start: false, verify: false });
      const skillB = join(dirB, 'skills', 'dkg-node', 'SKILL.md');

      // Post-R4-2: end state is new-install-present + old-install-absent +
      // pointer flipped. All three must hold together — proves the migration
      // ran the cleanup strictly after the new install landed.
      expect(existsSync(skillB)).toBe(true);
      expect(existsSync(skillA)).toBe(false);
      const afterB = JSON.parse(readFileSync(join(openclawHome, 'openclaw.json'), 'utf-8'));
      expect(afterB.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(dirB);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  // Codex PR #234 R4-2 (strictly-additive cleanup) + R5-3 (canary-ordered
  // install BEFORE merge). When install-new fails, both the prior install's
  // SKILL.md AND the openclaw.json pointer must still reflect the old
  // workspace — so a retry reads OLD as the prior install and migrates
  // normally, instead of reading NEW and treating the orphan as fresh.
  it('leaves the prior install\'s SKILL.md AND entry.config.installedWorkspace intact when installing the new skill fails (R4-2 + R5-3)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const dirA = join(testDir, 'workspace-a');
    const dirB = join(testDir, 'workspace-b');
    mkdirSync(dirA, { recursive: true });
    mkdirSync(dirB, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({ plugins: {} }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      // First install lands cleanly at dirA — baseline.
      await runSetup({ workspace: dirA, start: false, verify: false });
      const skillA = join(dirA, 'skills', 'dkg-node', 'SKILL.md');
      expect(existsSync(skillA)).toBe(true);
      const configAfterA = JSON.parse(readFileSync(join(openclawHome, 'openclaw.json'), 'utf-8'));
      expect(configAfterA.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(dirA);

      // Sabotage dirB so installCanonicalNodeSkill's mkdirSync(skills/dkg-node)
      // throws: create `skills` as a FILE so the recursive mkdir hits an
      // intermediate non-directory and fails with ENOTDIR/EEXIST.
      writeFileSync(join(dirB, 'skills'), 'not a directory\n');

      await expect(
        runSetup({ workspace: dirB, start: false, verify: false }),
      ).rejects.toThrow();

      // R4-2 guarantee: old install survived.
      expect(existsSync(skillA)).toBe(true);
      expect(existsSync(join(dirB, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);

      // R5-3 canary-ordered guarantee: install ran BEFORE merge, so when
      // install threw, the config pointer was never flipped. A retry will
      // correctly identify OLD as the prior install.
      const configAfterB = JSON.parse(readFileSync(join(openclawHome, 'openclaw.json'), 'utf-8'));
      expect(configAfterB.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(dirA);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  // Codex PR #234 R11-2: legacy adapter entries (pre-R2) lack
  // `entry.config.installedWorkspace`. Previously we'd fall back to the
  // config-derived workspace and clean up SKILL.md there, but that's
  // unsafe — a pre-R2 install done with `--workspace /A` against a config
  // that declares `/B` would make the fallback delete the wrong file.
  // Per the pre-launch no-migration stance + R11-2 decline of destructive
  // best-guess, migration is now SKIPPED for legacy entries. The new
  // install still lands cleanly at the current workspace; any pre-R2
  // orphan at the old path stays put (user cleans manually).
  it('SKIPS migration for legacy adapter entries without entry.config.installedWorkspace (R11-2)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const legacyWs = join(testDir, 'legacy-workspace');
    const newWs = join(testDir, 'new-workspace');
    mkdirSync(legacyWs, { recursive: true });
    mkdirSync(newWs, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });

    // Seed a legacy-shaped openclaw.json with an adapter entry that lacks
    // `entry.config.installedWorkspace` AND a SKILL.md at the workspace
    // that the old fallback would have picked.
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({
        plugins: {
          allow: ['adapter-openclaw'],
          load: { paths: [] },
          entries: {
            'adapter-openclaw': { enabled: true, config: { daemonUrl: 'http://127.0.0.1:9200' } },
          },
          slots: { memory: 'adapter-openclaw' },
        },
        agents: { defaults: { workspace: legacyWs } },
      }, null, 2) + '\n',
    );
    const legacySkill = join(legacyWs, 'skills', 'dkg-node', 'SKILL.md');
    mkdirSync(dirname(legacySkill), { recursive: true });
    writeFileSync(legacySkill, '# Legacy-install DKG Node Skill\n');

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      // Re-run setup with a different workspace. New install must land;
      // legacy SKILL.md must NOT be touched (no fallback = no destructive
      // cleanup from a guessed path).
      await runSetup({ workspace: newWs, start: false, verify: false });

      const newSkill = join(newWs, 'skills', 'dkg-node', 'SKILL.md');
      expect(existsSync(newSkill)).toBe(true);
      // The legacy SKILL.md survives untouched — no guessing, no deleting.
      expect(existsSync(legacySkill)).toBe(true);
      expect(readFileSync(legacySkill, 'utf-8')).toBe('# Legacy-install DKG Node Skill\n');

      // Post-merge the entry now carries the new installedWorkspace pointer
      // so future migrations fire correctly with an authoritative target.
      const after = JSON.parse(readFileSync(join(openclawHome, 'openclaw.json'), 'utf-8'));
      expect(after.plugins.entries['adapter-openclaw'].config.installedWorkspace).toBe(newWs);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  // Codex PR #234 R5-2 negative case: fresh install (no adapter entry at all)
  // must NOT trigger a migration against whatever the config-derived
  // workspace resolves to. Only an existing entry gates the fallback.
  it('does NOT trigger migration when the adapter entry is absent (fresh install)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const unrelatedWs = join(testDir, 'unrelated-workspace');
    const newWs = join(testDir, 'new-workspace');
    mkdirSync(unrelatedWs, { recursive: true });
    mkdirSync(newWs, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });

    // openclaw.json exists with a workspace pointing at an unrelated dir
    // (e.g. the user's default OpenClaw home) BUT no adapter entry at all.
    // This is a fresh install, not a migration.
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({
        plugins: { allow: [], load: { paths: [] }, entries: {}, slots: {} },
        agents: { defaults: { workspace: unrelatedWs } },
      }, null, 2) + '\n',
    );
    // Seed a user-placed file at the unrelated workspace — must survive.
    const unrelatedSkill = join(unrelatedWs, 'skills', 'dkg-node', 'SKILL.md');
    mkdirSync(dirname(unrelatedSkill), { recursive: true });
    writeFileSync(unrelatedSkill, '# User-placed file, NOT adapter-owned\n');
    const unrelatedBytes = readFileSync(unrelatedSkill, 'utf-8');

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await runSetup({ workspace: newWs, start: false, verify: false });

      // New install landed at newWs.
      expect(existsSync(join(newWs, 'skills', 'dkg-node', 'SKILL.md'))).toBe(true);
      // Unrelated workspace file is untouched — no migration ran.
      expect(existsSync(unrelatedSkill)).toBe(true);
      expect(readFileSync(unrelatedSkill, 'utf-8')).toBe(unrelatedBytes);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  it('does not re-retire anything when setup is re-run against the same workspace', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({ plugins: {} }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await runSetup({ workspace: ws, start: false, verify: false });
      const skillPath = join(ws, 'skills', 'dkg-node', 'SKILL.md');
      expect(existsSync(skillPath)).toBe(true);
      const firstSkillBytes = readFileSync(skillPath, 'utf-8');

      // Seed a sibling file to detect any inadvertent cleanup of the whole
      // dkg-node/ dir on the idempotent re-run (migration cleanup is scoped
      // to SKILL.md; the parent dir should remain intact in-place).
      const sibling = join(ws, 'skills', 'dkg-node', 'user-note.md');
      writeFileSync(sibling, '# kept by user\n');

      await runSetup({ workspace: ws, start: false, verify: false });

      // SKILL.md is still there (the fresh install re-copied it) and the
      // sibling user file is untouched — no migration cleanup happened.
      expect(existsSync(skillPath)).toBe(true);
      expect(readFileSync(skillPath, 'utf-8')).toBe(firstSkillBytes);
      expect(existsSync(sibling)).toBe(true);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  // Codex PR #234 R7-1: symlink aliases of the same workspace must NOT
  // trigger migration. Raw string compare sees `/real` and `/alias` as
  // different — the cleanup would then unlink the freshly-installed SKILL.md
  // through the alias path. `realpathSync`-based compare must collapse
  // them to a single canonical form so cleanup only fires on actual
  // workspace changes.
  it('does NOT trigger migration when the second setup routes through a symlink alias of the prior workspace (R7-1)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const realWs = join(testDir, 'ws-real');
    const aliasWs = join(testDir, 'ws-alias');
    mkdirSync(realWs, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });

    // Create the symlink. Windows needs admin / developer mode; skip the
    // test gracefully if the OS won't let us create the alias.
    let symlinkCreated = false;
    try {
      symlinkSync(realWs, aliasWs, 'dir');
      symlinkCreated = true;
    } catch {
      // Skip — can't exercise the R7-1 failure mode without a symlink.
    }
    if (!symlinkCreated) return;

    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({ plugins: {} }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      // First install targets the real path.
      await runSetup({ workspace: realWs, start: false, verify: false });
      const skillReal = join(realWs, 'skills', 'dkg-node', 'SKILL.md');
      expect(existsSync(skillReal)).toBe(true);
      const installedBytes = readFileSync(skillReal, 'utf-8');

      // Second install targets the alias (symlink). Both paths resolve to
      // the same physical directory; the install is effectively a no-op
      // re-copy, and migration MUST NOT fire (raw compare would make it
      // fire — that's the R7-1 bug).
      await runSetup({ workspace: aliasWs, start: false, verify: false });

      // The SKILL.md must still be on disk — if R7-1 regressed, the raw
      // compare would have treated alias ≠ real, fired migration, and
      // called `removeCanonicalNodeSkill(realWs)` which would delete this
      // file through the other view of the same directory.
      expect(existsSync(skillReal)).toBe(true);
      expect(readFileSync(skillReal, 'utf-8')).toBe(installedBytes);
      // The alias view sees the same file (same physical inode).
      expect(existsSync(join(aliasWs, 'skills', 'dkg-node', 'SKILL.md'))).toBe(true);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  // Codex PR #234 R6-3: migration cleanup silently swallows unlink errors.
  // When the old SKILL.md cannot be removed (file locked, permissions,
  // replaced by a directory, etc.) verifySkillRemoved must detect the
  // residue and surface it as a loud warning — otherwise the orphan is
  // invisible and Disconnect (which only knows about the new
  // entry.config.installedWorkspace) can never clean it up.
  it('warns loudly when migration cleanup silently fails to remove the prior SKILL.md (R6-3)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const dirA = join(testDir, 'workspace-a');
    const dirB = join(testDir, 'workspace-b');
    mkdirSync(dirA, { recursive: true });
    mkdirSync(dirB, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({ plugins: {} }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      // First install lands at dirA.
      await runSetup({ workspace: dirA, start: false, verify: false });
      const skillA = join(dirA, 'skills', 'dkg-node', 'SKILL.md');
      expect(existsSync(skillA)).toBe(true);

      // Sabotage the prior SKILL.md so unlinkSync fails: replace the FILE
      // with a DIRECTORY. unlinkSync on a directory throws EISDIR/EPERM on
      // every platform. removeCanonicalNodeSkill catches the throw + warns
      // + returns (best-effort), which is the exact silent-miss scenario
      // R6-3 flags. The R6-3 guard must then catch the residue via
      // verifySkillRemoved and surface a second, explicit warn.
      unlinkSync(skillA);
      mkdirSync(skillA, { recursive: true });

      // Manual console.warn hook (vi.spyOn sometimes misses calls routed
      // via the exported `warn` helper in setup.ts under ESM — swapping the
      // reference directly is what setup.ts's `console.warn(...)` dispatches
      // through, and the swap reliably captures both the inner
      // removeCanonicalNodeSkill warn and the outer R6-3 residue warn).
      const originalWarn = console.warn;
      const warnMessages: string[] = [];
      console.warn = (...args: any[]) => {
        warnMessages.push(args.map((a) => String(a)).join(' '));
      };
      try {
        // Second install targets dirB → migration fires → removeCanonicalNodeSkill
        // silent-fails on dirA's SKILL.md-as-directory → R6-3 warn fires.
        await runSetup({ workspace: dirB, start: false, verify: false });
      } finally {
        console.warn = originalWarn;
      }

      // New install landed regardless of cleanup failure — the warning is
      // advisory, not a blocker.
      expect(existsSync(join(dirB, 'skills', 'dkg-node', 'SKILL.md'))).toBe(true);
      // Residue at the prior workspace is still there (as a dir) — user
      // must clean up manually.
      expect(existsSync(skillA)).toBe(true);

      // Verify the R6-3 warn surfaced the orphan path + cleanup command.
      const migrationResidueWarn = warnMessages.find((m) =>
        m.includes('Migration cleanup did not remove the old SKILL.md'),
      );
      expect(migrationResidueWarn).toBeDefined();
      expect(migrationResidueWarn).toContain(skillA);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });
});

// ---------------------------------------------------------------------------
// runSetup openclaw.json preflight (Codex PR #234 R6-2 + R8-2)
// Before step 5 copies SKILL.md to disk, runSetup must preflight the
// openclaw.json that step 6 will merge into. If the preflight throws,
// step 5 never runs — so `mergeOpenClawConfig` can never fail AFTER
// `installCanonicalNodeSkill` has left an orphan on disk. R8-2 extends
// the preflight to also catch the `plugins.slots.contextEngine` wrong-
// slot guard that mergeOpenClawConfig enforces at merge time.
// ---------------------------------------------------------------------------

describe('runSetup openclaw.json preflight (R6-2 + R8-2)', () => {
  it('throws when openclaw.json is invalid JSON and does NOT install SKILL.md', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    // Invalid JSON: empty braces with a trailing stray token.
    writeFileSync(join(openclawHome, 'openclaw.json'), '{ not valid json ,,,\n');

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await expect(
        runSetup({ workspace: ws, start: false, verify: false }),
      ).rejects.toThrow(/not valid JSON/i);

      // Step 5 was gated behind the preflight throw → no SKILL.md landed.
      expect(existsSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  it('throws when openclaw.json is missing entirely and does NOT install SKILL.md', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    // No openclaw.json written — preflight's existsSync gate must fire.

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await expect(
        runSetup({ workspace: ws, start: false, verify: false }),
      ).rejects.toThrow(/openclaw\.json not found/);

      expect(existsSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  // the contextEngine wrong-slot guard is merge-time deep inside
  // mergeOpenClawConfig. The preflight must replicate it so a user who
  // misconfigured `plugins.slots.contextEngine = "adapter-openclaw"`
  // fails fast BEFORE step 5 writes the skill file.
  it('throws when plugins.slots.contextEngine === adapter-openclaw and does NOT install SKILL.md (R8-2)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const ws = join(testDir, 'workspace');
    mkdirSync(ws, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({
        plugins: {
          allow: [],
          load: { paths: [] },
          entries: {},
          // Misconfigured: adapter ID pinned to the wrong slot.
          slots: { contextEngine: 'adapter-openclaw' },
        },
      }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await expect(
        runSetup({ workspace: ws, start: false, verify: false }),
      ).rejects.toThrow(/plugins\.slots\.contextEngine/);

      // Preflight fired BEFORE step 5 → no orphan SKILL.md on disk.
      expect(existsSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  // Unix-only — Windows chmod semantics do not reliably block writes for
  // the owning process. The preflight still runs on Windows, just that this
  // specific failure mode (non-writable file) can't be simulated portably.
  const writabilityFailureModeSupported = process.platform !== 'win32';
  (writabilityFailureModeSupported ? it : it.skip)(
    'throws when openclaw.json is not writable and does NOT install SKILL.md',
    async () => {
      const { chmodSync } = await import('node:fs');
      const dkgHome = join(testDir, '.dkg');
      const openclawHome = join(testDir, '.openclaw');
      const ws = join(testDir, 'workspace');
      mkdirSync(ws, { recursive: true });
      mkdirSync(openclawHome, { recursive: true });
      const configPath = join(openclawHome, 'openclaw.json');
      writeFileSync(configPath, JSON.stringify({ plugins: {} }, null, 2) + '\n');
      chmodSync(configPath, 0o400); // read-only, no write bit for anyone.

      const originalDkg = process.env.DKG_HOME;
      const originalOpenclaw = process.env.OPENCLAW_HOME;
      process.env.DKG_HOME = dkgHome;
      process.env.OPENCLAW_HOME = openclawHome;

      try {
        await expect(
          runSetup({ workspace: ws, start: false, verify: false }),
        ).rejects.toThrow(/not writable/i);

        expect(existsSync(join(ws, 'skills', 'dkg-node', 'SKILL.md'))).toBe(false);
      } finally {
        // Restore perms so afterEach cleanup can unlink.
        try { chmodSync(configPath, 0o600); } catch { /* best-effort */ }
        process.env.DKG_HOME = originalDkg;
        process.env.OPENCLAW_HOME = originalOpenclaw;
      }
    },
  );
});

// ---------------------------------------------------------------------------
// Preflight runs BEFORE daemon + faucet (C10 extraction)
//
// With the preflight moved to new Step 4 (between writeDkgConfig and
// startDaemon), deterministic openclaw.json misconfigurations must throw
// BEFORE the faucet gets called. This matters because faucet calls count
// against the 3-calls-per-8h IP-level rate limit regardless of outcome
// — so a user with a broken openclaw.json shouldn't burn a slot on a
// setup that was always going to fail at merge.
// ---------------------------------------------------------------------------

describe('runSetup preflight runs before faucet (C10)', () => {
  beforeEach(() => {
    vi.mocked(requestFaucetFunding).mockReset();
    vi.mocked(requestFaucetFunding).mockResolvedValue({
      success: true,
      funded: ['0.01 ETH', '1000 TRAC'],
    });
  });

  it('does NOT call the faucet when openclaw.json is missing', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const ws = join(testDir, 'workspace');
    mkdirSync(dkgHome, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    mkdirSync(ws, { recursive: true });
    // Pre-seed wallets.json so Step 6 would succeed if it ever ran.
    writeFileSync(
      join(dkgHome, 'wallets.json'),
      JSON.stringify({ wallets: [{ address: '0xAA', privateKey: '0x01' }] }),
    );
    // Intentionally no openclaw.json — preflight must throw before faucet.

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await expect(
        runSetup({ workspace: ws, start: false, verify: false }),
      ).rejects.toThrow(/openclaw\.json not found/);

      expect(requestFaucetFunding).not.toHaveBeenCalled();
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  it('does NOT call the faucet when openclaw.json is invalid JSON', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const ws = join(testDir, 'workspace');
    mkdirSync(dkgHome, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    mkdirSync(ws, { recursive: true });
    writeFileSync(
      join(dkgHome, 'wallets.json'),
      JSON.stringify({ wallets: [{ address: '0xAA', privateKey: '0x01' }] }),
    );
    writeFileSync(join(openclawHome, 'openclaw.json'), '{ not valid json ,,,\n');

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await expect(
        runSetup({ workspace: ws, start: false, verify: false }),
      ).rejects.toThrow(/not valid JSON/i);

      expect(requestFaucetFunding).not.toHaveBeenCalled();
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });

  it('does NOT call the faucet when plugins.slots.contextEngine is wrong-slot-wired (R8-2)', async () => {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const ws = join(testDir, 'workspace');
    mkdirSync(dkgHome, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    mkdirSync(ws, { recursive: true });
    writeFileSync(
      join(dkgHome, 'wallets.json'),
      JSON.stringify({ wallets: [{ address: '0xAA', privateKey: '0x01' }] }),
    );
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({
        plugins: {
          allow: [],
          load: { paths: [] },
          entries: {},
          slots: { contextEngine: 'adapter-openclaw' }, // misconfigured
        },
      }, null, 2) + '\n',
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    try {
      await expect(
        runSetup({ workspace: ws, start: false, verify: false }),
      ).rejects.toThrow(/plugins\.slots\.contextEngine/);

      expect(requestFaucetFunding).not.toHaveBeenCalled();
    } finally {
      process.env.DKG_HOME = originalDkg;
      process.env.OPENCLAW_HOME = originalOpenclaw;
    }
  });
});

// ---------------------------------------------------------------------------
// readWallets — wallets.json address selection
// ---------------------------------------------------------------------------

describe('readWallets', () => {
  let originalDkg: string | undefined;
  let dkgHome: string;

  beforeEach(() => {
    originalDkg = process.env.DKG_HOME;
    dkgHome = join(testDir, '.dkg');
    mkdirSync(dkgHome, { recursive: true });
    process.env.DKG_HOME = dkgHome;
  });

  afterEach(() => {
    process.env.DKG_HOME = originalDkg;
  });

  it('returns the generated admin wallet before the three operational wallets', () => {
    const admin = '0xAAAA000000000000000000000000000000000001';
    const wallets = [
      '0xBBBB000000000000000000000000000000000001',
      '0xBBBB000000000000000000000000000000000002',
      '0xBBBB000000000000000000000000000000000003',
    ];
    writeFileSync(
      join(dkgHome, 'wallets.json'),
      JSON.stringify({
        adminWallet: { address: admin, privateKey: '0xadmin' },
        wallets: wallets.map(address => ({ address, privateKey: `key-${address}` })),
      }),
    );

    expect(readWallets()).toEqual([admin, ...wallets]);
  });

  it('returns only operational wallets for legacy wallets.json arrays', () => {
    const wallets = [
      '0xBBBB000000000000000000000000000000000001',
      '0xBBBB000000000000000000000000000000000002',
      '0xBBBB000000000000000000000000000000000003',
    ];
    writeFileSync(
      join(dkgHome, 'wallets.json'),
      JSON.stringify(wallets.map(address => ({ address, privateKey: `key-${address}` }))),
    );

    expect(readWallets()).toEqual(wallets);
  });

  it('deduplicates a malformed admin/operational overlap defensively', () => {
    const shared = '0xCCCC000000000000000000000000000000000001';
    const other = '0xCCCC000000000000000000000000000000000002';
    writeFileSync(
      join(dkgHome, 'wallets.json'),
      JSON.stringify({
        adminWallet: { address: shared, privateKey: '0xadmin' },
        wallets: [
          { address: shared, privateKey: '0xoperational-1' },
          { address: other, privateKey: '0xoperational-2' },
        ],
      }),
    );

    expect(readWallets()).toEqual([shared, other]);
  });
});

// ---------------------------------------------------------------------------
// readWalletsWithRetry — retry accounting (C4a extraction)
// ---------------------------------------------------------------------------

describe('readWalletsWithRetry', () => {
  it('exhausts exactly 5 retries when wallets never appear (6 reads, 5 sleeps)', async () => {
    const readFn = vi.fn(() => [] as string[]);
    const sleepFn = vi.fn(async () => {});

    const result = await readWalletsWithRetry(sleepFn, readFn);

    expect(result).toEqual([]);
    // 1 initial attempt + 5 retries = 6 reads. Locks the off-by-one bound.
    expect(readFn).toHaveBeenCalledTimes(6);
    expect(sleepFn).toHaveBeenCalledTimes(5);
    // Each sleep is a 1s delay. Locks the intended wait semantics.
    for (const call of sleepFn.mock.calls) {
      expect(call[0]).toBe(1_000);
    }
  });

  it('short-circuits when wallets appear on the 3rd attempt (3 reads, 2 sleeps)', async () => {
    // Missing on attempts 1–2, present on attempt 3.
    const readFn = vi.fn()
      .mockReturnValueOnce([])
      .mockReturnValueOnce([])
      .mockReturnValue(['0xAAAA0000000000000000000000000000000000AA']);
    const sleepFn = vi.fn(async () => {});

    const result = await readWalletsWithRetry(sleepFn, readFn);

    expect(result).toEqual(['0xAAAA0000000000000000000000000000000000AA']);
    expect(readFn).toHaveBeenCalledTimes(3);
    expect(sleepFn).toHaveBeenCalledTimes(2);
  });

  it('returns immediately without sleeping when wallets are available on first read', async () => {
    const readFn = vi.fn(() => ['0xBBBB0000000000000000000000000000000000BB']);
    const sleepFn = vi.fn(async () => {});

    const result = await readWalletsWithRetry(sleepFn, readFn);

    expect(result).toEqual(['0xBBBB0000000000000000000000000000000000BB']);
    expect(readFn).toHaveBeenCalledTimes(1);
    expect(sleepFn).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// logManualFundingInstructions — manual-curl fallback output
// ---------------------------------------------------------------------------

describe('logManualFundingInstructions', () => {
  let logSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(() => {
    logSpy.mockRestore();
  });

  it('batches the curl bodies at 4 addresses matching the auto-path cap', () => {
    const addrs = [
      '0x1111111111111111111111111111111111111111',
      '0x2222222222222222222222222222222222222222',
      '0x3333333333333333333333333333333333333333',
      '0x4444444444444444444444444444444444444444',
      '0x5555555555555555555555555555555555555555',
    ];
    logManualFundingInstructions(addrs, 'https://faucet.example.com/fund', 'v10_base_sepolia');

    const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n');
    // The curl body is built from JSON.stringify(batch) — assert the
    // cap by looking for the exact first-four array in the output and
    // the fifth address inside the second curl line.
    expect(logged).toContain(JSON.stringify(addrs.slice(0, 4)));
    expect(logged).toContain(JSON.stringify(addrs.slice(4)));
    const curlLines = logSpy.mock.calls
      .map(c => String(c[0]))
      .filter(line => line.includes('--data-raw'));
    expect(curlLines).toHaveLength(2);
    expect(curlLines[0]).not.toContain(addrs[4]);
    expect(curlLines[1]).toContain(addrs[4]);
  });

  // "emits a follow-on note listing the omitted wallets when more than 4
  // are passed" removed: `logManualFundingInstructions` now batches long
  // address lists instead of truncating-with-note, so the
  // `toMatch(/faucet supports up to 4 wallets/i)` + `toContain('1 wallet')`
  // sentinels no longer match the output shape. Batching is already
  // covered by sibling tests.

  it('does not emit the batching note when exactly 4 (or fewer) addresses are passed', () => {
    const addrs = [
      '0x1111111111111111111111111111111111111111',
      '0x2222222222222222222222222222222222222222',
      '0x3333333333333333333333333333333333333333',
      '0x4444444444444444444444444444444444444444',
    ];
    logManualFundingInstructions(addrs, 'https://faucet.example.com/fund', 'v10_base_sepolia');

    const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n');
    expect(logged).toContain('To fund wallets manually');
    expect(logged).not.toMatch(/up to 4 wallets per call/i);
    expect(logged).not.toMatch(/remaining/i);
  });
});

// ---------------------------------------------------------------------------
// runSetup Step 5 — faucet funding
//
// All tests in this block use `start: false, verify: false` to skip the
// daemon-start and verify steps (which would spawn a real `dkg start`
// subprocess). The faucet path still runs because `options.fund !== false`
// defaults `true`. Wallets are pre-seeded in DKG_HOME so the retry loop is
// not exercised here — retry accounting is covered by the
// `readWalletsWithRetry` suite above.
// ---------------------------------------------------------------------------

describe('runSetup Step 5 — faucet funding', () => {
  const SEEDED_ADMIN_WALLET = '0xAAAA0000000000000000000000000000000000AA';
  const SEEDED_OPERATIONAL_WALLETS = [
    '0xBBBB0000000000000000000000000000000000BB',
    '0xCCCC0000000000000000000000000000000000CC',
    '0xDDDD0000000000000000000000000000000000DD',
  ];

  function setupFaucetEnv() {
    const dkgHome = join(testDir, '.dkg');
    const openclawHome = join(testDir, '.openclaw');
    const workspace = join(testDir, 'workspace');
    mkdirSync(dkgHome, { recursive: true });
    mkdirSync(openclawHome, { recursive: true });
    mkdirSync(workspace, { recursive: true });
    writeFileSync(
      join(openclawHome, 'openclaw.json'),
      JSON.stringify({ plugins: {} }, null, 2) + '\n',
    );
    // Pre-seed wallets.json so readWallets() returns wallets on the first
    // attempt (bypasses the retry loop — that behavior is covered by the
    // readWalletsWithRetry suite above).
    writeFileSync(
      join(dkgHome, 'wallets.json'),
      JSON.stringify({
        adminWallet: { address: SEEDED_ADMIN_WALLET, privateKey: '0xadmin' },
        wallets: SEEDED_OPERATIONAL_WALLETS.map(address => ({ address, privateKey: `key-${address}` })),
      }),
    );

    const originalDkg = process.env.DKG_HOME;
    const originalOpenclaw = process.env.OPENCLAW_HOME;
    process.env.DKG_HOME = dkgHome;
    process.env.OPENCLAW_HOME = openclawHome;

    return {
      dkgHome,
      openclawHome,
      workspace,
      restore: () => {
        process.env.DKG_HOME = originalDkg;
        process.env.OPENCLAW_HOME = originalOpenclaw;
      },
    };
  }

  beforeEach(() => {
    // Reset the mock between tests so assertions on call counts / args stay
    // isolated. Default behavior restored each test so cases that don't care
    // get a happy response.
    vi.mocked(requestFaucetFunding).mockReset();
    vi.mocked(requestFaucetFunding).mockResolvedValue({
      success: true,
      funded: ['0.01 ETH', '1000 TRAC'],
    });
  });

  it('calls requestFaucetFunding with (url, mode, addresses, agentName) pulled from network.faucet.*', async () => {
    const env = setupFaucetEnv();
    try {
      // Pre-seed an IDENTITY.md so the agentName argument is deterministic.
      writeFileSync(join(env.workspace, 'IDENTITY.md'), '# Identity\n- **Name**: test-agent\n');
      await runSetup({ workspace: env.workspace, start: false, verify: false });

      expect(requestFaucetFunding).toHaveBeenCalledTimes(1);
      const [url, mode, addresses, nodeName] = vi.mocked(requestFaucetFunding).mock.calls[0];
      expect(url).toMatch(/^https?:\/\//);
      expect(typeof mode).toBe('string');
      expect(mode.length).toBeGreaterThan(0);
      expect(addresses).toEqual([SEEDED_ADMIN_WALLET, ...SEEDED_OPERATIONAL_WALLETS]);
      expect(nodeName).toBe('test-agent');

      // AC3 invariant (plugins.slots.memory === 'adapter-openclaw') must still
      // assert AFTER the faucet step. This confirms the faucet step did not
      // short-circuit Step 7 (merge).
      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins.slots.memory).toBe('adapter-openclaw');
      expect(cfg.plugins.entries['adapter-openclaw'].config.stateDir).toBe(join(env.workspace, '.dkg-adapter'));
    } finally {
      env.restore();
    }
  });

  it('skips the faucet call when options.fund === false (--no-fund) and still lands the merge', async () => {
    const env = setupFaucetEnv();
    try {
      await runSetup({ workspace: env.workspace, start: false, verify: false, fund: false });

      expect(requestFaucetFunding).not.toHaveBeenCalled();

      // AC3 still holds — --no-fund does not block the rest of the pipeline.
      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins.slots.memory).toBe('adapter-openclaw');
    } finally {
      env.restore();
    }
  });

  it('skips the faucet call under dryRun:true (no faucet request made, no merge written)', async () => {
    const env = setupFaucetEnv();
    try {
      await runSetup({ workspace: env.workspace, start: false, verify: false, dryRun: true });

      expect(requestFaucetFunding).not.toHaveBeenCalled();

      // Dry-run does not merge either — openclaw.json remains unchanged
      // (still the empty `plugins: {}` seeded in setupFaucetEnv).
      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins?.slots).toBeUndefined();
    } finally {
      env.restore();
    }
  });

  it('continues non-fatally and logs manual curl instructions when requestFaucetFunding returns a 429 failure', async () => {
    vi.mocked(requestFaucetFunding).mockResolvedValueOnce({
      success: false,
      funded: [],
      error: 'HTTP 429: rate limited',
    });
    const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
    const env = setupFaucetEnv();
    try {
      await expect(
        runSetup({ workspace: env.workspace, start: false, verify: false }),
      ).resolves.toBeUndefined();

      // Manual-curl block is logged (`console.log` with the literal "curl -X POST").
      const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n');
      expect(logged).toContain('To fund wallets manually');
      expect(logged).toContain('curl -X POST');

      // AC3 invariant holds — failure did not block Step 7 (merge).
      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins.slots.memory).toBe('adapter-openclaw');
    } finally {
      logSpy.mockRestore();
      env.restore();
    }
  });

  it('continues non-fatally when requestFaucetFunding returns a 5xx failure', async () => {
    vi.mocked(requestFaucetFunding).mockResolvedValueOnce({
      success: false,
      funded: [],
      error: 'HTTP 503: service unavailable',
    });
    const env = setupFaucetEnv();
    try {
      await expect(
        runSetup({ workspace: env.workspace, start: false, verify: false }),
      ).resolves.toBeUndefined();

      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins.slots.memory).toBe('adapter-openclaw');
    } finally {
      env.restore();
    }
  });

  it('continues non-fatally when requestFaucetFunding throws a network error', async () => {
    vi.mocked(requestFaucetFunding).mockRejectedValueOnce(new Error('ECONNREFUSED'));
    const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
    const env = setupFaucetEnv();
    try {
      await expect(
        runSetup({ workspace: env.workspace, start: false, verify: false }),
      ).resolves.toBeUndefined();

      const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n');
      expect(logged).toContain('To fund wallets manually');

      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins.slots.memory).toBe('adapter-openclaw');
    } finally {
      logSpy.mockRestore();
      env.restore();
    }
  });

  it('continues non-fatally when requestFaucetFunding throws an AbortError (timeout path)', async () => {
    // `requestFaucetFunding` in core wraps fetch with AbortSignal.timeout(...).
    // A triggered timeout surfaces as a DOMException with name "TimeoutError"
    // (or AbortError on some runtimes). Cover both.
    const timeoutErr = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
    vi.mocked(requestFaucetFunding).mockRejectedValueOnce(timeoutErr);
    const env = setupFaucetEnv();
    try {
      await expect(
        runSetup({ workspace: env.workspace, start: false, verify: false }),
      ).resolves.toBeUndefined();

      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins.slots.memory).toBe('adapter-openclaw');
    } finally {
      env.restore();
    }
  });

  it('warns and skips faucet when no wallets are available (readWallets returns empty after retries)', async () => {
    // Remove the pre-seeded wallets.json so readWallets returns [].
    const env = setupFaucetEnv();
    rmSync(join(env.dkgHome, 'wallets.json'));
    try {
      await expect(
        runSetup({ workspace: env.workspace, start: false, verify: false }),
      ).resolves.toBeUndefined();

      // With `start: false`, the retry loop is intentionally skipped (no
      // point retrying when we didn't start the daemon this run). We still
      // assert that runSetup did NOT call the faucet and that Step 7 (merge)
      // still ran — i.e., faucet step is truly non-fatal.
      expect(requestFaucetFunding).not.toHaveBeenCalled();

      const cfg = JSON.parse(readFileSync(join(env.openclawHome, 'openclaw.json'), 'utf-8'));
      expect(cfg.plugins.slots.memory).toBe('adapter-openclaw');
    } finally {
      env.restore();
    }
  });

  // ---- C9: effective-name drift protection -----------------------------
  //
  // The faucet Idempotency-Key seed is derived from the
  // `agentName` argument. `writeDkgConfig` uses first-wins semantics on
  // `name` (existing value wins unless --name was passed), so on re-runs
  // the name the node actually persists can differ from whatever
  // `discoverAgentName` returned in-memory this run (specifically when
  // IDENTITY.md has changed between runs). runSetup must thread the
  // post-writeDkgConfig effective name through to the faucet so retry
  // identity matches what's persisted on disk.

  it('faucet receives the persisted config.json name when IDENTITY.md changes between re-runs', async () => {
    const env = setupFaucetEnv();
    try {
      // Simulate "run 1 already happened": pre-seed ~/.dkg/config.json
      // with an older persisted name.
      writeFileSync(
        join(env.dkgHome, 'config.json'),
        JSON.stringify({ name: 'persisted-run1' }),
      );
      // "Run 2" has a different IDENTITY.md name — writeDkgConfig's
      // first-wins keeps the persisted name, so the faucet MUST use
      // "persisted-run1", not "changed-run2".
      writeFileSync(join(env.workspace, 'IDENTITY.md'), '# Identity\nName: changed-run2\n');

      await runSetup({ workspace: env.workspace, start: false, verify: false });

      expect(requestFaucetFunding).toHaveBeenCalledTimes(1);
      const [, , , nodeName] = vi.mocked(requestFaucetFunding).mock.calls[0];
      expect(nodeName).toBe('persisted-run1');
    } finally {
      env.restore();
    }
  });

  it('faucet receives the discovered IDENTITY.md name on first run (no pre-existing config.json)', async () => {
    const env = setupFaucetEnv();
    try {
      // No pre-existing config.json — writeDkgConfig will persist the
      // discovered IDENTITY.md name, and the faucet should receive it.
      writeFileSync(join(env.workspace, 'IDENTITY.md'), '# Identity\nName: first-run-name\n');

      await runSetup({ workspace: env.workspace, start: false, verify: false });

      const [, , , nodeName] = vi.mocked(requestFaucetFunding).mock.calls[0];
      expect(nodeName).toBe('first-run-name');
      // Sanity: writeDkgConfig actually persisted the first-run name.
      const cfg = JSON.parse(readFileSync(join(env.dkgHome, 'config.json'), 'utf-8'));
      expect(cfg.name).toBe('first-run-name');
    } finally {
      env.restore();
    }
  });

  it('explicit options.name override wins over both persisted and discovered names', async () => {
    const env = setupFaucetEnv();
    try {
      // Persisted name from a prior run AND a conflicting IDENTITY.md —
      // --name should beat both.
      writeFileSync(
        join(env.dkgHome, 'config.json'),
        JSON.stringify({ name: 'stale-persisted' }),
      );
      writeFileSync(join(env.workspace, 'IDENTITY.md'), '# Identity\nName: stale-identity\n');

      await runSetup({
        workspace: env.workspace,
        start: false,
        verify: false,
        name: 'explicit-override',
      });

      const [, , , nodeName] = vi.mocked(requestFaucetFunding).mock.calls[0];
      expect(nodeName).toBe('explicit-override');
      // writeDkgConfig's `nameExplicit` override path must have flipped
      // the persisted file to the explicit value too — otherwise the
      // on-disk and in-memory identities would disagree after this run.
      const cfg = JSON.parse(readFileSync(join(env.dkgHome, 'config.json'), 'utf-8'));
      expect(cfg.name).toBe('explicit-override');
    } finally {
      env.restore();
    }
  });

  it('C8 regression guard: persisted name still wins over random fallback when IDENTITY.md is absent', async () => {
    const env = setupFaucetEnv();
    try {
      // No IDENTITY.md, but a persisted name exists. Pre-C8 behavior was
      // to roll a new random `openclaw-agent-XXXXX` each call; C8 made
      // discoverAgentName honor the persisted value; C9 must not have
      // regressed that.
      writeFileSync(
        join(env.dkgHome, 'config.json'),
        JSON.stringify({ name: 'persisted-no-identity' }),
      );

      await runSetup({ workspace: env.workspace, start: false, verify: false });

      const [, , , nodeName] = vi.mocked(requestFaucetFunding).mock.calls[0];
      expect(nodeName).toBe('persisted-no-identity');
    } finally {
      env.restore();
    }
  });
});

// ---------------------------------------------------------------------------
// testnet.json drift invariant (decision #7 — positive assertion form)
// ---------------------------------------------------------------------------

describe('testnet.json faucet block invariant', () => {
  it('testnet.json exposes the fields requestFaucetFunding consumes, with v10_base_sepolia mode', () => {
    // Load via the same resolver runSetup uses at Step 3 — prevents a
    // "test reads a different file than the runtime" drift class. If
    // loadNetworkConfig's resolution changes (path, env var, package
    // layout), this test updates with it automatically.
    const cfg = loadNetworkConfig();

    // toMatchObject asserts the two fields requestFaucetFunding
    // (packages/core/src/faucet.ts) actually consumes — deletion or
    // rename of `mode` or `url` still fails the test — while leaving
    // the network config free to grow forward-compatibly with
    // additional fields.
    expect(cfg.faucet).toMatchObject({
      mode: 'v10_base_sepolia',
      url: expect.stringMatching(/^https?:\/\//),
    });
  });
});
