server/boat-included.test.ts
Rama main · réplica de lectura del repositorio original.
// 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");
});
});