Ctrl K
ComunidadGuías…

server/boat-included.test.ts

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

Archivo original ↗Preguntar a KiBot
// Cloud Pro's included Boat computers, over HTTP against one stub that plays
// both Boat (/boat) and the Admin's relay (/relay). The relay knows only the
// Admin's account, so the person's own key must never reach it and the
// included token must never reach Boat; the own key always wins.
import { createHash } from "node:crypto";
import { createServer, type Server } from "node:http";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

import type { AppConfig } from "./config.ts";

const INCLUDED = "box_omb_included-relay-token";
const LIMIT = "Your Pro plan's 200 cloud computer hours for September are used up. They reset on 1 October.";
const botId = "included-boat-test";
const boxId = "bx_23456789";
const OPERATION_ID = "bdop_0123456789abcdef0123456789abcdef";

let api: Server;
let base = "";
let state = "ready";
/** Whether an accepted resume wakes the computer at once. */
let wakeOnResume = true;
/** When set, every request is refused as an invalid credential. */
let rejectCredentials = false;
/** When set, every request fails as an outage would. */
let serverError = false;
/** This many listings are cut off before any answer, as a dropped connection is. */
let dropLists = 0;
/** Replies to POST /resume in order; the last one repeats. A 2xx wakes the computer. */
let resumeReplies: Array<{ status: number; body: unknown }> = [];
const requests: Array<{ method: string; path: string; auth: string }> = [];
let boat: typeof import("./boat.ts");
let loadConfig: typeof import("./config.ts").loadConfig;

const underBoat = () => requests.filter((request) => request.path.startsWith("/boat/"));
const underRelay = () => requests.filter((request) => request.path.startsWith("/relay/"));

// The legacy deterministic name findBoat still recognises.
const machineName = `ogb-${botId.slice(0, 8).toLowerCase().replace(/[^a-z0-9]/g, "")}-${createHash("sha256").update(botId).digest("hex").slice(0, 6)}`;

beforeAll(async () => {
  api = createServer((req, res) => {
    const url = new URL(req.url ?? "/", "http://boat.test");
    req.resume();
    req.on("end", () => {
      requests.push({ method: req.method ?? "GET", path: url.pathname, auth: String(req.headers.authorization ?? "") });
      const send = (status: number, body: unknown) => {
        res.writeHead(status, { "content-type": "application/json" });
        res.end(JSON.stringify(body));
      };
      if (rejectCredentials) return send(401, { ok: false, code: "unauthorized", message: "This cloud computer key is not valid." });
      if (serverError) return send(500, { ok: false, message: "internal error" });
      // Boat speaks its public API v1; the Admin relay at /api/box/v1 still speaks the old box dialect, and at /api/v1, v1.
      const [, base = "", rest = ""] = /^(\/boat\/api\/v1|\/relay\/api\/box\/v1|\/relay\/api\/v1)(\/.*)$/.exec(url.pathname) ?? [];
      const legacy = base === "/relay/api/box/v1";
      const [root, one, many] = legacy ? ["/boxes", "box", "boxes"] : ["/sandboxes", "sandbox", "sandboxes"];
      if (rest === root && dropLists > 0) { dropLists--; return void req.socket.destroy(); }
      if (rest === root) return send(200, { ok: true, [many]: [{ id: boxId, name: machineName, state }] });
      if (rest === `${root}/${boxId}` && req.method === "GET") return send(200, { ok: true, [one]: { id: boxId, name: machineName, state } });
      if (rest === `${root}/${boxId}` && req.method === "DELETE") {
        // Boat's operation, passed through as each dialect names its kind.
        return send(202, { ok: true, operation: { id: OPERATION_ID, kind: legacy ? "box" : "sandbox", targetId: boxId, status: "completed" } });
      }
      if (rest === `${root}/${boxId}/resume`) {
        const reply = resumeReplies.length > 1 ? resumeReplies.shift()! : resumeReplies[0] ?? { status: 202, body: { ok: true } };
        if (reply.status < 300 && wakeOnResume) state = "idle";
        return send(reply.status, reply.body);
      }
      if (rest === `${root}/${boxId}/desktop`) return send(200, { desktopUrl: "https://desktop.example.test/session" });
      if (rest === `${root}/${boxId}/commands`) return send(200, { exitCode: 0, stdout: "", stderr: "" });
      send(404, { ok: false, code: "not_found", message: `unexpected ${req.method} ${url.pathname}` });
    });
  });
  await new Promise<void>((resolve) => api.listen(0, "127.0.0.1", resolve));
  base = `http://127.0.0.1:${(api.address() as { port: number }).port}`;
});

afterAll(async () => {
  await new Promise<void>((resolve) => api.close(() => resolve()));
});

beforeEach(async () => {
  requests.length = 0;
  state = "ready";
  resumeReplies = [];
  rejectCredentials = false;
  serverError = false;
  dropLists = 0;
  wakeOnResume = true;
  vi.stubEnv("BOX_TOKEN", undefined);
  vi.stubEnv("OMB_BOAT_API", `${base}/boat/api/v1`);
  vi.stubEnv("OMB_CLOUD_BOAT_URL", `${base}/relay/api/box/v1`);
  vi.stubEnv("OMB_CLOUD_BOAT_TOKEN", INCLUDED);
  boat ??= await import("./boat.ts");
  loadConfig ??= (await import("./config.ts")).loadConfig;
});

afterEach(() => {
  vi.unstubAllEnvs();
});

describe("included Boat computers", () => {
  it("with no own key, uses the included token and only the relay", async () => {
    await boat.execOnBoat({}, botId, "true");
    expect(underRelay().length).toBeGreaterThan(0);
    expect(underBoat()).toEqual([]);
    expect(requests.every((request) => request.auth === `Bearer ${INCLUDED}`)).toBe(true);
  });

  it("an own key saved in Settings wins and goes only to Boat", async () => {
    await boat.execOnBoat({ box: { token: "box_own" } }, botId, "true");
    expect(underBoat().length).toBeGreaterThan(0);
    expect(underRelay()).toEqual([]);
    expect(requests.every((request) => request.auth === "Bearer box_own")).toBe(true);
  });

  it("an own key from the environment (BOX_TOKEN) wins and goes only to Boat", async () => {
    vi.stubEnv("BOX_TOKEN", "box_from_env");
    const cfg = loadConfig();
    expect(cfg.box?.token).toBe("box_from_env");
    await boat.execOnBoat(cfg, botId, "true");
    expect(underRelay()).toEqual([]);
    expect(requests.every((request) => request.path.startsWith("/boat/") && request.auth === "Bearer box_from_env")).toBe(true);
  });

  it("removing the own key falls back to the included one", async () => {
    const cfg: AppConfig = { box: { token: "box_own" } };
    await boat.execOnBoat(cfg, botId, "true");
    expect(underRelay()).toEqual([]);
    requests.length = 0;
    cfg.box!.token = "";
    await boat.execOnBoat(cfg, botId, "true");
    expect(underBoat()).toEqual([]);
    expect(requests.every((request) => request.auth === `Bearer ${INCLUDED}`)).toBe(true);
  });

  it("a cloud computer tool call with the included token still reaches only the relay", async () => {
    await boat.runCommand({ box: { token: boat.boatAccount({})!.token } }, boxId, "true");
    expect(requests).toHaveLength(1);
    expect(requests[0]).toMatchObject({ path: `/relay/api/box/v1/boxes/${boxId}/commands`, auth: `Bearer ${INCLUDED}` });
  });

  it("speaks v1 to Boat and the relay's own box dialect to a relay wired at /api/box/v1", async () => {
    await boat.execOnBoat({ box: { token: "box_own" } }, botId, "true");
    await boat.execOnBoat({}, botId, "true");
    expect(underBoat().every((request) => request.path.startsWith("/boat/api/v1/sandboxes"))).toBe(true);
    expect(underRelay().every((request) => request.path.startsWith("/relay/api/box/v1/boxes"))).toBe(true);
    for (const side of [underBoat(), underRelay()]) {
      expect(side.filter((request) => request.method === "POST" && request.path.endsWith(`/${boxId}/commands`))).toHaveLength(1);
    }
  });

  it("follows a delete through the box-dialect relay to completion", async () => {
    const owners = [{ botId, name: "Included", inUse: false }];
    await expect(boat.deleteManagedBoat({}, owners, boxId, machineName, undefined, { pollDelaysMs: [] })).resolves.toEqual({ ok: true });
    expect(requests.filter((request) => request.method === "DELETE").map((request) => request.path)).toEqual([`/relay/api/box/v1/boxes/${boxId}`]);
    expect(underBoat()).toEqual([]);
  });

  it("speaks only v1 with an own key, whatever its base is called", async () => {
    vi.stubEnv("OMB_BOAT_API", `${base}/boat/api/box/v1`);
    await boat.runCommand({ box: { token: "box_own" } }, boxId, "true");
    expect(requests).toEqual([{ method: "POST", path: `/boat/api/box/v1/sandboxes/${boxId}/commands`, auth: "Bearer box_own" }]);
  });

  it("speaks v1 to a relay once the Admin wires a v1 base", async () => {
    vi.stubEnv("OMB_CLOUD_BOAT_URL", `${base}/relay/api/v1`);
    await boat.runCommand({}, boxId, "true");
    expect(requests).toEqual([{ method: "POST", path: `/relay/api/v1/sandboxes/${boxId}/commands`, auth: `Bearer ${INCLUDED}` }]);
  });

  it("verifies a key being saved against Boat only, never the relay", async () => {
    expect(await boat.verifyToken("box_new")).toEqual({ ok: true });
    expect(requests).toEqual([{ method: "GET", path: "/boat/api/v1/sandboxes", auth: "Bearer box_new" }]);
  });

  it("tells a revoked Boat key (boat_, or box_ from before v1) from something that is no Boat key", async () => {
    rejectCredentials = true;
    const revoked = { ok: false, rejected: true, message: "boat.dev rejected that key — it may have been revoked or expired. Copy a fresh one from your boat.dev account." };
    expect(await boat.verifyToken("boat_revoked")).toEqual(revoked);
    expect(await boat.verifyToken("box_revoked")).toEqual(revoked);
    expect(await boat.verifyToken("sk-ant-not-a-boat-key")).toEqual({ ok: false, rejected: true, message: expect.stringMatching(/^That doesn't look like a Boat API key: they start with boat_/) });
  });

  it("does not call a key rejected when boat.dev could not check it", async () => {
    serverError = true;
    expect(await boat.verifyToken("boat_maybe")).toEqual({ ok: false, message: "boat.dev returned 500 for that token — try again in a moment." });
  });

  it("lists the computers with the included wording when the relay rejects its token", async () => {
    rejectCredentials = true;
    expect((await boat.listManagedBoats({}, [])).problem).toBe("The cloud computers included with your Cloud plan aren't available right now. Try again later.");
    expect((await boat.listManagedBoats({ box: { token: "box_own" } }, [])).problem).toMatch(/update it in Settings/);
  });

  it("lists again once after a dropped connection, and names MausBot Cloud's computer service when it stays unreachable", async () => {
    const lists = () => requests.filter((request) => request.path === "/relay/api/box/v1/boxes");
    dropLists = 1;
    await expect(boat.findBoat({}, "unlisted-bot")).resolves.toBeNull();
    expect(lists()).toHaveLength(2);
    dropLists = 2;
    await expect(boat.findBoat({}, "unlisted-bot")).rejects.toMatchObject({
      message: "Could not reach MausBot Cloud's computer service to list cloud computers — check your connection and refresh", status: 503, transient: true,
    });
    expect(lists()).toHaveLength(4);
    dropLists = 2;
    expect((await boat.listManagedBoats({ box: { token: "box_own" } }, [])).problem).toBe("Could not reach boat.dev to list cloud computers — check your connection and refresh");
    rejectCredentials = true;
    await expect(boat.findBoat({}, "unlisted-bot")).rejects.toMatchObject({ transient: false });
  });

  it("reports configured and included to Settings, never the token", () => {
    expect(boat.describeBoatAccount({})).toEqual({ configured: true, included: true });
    expect(boat.describeBoatAccount({ box: { token: "box_own" } })).toEqual({ configured: true });
    expect(JSON.stringify(boat.describeBoatAccount({}))).not.toContain(INCLUDED);
    vi.stubEnv("OMB_CLOUD_BOAT_TOKEN", undefined);
    expect(boat.boatConfigured({})).toBe(false);
    expect(boat.describeBoatAccount({})).toEqual({ configured: false });
  });
});

describe("waking a sleeping computer", () => {
  it("reports a refused resume with the relay's own words instead of waiting out the budget", async () => {
    state = "archived";
    resumeReplies = [{ status: 429, body: { ok: false, type: "sandbox.error", status: 429, code: "limit_reached", message: LIMIT, error: { code: "limit_reached", message: LIMIT, status: 429 } } }];
    const started = Date.now();
    await expect(boat.readyBoat({}, botId)).rejects.toThrow(LIMIT);
    await expect(boat.joinBoat({}, botId)).rejects.toThrow(LIMIT);
    expect(Date.now() - started).toBeLessThan(4_000);
    expect(requests.filter((request) => request.path.endsWith("/resume"))).toHaveLength(2);
    expect(underBoat()).toEqual([]);
  });

  it("keeps waiting through a conflict, which is a wake already under way", async () => {
    state = "archived";
    resumeReplies = [{ status: 409, body: { ok: false, code: "conflict", message: "already resuming" } }];
    await expect(boat.readyBoat({}, botId, 1)).resolves.toBeNull();
  });

  it("retries a server error on the next poll, as Boat asks, and wakes", async () => {
    state = "archived";
    resumeReplies = [
      { status: 503, body: { ok: false, code: "unavailable", message: "Boat is busy" } },
      { status: 202, body: { ok: true } },
    ];
    // Own key here: this is everyone's wake path, not only Cloud Pro's.
    await expect(boat.readyBoat({ box: { token: "box_own" } }, botId)).resolves.toMatchObject({ id: boxId, state: "idle" });
    expect(requests.filter((request) => request.path.endsWith("/resume"))).toHaveLength(2);
  }, 15_000);

  it("reports the last server error, not a bare timeout, when the wait runs out on it", async () => {
    state = "archived";
    resumeReplies = [{ status: 503, body: { ok: false, code: "unavailable", message: "Boat is busy" } }];
    // One poll fits the budget (each request ends with it, so it must leave
    // room for one read and one resume); the next would come after it.
    await expect(boat.readyBoat({ box: { token: "box_own" } }, botId, 2_000)).rejects.toThrow("waking the cloud computer failed: Boat is busy");
  });

  it("forgets a server error once a later resume is accepted", async () => {
    state = "archived";
    wakeOnResume = false;
    resumeReplies = [
      { status: 503, body: { ok: false, code: "unavailable", message: "Boat is busy" } },
      { status: 202, body: { ok: true } },
    ];
    // Two polls fit the budget: 503, then an accepted resume that is still
    // waking, with room left for the second poll's requests, which end with
    // the budget too.
    await expect(boat.readyBoat({ box: { token: "box_own" } }, botId, 4_000)).resolves.toBeNull();
    // A third poll can start when a timer fires a hair before the deadline;
    // what matters is that the 503 came first and was then forgotten.
    expect(requests.filter((request) => request.path.endsWith("/resume")).length).toBeGreaterThanOrEqual(2);
  }, 15_000);

  it("never asks the person to fix a key they never pasted when the relay rejects the included token", async () => {
    state = "archived";
    resumeReplies = [{ status: 401, body: { ok: false, code: "unauthorized", message: "This cloud computer key is not valid." } }];
    await expect(boat.readyBoat({}, botId)).rejects.toThrow("The cloud computers included with your Cloud plan aren't available right now. Try again later.");
    await expect(boat.readyBoat({ box: { token: "box_own" } }, botId)).rejects.toThrow("boat.dev rejected your Boat API key — paste a current one in Settings → API keys");
  });
});