/** Tests node-host invoke command routing and event emission. */
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { GatewayClient } from "../gateway/client.js";
import { saveExecApprovals, type ExecApprovalsSnapshot } from "../infra/exec-approvals.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withEnvAsync } from "../test-utils/env.js";
import type { SkillBinsProvider } from "./invoke-types.js";
import { handleInvoke } from "./invoke.js";

const tempDirs = useAutoCleanupTempDirTracker(afterEach);

afterEach(() => {
  resetPluginRuntimeStateForTest();
});

const approvalResolutionFailure = vi.hoisted(() => ({ error: null as Error | null }));
type ExecApprovalsUpdate = Parameters<
  typeof import("../infra/exec-approvals.js").updateExecApprovals
>[0];
const execApprovalsStoreMock = vi.hoisted(() => ({
  ensureError: undefined as Error | undefined,
  ensureResult: undefined as unknown,
  hasEnsureResult: false,
  ensureCalls: 0,
  readError: undefined as Error | undefined,
  readResult: undefined as unknown,
  hasReadResult: false,
  readCalls: 0,
  updateError: undefined as Error | undefined,
  updateResult: undefined as unknown,
  hasUpdateResult: false,
  updateCalls: 0,
  updateParams: undefined as ExecApprovalsUpdate | undefined,
}));

vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
  const original = await importOriginal<typeof import("../infra/exec-approvals.js")>();
  return {
    ...original,
    ensureExecApprovalsSnapshot: async () => {
      execApprovalsStoreMock.ensureCalls += 1;
      if (execApprovalsStoreMock.ensureError !== undefined) {
        throw execApprovalsStoreMock.ensureError;
      }
      if (execApprovalsStoreMock.hasEnsureResult) {
        return execApprovalsStoreMock.ensureResult as Awaited<
          ReturnType<typeof original.ensureExecApprovalsSnapshot>
        >;
      }
      return await original.ensureExecApprovalsSnapshot();
    },
    readExecApprovalsSnapshot: () => {
      execApprovalsStoreMock.readCalls += 1;
      if (execApprovalsStoreMock.readError !== undefined) {
        throw execApprovalsStoreMock.readError;
      }
      if (execApprovalsStoreMock.hasReadResult) {
        return execApprovalsStoreMock.readResult as ReturnType<
          typeof original.readExecApprovalsSnapshot
        >;
      }
      return original.readExecApprovalsSnapshot();
    },
    updateExecApprovals: async (...args: Parameters<typeof original.updateExecApprovals>) => {
      execApprovalsStoreMock.updateCalls += 1;
      execApprovalsStoreMock.updateParams = args[0];
      if (execApprovalsStoreMock.updateError !== undefined) {
        throw execApprovalsStoreMock.updateError;
      }
      if (execApprovalsStoreMock.hasUpdateResult) {
        return execApprovalsStoreMock.updateResult as Awaited<
          ReturnType<typeof original.updateExecApprovals>
        >;
      }
      return await original.updateExecApprovals(...args);
    },
    resolveExecApprovalsLocked: async (
      ...args: Parameters<typeof original.resolveExecApprovalsLocked>
    ) => {
      const error = approvalResolutionFailure.error;
      approvalResolutionFailure.error = null;
      if (error) {
        throw error;
      }
      return await original.resolveExecApprovalsLocked(...args);
    },
  };
});

vi.mock("../logger.js", async (importOriginal) => ({
  ...(await importOriginal<typeof import("../logger.js")>()),
  logWarn: vi.fn(),
}));

function createExecApprovalsSnapshot(
  overrides: Partial<ExecApprovalsSnapshot> = {},
): ExecApprovalsSnapshot {
  return {
    path: "/tmp/exec-approvals.json",
    exists: true,
    raw: "{}",
    hash: "hash-before",
    file: {
      version: 1,
      socket: {
        path: "/tmp/exec-approvals.sock",
        token: "secret-token",
      },
    },
    ...overrides,
  };
}

type InvokeResult = {
  ok?: boolean;
  payloadJSON?: string;
  error?: { code?: string; message?: string };
};

async function invokeExecApprovals(
  command: "system.execApprovals.get" | "system.execApprovals.set",
  params?: unknown,
): Promise<InvokeResult> {
  const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
  await handleInvoke(
    {
      id: `invoke-${command}`,
      nodeId: "node-1",
      command,
      paramsJSON: params === undefined ? undefined : JSON.stringify(params),
    },
    { request } as unknown as GatewayClient,
    { current: async () => [] },
  );
  return (request.mock.calls[0]?.[1] ?? {}) as InvokeResult;
}

describe("node host invoke", () => {
  beforeEach(() => {
    approvalResolutionFailure.error = null;
    execApprovalsStoreMock.ensureError = undefined;
    execApprovalsStoreMock.ensureResult = undefined;
    execApprovalsStoreMock.hasEnsureResult = false;
    execApprovalsStoreMock.ensureCalls = 0;
    execApprovalsStoreMock.readError = undefined;
    execApprovalsStoreMock.readResult = undefined;
    execApprovalsStoreMock.hasReadResult = false;
    execApprovalsStoreMock.readCalls = 0;
    execApprovalsStoreMock.updateError = undefined;
    execApprovalsStoreMock.updateResult = undefined;
    execApprovalsStoreMock.hasUpdateResult = false;
    execApprovalsStoreMock.updateCalls = 0;
    execApprovalsStoreMock.updateParams = undefined;
  });

  it("passes the owning agent session to plugin node commands", async () => {
    const handle = vi.fn(async () => '{"ok":true}');
    const registry = createEmptyPluginRegistry();
    registry.nodeHostCommands = [
      {
        pluginId: "canvas",
        pluginName: "Canvas",
        command: { command: "canvas.present", cap: "canvas", handle },
        source: "test",
      },
    ];
    setActivePluginRegistry(registry);
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
    const sendNodeEvent = vi.fn(async () => undefined);

    await handleInvoke(
      {
        id: "invoke-canvas",
        nodeId: "node-1",
        command: "canvas.present",
        paramsJSON: "{}",
        sessionKey: "agent:main:canvas",
      },
      { request } as unknown as GatewayClient,
      { current: async () => [] },
      undefined,
      { pluginCommandContext: { sendNodeEvent } },
    );

    expect(handle).toHaveBeenCalledWith("{}", undefined, {
      sendNodeEvent,
      sessionKey: "agent:main:canvas",
    });
  });

  it("does not publish a canceled non-duplex plugin result", async () => {
    const controller = new AbortController();
    let resolvePlugin: ((result: string) => void) | undefined;
    const handle = vi.fn(
      () =>
        new Promise<string>((resolve) => {
          resolvePlugin = resolve;
        }),
    );
    const registry = createEmptyPluginRegistry();
    registry.nodeHostCommands = [
      {
        pluginId: "canvas",
        pluginName: "Canvas",
        command: { command: "canvas.present", cap: "canvas", handle },
        source: "test",
      },
    ];
    setActivePluginRegistry(registry);
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);

    const invoking = handleInvoke(
      {
        id: "invoke-canvas-canceled",
        nodeId: "node-1",
        command: "canvas.present",
        paramsJSON: "{}",
      },
      { request } as unknown as GatewayClient,
      { current: async () => [] },
      undefined,
      { signal: controller.signal },
    );
    await vi.waitFor(() => expect(handle).toHaveBeenCalledOnce());

    controller.abort();
    resolvePlugin?.('{"stale":true}');
    await invoking;

    expect(request).not.toHaveBeenCalled();
  });

  it("publishes only the replacement result for a redelivered plugin invocation", async () => {
    const firstController = new AbortController();
    const secondController = new AbortController();
    const resolvePlugins: Array<(result: string) => void> = [];
    const handle = vi.fn(
      () =>
        new Promise<string>((resolve) => {
          resolvePlugins.push(resolve);
        }),
    );
    const registry = createEmptyPluginRegistry();
    registry.nodeHostCommands = [
      {
        pluginId: "canvas",
        pluginName: "Canvas",
        command: { command: "canvas.present", cap: "canvas", handle },
        source: "test",
      },
    ];
    setActivePluginRegistry(registry);
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
    const client = { request } as unknown as GatewayClient;
    const skillBins: SkillBinsProvider = { current: async () => [] };
    const frame = {
      id: "invoke-canvas-redelivered",
      nodeId: "node-1",
      command: "canvas.present",
      paramsJSON: "{}",
    };

    const first = handleInvoke(frame, client, skillBins, undefined, {
      signal: firstController.signal,
    });
    await vi.waitFor(() => expect(handle).toHaveBeenCalledOnce());
    firstController.abort();

    const replacement = handleInvoke(frame, client, skillBins, undefined, {
      signal: secondController.signal,
    });
    await vi.waitFor(() => expect(handle).toHaveBeenCalledTimes(2));

    resolvePlugins[0]?.('{"stale":true}');
    await first;
    expect(request).not.toHaveBeenCalled();

    resolvePlugins[1]?.('{"replacement":true}');
    await replacement;

    expect(request).toHaveBeenCalledOnce();
    expect(request).toHaveBeenCalledWith(
      "node.invoke.result",
      expect.objectContaining({
        id: frame.id,
        nodeId: frame.nodeId,
        ok: true,
        payloadJSON: '{"replacement":true}',
      }),
    );
    expect(secondController.signal.aborted).toBe(false);
  });

  it("lists node-host directories for the folder browser", async () => {
    const root = fs.realpathSync(tempDirs.make("openclaw-node-fs-listdir-"));
    fs.mkdirSync(path.join(root, "Projects"));
    fs.writeFileSync(path.join(root, "notes.txt"), "hidden from directory listing");
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);

    await handleInvoke(
      {
        id: "invoke-fs-listdir",
        nodeId: "node-1",
        command: "fs.listDir",
        paramsJSON: JSON.stringify({ path: root }),
      },
      { request } as unknown as GatewayClient,
      { current: async () => [] },
    );

    const result = request.mock.calls[0]?.[1] as InvokeResult | undefined;
    expect(JSON.parse(result?.payloadJSON ?? "{}")).toMatchObject({
      path: root,
      entries: [{ name: "Projects", path: path.join(root, "Projects") }],
    });
  });

  it("stages terminal uploads on the node host", async () => {
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
    await handleInvoke(
      {
        id: "invoke-terminal-upload",
        nodeId: "node-1",
        command: "terminal.upload",
        paramsJSON: JSON.stringify({
          name: "node report.pdf",
          contentBase64: Buffer.from("node bytes").toString("base64"),
        }),
      },
      { request } as unknown as GatewayClient,
      { current: async () => [] },
    );

    const result = request.mock.calls[0]?.[1] as InvokeResult | undefined;
    const payload = JSON.parse(result?.payloadJSON ?? "{}") as { path: string; size: number };
    expect(payload.size).toBe(10);
    expect(fs.readFileSync(payload.path, "utf8")).toBe("node bytes");
    fs.rmSync(path.dirname(payload.path), { recursive: true, force: true });
  });

  it("returns a redacted exec approvals snapshot", async () => {
    execApprovalsStoreMock.hasEnsureResult = true;
    execApprovalsStoreMock.ensureResult = createExecApprovalsSnapshot();
    const result = await invokeExecApprovals("system.execApprovals.get");
    const payload = JSON.parse(result.payloadJSON ?? "{}") as ExecApprovalsSnapshot;
    expect(payload).toEqual({
      path: "/tmp/exec-approvals.json",
      exists: true,
      hash: "hash-before",
      file: {
        version: 1,
        socket: { path: "/tmp/exec-approvals.sock" },
      },
    });
  });

  it("updates exec approvals and redacts the resulting snapshot", async () => {
    execApprovalsStoreMock.hasReadResult = true;
    execApprovalsStoreMock.readResult = createExecApprovalsSnapshot();
    execApprovalsStoreMock.hasUpdateResult = true;
    execApprovalsStoreMock.updateResult = createExecApprovalsSnapshot({
      hash: "hash-after",
      file: {
        version: 1,
        defaults: { security: "deny" },
        socket: { path: "/tmp/updated.sock", token: "updated-secret" },
      },
    });
    const result = await invokeExecApprovals("system.execApprovals.set", {
      baseHash: "hash-before",
      file: { version: 1, defaults: { security: "deny" } },
    });

    expect(execApprovalsStoreMock.updateCalls).toBe(1);
    expect(execApprovalsStoreMock.ensureCalls).toBe(0);
    expect(execApprovalsStoreMock.readCalls).toBe(1);
    expect(JSON.parse(result.payloadJSON ?? "{}")).toEqual({
      path: "/tmp/exec-approvals.json",
      exists: true,
      hash: "hash-after",
      file: {
        version: 1,
        defaults: { security: "deny" },
        socket: { path: "/tmp/updated.sock" },
      },
    });
  });

  it("rejects an exec approvals update with a stale base hash", async () => {
    execApprovalsStoreMock.hasReadResult = true;
    execApprovalsStoreMock.readResult = createExecApprovalsSnapshot();
    const result = await invokeExecApprovals("system.execApprovals.set", {
      baseHash: "stale-hash",
      file: { version: 1 },
    });

    expect(execApprovalsStoreMock.updateCalls).toBe(0);
    expect(result).toMatchObject({
      ok: false,
      error: {
        code: "INVALID_REQUEST",
        message: expect.stringContaining("exec approvals changed"),
      },
    });
  });

  it("rejects a stale save without recreating deleted node approval state", async () => {
    const missingSnapshot = createExecApprovalsSnapshot({
      exists: false,
      raw: null,
      hash: "sha256:missing",
      file: { version: 1, agents: {} },
    });
    execApprovalsStoreMock.hasReadResult = true;
    execApprovalsStoreMock.readResult = missingSnapshot;

    const result = await invokeExecApprovals("system.execApprovals.set", {
      baseHash: "hash-before",
      file: { version: 1, agents: {} },
    });

    expect(execApprovalsStoreMock.ensureCalls).toBe(0);
    expect(execApprovalsStoreMock.readCalls).toBe(1);
    expect(execApprovalsStoreMock.updateCalls).toBe(0);
    expect(missingSnapshot.file.socket).toBeUndefined();
    expect(result).toMatchObject({
      ok: false,
      error: {
        code: "INVALID_REQUEST",
        message: expect.stringContaining("exec approvals changed"),
      },
    });
  });

  it("initializes socket credentials in the first accepted node approval write", async () => {
    const missingSnapshot = createExecApprovalsSnapshot({
      exists: false,
      raw: null,
      hash: "sha256:missing",
      file: { version: 1, agents: {} },
    });
    execApprovalsStoreMock.hasReadResult = true;
    execApprovalsStoreMock.readResult = missingSnapshot;
    execApprovalsStoreMock.hasUpdateResult = true;
    execApprovalsStoreMock.updateResult = createExecApprovalsSnapshot({ hash: "sha256:created" });

    const result = await invokeExecApprovals("system.execApprovals.set", {
      file: { version: 1, agents: { main: {} } },
    });
    const prepared = execApprovalsStoreMock.updateParams?.update(missingSnapshot.file);

    expect(execApprovalsStoreMock.ensureCalls).toBe(0);
    expect(execApprovalsStoreMock.readCalls).toBe(1);
    expect(execApprovalsStoreMock.updateCalls).toBe(1);
    expect(execApprovalsStoreMock.updateParams?.baseHash).toBe(missingSnapshot.hash);
    expect(prepared?.socket?.path).toBeTruthy();
    expect(prepared?.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/);
    expect(JSON.parse(result.payloadJSON ?? "{}")).toMatchObject({
      hash: "sha256:created",
      file: { socket: { path: "/tmp/exec-approvals.sock" } },
    });
  });

  it("rejects an exec approvals update when the locked CAS loses its race", async () => {
    execApprovalsStoreMock.hasReadResult = true;
    execApprovalsStoreMock.readResult = createExecApprovalsSnapshot();
    execApprovalsStoreMock.hasUpdateResult = true;
    execApprovalsStoreMock.updateResult = null;
    const result = await invokeExecApprovals("system.execApprovals.set", {
      baseHash: "hash-before",
      file: { version: 1 },
    });

    expect(execApprovalsStoreMock.updateCalls).toBe(1);
    expect(result).toMatchObject({
      ok: false,
      error: {
        code: "INVALID_REQUEST",
        message: "INVALID_REQUEST: exec approvals changed; reload and retry",
      },
    });
  });

  it("classifies typed exec approvals lock timeouts as TIMEOUT", async () => {
    execApprovalsStoreMock.ensureError = Object.assign(new Error("approval lock unavailable"), {
      code: "file_lock_timeout",
    });
    const result = await invokeExecApprovals("system.execApprovals.get");

    expect(result).toMatchObject({
      ok: false,
      error: {
        code: "TIMEOUT",
        message: "Error: approval lock unavailable",
      },
    });
  });

  it("classifies stale exec approval locks as UNAVAILABLE", async () => {
    execApprovalsStoreMock.ensureError = Object.assign(new Error("stale approval lock"), {
      code: "file_lock_stale",
    });
    const result = await invokeExecApprovals("system.execApprovals.get");

    expect(result).toMatchObject({
      ok: false,
      error: {
        code: "UNAVAILABLE",
        message: "Error: stale approval lock",
      },
    });
  });

  it("classifies exec approvals filesystem failures as UNAVAILABLE", async () => {
    execApprovalsStoreMock.readError = Object.assign(new Error("permission denied"), {
      code: "EACCES",
    });
    const result = await invokeExecApprovals("system.execApprovals.set", {
      file: { version: 1 },
    });

    expect(result).toMatchObject({
      ok: false,
      error: {
        code: "UNAVAILABLE",
        message: "Error: permission denied",
      },
    });
  });

  it("classifies exec approvals update failures as UNAVAILABLE", async () => {
    execApprovalsStoreMock.hasReadResult = true;
    execApprovalsStoreMock.readResult = createExecApprovalsSnapshot();
    execApprovalsStoreMock.updateError = new Error("approval store write failed");
    const result = await invokeExecApprovals("system.execApprovals.set", {
      baseHash: "hash-before",
      file: { version: 1 },
    });

    expect(result).toMatchObject({
      ok: false,
      error: {
        code: "UNAVAILABLE",
        message: "Error: approval store write failed",
      },
    });
  });

  it("classifies malformed exec approvals set payloads as INVALID_REQUEST", async () => {
    const result = await invokeExecApprovals("system.execApprovals.set", {});

    expect(result).toMatchObject({
      ok: false,
      error: { code: "INVALID_REQUEST" },
    });
  });

  it.runIf(process.platform !== "win32")(
    "reports current allow-always coverage for prepared shell-wrapped system.run commands",
    async () => {
      const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
      const skillBins: SkillBinsProvider = { current: async () => [] };

      await handleInvoke(
        {
          id: "invoke-prepare",
          nodeId: "node-1",
          command: "system.run.prepare",
          paramsJSON: JSON.stringify({
            command: ["/bin/sh", "-lc", "/bin/echo ok"],
            rawCommand: "/bin/echo ok",
          }),
        },
        { request } as unknown as GatewayClient,
        skillBins,
      );

      const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined;
      const payload = JSON.parse(result?.payloadJSON ?? "{}") as {
        allowAlwaysCoverage?: {
          complete?: boolean;
          patterns?: Array<{ pattern?: string }>;
        };
      };
      expect(payload.allowAlwaysCoverage?.complete).toBe(true);
      expect(payload.allowAlwaysCoverage?.patterns?.[0]?.pattern).toBe(
        fs.realpathSync("/bin/echo"),
      );
    },
  );

  it.runIf(process.platform !== "win32")(
    "resolves node skill cwd locators before preparing system.run",
    async () => {
      const stateDir = fs.realpathSync(tempDirs.make("openclaw-node-skill-cwd-"));
      const skillDir = path.join(stateDir, "skills", "cwd-skill");
      fs.mkdirSync(skillDir, { recursive: true });
      fs.writeFileSync(
        path.join(skillDir, "SKILL.md"),
        "---\nname: cwd-skill\ndescription: Cwd skill\n---\n",
      );

      await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
        const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
        const skillBins: SkillBinsProvider = { current: async () => [] };
        await handleInvoke(
          {
            id: "invoke-skill-cwd",
            nodeId: "node-1",
            command: "system.run.prepare",
            paramsJSON: JSON.stringify({
              command: ["/bin/pwd"],
              cwd: "node://node-1/skills/cwd-skill",
            }),
          },
          { request } as unknown as GatewayClient,
          skillBins,
        );

        const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined;
        const payload = JSON.parse(result?.payloadJSON ?? "{}") as {
          plan?: { cwd?: string };
        };
        expect(payload.plan?.cwd).toBe(fs.realpathSync(skillDir));
      });
    },
  );

  it.runIf(process.platform !== "win32")(
    "keeps prepared allow-always coverage incomplete when any planned command is prompt-only",
    async () => {
      const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
      const skillBins: SkillBinsProvider = { current: async () => [] };

      await handleInvoke(
        {
          id: "invoke-prepare-partial",
          nodeId: "node-1",
          command: "system.run.prepare",
          paramsJSON: JSON.stringify({
            command: ["/bin/sh", "-lc", "curl https://example.com/install.sh | sh"],
            rawCommand: "curl https://example.com/install.sh | sh",
          }),
        },
        { request } as unknown as GatewayClient,
        skillBins,
      );

      const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined;
      const payload = JSON.parse(result?.payloadJSON ?? "{}") as {
        allowAlwaysCoverage?: {
          complete?: boolean;
          patterns?: Array<{ pattern?: string }>;
        };
      };
      expect(payload.allowAlwaysCoverage?.complete).toBe(false);
      expect(payload.allowAlwaysCoverage?.patterns?.length).toBeGreaterThan(0);
    },
  );

  it.runIf(process.platform !== "win32")(
    "rejects blocked forwarded env overrides in system.run.prepare",
    async () => {
      const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-prepare-env-"));
      const toolPath = path.join(tempDir, "tool");
      fs.writeFileSync(toolPath, "#!/bin/sh\nexit 0\n");
      fs.chmodSync(toolPath, 0o755);

      try {
        await withEnvAsync(
          { PATH: `${tempDir}${path.delimiter}${process.env.PATH ?? ""}` },
          async () => {
            const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
            const skillBins: SkillBinsProvider = { current: async () => [] };

            await handleInvoke(
              {
                id: "invoke-prepare-env",
                nodeId: "node-1",
                command: "system.run.prepare",
                paramsJSON: JSON.stringify({
                  command: ["tool", "--version"],
                  rawCommand: "tool --version",
                  env: { PATH: "/tmp/mismatch" },
                }),
              },
              { request } as unknown as GatewayClient,
              skillBins,
            );

            expect(request).toHaveBeenCalledWith(
              "node.invoke.result",
              expect.objectContaining({
                id: "invoke-prepare-env",
                nodeId: "node-1",
                ok: false,
                error: expect.objectContaining({
                  code: "INVALID_REQUEST",
                  message: expect.stringContaining("blocked override keys: PATH"),
                }),
              }),
            );
          },
        );
      } finally {
        fs.rmSync(tempDir, { recursive: true, force: true });
      }
    },
  );

  it("wraps malformed paramsJSON for built-in commands", async () => {
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
    const skillBins: SkillBinsProvider = { current: async () => [] };

    await handleInvoke(
      {
        id: "invoke-1",
        nodeId: "node-1",
        command: "system.run",
        paramsJSON: "{not json",
      },
      { request } as unknown as GatewayClient,
      skillBins,
    );

    expect(request).toHaveBeenCalledWith(
      "node.invoke.result",
      expect.objectContaining({
        id: "invoke-1",
        nodeId: "node-1",
        ok: false,
        error: expect.objectContaining({
          code: "INVALID_REQUEST",
          message: expect.stringContaining("paramsJSON malformed JSON"),
        }),
      }),
    );
  });

  it("returns a structured failure when system.run approval resolution rejects", async () => {
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
    const skillBins: SkillBinsProvider = { current: async () => [] };
    approvalResolutionFailure.error = new Error("approval lock unavailable");

    await expect(
      handleInvoke(
        {
          id: "invoke-approval-read-failure",
          nodeId: "node-1",
          command: "system.run",
          paramsJSON: JSON.stringify({ command: ["echo", "ok"] }),
        },
        { request } as unknown as GatewayClient,
        skillBins,
      ),
    ).resolves.toBeUndefined();

    expect(request).toHaveBeenCalledTimes(1);
    expect(request).toHaveBeenCalledWith(
      "node.invoke.result",
      expect.objectContaining({
        id: "invoke-approval-read-failure",
        nodeId: "node-1",
        ok: false,
        error: {
          code: "UNAVAILABLE",
          message: "node invocation failed",
        },
      }),
    );
  });

  it("forwards suppressNotifyOnExit on completed system.run events", async () => {
    const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-event-suppress-"));
    const stateDir = path.join(tempHome, ".openclaw");
    try {
      await withEnvAsync({ OPENCLAW_HOME: tempHome, OPENCLAW_STATE_DIR: stateDir }, async () => {
        saveExecApprovals({
          version: 1,
          defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
        });
        const scriptPath = path.join(tempHome, "noop.cjs");
        fs.writeFileSync(scriptPath, "");
        const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
        await handleInvoke(
          {
            id: "invoke-suppress-notify-prepare",
            nodeId: "node-1",
            command: "system.run.prepare",
            paramsJSON: JSON.stringify({
              command: [process.execPath, scriptPath],
              cwd: tempHome,
              sessionKey: "agent:main:main",
            }),
          },
          { request } as unknown as GatewayClient,
          { current: async () => [] },
        );
        const prepareResult = request.mock.calls.find(
          ([method, params]) =>
            method === "node.invoke.result" &&
            (params as { id?: string } | undefined)?.id === "invoke-suppress-notify-prepare",
        )?.[1] as { payloadJSON?: string | null } | undefined;
        const prepared = JSON.parse(prepareResult?.payloadJSON ?? "{}") as {
          plan?: Record<string, unknown>;
        };
        expect(prepared.plan).toBeDefined();
        await handleInvoke(
          {
            id: "invoke-suppress-notify",
            nodeId: "node-1",
            command: "system.run",
            paramsJSON: JSON.stringify({
              command: prepared.plan?.argv,
              rawCommand: prepared.plan?.commandText,
              cwd: prepared.plan?.cwd,
              sessionKey: "agent:main:main",
              systemRunPlan: prepared.plan,
              approved: true,
              approvalDecision: "allow-once",
              suppressNotifyOnExit: true,
            }),
          },
          { request } as unknown as GatewayClient,
          { current: async () => [] },
        );

        const event = request.mock.calls.find(
          ([method, params]) =>
            method === "node.event" &&
            (params as { event?: string } | undefined)?.event === "exec.finished",
        )?.[1] as { payloadJSON?: string | null } | undefined;
        expect(JSON.parse(event?.payloadJSON ?? "{}")).toMatchObject({
          suppressNotifyOnExit: true,
        });
      });
    } finally {
      closeOpenClawStateDatabaseForTest();
      fs.rmSync(tempHome, { recursive: true, force: true });
    }
  });

  it("consumes a failed terminal response after approval resolution rejects", async () => {
    const request = vi.fn<GatewayClient["request"]>().mockRejectedValue(new Error("gateway down"));
    const skillBins: SkillBinsProvider = { current: async () => [] };
    approvalResolutionFailure.error = new Error("approval lock unavailable");

    await expect(
      handleInvoke(
        {
          id: "invoke-approval-read-and-send-failure",
          nodeId: "node-1",
          command: "system.run",
          paramsJSON: JSON.stringify({ command: ["echo", "ok"] }),
        },
        { request } as unknown as GatewayClient,
        skillBins,
      ),
    ).resolves.toBeUndefined();

    expect(request).toHaveBeenCalledTimes(1);
  });

  it("includes effective exec policy in system.run.prepare responses", async () => {
    const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
    const skillBins: SkillBinsProvider = { current: async () => [] };

    await handleInvoke(
      {
        id: "invoke-1",
        nodeId: "node-1",
        command: "system.run.prepare",
        paramsJSON: JSON.stringify({
          command: ["echo", "ok"],
          rawCommand: "echo ok",
          agentId: "main",
          sessionKey: "agent:main:main",
        }),
      },
      { request } as unknown as GatewayClient,
      skillBins,
    );

    expect(request).toHaveBeenCalledWith(
      "node.invoke.result",
      expect.objectContaining({
        ok: true,
        payloadJSON: expect.any(String),
      }),
    );
    const result = request.mock.calls.find(([method]) => method === "node.invoke.result")?.[1] as {
      payloadJSON?: string;
    };
    const payload = JSON.parse(result.payloadJSON ?? "{}") as {
      execPolicy?: { security?: string; ask?: string };
      plan?: { policySnapshot?: unknown };
    };
    expect(payload.execPolicy).toEqual({ security: "allowlist", ask: "on-miss" });
    // The plan snapshot binds persisted approval state. Effective config-layer
    // policy is returned separately above and re-evaluated at execution.
    expect(payload.plan?.policySnapshot).toEqual({
      security: "full",
      ask: "off",
      askFallback: "deny",
      autoAllowSkills: false,
      allowlistRules: [],
    });
  });
});
