Ctrl K
ComunidadGuías…

server/boat-inventory.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createHash } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

type ProviderBoat = Record<string, unknown> & { id: string; name: string; state: string };
type RequestRecord = { method: string; path: string; search: string; headers: IncomingMessage["headers"] };
type PageResponse = { sandboxes: ProviderBoat[]; nextCursor: string | null };
type DeletionStatus = "pending" | "processing" | "blocked" | "completed";

const DELETION_OPERATION_ID = "bdop_0123456789abcdef0123456789abcdef";

const legacyNameFor = (botId: string) => {
  const prefix = botId.slice(0, 8).toLowerCase().replace(/[^a-z0-9]/g, "");
  const hash = createHash("sha256").update(botId).digest("hex").slice(0, 6);
  return `ogb-${prefix}-${hash}`;
};

describe("OpenMaus-managed Boat inventory", () => {
  let api: Server;
  let boats: ProviderBoat[] = [];
  let listStatus = 200;
  let listBody: Record<string, unknown> | null = null;
  let pageResponses: Map<string, PageResponse> | null = null;
  let stopStatus = 200;
  let deleteStatus = 202;
  let deleteRemovesBoat = true;
  let deleteBody: Record<string, unknown> | null = null;
  let deletionPollStatus = 200;
  let directStatus = 200;
  /** Accept direct Boat reads and never answer them: a stalled relay. */
  let directHangs = false;
  const hung: ServerResponse[] = [];
  let deletionStatuses: DeletionStatus[] = ["completed"];
  let lastDeletionStatus: DeletionStatus = "completed";
  /** The stage a blocked operation reports (docs.boat.dev/data-retention/delete), or none. */
  let blockedStage: string | null = null;
  let deletionTargetId = "";
  /** A deletion operation as Boat v1 reports it, its stage only while blocked. */
  const deletionOperation = (targetId: string) => ({
    id: DELETION_OPERATION_ID,
    kind: "sandbox",
    targetId,
    status: lastDeletionStatus,
    ...(lastDeletionStatus === "blocked" && blockedStage ? { stage: blockedStage } : {}),
    ...(lastDeletionStatus === "blocked" && blockedStage === "waiting_for_uploads" ? { expectedBy: "2026-10-13T21:30:00Z" } : {}),
  });
  const requests: RequestRecord[] = [];
  let boat: typeof import("./boat.ts");
  let journal: typeof import("./boat-create-idempotency.ts");
  let deleteJournal: typeof import("./boat-delete-journal.ts");
  const cfg = { box: { token: "box_test" } } as any;

  beforeAll(async () => {
    api = createServer((req, res) => {
      const url = new URL(req.url ?? "/", "http://boat.test");
      requests.push({ method: req.method ?? "GET", path: url.pathname, search: url.search, headers: req.headers });
      res.setHeader("content-type", "application/json");

      if (url.pathname === "/api/v1/sandboxes" && req.method === "GET") {
        const page = pageResponses?.get(url.searchParams.get("cursor") ?? "");
        res.writeHead(listStatus).end(JSON.stringify(listBody ?? {
          ok: listStatus < 400,
          sandboxes: page?.sandboxes ?? boats,
          ...(page ? { pageInfo: { nextCursor: page.nextCursor } } : {}),
        }));
        return;
      }
      if (url.pathname.endsWith("/commands") && req.method === "POST") {
        res.writeHead(200).end(JSON.stringify({ ok: true, exitCode: 0, stdout: "", stderr: "" }));
        return;
      }
      if (url.pathname.endsWith("/stop") && req.method === "POST") {
        res.writeHead(stopStatus).end(JSON.stringify(
          stopStatus < 400 ? { ok: true } : { ok: false, message: "stop refused" },
        ));
        return;
      }
      if (url.pathname === `/api/v1/deletion-operations/${DELETION_OPERATION_ID}` && req.method === "GET") {
        lastDeletionStatus = deletionStatuses.shift() ?? lastDeletionStatus;
        res.writeHead(deletionPollStatus).end(JSON.stringify(
          deletionPollStatus < 400
            ? {
                ok: true,
                type: "deletion.operation",
                operation: deletionOperation(deletionTargetId),
              }
            : { ok: false, message: "deletion status unavailable" },
        ));
        return;
      }
      if (req.method === "DELETE") {
        const boxId = url.pathname.split("/").at(-1) ?? "";
        deletionTargetId = boxId;
        lastDeletionStatus = deletionStatuses.shift() ?? "completed";
        if (deleteStatus < 400 && deleteRemovesBoat) {
          boats = boats.filter((candidate) => candidate.id !== boxId);
        }
        res.writeHead(deleteStatus).end(JSON.stringify(
          deleteBody ?? (deleteStatus < 400
            ? {
                ok: true,
                type: "sandbox.deleting",
                operation: deletionOperation(boxId),
              }
            : { ok: false, message: "delete refused" }),
        ));
        return;
      }
      const direct = boats.find((candidate) => url.pathname.endsWith(`/sandboxes/${candidate.id}`));
      if (direct && req.method === "GET") {
        if (directHangs) { hung.push(res); return; }
        res.writeHead(directStatus).end(JSON.stringify(
          directStatus < 400
            ? { ok: true, sandbox: direct }
            : { ok: false, message: "sandbox lookup unavailable" },
        ));
        return;
      }
      res.writeHead(404).end(JSON.stringify({ ok: false, message: "not found" }));
    });
    await new Promise<void>((resolve) => api.listen(0, "127.0.0.1", resolve));
    const port = (api.address() as { port: number }).port;
    vi.stubEnv("OMB_BOAT_API", `http://127.0.0.1:${port}/api/v1`);
    vi.resetModules();
    boat = await import("./boat.ts");
    journal = await import("./boat-create-idempotency.ts");
    deleteJournal = await import("./boat-delete-journal.ts");
  });

  beforeEach(() => {
    boats = [];
    listStatus = 200;
    listBody = null;
    pageResponses = null;
    stopStatus = 200;
    deleteStatus = 202;
    deleteRemovesBoat = true;
    deleteBody = null;
    deletionPollStatus = 200;
    directStatus = 200;
    directHangs = false;
    for (const response of hung.splice(0)) response.end("{}");
    deletionStatuses = ["completed"];
    lastDeletionStatus = "completed";
    blockedStage = null;
    deletionTargetId = "";
    requests.length = 0;
    for (const record of journal.boatCreateRecoverySnapshot()) {
      if (record.boxId) journal.retireDeletedBoatCreate(record.boxId);
    }
    for (const record of deleteJournal.boatDeletionSnapshot()) {
      deleteJournal.retireBoatDeletion(record.boxId);
    }
  });

  afterAll(async () => {
    vi.unstubAllEnvs();
    for (const response of hung.splice(0)) response.end("{}");
    await new Promise<void>((resolve) => api.close(() => resolve()));
  });

  it("lists only sanitized managed boats and keeps an owned legacy Boat discoverable", async () => {
    const botId = "current-bot-123";
    boats = [
      {
        id: "bx_23456789",
        name: legacyNameFor(botId),
        state: "READY",
        desktopUrl: "https://secret.example/?token=do-not-leak",
        dedicatedIp: "203.0.113.8",
        env: { PRIVATE_KEY: "do-not-leak" },
      },
      { id: "bx_abcdefgh", name: "ogb-orphaned-abcdef", state: "archived", desktopUrl: "secret" },
      { id: "bx_jkmnpqrs", name: "my-production-box", state: "running" },
      { id: "bad/id", name: "ogb-invalid0-123456", state: "running" },
    ];

    const inventory = await boat.listManagedBoats(cfg, [{ botId, name: "Research", inUse: true }]);

    expect(inventory).toEqual({
      configured: true,
      available: true,
      problem: null,
      instances: [
        {
          boxId: "bx_23456789",
          name: legacyNameFor(botId),
          state: "ready",
          ownerBotId: botId,
          ownerName: "Research",
          orphaned: false,
          inUse: true,
        },
      ],
    });
    expect(JSON.stringify(inventory)).not.toMatch(/desktopUrl|dedicatedIp|PRIVATE_KEY|do-not-leak|foreign-box/);
    expect(requests).toEqual([expect.objectContaining({
      method: "GET",
      path: "/api/v1/sandboxes",
      search: "?limit=200",
    })]);
  });

  it("keeps an adopted Boat visible when an eventually-consistent listing omits it", async () => {
    const botId = "legacy-adoption";
    const boxId = "bx_3456789a";
    boats = [{ id: boxId, name: legacyNameFor(botId), state: "ready" }];

    expect((await boat.listManagedBoats(cfg, [{ botId, name: "Legacy", inUse: false }])).available).toBe(true);
    requests.length = 0;
    expect((await boat.listManagedBoats(cfg, [{ botId, name: "Legacy", inUse: false }])).available).toBe(true);
    expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([
      "GET /api/v1/sandboxes",
    ]);
    expect(journal.boatCreateRecoverySnapshot().filter((record) => record.botId === botId)).toEqual([
      { botId, boxId, resolved: true },
    ]);

    pageResponses = new Map([["", { sandboxes: [], nextCursor: null }]]);
    requests.length = 0;
    expect((await boat.listManagedBoats(cfg, [{ botId, name: "Legacy", inUse: false }])).instances).toEqual([
      expect.objectContaining({
        boxId,
        name: legacyNameFor(botId),
        state: "ready",
        ownerBotId: botId,
      }),
    ]);
    expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([
      "GET /api/v1/sandboxes",
      `GET /api/v1/sandboxes/${boxId}`,
    ]);
    expect(journal.boatCreateRecoverySnapshot().filter((record) => record.botId === botId)).toEqual([
      { botId, boxId, resolved: true },
    ]);
  });

  it("does not use remembered Boat identities during a replacement-token probe", async () => {
    const botId = "replacement-recovery";
    const boxId = "bx_89abcdef";
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    boats = [{ id: boxId, name: await boat.boatNameFor(botId), state: "ready" }];
    pageResponses = new Map([["", { sandboxes: [], nextCursor: null }]]);

    const inventory = await boat.listManagedBoats(
      cfg,
      [{ botId, name: "Replacement", inUse: false }],
      { adoptLegacy: false },
    );

    expect(inventory.instances).toEqual([]);
    expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([
      "GET /api/v1/sandboxes",
    ]);
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({ botId, boxId, resolved: true });
  });

  it("retires an omitted recovery identity when direct inspection proves the Boat is gone", async () => {
    const botId = "stale-listing";
    const boxId = "bx_9abcdefg";
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    pageResponses = new Map([["", { sandboxes: [], nextCursor: null }]]);

    const inventory = await boat.listManagedBoats(cfg, [{ botId, name: "Stale", inUse: false }]);

    expect(inventory).toMatchObject({ available: true, instances: [] });
    expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([
      "GET /api/v1/sandboxes",
      `GET /api/v1/sandboxes/${boxId}`,
    ]);
    expect(journal.boatCreateRecoverySnapshot().some((record) => record.boxId === boxId)).toBe(false);
  });

  it("fails closed when LIST gives a remembered Boat id an unexpected name", async () => {
    const botId = "conflicting-name";
    const boxId = "bx_abcdefg2";
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    pageResponses = new Map([["", {
      sandboxes: [{ id: boxId, name: "provider-renamed-box", state: "ready" }],
      nextCursor: null,
    }]]);

    const inventory = await boat.listManagedBoats(cfg, [{ botId, name: "Conflict", inUse: false }]);

    expect(inventory).toMatchObject({ available: false, instances: [] });
    expect(inventory.problem).toMatch(/no longer has its OpenMausBot owner name/i);
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({ botId, boxId, resolved: true });
    expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([
      "GET /api/v1/sandboxes",
    ]);
  });

  it("deletes a remembered Boat even while account LIST still omits it", async () => {
    const botId = "omitted-delete";
    const boxId = "bx_bcdefgh2";
    const managedName = await boat.boatNameFor(botId);
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    boats = [{ id: boxId, name: managedName, state: "ARCHIVED" }];
    pageResponses = new Map([["", { sandboxes: [], nextCursor: null }]]);

    await expect(
      boat.deleteManagedBoat(cfg, [{ botId, name: "Omitted", inUse: false }], boxId, managedName),
    ).resolves.toEqual({ ok: true });

    expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([
      "GET /api/v1/sandboxes",
      `GET /api/v1/sandboxes/${boxId}`,
      `DELETE /api/v1/sandboxes/${boxId}`,
    ]);
    expect(journal.boatCreateRecoverySnapshot().some((record) => record.boxId === boxId)).toBe(false);
  });

  it("can compare a replacement account without adopting its legacy identities", async () => {
    const botId = "replacement-probe";
    boats = [{ id: "bx_789abcde", name: legacyNameFor(botId), state: "ready" }];

    const inventory = await boat.listManagedBoats(
      cfg,
      [{ botId, name: "Replacement", inUse: false }],
      { adoptLegacy: false },
    );

    expect(inventory.available).toBe(true);
    expect(inventory.instances).toEqual([
      expect.objectContaining({ boxId: "bx_789abcde", ownerBotId: botId }),
    ]);
    expect(journal.boatCreateRecoverySnapshot().some((record) => record.botId === botId)).toBe(false);
  });

  it("recognizes adopted legacy names when restoring their account credential", async () => {
    const botId = "legacy-credential";
    expect(await boat.boatNameMatchesBot(botId, await boat.boatNameFor(botId))).toBe(true);
    expect(await boat.boatNameMatchesBot(botId, legacyNameFor(botId))).toBe(true);
    expect(await boat.boatNameMatchesBot(botId, "provider-owned-boat")).toBe(false);
  });

  it("fails closed for malformed or conflicting identities that name this installation", async () => {
    const botId = "invalid-owned";
    const currentName = await boat.boatNameFor(botId);
    boats = [{ id: "bad/id", name: currentName, state: "ready" }];

    const malformed = await boat.listManagedBoats(cfg, [{ botId, name: "Invalid", inUse: false }]);
    expect(malformed).toMatchObject({ configured: true, available: false, instances: [] });
    expect(malformed.problem).toMatch(/invalid id/i);

    boats = [
      { id: "bx_456789ab", name: currentName, state: "ready" },
      { id: "bx_456789ab", name: "provider-duplicate", state: "ready" },
    ];
    const conflicting = await boat.listManagedBoats(cfg, [{ botId, name: "Invalid", inUse: false }]);
    expect(conflicting).toMatchObject({ configured: true, available: false, instances: [] });
    expect(conflicting.problem).toMatch(/conflicting id/i);
  });

  it("never hands a bot a sandbox Boat cancelled for want of a machine, or one in error", async () => {
    const botId = "terminal-owner";
    boats = [{ id: "bx_456789ab", name: legacyNameFor(botId), state: "ready" }];
    await expect(boat.findBoat(cfg, botId)).resolves.toMatchObject({ id: "bx_456789ab" }); // caches the id
    // The direct read of the cached id, then the listing, both pass it over.
    boats[0]!.state = "cancelled";
    await expect(boat.findBoat(cfg, botId)).resolves.toBeNull();
    boats[0]!.state = "error";
    await expect(boat.findBoat(cfg, botId)).resolves.toBeNull();
  });

  it("does not cache or use a malformed exact-name Boat identity", async () => {
    const botId = "invalid-find";
    boats = [{ id: "not-a-box", name: await boat.boatNameFor(botId), state: "ready" }];

    await expect(boat.findBoat(cfg, botId)).rejects.toThrow(/invalid cloud computer identity/i);
  });

  it("shows current-install orphans but hides foreign and ownerless legacy Boats", async () => {
    const currentBotId = "scoped-owner";
    const legacyBotId = "legacy-owner";
    const currentName = await boat.boatNameFor(currentBotId);
    const orphanName = await boat.boatNameFor("deleted-local-bot");
    const currentScope = currentName.match(/^ogb-([a-f0-9]{12})-/)?.[1];
    expect(currentScope).toBeTruthy();
    const foreignScope = currentScope === "000000000000" ? "111111111111" : "000000000000";
    const foreignName = currentName.replace(/^ogb-[a-f0-9]{12}-/, `ogb-${foreignScope}-`);
    const ownerlessLegacyName = "ogb-orphaned-abcdef";
    boats = [
      { id: "bx_23456789", name: currentName, state: "ready" },
      { id: "bx_abcdefgh", name: legacyNameFor(legacyBotId), state: "archived" },
      { id: "bx_jkmnpqrs", name: orphanName, state: "idle" },
      { id: "bx_mnpqrstu", name: foreignName, state: "running" },
      { id: "bx_npqrstuv", name: ownerlessLegacyName, state: "archived" },
    ];
    const owners = [
      { botId: currentBotId, name: "Current", inUse: false },
      { botId: legacyBotId, name: "Legacy", inUse: false },
    ];

    const inventory = await boat.listManagedBoats(cfg, owners);

    expect(inventory.instances).toHaveLength(3);
    expect(inventory.instances).toEqual(expect.arrayContaining([
      expect.objectContaining({ boxId: "bx_23456789", ownerBotId: currentBotId, orphaned: false }),
      expect.objectContaining({ boxId: "bx_abcdefgh", ownerBotId: legacyBotId, orphaned: false }),
      expect.objectContaining({ boxId: "bx_jkmnpqrs", ownerBotId: null, orphaned: true }),
    ]));
    expect(inventory.instances.some((instance) => instance.name === foreignName)).toBe(false);
    expect(inventory.instances.some((instance) => instance.name === ownerlessLegacyName)).toBe(false);

    requests.length = 0;
    await expect(boat.deleteManagedBoat(cfg, owners, "bx_mnpqrstu", foreignName)).rejects.toThrow(/no longer exists/);
    await expect(
      boat.deleteManagedBoat(cfg, owners, "bx_npqrstuv", ownerlessLegacyName),
    ).rejects.toThrow(/no longer exists/);
    expect(requests.some((request) => request.method === "DELETE")).toBe(false);
  });

  it("walks cursor pages once and refuses a repeated cursor instead of looping", async () => {
    const botId = "second-page-owner";
    pageResponses = new Map([
      ["", { sandboxes: [{ id: "foreign", name: "unmanaged", state: "ready" }], nextCursor: "page two/?=" }],
      ["page two/?=", { sandboxes: [{ id: "bx_mnpqrstu", name: legacyNameFor(botId), state: "idle" }], nextCursor: null }],
    ]);

    const inventory = await boat.listManagedBoats(cfg, [{ botId, name: "Page two", inUse: false }]);
    expect(inventory.instances).toEqual([
      expect.objectContaining({ boxId: "bx_mnpqrstu", ownerBotId: botId, state: "idle" }),
    ]);
    expect(requests.map((request) => request.search)).toEqual([
      "?limit=200",
      "?limit=200&cursor=page%20two%2F%3F%3D",
    ]);

    requests.length = 0;
    pageResponses = new Map([
      ["", { sandboxes: [], nextCursor: "same" }],
      ["same", { sandboxes: [], nextCursor: "same" }],
    ]);
    const loop = await boat.listManagedBoats(cfg, []);
    expect(loop).toMatchObject({ configured: true, available: false, instances: [] });
    expect(loop.problem).toMatch(/repeated.*cursor/i);
    expect(requests).toHaveLength(2);
  });

  it("distinguishes not configured and provider failure from an empty account", async () => {
    const unconfigured = await boat.listManagedBoats({} as any, []);
    expect(unconfigured).toEqual({ configured: false, available: false, problem: null, instances: [] });
    expect(requests).toHaveLength(0);

    listStatus = 503;
    listBody = { ok: false, message: "maintenance" };
    const unavailable = await boat.listManagedBoats(cfg, []);
    expect(unavailable).toMatchObject({ configured: true, available: false, instances: [] });
    expect(unavailable.problem).toMatch(/maintenance/);

    listStatus = 200;
    listBody = { ok: true, sandboxes: [] };
    expect(await boat.listManagedBoats(cfg, [])).toMatchObject({ configured: true, available: true, instances: [] });
  });

  it("revalidates ownership before sleep and surfaces a provider refusal", async () => {
    const botId = "sleeping-owner";
    boats = [{ id: "bx_tuvwxyz2", name: legacyNameFor(botId), state: "ready" }];
    const owners = [{ botId, name: "Sleeper", inUse: false }];

    await boat.sleepManagedBoat(cfg, owners, "bx_tuvwxyz2");
    expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([
      "GET /api/v1/sandboxes",
      "POST /api/v1/sandboxes/bx_tuvwxyz2/commands",
      "POST /api/v1/sandboxes/bx_tuvwxyz2/stop",
    ]);

    requests.length = 0;
    stopStatus = 500;
    await expect(boat.sleepManagedBoat(cfg, owners, "bx_tuvwxyz2")).rejects.toThrow(/boat sleep failed: stop refused/);
    expect(requests.some((request) => request.path.endsWith("/resume"))).toBe(false);
    expect(requests.some((request) => request.path.endsWith("/desktop"))).toBe(false);

    boats = [{ id: "bx_tuvwxyz2", name: legacyNameFor(botId), state: "provisioning" }];
    requests.length = 0;
    await expect(boat.sleepManagedBoat(cfg, owners, "bx_tuvwxyz2")).rejects.toThrow(/cannot sleep while it is provisioning/);
    expect(requests.every((request) => request.method === "GET")).toBe(true);
  });

  it("requires fresh exact confirmation for deletion and clears a cached owner id", async () => {
    const botId = "delete-owner";
    const managedName = legacyNameFor(botId);
    boats = [{ id: "bx_3456789a", name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Disposable", inUse: false }];
    const journal = await import("./boat-create-idempotency.ts");
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, "bx_3456789a"));

    await boat.findBoat(cfg, botId);
    requests.length = 0;
    await expect(boat.deleteManagedBoat(cfg, owners, "bx_3456789a", "stale-name")).rejects.toThrow(
      /confirmation no longer matches/,
    );
    expect(requests.some((request) => request.method === "DELETE")).toBe(false);
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({
      botId,
      boxId: "bx_3456789a",
      resolved: true,
    });

    deleteStatus = 503;
    await expect(boat.deleteManagedBoat(cfg, owners, "bx_3456789a", managedName)).rejects.toThrow(/delete refused/);
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({
      botId,
      boxId: "bx_3456789a",
      resolved: true,
    });
    deleteStatus = 202;

    requests.length = 0;
    deletionStatuses = ["pending", "processing", "completed"];
    await boat.deleteManagedBoat(cfg, owners, "bx_3456789a", managedName);
    const removal = requests.find((request) => request.method === "DELETE");
    expect(removal?.path).toBe("/api/v1/sandboxes/bx_3456789a");
    expect(removal?.headers["x-ascii-confirm-delete"]).toBe("bx_3456789a");
    expect(requests.filter((request) => request.path.includes("/deletion-operations/"))).toHaveLength(2);
    expect(journal.boatCreateRecoverySnapshot().find((record) => record.botId === botId)).toBeUndefined();

    boats = [];
    requests.length = 0;
    expect(await boat.findBoat(cfg, botId)).toBeNull();
    expect(requests[0]).toMatchObject({
      method: "GET",
      path: "/api/v1/sandboxes",
      search: "?limit=200",
    });
  });

  // Measured 2026-10-13: after file uploads, DELETE answered 202 and the operation stayed blocked in waiting_for_uploads for
  // about an hour while the sandbox was gone and billing stopped; Boat erases such data on its own.
  it.each([
    ["waiting_for_uploads", ["pending", "blocked"]],
    ["waiting_for_uploads", ["blocked"]],
    ["waiting_for_restore", ["pending", "blocked"]],
    ["retrying", ["pending", "blocked"]],
  ] as const)("finishes a delete whose purge is only blocked until Boat finishes it: %s after %j", async (stage, statuses) => {
    const botId = `self-finishing-${stage}-${statuses.length}`;
    const managedName = legacyNameFor(botId);
    const boxId = "bx_456789ab";
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Self-finishing", inUse: false }];
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    deletionStatuses = [...statuses];
    blockedStage = stage;
    deleteRemovesBoat = false;

    await expect(boat.deleteManagedBoat(cfg, owners, boxId, managedName)).resolves.toEqual({ ok: true });
    expect(deleteJournal.boatDeletionSnapshot()).toEqual([]);
    expect(journal.boatCreateRecoverySnapshot().some((record) => record.boxId === boxId)).toBe(false);
    boats = [];
    await expect(boat.findBoat(cfg, botId)).resolves.toBeNull();
  });

  it.each([[["pending", "blocked"]], [["blocked"]]] as const)("keeps the fence and says what to do when Boat keeps the data for a fork or named snapshot, after %j", async (statuses) => {
    const botId = `kept-delete-owner-${statuses.length}`;
    const managedName = legacyNameFor(botId);
    const boxId = "bx_456789ab";
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Kept", inUse: false }];
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    deletionStatuses = [...statuses];
    blockedStage = "kept_for_newer_snapshots";

    await expect(boat.deleteManagedBoat(cfg, owners, boxId, managedName))
      .rejects.toThrow(`boat.dev deleted cloud computer ${boxId} but is keeping its stored data, because a fork or named snapshot made from it still uses that data. To erase the data, delete those in boat.dev.`);
    expect(deleteJournal.boatDeletionSnapshot()).toEqual([expect.objectContaining({ boxId, phase: "blocked", status: "blocked" })]);
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({ botId, boxId, resolved: true });

    await expect(boat.listManagedBoats(cfg, owners)).resolves.toMatchObject({ available: true, instances: [] });
    expect(deleteJournal.boatDeletionSnapshot()).toEqual([]);
    expect(journal.boatCreateRecoverySnapshot().some((record) => record.boxId === boxId)).toBe(false);
  });

  it("keeps the fence when Boat blocks a delete without a documented stage", async () => {
    const botId = "blocked-delete-owner";
    const managedName = legacyNameFor(botId);
    const boxId = "bx_456789ab";
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Blocked", inUse: false }];
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    deletionStatuses = ["pending", "blocked"];
    deleteRemovesBoat = false;

    await expect(boat.deleteManagedBoat(cfg, owners, boxId, managedName)).rejects.toThrow(
      `boat.dev accepted the deletion of cloud computer ${boxId}, but says it is blocked without a reason OpenMausBot knows. boat.dev keeps retrying on its own; if ${boxId} is still listed in boat.dev later, delete it there.`,
    );
    expect(deleteJournal.boatDeletionSnapshot()).toEqual([expect.objectContaining({ boxId, phase: "blocked" })]);
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({ botId, boxId, resolved: true });
  });

  it("reports an accepted background deletion as pending without discarding recovery", async () => {
    const botId = "pending-delete-owner";
    const managedName = legacyNameFor(botId);
    const boxId = "bx_6789abcd";
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Pending", inUse: false }];
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    deletionStatuses = ["pending"];
    deleteRemovesBoat = false;

    await expect(
      boat.deleteManagedBoat(cfg, owners, boxId, managedName, undefined, { pollDelaysMs: [] }),
    ).resolves.toEqual({ ok: true, pending: true });
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({ botId, boxId, resolved: true });
    expect(requests.some((request) => request.method === "DELETE")).toBe(true);

    boats = [];
    pageResponses = new Map([["", { sandboxes: [], nextCursor: null }]]);
    await expect(boat.listManagedBoats(cfg, owners)).resolves.toMatchObject({ instances: [] });
    expect(journal.boatCreateRecoverySnapshot().some((record) => record.boxId === boxId)).toBe(false);
  });

  it("keeps an omitted pending deletion visible and fenced without sending DELETE twice", async () => {
    const botId = "pending-list-fence";
    const boxId = "bx_789abcd2";
    const managedName = await boat.boatNameFor(botId);
    const owners = [{ botId, name: "Pending fence", inUse: false }];
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    deletionStatuses = ["pending"];
    deleteRemovesBoat = false;

    await expect(
      boat.deleteManagedBoat(cfg, owners, boxId, managedName, undefined, { pollDelaysMs: [] }),
    ).resolves.toEqual({ ok: true, pending: true });
    expect(requests.filter((request) => request.method === "DELETE")).toHaveLength(1);

    // The account LIST is stale, but the exact operation/id record keeps the
    // Settings row honest and blocks all normal use after a restart.
    pageResponses = new Map([["", { sandboxes: [], nextCursor: null }]]);
    await expect(boat.listManagedBoats(cfg, owners)).resolves.toMatchObject({
      available: true,
      instances: [expect.objectContaining({ boxId, state: "removing", ownerBotId: botId })],
    });
    await expect(boat.findBoat(cfg, botId)).rejects.toThrow(/being deleted/i);
    await expect(boat.runCommand(cfg, boxId, "echo nope")).rejects.toThrow(/being deleted/i);

    await expect(
      boat.deleteManagedBoat(cfg, owners, boxId, managedName, undefined, { pollDelaysMs: [] }),
    ).resolves.toEqual({ ok: true, pending: true });
    expect(requests.filter((request) => request.method === "DELETE")).toHaveLength(1);

    deletionStatuses = ["completed"];
    await expect(boat.listManagedBoats(cfg, owners)).resolves.toMatchObject({ instances: [] });
    expect(deleteJournal.boatDeletionSnapshot()).toEqual([]);
    expect(journal.boatCreateRecoverySnapshot().some((record) => record.boxId === boxId)).toBe(false);
  });

  it("keeps an orphan deletion recoverable even without a create receipt", async () => {
    const boxId = "bx_89abcdef";
    const managedName = await boat.boatNameFor("deleted-local-owner");
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    deletionStatuses = ["pending"];
    deleteRemovesBoat = false;

    await expect(
      boat.deleteManagedBoat(cfg, [], boxId, managedName, undefined, { pollDelaysMs: [] }),
    ).resolves.toEqual({ ok: true, pending: true });
    pageResponses = new Map([["", { sandboxes: [], nextCursor: null }]]);

    await expect(boat.listManagedBoats(cfg, [])).resolves.toMatchObject({
      instances: [expect.objectContaining({ boxId, state: "removing", ownerBotId: null, orphaned: true })],
    });
    expect(deleteJournal.boatDeletionSnapshot()).toEqual([
      expect.objectContaining({ boxId, name: managedName, ownerBotId: null, phase: "accepted" }),
    ]);
  });

  it("keeps a valid accepted deletion pending when direct confirmation is unavailable", async () => {
    const botId = "unavailable-delete-check";
    const managedName = legacyNameFor(botId);
    const boxId = "bx_bcdefgh3";
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Unavailable check", inUse: false }];
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    deletionStatuses = ["pending"];
    deleteRemovesBoat = false;
    directStatus = 503;

    await expect(
      boat.deleteManagedBoat(cfg, owners, boxId, managedName, undefined, { pollDelaysMs: [] }),
    ).resolves.toEqual({ ok: true, pending: true });
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({ botId, boxId, resolved: true });
  });

  it("rejects a malformed successful deletion envelope while the Boat still exists", async () => {
    const botId = "invalid-delete-receipt";
    const managedName = legacyNameFor(botId);
    const boxId = "bx_789abcde";
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Invalid receipt", inUse: false }];
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    deleteRemovesBoat = false;
    deleteBody = { ok: true, operation: { status: "pending" } };

    await expect(
      boat.deleteManagedBoat(cfg, owners, boxId, managedName, undefined, { pollDelaysMs: [] }),
    ).rejects.toThrow(/invalid deletion receipt/i);
    expect(journal.boatCreateRecoverySnapshot()).toContainEqual({ botId, boxId, resolved: true });
  });

  it("retires the exact receipt when the Boat disappears between revalidation and DELETE", async () => {
    const botId = "already-gone-owner";
    const managedName = legacyNameFor(botId);
    const boxId = "bx_56789abc";
    boats = [{ id: boxId, name: managedName, state: "archived" }];
    const owners = [{ botId, name: "Gone", inUse: false }];
    const journal = await import("./boat-create-idempotency.ts");
    const attempt = journal.beginBoatCreate(botId, JSON.stringify({ ttlSeconds: 7_200, noEnv: true }));
    journal.resolveBoatCreate(journal.rememberCreatedBoat(attempt.request, boxId));
    deleteStatus = 404;

    await expect(boat.deleteManagedBoat(cfg, owners, boxId, managedName)).resolves.toEqual({ ok: true });
    expect(journal.boatCreateRecoverySnapshot().some((entry) => entry.botId === botId)).toBe(false);
  });

  it("bounds the direct read of a known Boat, so a stalled relay can't hold a turn's setup", async () => {
    const botId = "stalled-direct-read";
    boats = [{ id: "bx_456789ab", name: legacyNameFor(botId), state: "ready" }];
    await expect(boat.findBoat(cfg, botId)).resolves.toMatchObject({ id: "bx_456789ab" }); // caches the id
    directHangs = true;
    requests.length = 0;
    // The direct read's own deadline expires at once instead of after 20 s; without one the read waits on the stalled relay.
    const deadline = vi.spyOn(AbortSignal, "timeout")
      .mockImplementationOnce(() => AbortSignal.abort(new DOMException("deadline", "TimeoutError")));
    try {
      await expect(boat.findBoat(cfg, botId)).resolves.toMatchObject({ id: "bx_456789ab" });
      expect(deadline).toHaveBeenNthCalledWith(1, 20_000);
      expect(requests.map(request => `${request.method} ${request.path}`)).toEqual(["GET /api/v1/sandboxes"]);
    } finally {
      deadline.mockRestore();
    }
  }, 10_000);

  it("finds an existing boat on a later page and fails closed when listing is unavailable", async () => {
    const secondPageBot = "existing-second-page";
    pageResponses = new Map([
      ["", { sandboxes: [], nextCursor: "next" }],
      ["next", {
        sandboxes: [{ id: "bx_npqrstuv", name: legacyNameFor(secondPageBot), state: "ready" }],
        nextCursor: null,
      }],
    ]);

    await expect(boat.findBoat(cfg, secondPageBot)).resolves.toMatchObject({ id: "bx_npqrstuv" });
    expect(requests.map((request) => request.search)).toEqual([
      "?limit=200",
      "?limit=200&cursor=next",
    ]);
    expect(requests.some((request) => request.method === "POST" && request.path.endsWith("/sandboxes"))).toBe(false);

    requests.length = 0;
    pageResponses = null;
    listStatus = 503;
    listBody = { ok: false, message: "maintenance" };
    await expect(boat.provisionBoat(cfg, "provider-down-bot", "Offline")).rejects.toThrow(/maintenance/);
    expect(requests.some((request) => request.method === "POST" && request.path.endsWith("/sandboxes"))).toBe(false);
  });

  it("does not mutate a missing or busy managed boat and reports delete failures", async () => {
    const botId = "busy-owner";
    const managedName = legacyNameFor(botId);
    const owners = [{ botId, name: "Busy", inUse: true }];

    await expect(boat.deleteManagedBoat(cfg, owners, "bx_456789ab", managedName)).rejects.toThrow(/no longer exists/);
    expect(requests.some((request) => request.method === "DELETE")).toBe(false);

    boats = [{ id: "bx_56789abc", name: managedName, state: "running" }];
    requests.length = 0;
    await expect(boat.sleepManagedBoat(cfg, owners, "bx_56789abc")).rejects.toThrow(/in use/);
    expect(requests.every((request) => request.method === "GET")).toBe(true);

    requests.length = 0;
    deleteStatus = 500;
    await expect(boat.deleteManagedBoat(cfg, [{ ...owners[0], inUse: false }], "bx_56789abc", managedName)).rejects.toThrow(
      /boat delete failed: delete refused/,
    );
  });
});