Ctrl K
ComunidadGuías…

server/boat.ts

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

Archivo original ↗Preguntar a KiBot
// Boat (boat.dev) public API v1: https://docs.boat.dev/api/v1 (OpenAPI https://docs.boat.dev/openapi/boat-v1.yaml).
// Probed 2026-07-24, v1 2026-10-13: POST /sandboxes/{id}/commands runs shell synchronously (~0.2s); create answers 202
// and is ready in ~2s; stop→archived ~5s, resume→idle ~8s; disk persists, tmux does not. Stream tokens rotate on every
// state change and the dedicated IP across archive/resume, so never persist either.
import { createHash } from "node:crypto";

import { DATA_DIR, type AppConfig } from "./config.ts";
import { loadEnvironmentId } from "./environment.ts";
import { boatCredential, boatProviderApi, type ServiceCredential } from "./included-services.ts";
import { relayBody, relayPath } from "./boat-relay.ts";
import {
  blockedUntilBoatFinishes,
  boatDeletionOperation,
  deletionBlockedError,
  keptForNewerSnapshots,
  type BoatDeletionOperation,
} from "./boat-deletion.ts";
import {
  adoptResolvedBoat,
  beginBoatCreate,
  boatCreateRecoverySnapshot,
  discardBoatCreate,
  rememberCreatedBoat,
  resolveBoatCreate,
  retireDeletedBoatCreate,
  type BoatCreateRequest,
} from "./boat-create-idempotency.ts";
import {
  BOAT_DELETING, BOAT_DELETION_FINISHED, boatDeletionSnapshot,
  getBoatDeletion,
  hasPendingBoatDeletionForBot,
  markBoatDeletionAccepted,
  markBoatDeletionBlocked,
  prepareBoatDeletion,
  retireBoatDeletion,
  type BoatDeletionRecord,
} from "./boat-delete-journal.ts";

const shellQuote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`;

export const MAX_REMOTE_COMMAND_LENGTH = 4_000;

/** Runs an owner-supplied console command without the boat's provider or account credentials in its environment. */
export function isolatedRemoteCommand(command: string): string {
  return [
    "exec env -i",
    'HOME="$HOME"',
    'USER="${USER:-$(id -un)}"',
    'LOGNAME="${LOGNAME:-${USER:-$(id -un)}}"',
    'PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"',
    'DISPLAY="${DISPLAY:-:0}"',
    'XAUTHORITY="${XAUTHORITY:-$HOME/.Xauthority}"',
    'XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"',
    'DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-}"',
    "/bin/bash -c",
    shellQuote(command),
  ].join(" ");
}

const READY = new Set(["idle", "ready", "running"]);
export const SLEEPING_BOAT_STATES = new Set(["archived", "archiving"]);
/** Never usable again: `cancelled` is a create that got no machine; its GET answers 404 next. */
const TERMINAL = new Set(["error", "cancelled"]);
const DEFAULT_BOAT_TTL_SECONDS = 8 * 60 * 60;
const TRIAL_BOAT_TTL_SECONDS = 2 * 60 * 60;
const BOAT_CREATE_IN_PROGRESS_RETRY_DELAYS_MS = [250, 750, 1_500] as const;
const BOAT_DELETE_OPERATION_POLL_DELAYS_MS = [0, 100, 250, 500, 1_000, 2_000] as const;
const BOAT_INVENTORY_PAGE_SIZE = 200;
// Bounded: a broken or adversarial cursor must not hold Settings open.
const MAX_BOAT_INVENTORY_PAGES = 10;
const LEGACY_MANAGED_BOAT_NAME = /^ogb-[a-z0-9]{1,8}-[a-f0-9]{6}$/;
const SCOPED_MANAGED_BOAT_NAME = /^ogb-[a-f0-9]{12}-[a-z0-9]{1,8}-[a-f0-9]{6}$/;
const BOAT_ID = /^bx_[23456789abcdefghjkmnpqrstuvwxyz]{8}$/;
/** Sandbox.state in v1, plus OpenMausBot's own `removing` for a sandbox whose deletion is under way. */
const BOAT_STATES = new Set([
  "init", "provisioning", "provisioned", "cloning", "ready", "idle", "running", "archiving", "archived", "error", "cancelled", "removing",
]);
// Names carry a hash of the local environment id, so another installation on the same account never claims these computers.
let scopedBoatPrefixCache: string | null = null;

/** Call only after startup has migrated the data dir and taken its writer lease. */
function scopedBoatPrefix(): string {
  if (scopedBoatPrefixCache) return scopedBoatPrefixCache;
  const scope = createHash("sha256")
    .update(loadEnvironmentId(DATA_DIR))
    .digest("hex")
    .slice(0, 12);
  scopedBoatPrefixCache = `ogb-${scope}-`;
  return scopedBoatPrefixCache;
}

export interface ManagedBoatOwner {
  botId: string;
  name: string;
  inUse: boolean;
}

export interface ManagedBoatInventoryInstance {
  boxId: string;
  name: string;
  state: string;
  ownerBotId: string | null;
  ownerName: string | null;
  orphaned: boolean;
  inUse: boolean;
}

export interface ManagedBoatInventory {
  configured: boolean;
  available: boolean;
  problem: string | null;
  credentialRejected?: boolean;
  instances: ManagedBoatInventoryInstance[];
}

export interface BoatIdentityInspection {
  available: boolean;
  identity: { boxId: string; name: string; state: string } | null;
  problem: string | null;
}

export type BoatTurnLifecycleAction = "attach" | "provision" | "wake";

export function boatTurnLifecycleAction(state: string | null): BoatTurnLifecycleAction {
  if (state && READY.has(state)) return "attach";
  return state ? "wake" : "provision";
}

export type ManagedBoatMutationClaim = (
  instance: ManagedBoatInventoryInstance,
) => (() => void) | void;

/** One credential for the whole operation, even if Settings swaps the config mid-request. */
function snapshotBoatConfig(cfg: AppConfig): AppConfig {
  return { box: cfg.box ? { token: cfg.box.token } : undefined };
}

/** An own token goes to Boat; Cloud Pro's included token only to its relay. */
export function boatFetch(cfg: AppConfig, path: string, opts: RequestInit = {}) {
  const account = boatAccount(cfg);
  return fetch(`${account?.api ?? boatProviderApi()}${relayPath(account, path)}`, {
    ...opts,
    headers: {
      authorization: `Bearer ${account?.token}`,
      "content-type": "application/json",
      ...opts.headers,
    },
  });
}

async function boatJson(cfg: AppConfig, path: string, opts: RequestInit = {}) {
  const res = await boatFetch(cfg, path, opts);
  const body: any = relayBody(boatAccount(cfg), await res.json().catch(() => null));
  return { ok: res.ok && body?.ok !== false, status: res.status, body };
}

function boatDeleteProvedAbsent(result: Awaited<ReturnType<typeof boatJson>>): boolean {
  return result.status === 404 || result.status === 410;
}

/** Retire the create receipt before the deletion fence, so a failed write leaves the fence in place. */
function finishRecordedBoatDeletion(boxId: string): void {
  retireDeletedBoatCreate(boxId);
  forgetBoatId(boxId);
  retireBoatDeletion(boxId);
}

function recordBoatDeletionOperation(boxId: string, operation: BoatDeletionOperation): BoatDeletionRecord | "confirmed" {
  if (operation.status === "blocked") {
    if (!blockedUntilBoatFinishes(operation)) return markBoatDeletionBlocked(boxId, operation);
  } else {
    const record = markBoatDeletionAccepted(boxId, operation);
    if (operation.status !== "completed") return record;
  }
  finishRecordedBoatDeletion(boxId);
  return "confirmed";
}

/** `kept`: blocked by data a fork or named snapshot still uses, reported only to the person's own delete. */
type BoatDeletionReconciliation = "confirmed" | "pending" | "blocked" | "kept";

/** Account LIST omission is never evidence: Boat's LIST is eventually consistent. */
async function reconcileRecordedBoatDeletion(
  cfg: AppConfig,
  initial: BoatDeletionRecord,
  pollDelaysMs: readonly number[] = [],
  reportKept = false,
): Promise<BoatDeletionReconciliation> {
  let record = initial;
  if (record.phase === "accepted" && record.status === "completed") {
    finishRecordedBoatDeletion(record.boxId);
    return "confirmed";
  }

  if (record.phase === "accepted" && record.operationId) {
    for (const delayMs of pollDelaysMs) {
      if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
      let polled: Awaited<ReturnType<typeof boatJson>>;
      try {
        polled = await boatJson(cfg, `/deletion-operations/${record.operationId}`, {
          signal: AbortSignal.timeout(20_000),
        });
      } catch {
        break;
      }
      if (!polled.ok) break;
      const operation = boatDeletionOperation(polled.body, record.boxId, record.operationId);
      if (!operation) break;
      const next = recordBoatDeletionOperation(record.boxId, operation);
      if (next === "confirmed") return next;
      record = next;
      // Boat's own word decides: the sandbox leaves every list as soon as the delete is accepted.
      if (reportKept && keptForNewerSnapshots(operation)) return "kept";
      if (record.phase === "blocked") break;
    }
  }

  // Only a direct 404/410 on the immutable id is alternate completion proof.
  const inspected = await inspectBoatIdentity(cfg, record.boxId);
  if (!inspected.available) return record.phase === "blocked" ? "blocked" : "pending";
  if (!inspected.identity) {
    finishRecordedBoatDeletion(record.boxId);
    return "confirmed";
  }
  if (inspected.identity.name !== record.name) {
    throw Object.assign(
      new Error("A cloud computer being deleted no longer has its remembered name — repair it in boat.dev before continuing"),
      { status: 503 },
    );
  }
  return record.phase === "blocked" ? "blocked" : "pending";
}

/** Before Settings swaps credentials: here a bare 404 is no proof, it may be a different account. */
export async function verifyBoatDeletionCredential(cfg: AppConfig): Promise<void> {
  cfg = snapshotBoatConfig(cfg);
  for (const initial of boatDeletionSnapshot()) {
    let record = initial;
    let operationAuthorized = false;
    if (record.phase === "accepted" && record.operationId) {
      let polled: Awaited<ReturnType<typeof boatJson>> | null = null;
      try {
        polled = await boatJson(cfg, `/deletion-operations/${record.operationId}`, {
          signal: AbortSignal.timeout(20_000),
        });
      } catch {
      }
      if (polled?.ok) {
        const operation = boatDeletionOperation(polled.body, record.boxId, record.operationId);
        if (operation) {
          operationAuthorized = true;
          const next = recordBoatDeletionOperation(record.boxId, operation);
          if (next === "confirmed") continue;
          record = next;
        }
      }
    }

    if (operationAuthorized) continue;

    const inspected = await inspectBoatIdentity(cfg, record.boxId);
    if (inspected.available && inspected.identity?.name === record.name) continue;
    if (!inspected.available) {
      throw Object.assign(
        new Error(`${inspected.problem ?? "a deleting cloud computer could not be verified"}. Retry with the Boat account that owns it`),
        { status: 503 },
      );
    }
    throw Object.assign(
      new Error("that Boat token cannot access the cloud computers whose deletion is still being reconciled"),
      { status: 409 },
    );
  }
}

async function confirmAcceptedBoatDeletion(
  cfg: AppConfig,
  record: BoatDeletionRecord,
  acceptedBody: any,
  pollDelaysMs: readonly number[] = BOAT_DELETE_OPERATION_POLL_DELAYS_MS,
): Promise<BoatDeletionReconciliation> {
  const operation = boatDeletionOperation(acceptedBody, record.boxId);
  if (!operation) {
    const inspected = await inspectBoatIdentity(cfg, record.boxId);
    if (inspected.available && !inspected.identity) {
      finishRecordedBoatDeletion(record.boxId);
      return "confirmed";
    }
    throw Object.assign(
      new Error(`boat.dev returned an invalid deletion receipt for ${record.boxId}; its deletion fence was kept`),
      { status: 503 },
    );
  }
  const next = recordBoatDeletionOperation(record.boxId, operation);
  if (next === "confirmed") return next;
  if (keptForNewerSnapshots(operation)) return "kept";
  return reconcileRecordedBoatDeletion(cfg, next, pollDelaysMs, true);
}

/** Send DELETE only after the immutable target is on disk. */
async function requestRecordedBoatDeletion(
  cfg: AppConfig,
  identity: { boxId: string; name: string; ownerBotId: string | null },
  pollDelaysMs: readonly number[] = BOAT_DELETE_OPERATION_POLL_DELAYS_MS,
): Promise<BoatDeletionReconciliation> {
  const deletion = prepareBoatDeletion(identity);
  let removed: Awaited<ReturnType<typeof boatJson>>;
  try {
    removed = await boatJson(cfg, `/sandboxes/${identity.boxId}`, {
      method: "DELETE",
      headers: { "X-Ascii-Confirm-Delete": identity.boxId },
    });
  } catch (error) {
    throw Object.assign(
      new Error("Could not confirm whether boat.dev accepted the delete. The computer was kept fenced; retry Delete to reconcile it"),
      { status: 503, cause: error },
    );
  }
  if (boatDeleteProvedAbsent(removed)) {
    finishRecordedBoatDeletion(identity.boxId);
    return "confirmed";
  }
  if (!removed.ok) {
    markBoatDeletionBlocked(identity.boxId);
    throw Object.assign(new Error(boatErrorMessage(removed.status, "boat delete", removed.body, usesIncludedBoat(cfg))), { status: removed.status });
  }
  const confirmation = await confirmAcceptedBoatDeletion(cfg, deletion, removed.body, pollDelaysMs);
  if (confirmation === "blocked" || confirmation === "kept") throw deletionBlockedError(identity.boxId, confirmation === "kept");
  return confirmation;
}

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

function legacyBoatNameFor(botId: string): string {
  const { prefix, hash } = boatBotNameParts(botId);
  return `ogb-${prefix}-${hash}`;
}

export async function boatNameFor(botId: string) {
  const { prefix, hash } = boatBotNameParts(botId);
  return `${scopedBoatPrefix()}${prefix}-${hash}`;
}

export async function boatNameMatchesBot(botId: string, name: string): Promise<boolean> {
  return name === await boatNameFor(botId) || name === legacyBoatNameFor(botId);
}

export async function runCommand(cfg: AppConfig, boxId: string, command: string, { timeoutMs = 120_000, signal }: { timeoutMs?: number; signal?: AbortSignal } = {}) {
  assertBoatNotDeleting(boxId);
  const res = await boatFetch(cfg, `/sandboxes/${boxId}/commands`, {
    method: "POST",
    body: JSON.stringify({ command }),
    signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
  });
  const body: any = await res.json().catch(() => null);
  return {
    ok: res.ok && body?.exitCode === 0,
    exitCode: body?.exitCode ?? null,
    stdout: body?.stdout ?? "",
    stderr: body?.stderr ?? "", ...(body?.timedOut === true ? { timedOut: true as const } : {}),
  };
}

// Boat desktop access: VNC (POST /desktop?vnc=1) first, a plain WebSocket that survives P2P-blocking networks and answers
// {provisioning:true} until ready; WebRTC (POST /desktop) is a STUN-only fallback that can hang. The sandbox's desktopUrl is not usable alone.
async function mintDesktopUrl(cfg: AppConfig, boxId: string, { vncBudgetMs = 60_000, requestMs = 30_000 } = {}) {
  assertBoatNotDeleting(boxId);
  const t0 = Date.now();
  while (Date.now() - t0 < vncBudgetMs) {
    assertBoatNotDeleting(boxId);
    const { body } = await boatJson(cfg, `/sandboxes/${boxId}/desktop?vnc=1`, { method: "POST", signal: AbortSignal.timeout(requestMs) });
    if (body?.desktopUrl) return body.desktopUrl;
    if (!body?.provisioning) break;
    await new Promise((r) => setTimeout(r, 3000));
  }
  const { body } = await boatJson(cfg, `/sandboxes/${boxId}/desktop`, { method: "POST", signal: AbortSignal.timeout(requestMs) });
  return body?.desktopUrl ?? null;
}

async function waitReady(cfg: AppConfig, boxId: string, budgetMs = 90_000) {
  assertBoatNotDeleting(boxId);
  const deadline = Date.now() + budgetMs;
  const untilDeadline = () => AbortSignal.timeout(Math.max(1, deadline - Date.now()));
  const outOfTime = (error: unknown) => error instanceof Error && error.name === "TimeoutError";
  let resumeFailure: Error | null = null;
  try {
    while (Date.now() < deadline) {
      assertBoatNotDeleting(boxId);
      const { body } = await boatJson(cfg, `/sandboxes/${boxId}`, { signal: untilDeadline() });
      const state = body?.sandbox?.state;
      if (READY.has(state)) return body.sandbox;
      if (TERMINAL.has(state)) return null;
      // Boat: an archiving sandbox resumes only after its snapshot lands; a 5xx is retried next poll; 409 races a wake under way.
      if (state === "archived") {
        const resumed = await boatJson(cfg, `/sandboxes/${boxId}/resume`, { method: "POST", signal: untilDeadline() });
        if (resumed.ok) resumeFailure = null;
        else if (resumed.status !== 409) {
          const refusal = boatRefusal(resumed.status, "waking the cloud computer", resumed.body, usesIncludedBoat(cfg));
          if (resumed.status < 500) throw refusal;
          resumeFailure = refusal;
        }
      }
      await new Promise((r) => setTimeout(r, Math.min(2500, Math.max(0, deadline - Date.now()))));
    }
  } catch (error) {
    if (!outOfTime(error)) throw error;
  }
  if (resumeFailure) throw resumeFailure;
  return null;
}

const boatIdCache = new Map<string, string>();

function boatInventoryProblem(status: number, body: any, included = false): string {
  if (status === 401 || status === 403) {
    return included ? INCLUDED_BOAT_UNAVAILABLE : "boat.dev rejected the Boat API key — update it in Settings → API keys";
  }
  const service = boatServiceName(included);
  if (status === 429) return `${service} is rate-limiting this account — wait a minute and refresh`;
  const message = typeof body?.message === "string" ? body.message.trim() : "";
  return message ? `${service} could not list cloud computers: ${message}` : `${service} could not list cloud computers (${status})`;
}

/** A read that fails on the network or times out is sent once more; any answer, even an error status, is final. */
function boatRead(cfg: AppConfig, path: string) {
  const read = () => boatJson(cfg, path, { signal: AbortSignal.timeout(20_000) });
  return read().catch(async () => { await new Promise((resolve) => setTimeout(resolve, 1_000)); return read(); });
}

function safeBoatState(value: unknown): string {
  if (typeof value !== "string") return "unknown";
  const state = value.toLowerCase();
  return BOAT_STATES.has(state) ? state : "unknown";
}

async function listBoatPages(
  cfg: AppConfig,
): Promise<{ ok: true; boats: any[] } | { ok: false; problem: string; credentialRejected?: boolean; unreachable?: boolean }> {
  const boats: any[] = [];
  const seenCursors = new Set<string>();
  let cursor: string | null = null;

  for (let page = 0; page < MAX_BOAT_INVENTORY_PAGES; page += 1) {
    const path = `/sandboxes?limit=${BOAT_INVENTORY_PAGE_SIZE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
    let listed: Awaited<ReturnType<typeof boatJson>>;
    try {
      listed = await boatRead(cfg, path);
    } catch {
      return { ok: false, unreachable: true, problem: `Could not reach ${boatServiceName(usesIncludedBoat(cfg))} to list cloud computers — check your connection and refresh` };
    }
    if (!listed.ok || !Array.isArray(listed.body?.sandboxes)) {
      const credentialRejected = listed.status === 401 || listed.status === 403;
      return { ok: false, problem: boatInventoryProblem(listed.status, listed.body, usesIncludedBoat(cfg)), credentialRejected };
    }
    boats.push(...listed.body.sandboxes);

    const next = listed.body?.pageInfo?.nextCursor;
    if (next === undefined || next === null || next === "") return { ok: true, boats };
    if (typeof next !== "string" || next.length > 4_096) {
      return { ok: false, problem: "boat.dev returned an invalid cloud computer page cursor" };
    }
    if (seenCursors.has(next)) {
      return { ok: false, problem: "boat.dev repeated a cloud computer page cursor — refresh and try again" };
    }
    seenCursors.add(next);
    cursor = next;
  }

  return { ok: false, problem: "boat.dev returned too many cloud computer pages — narrow the account inventory and refresh" };
}

/** Only rows with this installation's name shape leave, without provider links, IPs or env; foreign rows never become deletion targets. */
export async function listManagedBoats(
  cfg: AppConfig,
  owners: ManagedBoatOwner[],
  options: { adoptLegacy?: boolean } = {},
): Promise<ManagedBoatInventory> {
  cfg = snapshotBoatConfig(cfg);
  if (!boatConfigured(cfg)) {
    return { configured: false, available: false, problem: null, instances: [] };
  }

  const listed = await listBoatPages(cfg);
  if (!listed.ok) return { configured: true, available: false, problem: listed.problem, credentialRejected: listed.credentialRejected, instances: [] };

  const namedOwners = await Promise.all(owners.map(async (owner) => ({
    currentName: await boatNameFor(owner.botId),
    legacyName: legacyBoatNameFor(owner.botId),
    owner,
  })));
  const invalidInventory = (problem: string): ManagedBoatInventory => ({
    configured: true,
    available: false,
    problem,
    instances: [],
  });

  let candidates = [...listed.boats];
  if (options.adoptLegacy !== false) {
    const namedOwnerByBotId = new Map(namedOwners.map((entry) => [entry.owner.botId, entry] as const));
    let recoveries: ReturnType<typeof boatCreateRecoverySnapshot>;
    try {
      recoveries = boatCreateRecoverySnapshot();
    } catch {
      return invalidInventory("OpenMausBot could not safely read its cloud computer recovery records");
    }
    for (const recovery of recoveries) {
      if (!recovery.resolved || !recovery.boxId) continue;
      const namedOwner = namedOwnerByBotId.get(recovery.botId);
      if (!namedOwner) continue;

      const matchingRows = candidates.filter((candidate) => candidate?.id === recovery.boxId);
      if (matchingRows.length > 1) {
        return invalidInventory("boat.dev returned a conflicting id for an OpenMaus-managed cloud computer — refresh or repair it in boat.dev");
      }
      if (matchingRows.length === 1) {
        const listedName = typeof matchingRows[0]?.name === "string" ? matchingRows[0].name : "";
        if (listedName !== namedOwner.currentName && listedName !== namedOwner.legacyName) {
          return invalidInventory("A remembered cloud computer no longer has its OpenMausBot owner name — repair it in boat.dev before continuing");
        }
        continue;
      }

      const inspected = await inspectBoatIdentity(cfg, recovery.boxId);
      if (!inspected.available) {
        return invalidInventory(inspected.problem ?? "A remembered cloud computer could not be verified");
      }
      if (!inspected.identity) {
        candidates = candidates.filter((candidate) => candidate?.id !== recovery.boxId);
        retireDeletedBoatCreate(recovery.boxId);
        continue;
      }
      if (
        inspected.identity.name !== namedOwner.currentName
        && inspected.identity.name !== namedOwner.legacyName
      ) {
        return invalidInventory("A remembered cloud computer no longer has its OpenMausBot owner name — repair it in boat.dev before continuing");
      }
      const directCandidate = {
        id: inspected.identity.boxId,
        name: inspected.identity.name,
        state: inspected.identity.state,
      };
      candidates.push(directCandidate);
    }

    let deletions: BoatDeletionRecord[];
    try {
      deletions = boatDeletionSnapshot();
    } catch {
      return invalidInventory("OpenMausBot could not safely read its cloud computer deletion records");
    }
    for (const deletion of deletions) {
      let state: BoatDeletionReconciliation;
      try {
        state = await reconcileRecordedBoatDeletion(cfg, deletion, [0]);
      } catch (error) {
        return invalidInventory(error instanceof Error ? error.message : "A cloud computer deletion could not be verified");
      }
      if (state === "confirmed") {
        candidates = candidates.filter((candidate) => candidate?.id !== deletion.boxId);
        continue;
      }

      const matchingRows = candidates.filter((candidate) => candidate?.id === deletion.boxId);
      if (matchingRows.length > 1) {
        return invalidInventory("boat.dev returned a conflicting id for a cloud computer being deleted");
      }
      if (matchingRows.length === 1) {
        if (matchingRows[0]?.name !== deletion.name) {
          return invalidInventory("A cloud computer being deleted no longer has its remembered name — repair it in boat.dev before continuing");
        }
        if (getBoatDeletion(deletion.boxId)?.phase === "accepted") {
          matchingRows[0] = { ...matchingRows[0], state: "removing" };
          candidates = candidates.map((candidate) => candidate?.id === deletion.boxId ? matchingRows[0] : candidate);
        }
        continue;
      }

      const current = getBoatDeletion(deletion.boxId);
      if (!current) continue;
      if (current.phase === "accepted") {
        candidates.push({ id: current.boxId, name: current.name, state: "removing" });
        continue;
      }
      const inspected = await inspectBoatIdentity(cfg, current.boxId);
      if (!inspected.available || !inspected.identity || inspected.identity.name !== current.name) {
        return invalidInventory(inspected.problem ?? "A cloud computer deletion target could not be verified");
      }
      candidates.push({
        id: inspected.identity.boxId,
        name: inspected.identity.name,
        state: inspected.identity.state,
      });
    }
  }
  const ownerByCurrentName = new Map(namedOwners.map(({ currentName, owner }) => [currentName, owner] as const));
  const ownerByLegacyName = new Map(namedOwners.map(({ legacyName, owner }) => [legacyName, owner] as const));
  const boatIdCounts = new Map<string, number>();
  for (const candidate of candidates) {
    if (!candidate || typeof candidate !== "object") continue;
    const boxId = typeof candidate.id === "string" ? candidate.id : "";
    if (BOAT_ID.test(boxId)) boatIdCounts.set(boxId, (boatIdCounts.get(boxId) ?? 0) + 1);
  }
  const ownedBoatByBot = new Map<string, string>();
  for (const candidate of candidates) {
    if (!candidate || typeof candidate !== "object") continue;
    const name = typeof candidate.name === "string" ? candidate.name : "";
    const owner = ownerByCurrentName.get(name) ?? ownerByLegacyName.get(name) ?? null;
    if (!owner) continue;
    const boxId = typeof candidate.id === "string" ? candidate.id : "";
    if (!BOAT_ID.test(boxId)) {
      return invalidInventory("boat.dev returned an invalid id for an OpenMaus-managed cloud computer — refresh or repair it in boat.dev");
    }
    const existing = ownedBoatByBot.get(owner.botId);
    if (existing && existing !== boxId) {
      return invalidInventory("boat.dev returned conflicting cloud computers for one OpenMaus bot — repair them in boat.dev before continuing");
    }
    ownedBoatByBot.set(owner.botId, boxId);
  }
  const instances: ManagedBoatInventoryInstance[] = [];
  const seenBoatIds = new Set<string>();
  const scopedPrefix = scopedBoatPrefix();
  for (const candidate of candidates) {
    if (!candidate || typeof candidate !== "object") continue;
    const boxId = typeof candidate.id === "string" ? candidate.id : "";
    const name = typeof candidate.name === "string" ? candidate.name : "";
    let owner: ManagedBoatOwner | null = null;
    let legacyOwner = false;
    if (SCOPED_MANAGED_BOAT_NAME.test(name)) {
      if (!name.startsWith(scopedPrefix)) continue;
      owner = ownerByCurrentName.get(name) ?? null;
    } else if (LEGACY_MANAGED_BOAT_NAME.test(name)) {
      owner = ownerByLegacyName.get(name) ?? null;
      if (!owner) continue;
      legacyOwner = true;
    } else {
      continue;
    }
    // Throw, never skip: deletion must not mistake provider corruption for absence.
    if (!BOAT_ID.test(boxId)) {
      return invalidInventory("boat.dev returned an invalid id for an OpenMaus-managed cloud computer — refresh or repair it in boat.dev");
    }
    if ((boatIdCounts.get(boxId) ?? 0) !== 1 || seenBoatIds.has(boxId)) {
      return invalidInventory("boat.dev returned a conflicting id for an OpenMaus-managed cloud computer — refresh or repair it in boat.dev");
    }
    if (legacyOwner && owner && options.adoptLegacy !== false) {
      try {
        adoptResolvedBoat(owner.botId, boxId);
      } catch {
        return invalidInventory("OpenMausBot could not safely remember this legacy cloud computer's owner — repair it in boat.dev before continuing");
      }
    }
    seenBoatIds.add(boxId);
    instances.push({
      boxId,
      name,
      state: safeBoatState(candidate.state),
      ownerBotId: owner?.botId ?? null,
      ownerName: owner?.name ?? null,
      orphaned: owner === null,
      inUse: owner?.inUse ?? false,
    });
  }
  instances.sort((a, b) => {
    if (a.orphaned !== b.orphaned) return a.orphaned ? 1 : -1;
    return (a.ownerName ?? a.name).localeCompare(b.ownerName ?? b.name);
  });
  return { configured: true, available: true, problem: null, instances };
}

/** A direct read, not eventually consistent like LIST; only id, name and allowlisted state cross this boundary. */
export async function inspectBoatIdentity(cfg: AppConfig, boxId: string): Promise<BoatIdentityInspection> {
  cfg = snapshotBoatConfig(cfg);
  if (!BOAT_ID.test(boxId)) {
    return { available: false, identity: null, problem: "the remembered cloud computer id is invalid" };
  }
  let inspected: Awaited<ReturnType<typeof boatJson>>;
  try {
    inspected = await boatJson(cfg, `/sandboxes/${boxId}`, { signal: AbortSignal.timeout(20_000) });
  } catch {
    return {
      available: false,
      identity: null,
      problem: `Could not reach ${boatServiceName(usesIncludedBoat(cfg))} to verify a remembered cloud computer`,
    };
  }
  const candidate = inspected.body?.sandbox;
  const returnedId = typeof candidate?.id === "string" ? candidate.id : "";
  // A cancelled create got no machine: Boat reports it once, nameless, then 404s.
  if (inspected.status === 404 || inspected.status === 410 || (inspected.ok && returnedId === boxId && candidate.state === "cancelled")) {
    return { available: true, identity: null, problem: null };
  }
  if (!inspected.ok) {
    return { available: false, identity: null, problem: boatInventoryProblem(inspected.status, inspected.body, usesIncludedBoat(cfg)) };
  }
  const name = typeof candidate?.name === "string" ? candidate.name : "";
  if (returnedId !== boxId || name.length === 0 || name.length > 100 || /[\r\n]/.test(name)) {
    return { available: false, identity: null, problem: "boat.dev returned an invalid cloud computer identity" };
  }
  return { available: true, identity: { boxId, name, state: safeBoatState(candidate.state) }, problem: null };
}

function inventoryFailure(inventory: ManagedBoatInventory): Error & { status: number } {
  const error = new Error(
    inventory.configured
      ? (inventory.problem ?? "Cloud computer inventory is unavailable")
      : "Boat is not configured — add its API key in Settings → API keys",
  ) as Error & { status: number };
  error.status = inventory.configured ? 503 : 409;
  return error;
}

function deletionFenceError(): Error & { status: number } {
  return Object.assign(
    new Error("this cloud computer is being deleted — wait for it to finish, or retry Delete if it needs attention"),
    { status: 409, code: BOAT_DELETING },
  );
}

export function assertBoatNotDeleting(boxId: string): void {
  if (getBoatDeletion(boxId)) throw deletionFenceError();
}

function assertBotBoatNotDeleting(botId: string): void {
  if (hasPendingBoatDeletionForBot(botId)) throw deletionFenceError();
}

async function revalidateManagedBoat(
  cfg: AppConfig,
  owners: ManagedBoatOwner[],
  boxId: string,
): Promise<ManagedBoatInventoryInstance> {
  if (!BOAT_ID.test(boxId)) throw Object.assign(new Error("invalid cloud computer id"), { status: 400 });
  const inventory = await listManagedBoats(cfg, owners);
  if (!inventory.available) throw inventoryFailure(inventory);
  const instance = inventory.instances.find((candidate) => candidate.boxId === boxId);
  if (!instance) {
    throw Object.assign(new Error("that OpenMaus-managed cloud computer no longer exists"), { status: 404 });
  }
  return instance;
}

const QUIESCE_BROWSER = [
  'for name in chrome google-chrome chromium chromium-browser; do pid=$(pgrep -o -x "$name" 2>/dev/null || true); [ -z "$pid" ] || kill -TERM "$pid" 2>/dev/null || true; done',
  'for i in 1 2 3 4 5 6 7 8; do if ! pgrep -x chrome >/dev/null 2>&1 && ! pgrep -x google-chrome >/dev/null 2>&1 && ! pgrep -x chromium >/dev/null 2>&1 && ! pgrep -x chromium-browser >/dev/null 2>&1; then break; fi; sleep 0.25; done',
].join("; ");

async function stopBoat(cfg: AppConfig, boxId: string): Promise<void> {
  assertBoatNotDeleting(boxId);
  await runCommand(cfg, boxId, QUIESCE_BROWSER, { timeoutMs: 5_000 }).catch(() => null);
  const stopped = await boatJson(cfg, `/sandboxes/${boxId}/stop`, { method: "POST" });
  if (!stopped.ok) {
    throw Object.assign(new Error(boatErrorMessage(stopped.status, "boat sleep", stopped.body, usesIncludedBoat(cfg))), { status: stopped.status });
  }
}

function forgetBoatId(boxId: string): void {
  for (const [botId, cachedId] of boatIdCache) {
    if (cachedId === boxId) boatIdCache.delete(botId);
  }
}

export async function sleepManagedBoat(
  cfg: AppConfig,
  owners: ManagedBoatOwner[],
  boxId: string,
  claim?: ManagedBoatMutationClaim,
) {
  cfg = snapshotBoatConfig(cfg);
  assertBoatNotDeleting(boxId);
  const instance = await revalidateManagedBoat(cfg, owners, boxId);
  if (instance.inUse) {
    throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
  }
  if (!SLEEPING_BOAT_STATES.has(instance.state) && !READY.has(instance.state)) {
    throw Object.assign(new Error(`this cloud computer cannot sleep while it is ${instance.state}`), { status: 409 });
  }
  const release = claim?.(instance);
  try {
    if (!SLEEPING_BOAT_STATES.has(instance.state)) await stopBoat(cfg, instance.boxId);
    forgetBoatId(instance.boxId);
    return { ok: true };
  } finally {
    release?.();
  }
}

/** The caller must echo the freshly listed name as well as the id; boat.dev requires the id in its confirmation header. */
export async function deleteManagedBoat(
  cfg: AppConfig,
  owners: ManagedBoatOwner[],
  boxId: string,
  confirmName: string,
  claim?: ManagedBoatMutationClaim,
  options: { pollDelaysMs?: readonly number[] } = {},
) {
  cfg = snapshotBoatConfig(cfg);
  const remembered = getBoatDeletion(boxId);
  if (remembered) {
    const reconciled = await reconcileRecordedBoatDeletion(cfg, remembered, [0]);
    if (reconciled === "confirmed") return { ok: true };
    // Retrying DELETE would create a second operation and weaken the only trustworthy receipt.
    const current = getBoatDeletion(boxId);
    if (current?.phase === "accepted") {
      return { ok: true, pending: true as const };
    }
  }
  const instance = await revalidateManagedBoat(cfg, owners, boxId);
  if (instance.inUse) {
    throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
  }
  if (confirmName !== instance.name) {
    throw Object.assign(new Error("cloud computer confirmation no longer matches — refresh and try again"), { status: 409 });
  }
  const release = claim?.(instance);
  try {
    const confirmation = await requestRecordedBoatDeletion(cfg, {
      boxId: instance.boxId,
      name: instance.name,
      ownerBotId: instance.ownerBotId,
    }, options.pollDelaysMs);
    if (confirmation === "pending") {
      forgetBoatId(instance.boxId);
      return { ok: true, pending: true as const };
    }
    return { ok: true };
  } finally {
    release?.();
  }
}

export async function findBoat(cfg: AppConfig, botId: string) {
  cfg = snapshotBoatConfig(cfg);
  assertBotBoatNotDeleting(botId);
  const cachedId = boatIdCache.get(botId);
  if (cachedId) {
    let direct: Awaited<ReturnType<typeof boatJson>> | null = null;
    try {
      direct = await boatJson(cfg, `/sandboxes/${cachedId}`, { signal: AbortSignal.timeout(20_000) });
    } catch {
    }
    const directBoat = direct?.body?.sandbox;
    if (direct?.ok && directBoat?.id === cachedId && !TERMINAL.has(directBoat.state)) return directBoat;
    if (direct?.ok && directBoat?.id !== cachedId) {
      throw Object.assign(new Error("boat.dev returned an invalid cloud computer identity"), { status: 503 });
    }
    boatIdCache.delete(botId);
  }
  const name = await boatNameFor(botId);
  const legacyName = legacyBoatNameFor(botId);
  const listed = await listBoatPages(cfg);
  if (!listed.ok) {
    throw Object.assign(new Error(listed.problem), { status: 503, transient: listed.unreachable === true });
  }
  const expected = listed.boats.filter((candidate: any) => candidate?.name === name || candidate?.name === legacyName);
  if (expected.some((candidate: any) => !BOAT_ID.test(candidate?.id))) {
    throw Object.assign(new Error("boat.dev returned an invalid cloud computer identity"), { status: 503 });
  }
  const found = expected.find((candidate: any) => candidate.name === name && !TERMINAL.has(candidate.state))
    ?? expected.find((candidate: any) => candidate.name === legacyName && !TERMINAL.has(candidate.state))
    ?? null;
  if (found) {
    const duplicateId = listed.boats.filter((candidate: any) => candidate?.id === found.id).length !== 1;
    if (duplicateId) {
      throw Object.assign(new Error("boat.dev returned a conflicting cloud computer identity"), { status: 503 });
    }
    if (found.name === legacyName) adoptResolvedBoat(botId, found.id);
    boatIdCache.set(botId, found.id);
  }
  return found;
}

export async function readyBoat(cfg: AppConfig, botId: string, budgetMs = 60_000) {
  cfg = snapshotBoatConfig(cfg);
  const boat = await findBoat(cfg, botId);
  if (!boat) return null;
  if (READY.has(boat.state)) return boat;
  return waitReady(cfg, boat.id, budgetMs);
}

export function boatAccount(cfg: AppConfig): ServiceCredential | null {
  return boatCredential(cfg.box?.token);
}

export function boatConfigured(cfg: AppConfig) {
  return Boolean(boatAccount(cfg));
}

function usesIncludedBoat(cfg: AppConfig): boolean {
  return boatAccount(cfg)?.included === true;
}

/** Configured-or-not for Settings, never the token. */
export function describeBoatAccount(cfg: AppConfig): { configured: boolean; included?: true } {
  const account = boatAccount(cfg);
  return { configured: Boolean(account), ...(account?.included ? { included: true as const } : {}) };
}

export async function verifyToken(token: string): Promise<{ ok: true } | { ok: false; message: string; rejected?: true }> {
  try {
    const res = await fetch(`${boatProviderApi()}/sandboxes?limit=1`, {
      headers: { authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(20_000),
    });
    if (res.ok) return { ok: true };
    if (res.status === 401 || res.status === 403) {
      // Boat API keys are prefixed boat_ (box_ before v1).
      return {
        ok: false, rejected: true,
        message: /^(boat|box)_/.test(token)
          ? "boat.dev rejected that key — it may have been revoked or expired. Copy a fresh one from your boat.dev account."
          : "That doesn't look like a Boat API key: they start with boat_ (older ones with box_). Copy the API key from your boat.dev account (an account or session token won't work here).",
      };
    }
    return { ok: false, message: `boat.dev returned ${res.status} for that token — try again in a moment.` };
  } catch {
    return { ok: false, message: "Couldn't reach boat.dev to check that token — check your connection and retry." };
  }
}

const INCLUDED_BOAT_UNAVAILABLE = "The cloud computers included with your Cloud plan aren't available right now. Try again later.";
const boatServiceName = (included: boolean) => included ? "MausBot Cloud's computer service" : "boat.dev";

export function boatErrorMessage(status: number, what: string, body?: any, included = false): string {
  const theirs = typeof body?.message === "string" ? body.message.trim() : "";
  const link = typeof body?.error?.details?.billingUrl === "string" ? body.error.details.billingUrl : "";
  if (status === 402) {
    return [theirs || "boat.dev needs a paid Boat plan before it will create a computer.", link].filter(Boolean).join(" ");
  }
  if (status === 401 || status === 403) {
    if (included) return INCLUDED_BOAT_UNAVAILABLE;
    return "boat.dev rejected your Boat API key — paste a current one in Settings → API keys";
  }
  if (status === 429) {
    return theirs || "boat.dev is rate-limiting this account — wait a minute and try again";
  }
  return theirs ? `${what} failed: ${theirs}` : `${what} failed (${status})`;
}

export function boatRefusal(status: number, what: string, body?: any, included = false): Error & { boatStatus: number; boatCode?: string } {
  const code = body?.error?.code ?? body?.code;
  return Object.assign(new Error(boatErrorMessage(status, what, body, included)), {
    boatStatus: status, ...(typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code) ? { boatCode: code } : {}),
  });
}

/** boat.dev trial accounts refuse the eight-hour auto-stop with `trial_auto_stop_required`; retry once at the advertised maximum (documented: two hours). */
function trialBoatTtlSeconds(body: any): number | null {
  const code = body?.error?.code ?? body?.code;
  if (code !== "trial_auto_stop_required") return null;
  const details = body?.error?.details ?? body?.details ?? {};
  for (const value of [details.maxTtlSeconds, details.maximumTtlSeconds, details.maxAutoStopSeconds]) {
    if (Number.isInteger(value) && value > 0 && value <= DEFAULT_BOAT_TTL_SECONDS) return value;
  }
  return TRIAL_BOAT_TTL_SECONDS;
}

type BoatCreateResult = Awaited<ReturnType<typeof boatJson>> & {
  request: BoatCreateRequest;
  /** Auto-delete only a Boat this exact call created; a journal recovery may hold user data. */
  createdThisAttempt: boolean;
};

function idempotentCreateInProgress(result: Awaited<ReturnType<typeof boatJson>>): boolean {
  const code = result.body?.error?.code ?? result.body?.code;
  return result.status === 409 && code === "idempotency_in_progress";
}

async function requestBoatCreate(cfg: AppConfig, botId: string, ttlSeconds: number): Promise<BoatCreateResult> {
  // noEnv: no key this server holds ever reaches the computer. A trial-TTL retry needs a different idempotency key.
  const body = JSON.stringify({ ttlSeconds, noEnv: true });
  let attempt = beginBoatCreate(botId, body);
  let request = attempt.request;
  let createdThisAttempt = attempt.startedNow;

  // The previous process got the sandbox but died before naming it: never create again first; only proven absence frees the key.
  if (request.boxId) {
    const recovered = await boatJson(cfg, `/sandboxes/${request.boxId}`, {
      signal: AbortSignal.timeout(20_000),
    });
    if (recovered.status !== 404 && recovered.status !== 410) {
      return { ...recovered, request, createdThisAttempt: false };
    }
    discardBoatCreate(request);
    attempt = beginBoatCreate(botId, body);
    request = attempt.request;
    createdThisAttempt = attempt.startedNow;
  }

  let last: Awaited<ReturnType<typeof boatJson>> | null = null;
  let ambiguousRetries = 0;
  let inProgressRetries = 0;
  for (;;) {
    try {
      last = await boatJson(cfg, "/sandboxes", {
        method: "POST",
        headers: { "Idempotency-Key": request.idempotencyKey },
        signal: AbortSignal.timeout(45_000),
        body,
      });
    } catch (error) {
      // A dropped response is ambiguous: Boat may already have created the sandbox, and one retry with the same key recovers it.
      if (ambiguousRetries++ === 0) continue;
      throw error;
    }
    const boxId = last.body?.sandbox?.id;
    if (last.ok && typeof boxId === "string" && boxId) {
      request = rememberCreatedBoat(request, boxId);
      return { ...last, request, createdThisAttempt };
    }
    if (idempotentCreateInProgress(last)) {
      const delay = BOAT_CREATE_IN_PROGRESS_RETRY_DELAYS_MS[inProgressRetries++];
      if (delay !== undefined) {
        await new Promise((resolve) => setTimeout(resolve, delay));
        continue;
      }
      return { ...last, request, createdThisAttempt };
    }
    if ((last.status >= 500 || last.ok) && ambiguousRetries++ === 0) continue;
    // A 5xx or idempotency conflict can follow a provider-side create, so the key is kept for recovery.
    if (last.status < 500 && last.status !== 409 && !last.ok) discardBoatCreate(request);
    return { ...last, request, createdThisAttempt };
  }
}

async function createBoat(cfg: AppConfig, botId: string) {
  const first = await requestBoatCreate(cfg, botId, DEFAULT_BOAT_TTL_SECONDS);
  if (first.ok) return first;
  const trialTtl = trialBoatTtlSeconds(first.body);
  return trialTtl === null ? first : requestBoatCreate(cfg, botId, trialTtl);
}

/** A prior delete always wins: one click must never both erase and recreate a computer. */
async function finishPriorDeletionBeforeProvision(cfg: AppConfig, botId: string): Promise<void> {
  const remembered = boatDeletionSnapshot().filter((record) => record.ownerBotId === botId);
  if (!remembered.length) return;
  for (const deletion of remembered) {
    let state = await reconcileRecordedBoatDeletion(cfg, deletion, [0]);
    if (state !== "confirmed") {
      const current = getBoatDeletion(deletion.boxId);
      if (current?.phase === "prepared" || current?.phase === "blocked") {
        state = await requestRecordedBoatDeletion(cfg, {
          boxId: current.boxId,
          name: current.name,
          ownerBotId: current.ownerBotId,
        });
      }
    }
    if (state !== "confirmed") throw deletionFenceError();
  }
  throw Object.assign(
    new Error("the previous cloud computer deletion finished — retry to create a new computer"),
    { status: 409, code: BOAT_DELETION_FINISHED },
  );
}

export async function boatStatus(cfg: AppConfig, botId: string) {
  cfg = snapshotBoatConfig(cfg);
  if (!boatConfigured(cfg)) return { configured: false, box: null };
  const boat = await findBoat(cfg, botId);
  return {
    configured: true,
    box: boat ? { boxId: boat.id, state: boat.state, desktopAvailable: boat.desktopAvailable ?? null } : null,
  };
}

export async function provisionBoat(cfg: AppConfig, botId: string, _botName: string) {
  cfg = snapshotBoatConfig(cfg);
  if (!boatConfigured(cfg)) {
    throw new Error("Boat is not configured — add its API key in Settings → API keys");
  }
  await finishPriorDeletionBeforeProvision(cfg, botId);
  const vmName = await boatNameFor(botId);
  let boat = await findBoat(cfg, botId);
  let created = false;
  let createRequest: BoatCreateRequest | null = null;
  try {
    if (!boat) {
      // Another process may have prepared a deletion since the lookup; never create a replacement before the fence is reconciled.
      assertBotBoatNotDeleting(botId);
      // Boat's TTL archives the computer if every stop path dies.
      const createRes = await createBoat(cfg, botId);
      if (!createRes.ok || !createRes.body?.sandbox?.id) {
        throw boatRefusal(createRes.status, "boat create", createRes.body, usesIncludedBoat(cfg));
      }
      boat = createRes.body.sandbox;
      createRequest = createRes.request;
      created = createRes.createdThisAttempt;
      // v1 create takes no name: the deterministic one is set right after.
      const rename = await boatJson(cfg, `/sandboxes/${boat.id}`, {
        method: "PATCH",
        body: JSON.stringify({ name: vmName }),
      });
      if (!rename.ok) throw new Error(boatErrorMessage(rename.status, "naming the cloud computer", rename.body, usesIncludedBoat(cfg)));
      if (createRequest) createRequest = resolveBoatCreate(createRequest);
    }
    const ready = await waitReady(cfg, boat.id);
    if (!ready) throw new Error("the cloud computer did not become ready within 90s — retry in a minute");

    // Nothing to install: every sandbox ships its own computer-use driver.
    const joinUrl = await mintDesktopUrl(cfg, boat.id);
    if (!joinUrl) throw new Error("the cloud computer's desktop link could not be created");
    return { boxId: boat.id, machineName: vmName, reused: !created, state: ready.state, joinUrl };
  } catch (error) {
    if (!created || !boat?.id) throw error;
    const originalMessage = error instanceof Error ? error.message : String(error);
    const inspected = await inspectBoatIdentity(cfg, boat.id);
    if (inspected.available && !inspected.identity) {
      retireDeletedBoatCreate(boat.id);
      boatIdCache.delete(botId);
      throw error;
    }
    let cleanupConfirmation: BoatDeletionReconciliation;
    try {
      cleanupConfirmation = await requestRecordedBoatDeletion(cfg, {
        boxId: boat.id,
        name: inspected.identity?.name ?? vmName,
        ownerBotId: botId,
      });
    } catch (cleanupError) {
      const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
      throw new Error(`${originalMessage}. The new computer's deletion was not confirmed: ${cleanupMessage}. Check sandbox ${boat.id} in boat.dev.`);
    }
    if (cleanupConfirmation === "confirmed") throw error;
    boatIdCache.delete(botId);
    throw Object.assign(
      new Error(
        `${originalMessage}. boat.dev accepted deletion of the new computer, but it is still pending; `
        + `its recovery record was kept, along with its deletion fence. Check sandbox ${boat.id} in boat.dev.`,
        { cause: error },
      ),
      { status: 503 },
    );
  }
}

export async function joinBoat(cfg: AppConfig, botId: string) {
  cfg = snapshotBoatConfig(cfg);
  const boat = await findBoat(cfg, botId);
  if (!boat) throw new Error("no computer yet — provision it first");
  const ready = await waitReady(cfg, boat.id);
  if (!ready) throw new Error("the cloud computer did not wake in time — try again");
  // Archive/resume keeps disk but not processes; the sandbox restarts its own driver daemon.
  return { joinUrl: await mintDesktopUrl(cfg, boat.id), state: ready.state ?? null };
}

/** The only join path allowed while a bot turn is active: no lifecycle or guest-process change. */
export async function joinReadyBoat(cfg: AppConfig, botId: string) {
  cfg = snapshotBoatConfig(cfg);
  const boat = await findBoat(cfg, botId);
  if (!boat) throw Object.assign(new Error("no computer yet — provision it first"), { status: 409 });
  if (!READY.has(boat.state)) {
    throw Object.assign(
      new Error("the cloud computer is sleeping or starting — interrupt the bot before waking it"),
      { status: 409 },
    );
  }
  return { joinUrl: await mintDesktopUrl(cfg, boat.id), state: boat.state ?? null };
}

export async function sleepBoat(cfg: AppConfig, botId: string) {
  cfg = snapshotBoatConfig(cfg);
  const boat = await findBoat(cfg, botId);
  if (!boat) throw new Error("no computer for this bot");
  await stopBoat(cfg, boat.id);
  forgetBoatId(boat.id);
  return { ok: true };
}

export async function execOnBoat(cfg: AppConfig, botId: string, command: string) {
  cfg = snapshotBoatConfig(cfg);
  if (command.length > MAX_REMOTE_COMMAND_LENGTH) {
    throw new RangeError(`command is too long (maximum ${MAX_REMOTE_COMMAND_LENGTH} characters)`);
  }
  const boat = await findBoat(cfg, botId);
  if (!boat) throw new Error("no computer for this bot yet");
  const ready = await waitReady(cfg, boat.id, 60_000);
  if (!ready) throw new Error("the cloud computer did not wake");
  const out = await runCommand(cfg, boat.id, isolatedRemoteCommand(command));
  return { exitCode: out.exitCode, stdout: out.stdout.slice(-4000), stderr: out.stderr.slice(-2000) };
}

// Base64 over command stdout is not reliable for full-size frames (probed 2026-08-12: corrupted length), so frames come over HTTP.
// X11 captures leave the pointer out by default, hence scrot --pointer and ffmpeg -draw_mouse.
const PANEL_PATH = "/tmp/ogb-panel.jpg";
export const PANEL_FRAME_WIDTH = 1920;
export const PANEL_FRAME_QUALITY = 85;
// ffmpeg's -q:v runs 2 (best) to 31; 3 lands near JPEG quality 85.
const PANEL_FRAME_FFMPEG_Q = 3;

export function panelShotCommand({ width = PANEL_FRAME_WIDTH, quality = PANEL_FRAME_QUALITY, framePath = PANEL_PATH, nativeSize = false } = {}): string {
  return [
    "export DISPLAY=${DISPLAY:-:0}",
    `f=${shellQuote(framePath)}`,
    'rm -f "$f"',
    'w=$(xdotool getdisplaygeometry 2>/dev/null | cut -d" " -f1)',
    'case "$w" in ""|*[!0-9]*) w=0;; esac',
    `scrot -o -p -q ${quality} "$f" 2>/dev/null || import -window root -quality ${quality} "$f" 2>/dev/null || ffmpeg -y -f x11grab -draw_mouse 1 -i "$DISPLAY" -frames:v 1 -q:v ${PANEL_FRAME_FFMPEG_Q} "$f" >/dev/null 2>&1`,
    ...(nativeSize ? [] : [`if [ "$w" -gt ${width} ] 2>/dev/null && command -v convert >/dev/null 2>&1; then convert "$f" -resize ${width}x -quality ${quality} "$f" 2>/dev/null || true; fi`]),
    'test -s "$f" && echo captured',
  ].join("; ");
}
const SHOT_CMD = panelShotCommand();

// A compromised sandbox can answer with any size; cap what one frame may buffer.
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
const FRAME_TOO_LARGE = "the cloud computer's frame exceeds the 8 MB limit";

export async function readFileBase64(cfg: AppConfig, boxId: string, path: string, signal?: AbortSignal): Promise<string | null> {
  let bytes: Buffer | null = null;
  let tooLarge = false;
  try {
    const res = await boatFetch(cfg, `/sandboxes/${boxId}/artifacts?path=${encodeURIComponent(path)}`, { signal });
    if (res.ok) {
      const declaredLength = res.headers.get("content-length");
      if (declaredLength !== null && Number(declaredLength) > MAX_FRAME_BYTES) tooLarge = true;
      else bytes = Buffer.from(await res.arrayBuffer());
    }
  } catch {
  }
  if (tooLarge || (bytes !== null && bytes.length > MAX_FRAME_BYTES)) {
    throw new Error(FRAME_TOO_LARGE);
  }
  if (bytes?.length) return bytes.toString("base64");
  signal?.throwIfAborted();
  const { ok, body } = await boatJson(cfg, `/sandboxes/${boxId}/files?path=${encodeURIComponent(path)}&encoding=base64`, { signal });
  const content = body?.content;
  if (ok && typeof content === "string" && content) {
    if (Buffer.byteLength(content, "base64") > MAX_FRAME_BYTES) throw new Error(FRAME_TOO_LARGE);
    return content;
  }
  return null;
}

export async function screenshotBoat(cfg: AppConfig, botId: string, knownBoatId?: string, options?: { signal?: AbortSignal; nativeSize?: boolean }) {
  cfg = snapshotBoatConfig(cfg);
  let boxId = knownBoatId;
  if (!boxId) {
    const boat = await findBoat(cfg, botId);
    if (!boat) throw new Error("no computer for this bot yet");
    if (!READY.has(boat.state)) throw new Error(`the cloud computer is ${boat.state}`);
    boxId = boat.id as string;
  }
  const framePath = options?.nativeSize ? PANEL_PATH + ".model.jpg" : PANEL_PATH;
  const out = await runCommand(cfg, boxId, options?.nativeSize ? panelShotCommand({ framePath, nativeSize: true }) : SHOT_CMD,
    { timeoutMs: 60_000, signal: options?.signal });
  if (!/captured/.test(out.stdout)) {
    throw new Error(out.stderr.slice(0, 200) || "screen capture failed on the cloud computer");
  }
  const data = await readFileBase64(cfg, boxId, framePath, options?.signal);
  if (!data) throw new Error("could not read the frame back from the cloud computer");
  return { png: data, format: "jpeg" };
}