3034 lines
99 KiB
TypeScript
3034 lines
99 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
FleetDurableObject,
|
|
bridgeTicketFromRequest,
|
|
codeForwardHeaders,
|
|
codeResponseHeaders,
|
|
flushPendingWebVNC,
|
|
forwardOrBufferWebVNC,
|
|
resetWebVNCBridge,
|
|
shouldActivateEgressSession,
|
|
type WebVNCBuffer,
|
|
} from "../src/fleet";
|
|
import type {
|
|
Env,
|
|
ExternalRunnerRecord,
|
|
LeaseRecord,
|
|
ProvisioningAttempt,
|
|
RunRecord,
|
|
} from "../src/types";
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
class MemoryStorage {
|
|
private readonly values = new Map<string, unknown>();
|
|
|
|
async get<T>(key: string): Promise<T | undefined> {
|
|
return this.values.get(key) as T | undefined;
|
|
}
|
|
|
|
async put<T>(key: string, value: T): Promise<void> {
|
|
this.values.set(key, value);
|
|
}
|
|
|
|
async delete(key: string): Promise<void> {
|
|
this.values.delete(key);
|
|
}
|
|
|
|
async deleteAlarm(): Promise<void> {}
|
|
|
|
async setAlarm(_time: number): Promise<void> {}
|
|
|
|
async list<T>({ prefix = "" }: { prefix?: string } = {}): Promise<Map<string, T>> {
|
|
const matches = new Map<string, T>();
|
|
for (const [key, value] of this.values) {
|
|
if (key.startsWith(prefix)) {
|
|
matches.set(key, value as T);
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
seed<T>(key: string, value: T): void {
|
|
this.values.set(key, value);
|
|
}
|
|
|
|
value<T>(key: string): T | undefined {
|
|
return this.values.get(key) as T | undefined;
|
|
}
|
|
}
|
|
|
|
describe("fleet lease identity and idle", () => {
|
|
it("creates leases through the public route with slug and idle metadata", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage, {
|
|
hetzner: fakeProvider(),
|
|
});
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/leases", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
leaseID: "cbx_abcdef123456",
|
|
slug: "Blue Lobster",
|
|
provider: "hetzner",
|
|
class: "standard",
|
|
serverType: "cpx62",
|
|
ttlSeconds: 1200,
|
|
idleTimeoutSeconds: 360,
|
|
keep: true,
|
|
sshPublicKey: "ssh-ed25519 test",
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { lease } = (await create.json()) as { lease: LeaseRecord };
|
|
expect(lease.id).toBe("cbx_abcdef123456");
|
|
expect(lease.slug).toBe("blue-lobster");
|
|
expect(lease.idleTimeoutSeconds).toBe(360);
|
|
expect(lease.ttlSeconds).toBe(1200);
|
|
expect(lease.lastTouchedAt).toBeTruthy();
|
|
expect(Date.parse(lease.expiresAt)).toBeGreaterThan(Date.parse(lease.createdAt));
|
|
|
|
const bySlug = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(bySlug.status).toBe(200);
|
|
const found = (await bySlug.json()) as { lease: LeaseRecord };
|
|
expect(found.lease.id).toBe("cbx_abcdef123456");
|
|
expect(found.lease.slug).toBe("blue-lobster");
|
|
});
|
|
|
|
it("shares leases with explicit users or the owning org", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const ownerHeaders = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
const friendHeaders = {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
const strangerHeaders = {
|
|
"x-crabbox-owner": "stranger@example.com",
|
|
"x-crabbox-org": "elsewhere",
|
|
};
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
desktop: true,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
|
|
const hidden = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster", { headers: friendHeaders }),
|
|
);
|
|
expect(hidden.status).toBe(404);
|
|
|
|
const shared = await fleet.fetch(
|
|
request("PUT", "/v1/leases/blue-lobster/share", {
|
|
headers: ownerHeaders,
|
|
body: { users: { "Friend@Example.com": "use" } },
|
|
}),
|
|
);
|
|
expect(shared.status).toBe(200);
|
|
await expect(shared.json()).resolves.toMatchObject({
|
|
leaseID: "cbx_000000000001",
|
|
share: { users: { "friend@example.com": "use" } },
|
|
});
|
|
|
|
const friendLease = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster", { headers: friendHeaders }),
|
|
);
|
|
expect(friendLease.status).toBe(200);
|
|
await expect(friendLease.json()).resolves.toMatchObject({
|
|
lease: { id: "cbx_000000000001", share: { users: { "friend@example.com": "use" } } },
|
|
});
|
|
|
|
const friendTicket = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/webvnc/ticket", {
|
|
headers: friendHeaders,
|
|
body: {},
|
|
}),
|
|
);
|
|
expect(friendTicket.status).toBe(200);
|
|
|
|
const friendRelease = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/release", {
|
|
headers: friendHeaders,
|
|
body: {},
|
|
}),
|
|
);
|
|
expect(friendRelease.status).toBe(403);
|
|
|
|
const orgShared = await fleet.fetch(
|
|
request("PUT", "/v1/leases/blue-lobster/share", {
|
|
headers: ownerHeaders,
|
|
body: { users: { "friend@example.com": "use" }, org: "manage" },
|
|
}),
|
|
);
|
|
expect(orgShared.status).toBe(200);
|
|
await expect(orgShared.json()).resolves.toMatchObject({
|
|
share: { users: { "friend@example.com": "use" }, org: "manage" },
|
|
});
|
|
|
|
const friendSharePage = await fleet.fetch(
|
|
request("GET", "/portal/leases/blue-lobster/share", { headers: friendHeaders }),
|
|
);
|
|
expect(friendSharePage.status).toBe(200);
|
|
const friendShareBody = await friendSharePage.text();
|
|
expect(friendShareBody).toContain("share blue-lobster");
|
|
expect(friendShareBody).toContain("share-shell");
|
|
expect(friendShareBody).toContain("back to lease");
|
|
expect(friendShareBody).toContain('class="button action" type="submit">save</button>');
|
|
expect(friendShareBody).toContain('class="button action" type="submit">add</button>');
|
|
|
|
const embeddedSharePage = await fleet.fetch(
|
|
request("GET", "/portal/leases/blue-lobster/share?embed=1", { headers: friendHeaders }),
|
|
);
|
|
expect(embeddedSharePage.status).toBe(200);
|
|
expect(embeddedSharePage.headers.get("content-security-policy")).toContain(
|
|
"frame-ancestors 'self'",
|
|
);
|
|
const embeddedShareBody = await embeddedSharePage.text();
|
|
expect(embeddedShareBody).toContain("share-shell-embedded");
|
|
expect(embeddedShareBody).toContain("/portal/leases/cbx_000000000001/share?embed=1");
|
|
expect(embeddedShareBody).not.toContain("back to lease");
|
|
|
|
const stranger = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster", { headers: strangerHeaders }),
|
|
);
|
|
expect(stranger.status).toBe(404);
|
|
});
|
|
|
|
it("mints brokered Tailscale keys, records non-secret metadata, and accepts readiness updates", async () => {
|
|
const storage = new MemoryStorage();
|
|
let providerConfig:
|
|
| {
|
|
tailscale?: boolean;
|
|
tailscaleAuthKey?: string;
|
|
tailscaleHostname?: string;
|
|
tailscaleTags?: string[];
|
|
tailscaleExitNode?: string;
|
|
tailscaleExitNodeAllowLanAccess?: boolean;
|
|
}
|
|
| undefined;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "https://api.tailscale.com/api/v2/oauth/token") {
|
|
return jsonResponse({ access_token: "oauth-token" });
|
|
}
|
|
if (url === "https://api.tailscale.com/api/v2/tailnet/-/keys") {
|
|
return jsonResponse({ key: "tskey-oneoff" });
|
|
}
|
|
return jsonResponse({ message: `unexpected ${url}` }, 500);
|
|
}),
|
|
);
|
|
const fleet = testFleet(
|
|
storage,
|
|
{
|
|
hetzner: fakeProvider((config) => {
|
|
providerConfig = config;
|
|
}),
|
|
},
|
|
{
|
|
CRABBOX_TAILSCALE_CLIENT_ID: "client-id",
|
|
CRABBOX_TAILSCALE_CLIENT_SECRET: "client-secret",
|
|
CRABBOX_TAILSCALE_TAGS: "tag:crabbox,tag:ci",
|
|
},
|
|
);
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/leases", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
leaseID: "cbx_abcdef123456",
|
|
slug: "Blue Lobster",
|
|
provider: "hetzner",
|
|
tailscale: true,
|
|
tailscaleTags: ["tag:ci"],
|
|
tailscaleHostname: "crabbox-{slug}",
|
|
tailscaleExitNode: "mac-studio.tailnet.ts.net",
|
|
tailscaleExitNodeAllowLanAccess: true,
|
|
sshPublicKey: "ssh-ed25519 test",
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { lease } = (await create.json()) as { lease: LeaseRecord };
|
|
expect(lease.tailscale).toEqual({
|
|
enabled: true,
|
|
hostname: "crabbox-blue-lobster",
|
|
tags: ["tag:ci"],
|
|
state: "requested",
|
|
exitNode: "mac-studio.tailnet.ts.net",
|
|
exitNodeAllowLanAccess: true,
|
|
});
|
|
expect(JSON.stringify(lease)).not.toContain("tskey-oneoff");
|
|
expect(providerConfig).toMatchObject({
|
|
tailscale: true,
|
|
tailscaleAuthKey: "tskey-oneoff",
|
|
tailscaleHostname: "crabbox-blue-lobster",
|
|
tailscaleTags: ["tag:ci"],
|
|
tailscaleExitNode: "mac-studio.tailnet.ts.net",
|
|
tailscaleExitNodeAllowLanAccess: true,
|
|
});
|
|
|
|
const update = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/tailscale", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
enabled: true,
|
|
hostname: "crabbox-blue-lobster",
|
|
fqdn: "crabbox-blue-lobster.example.ts.net",
|
|
ipv4: "100.64.0.10",
|
|
exitNode: "mac-studio.tailnet.ts.net",
|
|
exitNodeAllowLanAccess: true,
|
|
state: "ready",
|
|
},
|
|
}),
|
|
);
|
|
expect(update.status).toBe(200);
|
|
const updated = (await update.json()) as { lease: LeaseRecord };
|
|
expect(updated.lease.tailscale?.ipv4).toBe("100.64.0.10");
|
|
expect(updated.lease.tailscale?.exitNode).toBe("mac-studio.tailnet.ts.net");
|
|
expect(updated.lease.tailscale?.state).toBe("ready");
|
|
});
|
|
|
|
it("rejects brokered Tailscale tags outside the coordinator allowlist", async () => {
|
|
const fleet = testFleet(
|
|
new MemoryStorage(),
|
|
{ hetzner: fakeProvider() },
|
|
{
|
|
CRABBOX_TAILSCALE_CLIENT_ID: "client-id",
|
|
CRABBOX_TAILSCALE_CLIENT_SECRET: "client-secret",
|
|
CRABBOX_TAILSCALE_TAGS: "tag:crabbox",
|
|
},
|
|
);
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/leases", {
|
|
body: {
|
|
leaseID: "cbx_abcdef123456",
|
|
provider: "hetzner",
|
|
tailscale: true,
|
|
tailscaleTags: ["tag:prod"],
|
|
sshPublicKey: "ssh-ed25519 test",
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(400);
|
|
await expect(create.json()).resolves.toMatchObject({
|
|
error: "invalid_tailscale_tags",
|
|
message: "tailscale tags not allowed: tag:prod",
|
|
});
|
|
});
|
|
|
|
it("reports brokered Tailscale disabled when OAuth secrets are absent", async () => {
|
|
const fleet = testFleet(new MemoryStorage(), { hetzner: fakeProvider() });
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/leases", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
leaseID: "cbx_abcdef123456",
|
|
provider: "hetzner",
|
|
tailscale: true,
|
|
sshPublicKey: "ssh-ed25519 test",
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(403);
|
|
await expect(create.json()).resolves.toMatchObject({
|
|
error: "tailscale_disabled",
|
|
message: "Tailscale is disabled for this coordinator",
|
|
});
|
|
});
|
|
|
|
it("passes the Cloudflare request source IP as AWS SSH ingress CIDR", async () => {
|
|
let awsCIDRs: string[] = [];
|
|
const fleet = testFleet(new MemoryStorage(), {
|
|
aws: fakeProvider((config) => {
|
|
awsCIDRs = config.awsSSHCIDRs;
|
|
}),
|
|
});
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/leases", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"cf-connecting-ip": "203.0.113.7",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
leaseID: "cbx_abcdef123456",
|
|
provider: "aws",
|
|
class: "standard",
|
|
serverType: "c7a.8xlarge",
|
|
ttlSeconds: 1200,
|
|
idleTimeoutSeconds: 360,
|
|
sshPublicKey: "ssh-ed25519 test",
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
expect(awsCIDRs).toEqual(["203.0.113.7/32"]);
|
|
});
|
|
|
|
it("honors requested AWS SSH ingress CIDRs over request source IP", async () => {
|
|
let awsCIDRs: string[] = [];
|
|
const fleet = testFleet(new MemoryStorage(), {
|
|
aws: fakeProvider((config) => {
|
|
awsCIDRs = config.awsSSHCIDRs;
|
|
}),
|
|
});
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/leases", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"cf-connecting-ip": "203.0.113.7",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
leaseID: "cbx_abcdef123456",
|
|
provider: "aws",
|
|
class: "standard",
|
|
serverType: "c7a.8xlarge",
|
|
awsSSHCIDRs: ["198.51.100.0/24"],
|
|
ttlSeconds: 1200,
|
|
idleTimeoutSeconds: 360,
|
|
sshPublicKey: "ssh-ed25519 test",
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
expect(awsCIDRs).toEqual(["198.51.100.0/24"]);
|
|
});
|
|
|
|
it("records requested type and provider fallback attempts on resolved leases", async () => {
|
|
const attempts: ProvisioningAttempt[] = [
|
|
{
|
|
region: "eu-west-1",
|
|
serverType: "c7a.48xlarge",
|
|
market: "spot",
|
|
category: "quota",
|
|
message: "quota L-34B43A08 in eu-west-1 is 64 vCPUs; c7a.48xlarge needs 192 vCPUs",
|
|
},
|
|
];
|
|
const fleet = testFleet(new MemoryStorage(), {
|
|
aws: fakeProvider(undefined, {
|
|
provider: "aws",
|
|
serverType: "c7i.24xlarge",
|
|
cloudID: "i-123",
|
|
market: "on-demand",
|
|
attempts,
|
|
}),
|
|
});
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/leases", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
leaseID: "cbx_abcdef123456",
|
|
provider: "aws",
|
|
class: "beast",
|
|
serverType: "c7a.48xlarge",
|
|
ttlSeconds: 1200,
|
|
idleTimeoutSeconds: 360,
|
|
sshPublicKey: "ssh-ed25519 test",
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { lease } = (await create.json()) as { lease: LeaseRecord };
|
|
expect(lease.requestedServerType).toBe("c7a.48xlarge");
|
|
expect(lease.serverType).toBe("c7i.24xlarge");
|
|
expect(lease.market).toBe("on-demand");
|
|
expect(lease.provisioningAttempts).toEqual(attempts);
|
|
expect(lease.capacityHints?.map((hint) => hint.code)).toEqual([
|
|
"aws_capacity_routed",
|
|
"aws_quota_pressure",
|
|
"aws_on_demand_fallback",
|
|
"capacity_large_class",
|
|
]);
|
|
expect(lease.capacityHints?.[0]?.regionsTried).toEqual(["eu-west-1", "eu-west-2"]);
|
|
});
|
|
|
|
it("scopes non-admin usage to the current owner", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
estimatedHourlyUSD: 1,
|
|
maxEstimatedUSD: 1,
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000002",
|
|
testLease({
|
|
id: "cbx_000000000002",
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
estimatedHourlyUSD: 1,
|
|
maxEstimatedUSD: 1,
|
|
}),
|
|
);
|
|
const usage = await fleet.fetch(
|
|
request("GET", "/v1/usage?scope=all&owner=peter@example.com", {
|
|
headers: {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(usage.status).toBe(200);
|
|
const body = (await usage.json()) as {
|
|
usage: { scope: string; owner: string; leases: number };
|
|
};
|
|
expect(body.usage.scope).toBe("user");
|
|
expect(body.usage.owner).toBe("friend@example.com");
|
|
expect(body.usage.leases).toBe(1);
|
|
});
|
|
|
|
it("resolves owner-scoped slugs and heartbeat extends idle expiry", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const touchedAt = new Date(Date.now() - 10 * 60 * 1000);
|
|
const expiresAt = new Date(touchedAt.getTime() + 1800 * 1000);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
createdAt: touchedAt.toISOString(),
|
|
updatedAt: touchedAt.toISOString(),
|
|
lastTouchedAt: touchedAt.toISOString(),
|
|
ttlSeconds: 5400,
|
|
idleTimeoutSeconds: 1800,
|
|
expiresAt: expiresAt.toISOString(),
|
|
}),
|
|
);
|
|
|
|
const heartbeat = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/heartbeat", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
idleTimeoutSeconds: 2400,
|
|
telemetry: {
|
|
capturedAt: "2026-05-05T01:02:03Z",
|
|
source: "ssh-linux",
|
|
load1: 0.42,
|
|
memoryUsedBytes: 1024,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 50,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(heartbeat.status).toBe(200);
|
|
const { lease } = (await heartbeat.json()) as { lease: LeaseRecord };
|
|
expect(lease.id).toBe("cbx_000000000001");
|
|
expect(lease.slug).toBe("blue-lobster");
|
|
expect(lease.idleTimeoutSeconds).toBe(2400);
|
|
expect(lease.telemetry).toMatchObject({
|
|
capturedAt: "2026-05-05T01:02:03.000Z",
|
|
source: "ssh-linux",
|
|
load1: 0.42,
|
|
memoryUsedBytes: 1024,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 50,
|
|
});
|
|
expect(lease.telemetryHistory).toHaveLength(1);
|
|
expect(lease.telemetryHistory?.[0]).toMatchObject({ load1: 0.42, memoryPercent: 50 });
|
|
expect(Date.parse(lease.expiresAt)).toBeGreaterThan(expiresAt.getTime());
|
|
|
|
const secondHeartbeat = await fleet.fetch(
|
|
request("POST", "/v1/leases/cbx_000000000001/heartbeat", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
telemetry: {
|
|
capturedAt: "2026-05-05T01:03:03Z",
|
|
source: "ssh-linux",
|
|
load1: 0.84,
|
|
memoryPercent: 55,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(secondHeartbeat.status).toBe(200);
|
|
const second = (await secondHeartbeat.json()) as { lease: LeaseRecord };
|
|
expect(second.lease.telemetry).toMatchObject({
|
|
capturedAt: "2026-05-05T01:03:03.000Z",
|
|
load1: 0.84,
|
|
memoryPercent: 55,
|
|
});
|
|
expect(second.lease.telemetryHistory?.map((sample) => sample.load1)).toEqual([0.42, 0.84]);
|
|
expect(second.lease.telemetryHistory?.map((sample) => sample.capturedAt)).toEqual([
|
|
"2026-05-05T01:02:03.000Z",
|
|
"2026-05-05T01:03:03.000Z",
|
|
]);
|
|
});
|
|
|
|
it("keeps lease telemetry history bounded to the latest samples", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
telemetryHistory: Array.from({ length: 60 }, (_, index) => ({
|
|
capturedAt: new Date(Date.UTC(2026, 4, 5, 1, index, 0)).toISOString(),
|
|
source: "ssh-linux",
|
|
load1: index,
|
|
})),
|
|
}),
|
|
);
|
|
|
|
const heartbeat = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/heartbeat", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
telemetry: {
|
|
capturedAt: "2026-05-05T02:00:00Z",
|
|
source: "ssh-linux",
|
|
load1: 61,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(heartbeat.status).toBe(200);
|
|
const { lease } = (await heartbeat.json()) as { lease: LeaseRecord };
|
|
expect(lease.telemetryHistory).toHaveLength(60);
|
|
expect(lease.telemetryHistory?.[0]?.capturedAt).toBe("2026-05-05T01:01:00.000Z");
|
|
expect(lease.telemetryHistory?.at(-1)).toMatchObject({
|
|
capturedAt: "2026-05-05T02:00:00.000Z",
|
|
load1: 61,
|
|
});
|
|
});
|
|
|
|
it("hides exact lease IDs and lists from other non-admin users", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000002",
|
|
testLease({
|
|
id: "cbx_000000000002",
|
|
slug: "amber-krill",
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
const friendHeaders = {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
|
|
const byExactID = await fleet.fetch(
|
|
request("GET", "/v1/leases/cbx_000000000001", { headers: friendHeaders }),
|
|
);
|
|
expect(byExactID.status).toBe(404);
|
|
|
|
const heartbeat = await fleet.fetch(
|
|
request("POST", "/v1/leases/cbx_000000000001/heartbeat", {
|
|
headers: friendHeaders,
|
|
body: {},
|
|
}),
|
|
);
|
|
expect(heartbeat.status).toBe(404);
|
|
|
|
const list = await fleet.fetch(request("GET", "/v1/leases", { headers: friendHeaders }));
|
|
const body = (await list.json()) as { leases: LeaseRecord[] };
|
|
expect(body.leases.map((lease) => lease.id)).toEqual(["cbx_000000000002"]);
|
|
});
|
|
|
|
it("renders the portal with only the current owner leases", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
desktop: true,
|
|
code: true,
|
|
telemetry: {
|
|
capturedAt: new Date(Date.now() - 15_000).toISOString(),
|
|
source: "ssh-linux",
|
|
load1: 0.42,
|
|
load5: 0.24,
|
|
load15: 0.12,
|
|
memoryUsedBytes: 1024,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 50,
|
|
diskUsedBytes: 1024 * 1024 * 1024,
|
|
diskTotalBytes: 4 * 1024 * 1024 * 1024,
|
|
diskPercent: 25,
|
|
uptimeSeconds: 3600,
|
|
},
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000002",
|
|
testLease({
|
|
id: "cbx_000000000002",
|
|
slug: "amber-krill",
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
desktop: true,
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000003",
|
|
testLease({
|
|
id: "cbx_000000000003",
|
|
slug: "old-clam",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
desktop: true,
|
|
code: true,
|
|
state: "released",
|
|
releasedAt: "2026-05-01T00:20:00.000Z",
|
|
endedAt: "2026-05-01T00:20:00.000Z",
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000004",
|
|
testLease({
|
|
id: "cbx_000000000004",
|
|
slug: "silver-window",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
provider: "aws",
|
|
target: "windows",
|
|
windowsMode: "normal",
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000005",
|
|
testLease({
|
|
id: "cbx_000000000005",
|
|
slug: "wsl-window",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
provider: "aws",
|
|
target: "windows",
|
|
windowsMode: "wsl2",
|
|
}),
|
|
);
|
|
await fleet.fetch(
|
|
request("POST", "/v1/runners/sync", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
provider: "blacksmith-testbox",
|
|
runners: [
|
|
{
|
|
id: "tbx_01testbox",
|
|
status: "ready",
|
|
repo: "openclaw",
|
|
workflow: ".github/workflows/ci-check-testbox.yml",
|
|
job: "check",
|
|
ref: "main",
|
|
createdAt: "2026-05-05T10:00:00.000Z",
|
|
actionsRepo: "openclaw/openclaw",
|
|
actionsRunID: "123456",
|
|
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
|
|
actionsRunStatus: "in_progress",
|
|
actionsWorkflowName: "ci-check-testbox",
|
|
actionsWorkflowURL:
|
|
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
await fleet.fetch(
|
|
request("POST", "/v1/runners/sync", {
|
|
headers: {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
provider: "blacksmith-testbox",
|
|
runners: [
|
|
{
|
|
id: "tbx_friendbox",
|
|
status: "ready",
|
|
repo: "openclaw",
|
|
workflow: ".github/workflows/ci-check-testbox.yml",
|
|
job: "check",
|
|
ref: "main",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const response = await fleet.fetch(
|
|
request("GET", "/portal", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = await response.text();
|
|
expect(body).toContain('class="portal-shell"');
|
|
expect(body).toContain("<h1>🦀 crabbox</h1>");
|
|
expect(body).toContain('class="portal-actions"');
|
|
expect(body).toContain("table-scroll");
|
|
expect(body).toContain(".lease-table th:nth-child(1)");
|
|
expect(body).toContain(
|
|
'data-filter-buttons="active:active,ended:ended,external:external,stale:stale,stuck:stuck,aws:aws,hetzner:hetzner,blacksmith-testbox:blacksmith,linux:linux,macos:macos,windows:windows,all:all"',
|
|
);
|
|
expect(body).toContain('data-filter-default="active"');
|
|
expect(body).not.toContain("external runners");
|
|
expect(body).toContain("1 external");
|
|
expect(body).toContain('class="external-row"');
|
|
expect(body).toContain("no box access");
|
|
expect(body).toContain("stuck");
|
|
expect(body).toContain(
|
|
'data-filter-tags="active stuck actions mine external blacksmith-testbox ready in_progress',
|
|
);
|
|
expect(body).toContain("tbx_01testbox");
|
|
expect(body).toContain("/portal/runners/blacksmith-testbox/tbx_01testbox");
|
|
expect(body).toContain("blacksmith-testbox");
|
|
expect(body).toContain("ci-check-testbox.yml");
|
|
expect(body).toContain("https://github.com/openclaw/openclaw/actions/runs/123456");
|
|
expect(body).toContain(
|
|
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
|
|
);
|
|
expect(body).toContain('class="row-link"');
|
|
expect(body).toContain(
|
|
'data-copy-value="crabbox stop --provider blacksmith-testbox tbx_01testbox"',
|
|
);
|
|
expect(body).not.toContain("tbx_friendbox");
|
|
expect(body).toContain('data-provider="hetzner"');
|
|
expect(body).toContain('data-target="linux"');
|
|
expect(body).toContain('data-target="windows"');
|
|
expect(body).toContain("<span>win</span>");
|
|
expect(body).toContain("<span>win (wsl2)</span>");
|
|
expect(body).toContain('data-filter-tags="active mine hetzner linux"');
|
|
expect(body).toContain('class="access-cell"');
|
|
expect(body).toContain('title="server"');
|
|
expect(body).toContain('data-access="vscode"');
|
|
expect(body).toContain('data-access="vnc"');
|
|
expect(body).toContain("data-sort=");
|
|
expect(body).toContain("<time datetime=");
|
|
expect(body).not.toContain("windows / normal");
|
|
expect(body).toContain("blue-lobster");
|
|
expect(body).toContain("old-clam");
|
|
expect(body).toContain("released");
|
|
expect(body).toContain("/portal/leases/cbx_000000000001");
|
|
expect(body).toContain("/portal/leases/cbx_000000000001/vnc");
|
|
expect(body).toContain("/portal/leases/cbx_000000000001/code/");
|
|
expect(body).not.toContain("amber-krill");
|
|
});
|
|
|
|
it("syncs external runner visibility and marks missing runners stale", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const headers = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
|
|
const sync = await fleet.fetch(
|
|
request("POST", "/v1/runners/sync", {
|
|
headers,
|
|
body: {
|
|
provider: "blacksmith-testbox",
|
|
runners: [
|
|
{
|
|
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
|
|
status: "ready",
|
|
repo: "openclaw",
|
|
workflow: ".github/workflows/ci-check-testbox.yml",
|
|
job: "check",
|
|
ref: "main",
|
|
createdAt: "2026-05-06T09:45:16.000000Z",
|
|
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
|
|
actionsWorkflowURL:
|
|
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(sync.status).toBe(200);
|
|
const synced = (await sync.json()) as { runners: ExternalRunnerRecord[] };
|
|
expect(synced.runners).toHaveLength(1);
|
|
expect(synced.runners[0]).toMatchObject({
|
|
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
|
|
provider: "blacksmith-testbox",
|
|
status: "ready",
|
|
repo: "openclaw",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
|
|
});
|
|
|
|
const friendList = await fleet.fetch(
|
|
request("GET", "/v1/runners", {
|
|
headers: {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
const friendBody = (await friendList.json()) as { runners: ExternalRunnerRecord[] };
|
|
expect(friendBody.runners).toHaveLength(0);
|
|
|
|
const staleSync = await fleet.fetch(
|
|
request("POST", "/v1/runners/sync", {
|
|
headers,
|
|
body: {
|
|
provider: "blacksmith-testbox",
|
|
runners: [],
|
|
},
|
|
}),
|
|
);
|
|
expect(staleSync.status).toBe(200);
|
|
const staleBody = (await staleSync.json()) as { stale: ExternalRunnerRecord[] };
|
|
expect(staleBody.stale).toHaveLength(1);
|
|
expect(staleBody.stale[0]).toMatchObject({
|
|
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
|
|
status: "missing",
|
|
stale: true,
|
|
});
|
|
});
|
|
|
|
it("renders external runner detail pages for visible runners", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const headers = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
const sync = await fleet.fetch(
|
|
request("POST", "/v1/runners/sync", {
|
|
headers,
|
|
body: {
|
|
provider: "blacksmith-testbox",
|
|
runners: [
|
|
{
|
|
id: "tbx_detail",
|
|
status: "ready",
|
|
repo: "openclaw",
|
|
workflow: ".github/workflows/ci-check-testbox.yml",
|
|
job: "check",
|
|
ref: "main",
|
|
createdAt: "2026-05-06T09:45:16.000000Z",
|
|
actionsRepo: "openclaw/openclaw",
|
|
actionsRunID: "123456",
|
|
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
|
|
actionsRunStatus: "queued",
|
|
actionsWorkflowName: "Blacksmith Testbox",
|
|
actionsWorkflowURL:
|
|
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(sync.status).toBe(200);
|
|
|
|
const detail = await fleet.fetch(
|
|
request("GET", "/portal/runners/blacksmith-testbox/tbx_detail", { headers }),
|
|
);
|
|
expect(detail.status).toBe(200);
|
|
const body = await detail.text();
|
|
expect(body).toContain("tbx_detail");
|
|
expect(body).toContain("actions owner");
|
|
expect(body).toContain("Blacksmith Testbox");
|
|
expect(body).toContain("https://github.com/openclaw/openclaw/actions/runs/123456");
|
|
expect(body).toContain("crabbox stop --provider blacksmith-testbox tbx_detail");
|
|
expect(body).toContain("visibility only");
|
|
|
|
const hidden = await fleet.fetch(
|
|
request("GET", "/portal/runners/blacksmith-testbox/tbx_detail", {
|
|
headers: {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(hidden.status).toBe(404);
|
|
});
|
|
|
|
it("shows non-owned runner leases only in the admin portal", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000002",
|
|
testLease({
|
|
id: "cbx_000000000002",
|
|
slug: "testbox-runner",
|
|
owner: "blacksmith",
|
|
org: "openclaw",
|
|
provider: "aws",
|
|
class: "standard",
|
|
}),
|
|
);
|
|
|
|
const userResponse = await fleet.fetch(
|
|
request("GET", "/portal", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
const userBody = await userResponse.text();
|
|
expect(userBody).toContain("blue-lobster");
|
|
expect(userBody).not.toContain("testbox-runner");
|
|
expect(userBody).not.toContain("system:system");
|
|
|
|
const adminResponse = await fleet.fetch(
|
|
request("GET", "/portal", {
|
|
headers: {
|
|
"x-crabbox-admin": "true",
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(adminResponse.status).toBe(200);
|
|
const adminBody = await adminResponse.text();
|
|
expect(adminBody).toContain("blue-lobster");
|
|
expect(adminBody).toContain("testbox-runner");
|
|
expect(adminBody).toContain("1 system");
|
|
expect(adminBody).toContain("mine:mine,system:system");
|
|
expect(adminBody).toContain('data-filter-tags="active mine hetzner linux"');
|
|
expect(adminBody).toContain('data-filter-tags="active system aws linux"');
|
|
expect(adminBody).toContain("cbx_000000000002 · blacksmith");
|
|
|
|
const detail = await fleet.fetch(
|
|
request("GET", "/portal/leases/cbx_000000000002", {
|
|
headers: {
|
|
"x-crabbox-admin": "true",
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(detail.status).toBe(200);
|
|
});
|
|
|
|
it("defaults the portal lease table to all leases when none are active", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000003",
|
|
testLease({
|
|
id: "cbx_000000000003",
|
|
slug: "old-clam",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
state: "expired",
|
|
endedAt: "2026-05-01T00:20:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const response = await fleet.fetch(
|
|
request("GET", "/portal", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = await response.text();
|
|
expect(body).toContain('data-filter-default="all"');
|
|
expect(body).toContain("old-clam");
|
|
expect(body).toContain("expired");
|
|
expect(body).not.toContain("no leases visible");
|
|
});
|
|
|
|
it("renders lease detail pages with run logs and stop controls", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage, { hetzner: fakeProvider() });
|
|
const headers = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
desktop: true,
|
|
code: true,
|
|
telemetry: {
|
|
capturedAt: new Date(Date.now() - 15_000).toISOString(),
|
|
source: "ssh-linux",
|
|
load1: 0.42,
|
|
load5: 0.24,
|
|
load15: 0.12,
|
|
memoryUsedBytes: 1024,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 50,
|
|
diskUsedBytes: 1024 * 1024 * 1024,
|
|
diskTotalBytes: 4 * 1024 * 1024 * 1024,
|
|
diskPercent: 25,
|
|
uptimeSeconds: 3600,
|
|
},
|
|
telemetryHistory: [
|
|
{
|
|
capturedAt: new Date(Date.now() - 45_000).toISOString(),
|
|
source: "ssh-linux",
|
|
load1: 0.22,
|
|
memoryPercent: 42,
|
|
diskPercent: 24,
|
|
},
|
|
{
|
|
capturedAt: new Date(Date.now() - 30_000).toISOString(),
|
|
source: "ssh-linux",
|
|
load1: 0.32,
|
|
memoryPercent: 47,
|
|
diskPercent: 25,
|
|
},
|
|
],
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"run:run_000000000001",
|
|
testRun({
|
|
id: "run_000000000001",
|
|
leaseID: "cbx_000000000001",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
command: ["go", "test", "./..."],
|
|
state: "failed",
|
|
phase: "failed",
|
|
exitCode: 1,
|
|
durationMs: 1234,
|
|
logBytes: 11,
|
|
telemetry: {
|
|
start: {
|
|
capturedAt: "2026-05-01T00:00:00.000Z",
|
|
source: "ssh-linux",
|
|
load1: 0.12,
|
|
memoryUsedBytes: 1024,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 50,
|
|
diskUsedBytes: 1024 * 1024,
|
|
diskTotalBytes: 4 * 1024 * 1024,
|
|
diskPercent: 25,
|
|
},
|
|
end: {
|
|
capturedAt: "2026-05-01T00:00:02.000Z",
|
|
source: "ssh-linux",
|
|
load1: 0.42,
|
|
load5: 0.24,
|
|
load15: 0.12,
|
|
memoryUsedBytes: 1536,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 75,
|
|
diskUsedBytes: 2 * 1024 * 1024,
|
|
diskTotalBytes: 4 * 1024 * 1024,
|
|
diskPercent: 50,
|
|
},
|
|
},
|
|
results: {
|
|
format: "junit",
|
|
files: ["junit.xml"],
|
|
suites: 1,
|
|
tests: 2,
|
|
failures: 1,
|
|
errors: 0,
|
|
skipped: 0,
|
|
timeSeconds: 0.42,
|
|
failed: [
|
|
{
|
|
suite: "portal",
|
|
name: "renders detail",
|
|
message: "expected detail page",
|
|
kind: "failure",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"run:run_000000000002",
|
|
testRun({
|
|
id: "run_000000000002",
|
|
leaseID: "cbx_000000000001",
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
storage.seed("runlog:run_000000000001", "portal log\n");
|
|
storage.seed("runevent:run_000000000001:000000000001", {
|
|
runID: "run_000000000001",
|
|
seq: 1,
|
|
type: "command.finished",
|
|
phase: "failed",
|
|
createdAt: "2026-05-01T00:00:01.000Z",
|
|
});
|
|
|
|
const page = await fleet.fetch(request("GET", "/portal/leases/blue-lobster", { headers }));
|
|
expect(page.status).toBe(200);
|
|
const body = await page.text();
|
|
expect(body).toContain("crabbox ssh --id blue-lobster");
|
|
expect(body).toContain("crabbox run --id blue-lobster -- <command>");
|
|
expect(body).toContain(
|
|
"crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
|
);
|
|
expect(body).toContain("crabbox code --id blue-lobster --open");
|
|
expect(body).toContain("data-copy-command");
|
|
expect(body).toContain('querySelector("code")');
|
|
expect(body).toContain('class="portal-shell lease-shell"');
|
|
expect(body).toContain("<h1>🦀 crabbox</h1>");
|
|
expect(body).toContain("blue-lobster · hetzner linux lease");
|
|
expect(body).toContain('data-search-placeholder="search runs"');
|
|
expect(body).toContain(
|
|
'data-filter-buttons="succeeded:succeeded,failed:failed,running:running,all:all"',
|
|
);
|
|
expect(body).not.toContain("<th>phase</th>");
|
|
expect(body).not.toContain("<th>log</th>");
|
|
expect(body).toContain('title="2026-05-01T00:00:00Z"');
|
|
expect(body).toContain('data-provider="hetzner"');
|
|
expect(body).toContain('data-target="linux"');
|
|
expect(body).toContain("<dt>load</dt><dd>0.42 / 0.24 / 0.12</dd>");
|
|
expect(body).toContain("<dt>memory</dt><dd>1.0 KiB / 2.0 KiB (50%)</dd>");
|
|
expect(body).toContain("<dt>disk</dt><dd>1.0 GiB / 4.0 GiB (25%)</dd>");
|
|
expect(body).toContain("<dt>uptime</dt><dd>1h</dd>");
|
|
expect(body).toContain("box telemetry");
|
|
expect(body).toContain('class="telemetry-chart"');
|
|
expect(body).toContain("<span>0.42</span>");
|
|
expect(body).toContain("<span>50%</span>");
|
|
expect(body).toContain("load 0.42 · mem 75% · +512 B");
|
|
expect(body).toContain("table-search");
|
|
expect(body).toContain("/portal/runs/run_000000000001");
|
|
expect(body).toContain("/portal/runs/run_000000000001/logs");
|
|
expect(body).toContain("/portal/runs/run_000000000001/events");
|
|
expect(body).toContain("/portal/leases/cbx_000000000001/release");
|
|
expect(body).not.toContain("run_000000000002");
|
|
|
|
const logs = await fleet.fetch(
|
|
request("GET", "/portal/runs/run_000000000001/logs", { headers }),
|
|
);
|
|
expect(logs.status).toBe(200);
|
|
expect(logs.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
|
expect(await logs.text()).toBe("portal log\n");
|
|
|
|
const runPage = await fleet.fetch(request("GET", "/portal/runs/run_000000000001", { headers }));
|
|
expect(runPage.status).toBe(200);
|
|
expect(runPage.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
const runBody = await runPage.text();
|
|
expect(runBody).toContain('class="portal-shell run-shell"');
|
|
expect(runBody).toContain('class="panel detail-card run-summary-card"');
|
|
expect(runBody).toContain(
|
|
".run-shell .meta-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }",
|
|
);
|
|
expect(runBody).toContain("<h1>🦀 crabbox</h1>");
|
|
expect(runBody).toContain(
|
|
".portal-header-meta { flex:1 1 auto; min-width:0; overflow:hidden; }",
|
|
);
|
|
expect(runBody).toContain(".command-row > div { min-width:0; overflow:hidden; }");
|
|
expect(runBody).toContain("run_000000000001 · cbx_000000000001 · failed");
|
|
expect(runBody).not.toContain('<span class="mono">go test ./...</span>');
|
|
expect(runBody).toContain("run_000000000001");
|
|
expect(runBody).toContain("go test ./...");
|
|
expect(runBody).toContain("data-copy-command");
|
|
expect(runBody).toContain("portal log");
|
|
expect(runBody).toContain('data-copy-target="#run-log-tail"');
|
|
expect(runBody).toContain('data-search-placeholder="search events"');
|
|
expect(runBody).toContain(
|
|
'data-filter-buttons="run:run,command:command,sync:sync,stdout:stdout,stderr:stderr,all:all"',
|
|
);
|
|
expect(runBody).toContain('data-filter-tags="command failed"');
|
|
expect(runBody).toContain('class="run-telemetry-grid"');
|
|
expect(runBody).toContain(".run-artifact-card .button { width:100%; }");
|
|
expect(runBody).toContain("@media (max-width: 980px)");
|
|
expect(runBody).toContain(
|
|
".run-telemetry-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }",
|
|
);
|
|
expect(runBody).toContain("<span>load</span>");
|
|
expect(runBody).toContain("<strong>0.42 / 0.24 / 0.12</strong>");
|
|
expect(runBody).toContain("<strong>1.5 KiB / 2.0 KiB (75%)</strong>");
|
|
expect(runBody).toContain("<small>delta +512 B</small>");
|
|
expect(runBody).toContain("table-search");
|
|
expect(runBody).toContain("renders detail");
|
|
expect(runBody).toContain("/portal/leases/cbx_000000000001");
|
|
expect(runBody).toContain("/portal/runs/run_000000000001/logs");
|
|
|
|
const events = await fleet.fetch(
|
|
request("GET", "/portal/runs/run_000000000001/events", { headers }),
|
|
);
|
|
expect(events.status).toBe(200);
|
|
await expect(events.json()).resolves.toMatchObject({
|
|
events: [{ runID: "run_000000000001", type: "command.finished" }],
|
|
});
|
|
|
|
const stop = await fleet.fetch(
|
|
request("POST", "/portal/leases/blue-lobster/release", { headers }),
|
|
);
|
|
expect(stop.status).toBe(303);
|
|
expect(stop.headers.get("location")).toBe("/portal");
|
|
expect(storage.value<LeaseRecord>("lease:cbx_000000000001")).toMatchObject({
|
|
state: "released",
|
|
keep: false,
|
|
});
|
|
});
|
|
|
|
it("serves code pages only for code leases and requires a bridge ticket", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const headers = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
code: true,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000002",
|
|
testLease({
|
|
id: "cbx_000000000002",
|
|
slug: "plain-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
code: false,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
|
|
const page = await fleet.fetch(
|
|
request("GET", "/portal/leases/blue-lobster/code/", { headers }),
|
|
);
|
|
expect(page.status).toBe(200);
|
|
const pageBody = await page.text();
|
|
expect(pageBody).toContain("crabbox code --id blue-lobster --open");
|
|
expect(pageBody).toContain('class="vnc-page code-wait-page"');
|
|
expect(pageBody).toContain("<h1>🦀 crabbox</h1>");
|
|
expect(pageBody).toContain("code blue-lobster");
|
|
expect(pageBody).toContain('id="code-status"');
|
|
expect(pageBody).toContain('id="code-copy"');
|
|
expect(pageBody).toContain("/portal/leases/cbx_000000000001/code/health");
|
|
expect(pageBody).toContain("window.location.reload()");
|
|
|
|
const health = await fleet.fetch(
|
|
request("GET", "/portal/leases/blue-lobster/code/health", { headers }),
|
|
);
|
|
expect(health.status).toBe(200);
|
|
const healthBody = (await health.json()) as {
|
|
lease: { id: string; code: boolean };
|
|
code: { agentConnected: boolean };
|
|
};
|
|
expect(healthBody.lease).toMatchObject({ id: "cbx_000000000001", code: true });
|
|
expect(healthBody.code.agentConnected).toBe(false);
|
|
|
|
const plain = await fleet.fetch(
|
|
request("GET", "/portal/leases/plain-lobster/code/", { headers }),
|
|
);
|
|
expect(plain.status).toBe(409);
|
|
|
|
const ticket = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/code/ticket", { headers, body: {} }),
|
|
);
|
|
expect(ticket.status).toBe(200);
|
|
const ticketBody = (await ticket.json()) as { ticket: string; leaseID: string };
|
|
expect(ticketBody.ticket).toMatch(/^code_[a-f0-9]{32}$/);
|
|
expect(ticketBody.leaseID).toBe("cbx_000000000001");
|
|
|
|
const agent = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/code/agent", { headers }),
|
|
);
|
|
expect(agent.status).toBe(426);
|
|
|
|
const missingTicket = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/code/agent", {
|
|
headers: { upgrade: "websocket" },
|
|
}),
|
|
);
|
|
expect(missingTicket.status).toBe(401);
|
|
});
|
|
|
|
it("accepts bridge tickets in authorization before falling back to query strings", () => {
|
|
expect(
|
|
bridgeTicketFromRequest(
|
|
request("GET", "/v1/leases/blue-lobster/code/agent?ticket=code_query", {
|
|
headers: { authorization: "Bearer code_header" },
|
|
}),
|
|
),
|
|
).toBe("code_header");
|
|
expect(
|
|
bridgeTicketFromRequest(
|
|
request("GET", "/v1/leases/blue-lobster/code/agent?ticket=code_query"),
|
|
),
|
|
).toBe("code_query");
|
|
});
|
|
|
|
it("uses a VS Code-compatible CSP for code proxy responses", () => {
|
|
const headers = codeResponseHeaders({
|
|
"content-security-policy": "default-src 'none'; script-src 'self'",
|
|
"content-length": "123",
|
|
"content-type": "text/html",
|
|
"cache-control": "public, max-age=31536000",
|
|
});
|
|
|
|
const csp = headers.get("content-security-policy") || "";
|
|
expect(csp).toContain("script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:");
|
|
expect(csp).toContain("https://static.cloudflareinsights.com");
|
|
expect(csp).toContain("worker-src 'self' data: blob:");
|
|
expect(headers.get("content-length")).toBeNull();
|
|
expect(headers.get("content-type")).toBe("text/html");
|
|
expect(headers.get("cache-control")).toBe("no-store, no-transform");
|
|
});
|
|
|
|
it("forwards only the VS Code token cookie to code-server", () => {
|
|
const headers = codeForwardHeaders(
|
|
new Headers({
|
|
cookie: "crabbox_session=secret; vscode-tkn=remote-token; other=value",
|
|
origin: "https://crabbox.openclaw.ai",
|
|
}),
|
|
);
|
|
|
|
expect(headers["cookie"]).toBe("vscode-tkn=remote-token");
|
|
expect(headers["cookie"]).not.toContain("crabbox_session");
|
|
expect(headers.origin).toBe("https://crabbox.openclaw.ai");
|
|
});
|
|
|
|
it("creates scoped egress tickets and reports bridge status", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const headers = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
|
|
const invalidRole = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/egress/ticket", {
|
|
headers,
|
|
body: { role: "viewer" },
|
|
}),
|
|
);
|
|
expect(invalidRole.status).toBe(400);
|
|
|
|
const ticket = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/egress/ticket", {
|
|
headers,
|
|
body: {
|
|
role: "host",
|
|
sessionID: "egress_test123",
|
|
profile: "discord",
|
|
allow: ["discord.com", "*.discordcdn.com"],
|
|
},
|
|
}),
|
|
);
|
|
expect(ticket.status).toBe(200);
|
|
const ticketBody = (await ticket.json()) as {
|
|
ticket: string;
|
|
leaseID: string;
|
|
role: string;
|
|
sessionID: string;
|
|
};
|
|
expect(ticketBody.ticket).toMatch(/^egress_[a-f0-9]{32}$/);
|
|
expect(ticketBody.leaseID).toBe("cbx_000000000001");
|
|
expect(ticketBody.role).toBe("host");
|
|
expect(ticketBody.sessionID).toBe("egress_test123");
|
|
|
|
const camelTicket = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/egress/ticket", {
|
|
headers,
|
|
body: {
|
|
role: "client",
|
|
sessionId: "egress_camel123",
|
|
allow: ["discord.com"],
|
|
},
|
|
}),
|
|
);
|
|
expect(camelTicket.status).toBe(200);
|
|
await expect(camelTicket.json()).resolves.toMatchObject({
|
|
role: "client",
|
|
sessionID: "egress_camel123",
|
|
});
|
|
|
|
const status = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/egress/status", { headers }),
|
|
);
|
|
expect(status.status).toBe(200);
|
|
await expect(status.json()).resolves.toMatchObject({
|
|
leaseID: "cbx_000000000001",
|
|
hostConnected: false,
|
|
clientConnected: false,
|
|
});
|
|
|
|
const portalPage = await fleet.fetch(
|
|
request("GET", "/portal/leases/blue-lobster", { headers }),
|
|
);
|
|
expect(portalPage.status).toBe(200);
|
|
const portalBody = await portalPage.text();
|
|
expect(portalBody).toContain("<strong>egress</strong><small>waiting for host</small>");
|
|
expect(portalBody).toContain("discord · discord.com");
|
|
expect(portalBody).toContain("crabbox egress status --id blue-lobster");
|
|
expect(portalBody).toContain("crabbox egress stop --id blue-lobster");
|
|
|
|
const missingTicket = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/egress/host", {
|
|
headers: { upgrade: "websocket" },
|
|
}),
|
|
);
|
|
expect(missingTicket.status).toBe(401);
|
|
});
|
|
|
|
it("keeps egress status on the latest ticketed session", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const headers = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
|
|
const staleTicket = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/egress/ticket", {
|
|
headers,
|
|
body: { role: "host", sessionID: "egress_old001", allow: ["example.com"] },
|
|
}),
|
|
);
|
|
expect(staleTicket.status).toBe(200);
|
|
const staleTicketBody = (await staleTicket.json()) as { ticket: string };
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
|
|
const currentTicket = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/egress/ticket", {
|
|
headers,
|
|
body: { role: "client", sessionID: "egress_new001", allow: ["example.com"] },
|
|
}),
|
|
);
|
|
expect(currentTicket.status).toBe(200);
|
|
|
|
const status = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/egress/status", { headers }),
|
|
);
|
|
expect(status.status).toBe(200);
|
|
await expect(status.json()).resolves.toMatchObject({
|
|
sessionID: "egress_new001",
|
|
hostConnected: false,
|
|
clientConnected: false,
|
|
});
|
|
expect(staleTicketBody.ticket).toMatch(/^egress_[a-f0-9]{32}$/);
|
|
});
|
|
|
|
it("does not let an older egress session replace a newer current session", () => {
|
|
expect(
|
|
shouldActivateEgressSession(
|
|
{ sessionID: "egress_new", createdAt: "2026-05-07T10:00:00.000Z" },
|
|
"egress_old",
|
|
"2026-05-07T09:59:59.000Z",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
shouldActivateEgressSession(
|
|
{ sessionID: "egress_new", createdAt: "2026-05-07T10:00:00.000Z" },
|
|
"egress_new",
|
|
"2026-05-07T09:59:59.000Z",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("serves WebVNC pages only for desktop leases and requires an agent upgrade", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const headers = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
desktop: true,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
storage.seed(
|
|
"lease:cbx_000000000002",
|
|
testLease({
|
|
id: "cbx_000000000002",
|
|
slug: "plain-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
desktop: false,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
);
|
|
|
|
const page = await fleet.fetch(request("GET", "/portal/leases/blue-lobster/vnc", { headers }));
|
|
expect(page.status).toBe(200);
|
|
expect(page.headers.get("content-security-policy")).toContain("script-src 'self' 'nonce-");
|
|
const pageBody = await page.text();
|
|
expect(pageBody).toContain(
|
|
"crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
|
);
|
|
expect(pageBody).toContain("/portal/assets/novnc/rfb.js");
|
|
expect(pageBody).toContain("<h1>🦀 crabbox</h1>");
|
|
expect(pageBody).toContain("WebVNC blue-lobster");
|
|
expect(pageBody).toContain("function scheduleRetry");
|
|
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
|
|
expect(pageBody).toContain("/portal/leases/cbx_000000000001/share");
|
|
expect(pageBody).toContain("/portal/leases/cbx_000000000001/share?embed=1");
|
|
expect(pageBody).toContain("vnc-share-dialog");
|
|
expect(pageBody).toContain("vnc-share-frame");
|
|
expect(pageBody).toContain('document.getElementById("vnc-share")');
|
|
expect(pageBody).toContain("vnc-copy-remote");
|
|
expect(pageBody).toContain("vnc-paste");
|
|
expect(pageBody).toContain("vnc-copy");
|
|
expect(pageBody).toContain('addEventListener("clipboard"');
|
|
expect(pageBody).toContain("remote clipboard ready");
|
|
expect(pageBody).toContain("clipboardPasteFrom");
|
|
expect(pageBody).toContain("rfb.showDotCursor = true");
|
|
expect(pageBody).toContain('target === "macos"');
|
|
expect(pageBody).toContain("MetaLeft");
|
|
expect(pageBody).toContain("ControlLeft");
|
|
expect(pageBody).toContain("position:sticky");
|
|
expect(pageBody).toContain('data-provider="hetzner"');
|
|
expect(pageBody).toContain('data-target="linux"');
|
|
expect(pageBody).toContain("WebVNC daemon not running; run the bridge command below");
|
|
expect(pageBody).toContain("waiting for an available WebVNC observer slot");
|
|
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/control");
|
|
expect(pageBody).toContain("vnc-takeover");
|
|
expect(pageBody).toContain("vnc-control");
|
|
expect(pageBody).toContain("take control");
|
|
expect(pageBody).toContain("you control");
|
|
expect(pageBody).not.toContain("vnc-role");
|
|
expect(pageBody).not.toContain("status-pill vnc-role");
|
|
expect(pageBody).toContain("rfb.viewOnly = !controlling");
|
|
expect(pageBody).toContain('fragment.get("username")');
|
|
expect(pageBody).toContain('types.includes("username")');
|
|
expect(pageBody).not.toContain("cdn.jsdelivr.net");
|
|
|
|
const status = await fleet.fetch(
|
|
request("GET", "/portal/leases/blue-lobster/vnc/status", { headers }),
|
|
);
|
|
expect(status.status).toBe(200);
|
|
await expect(status.json()).resolves.toMatchObject({
|
|
leaseID: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
bridgeConnected: false,
|
|
viewerConnected: false,
|
|
command: "crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
|
events: [],
|
|
message:
|
|
"WebVNC daemon not running; run: crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
|
});
|
|
|
|
const apiStatus = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/webvnc/status", { headers }),
|
|
);
|
|
expect(apiStatus.status).toBe(200);
|
|
await expect(apiStatus.json()).resolves.toMatchObject({
|
|
leaseID: "cbx_000000000001",
|
|
bridgeConnected: false,
|
|
viewerConnected: false,
|
|
events: [],
|
|
});
|
|
|
|
const reset = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/webvnc/reset", { headers, body: {} }),
|
|
);
|
|
expect(reset.status).toBe(200);
|
|
await expect(reset.json()).resolves.toMatchObject({
|
|
leaseID: "cbx_000000000001",
|
|
bridgeWasConnected: false,
|
|
viewerWasConnected: false,
|
|
command: "crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
|
events: [{ event: "reset", reason: "WebVNC reset requested" }],
|
|
});
|
|
|
|
const plain = await fleet.fetch(
|
|
request("GET", "/portal/leases/plain-lobster/vnc", { headers }),
|
|
);
|
|
expect(plain.status).toBe(409);
|
|
|
|
const ticket = await fleet.fetch(
|
|
request("POST", "/v1/leases/blue-lobster/webvnc/ticket", { headers, body: {} }),
|
|
);
|
|
expect(ticket.status).toBe(200);
|
|
const ticketBody = (await ticket.json()) as { ticket: string; leaseID: string };
|
|
expect(ticketBody.ticket).toMatch(/^wvnc_[a-f0-9]{32}$/);
|
|
expect(ticketBody.leaseID).toBe("cbx_000000000001");
|
|
|
|
const agent = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/webvnc/agent", { headers }),
|
|
);
|
|
expect(agent.status).toBe(426);
|
|
|
|
const missingTicket = await fleet.fetch(
|
|
request("GET", "/v1/leases/blue-lobster/webvnc/agent", {
|
|
headers: { upgrade: "websocket" },
|
|
}),
|
|
);
|
|
expect(missingTicket.status).toBe(401);
|
|
});
|
|
|
|
it("buffers initial WebVNC bridge bytes until the viewer attaches", async () => {
|
|
const buffers = new Map<string, WebVNCBuffer>();
|
|
const sent: Array<string | ArrayBuffer> = [];
|
|
const viewer = {
|
|
readyState: WebSocket.OPEN,
|
|
send(data: string | ArrayBuffer) {
|
|
sent.push(data);
|
|
},
|
|
} as WebSocket;
|
|
|
|
await forwardOrBufferWebVNC("RFB 003.008\n", undefined, buffers, "cbx_000000000001");
|
|
expect(sent).toEqual([]);
|
|
expect(buffers.get("cbx_000000000001")).toMatchObject({
|
|
chunks: ["RFB 003.008\n"],
|
|
bytes: 12,
|
|
});
|
|
|
|
flushPendingWebVNC(buffers, "cbx_000000000001", viewer);
|
|
expect(sent).toEqual(["RFB 003.008\n"]);
|
|
expect(buffers.has("cbx_000000000001")).toBe(false);
|
|
});
|
|
|
|
it("converts WebVNC Blob frames before forwarding", async () => {
|
|
const buffers = new Map<string, WebVNCBuffer>();
|
|
const sent: Array<string | ArrayBuffer> = [];
|
|
const viewer = {
|
|
readyState: WebSocket.OPEN,
|
|
send(data: string | ArrayBuffer) {
|
|
sent.push(data);
|
|
},
|
|
} as WebSocket;
|
|
|
|
await forwardOrBufferWebVNC(new Blob(["RFB 003.008\n"]), viewer, buffers, "cbx_000000000001");
|
|
|
|
expect(sent).toHaveLength(1);
|
|
expect(new TextDecoder().decode(sent[0] as ArrayBuffer)).toBe("RFB 003.008\n");
|
|
expect(buffers.has("cbx_000000000001")).toBe(false);
|
|
});
|
|
|
|
it("resets the WebVNC bridge when the viewer goes away", () => {
|
|
const buffers = new Map<string, WebVNCBuffer>();
|
|
buffers.set("cbx_000000000001", { chunks: ["RFB 003.008\n"], bytes: 12 });
|
|
buffers.set("cbx_000000000001:agent_a", { chunks: ["RFB 003.008\n"], bytes: 12 });
|
|
const closed: Array<{ code: number; reason: string }> = [];
|
|
const agents = new Map<string, Map<string, WebSocket>>();
|
|
agents.set(
|
|
"cbx_000000000001",
|
|
new Map([
|
|
[
|
|
"agent_a",
|
|
{
|
|
readyState: WebSocket.OPEN,
|
|
close(code: number, reason: string) {
|
|
closed.push({ code, reason });
|
|
},
|
|
} as WebSocket,
|
|
],
|
|
]),
|
|
);
|
|
|
|
resetWebVNCBridge(agents, buffers, "cbx_000000000001", 1011, "WebVNC viewer disconnected");
|
|
|
|
expect(closed).toEqual([{ code: 1011, reason: "WebVNC viewer disconnected" }]);
|
|
expect(agents.has("cbx_000000000001")).toBe(false);
|
|
expect(buffers.has("cbx_000000000001")).toBe(false);
|
|
expect(buffers.has("cbx_000000000001:agent_a")).toBe(false);
|
|
});
|
|
|
|
it("keeps pool inventory admin-only", async () => {
|
|
const fleet = testFleet(new MemoryStorage(), {
|
|
aws: fakeProvider(),
|
|
hetzner: fakeProvider(),
|
|
});
|
|
const denied = await fleet.fetch(
|
|
request("GET", "/v1/pool", {
|
|
headers: {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(denied.status).toBe(403);
|
|
|
|
const allowed = await fleet.fetch(
|
|
request("GET", "/v1/pool", {
|
|
headers: { "x-crabbox-admin": "true" },
|
|
}),
|
|
);
|
|
expect(allowed.status).toBe(200);
|
|
});
|
|
|
|
it("creates, waits, and promotes AWS images through admin routes", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage, {
|
|
aws: fakeProvider(),
|
|
});
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
provider: "aws",
|
|
cloudID: "i-123",
|
|
region: "eu-west-1",
|
|
}),
|
|
);
|
|
|
|
const denied = await fleet.fetch(
|
|
request("POST", "/v1/images", {
|
|
body: { leaseID: "cbx_000000000001", name: "openclaw-crabbox-test" },
|
|
}),
|
|
);
|
|
expect(denied.status).toBe(403);
|
|
|
|
const created = await fleet.fetch(
|
|
request("POST", "/v1/images", {
|
|
headers: { "x-crabbox-admin": "true" },
|
|
body: { leaseID: "cbx_000000000001", name: "openclaw-crabbox-test" },
|
|
}),
|
|
);
|
|
expect(created.status).toBe(201);
|
|
const createdBody = (await created.json()) as { image: { id: string; state: string } };
|
|
expect(createdBody.image).toEqual(
|
|
expect.objectContaining({ id: "ami-000000000001", state: "pending" }),
|
|
);
|
|
|
|
const promoted = await fleet.fetch(
|
|
request("POST", "/v1/images/ami-000000000001/promote", {
|
|
headers: { "x-crabbox-admin": "true" },
|
|
body: {},
|
|
}),
|
|
);
|
|
expect(promoted.status).toBe(200);
|
|
expect(storage.value("image:aws:promoted")).toEqual(
|
|
expect.objectContaining({ id: "ami-000000000001", state: "available" }),
|
|
);
|
|
});
|
|
|
|
it("mints broker-owned artifact upload URLs without exposing secrets", async () => {
|
|
const fleet = testFleet(
|
|
new MemoryStorage(),
|
|
{},
|
|
{
|
|
CRABBOX_ARTIFACTS_BACKEND: "r2",
|
|
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
|
|
CRABBOX_ARTIFACTS_PREFIX: "qa",
|
|
CRABBOX_ARTIFACTS_BASE_URL: "https://artifacts.example.com",
|
|
CRABBOX_ARTIFACTS_REGION: "auto",
|
|
CRABBOX_ARTIFACTS_ENDPOINT_URL: "https://account.r2.cloudflarestorage.com",
|
|
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
|
|
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
|
|
},
|
|
);
|
|
|
|
const response = await fleet.fetch(
|
|
request("POST", "/v1/artifacts/uploads", {
|
|
headers: { "x-crabbox-owner": "peter@example.com" },
|
|
body: {
|
|
prefix: "pr-42",
|
|
files: [
|
|
{
|
|
name: "screenshots/after.png",
|
|
size: 123,
|
|
contentType: "image/png",
|
|
sha256: await sha256HexForTest("after"),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
const body = (await response.json()) as {
|
|
backend: string;
|
|
bucket: string;
|
|
prefix: string;
|
|
files: Array<{
|
|
name: string;
|
|
key: string;
|
|
url: string;
|
|
upload: { url: string; headers: Record<string, string> };
|
|
}>;
|
|
};
|
|
expect(body.backend).toBe("r2");
|
|
expect(body.bucket).toBe("qa-artifacts");
|
|
expect(body.prefix).toBe("qa/peter@example.com/pr-42");
|
|
expect(body.files[0].key).toBe("qa/peter@example.com/pr-42/screenshots/after.png");
|
|
expect(body.files[0].url).toBe(
|
|
"https://artifacts.example.com/qa/peter%40example.com/pr-42/screenshots/after.png",
|
|
);
|
|
expect(body.files[0].upload.headers["content-length"]).toBe("123");
|
|
expect(body.files[0].upload.headers["content-type"]).toBe("image/png");
|
|
expect(body.files[0].upload.url).toContain("X-Amz-Signature=");
|
|
expect(new URL(body.files[0].upload.url).searchParams.get("X-Amz-SignedHeaders")).toContain(
|
|
"content-length",
|
|
);
|
|
expect(JSON.stringify(body)).not.toContain("super-secret");
|
|
});
|
|
|
|
it("reports artifact broker setup errors without provider-specific local credentials", async () => {
|
|
const fleet = testFleet();
|
|
const response = await fleet.fetch(
|
|
request("POST", "/v1/artifacts/uploads", {
|
|
body: { files: [{ name: "screenshot.png", size: 1 }] },
|
|
}),
|
|
);
|
|
const body = (await response.json()) as { error: string; message: string };
|
|
expect(response.status).toBe(400);
|
|
expect(body.error).toBe("artifact_upload_unavailable");
|
|
expect(body.message).toContain("artifact broker is not configured");
|
|
});
|
|
|
|
it("requires an R2 endpoint before minting artifact upload URLs", async () => {
|
|
const fleet = testFleet(
|
|
new MemoryStorage(),
|
|
{},
|
|
{
|
|
CRABBOX_ARTIFACTS_BACKEND: "r2",
|
|
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
|
|
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
|
|
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
|
|
},
|
|
);
|
|
|
|
const response = await fleet.fetch(
|
|
request("POST", "/v1/artifacts/uploads", {
|
|
body: { files: [{ name: "screenshot.png", size: 1 }] },
|
|
}),
|
|
);
|
|
const body = (await response.json()) as { error: string; message: string };
|
|
expect(response.status).toBe(400);
|
|
expect(body.error).toBe("artifact_upload_unavailable");
|
|
expect(body.message).toContain("CRABBOX_ARTIFACTS_ENDPOINT_URL");
|
|
});
|
|
|
|
it("caps aggregate artifact upload bytes before minting grants", async () => {
|
|
const fleet = testFleet(
|
|
new MemoryStorage(),
|
|
{},
|
|
{
|
|
CRABBOX_ARTIFACTS_BACKEND: "r2",
|
|
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
|
|
CRABBOX_ARTIFACTS_ENDPOINT_URL: "https://account.r2.cloudflarestorage.com",
|
|
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
|
|
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
|
|
},
|
|
);
|
|
|
|
const response = await fleet.fetch(
|
|
request("POST", "/v1/artifacts/uploads", {
|
|
body: {
|
|
files: Array.from({ length: 6 }, (_, index) => ({
|
|
name: `video-${index}.mp4`,
|
|
size: 1024 * 1024 * 1024,
|
|
})),
|
|
},
|
|
}),
|
|
);
|
|
const body = (await response.json()) as { error: string; message: string };
|
|
expect(response.status).toBe(400);
|
|
expect(body.error).toBe("artifact_upload_unavailable");
|
|
expect(body.message).toContain("5368709120 bytes");
|
|
});
|
|
});
|
|
|
|
describe("fleet run history", () => {
|
|
it("creates early run sessions and appends durable events", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
provider: "aws",
|
|
serverType: "t3.small",
|
|
}),
|
|
);
|
|
const ownerHeaders = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/runs", {
|
|
headers: ownerHeaders,
|
|
body: {
|
|
provider: "aws",
|
|
class: "standard",
|
|
serverType: "t3.small",
|
|
command: ["pnpm", "test"],
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { run } = (await create.json()) as { run: { id: string; phase: string } };
|
|
expect(run.phase).toBe("starting");
|
|
|
|
const attached = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/events`, {
|
|
headers: ownerHeaders,
|
|
body: {
|
|
type: "lease.created",
|
|
leaseID: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
provider: "aws",
|
|
class: "standard",
|
|
serverType: "t3.small",
|
|
},
|
|
}),
|
|
);
|
|
expect(attached.status).toBe(201);
|
|
|
|
const stdout = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/events`, {
|
|
headers: ownerHeaders,
|
|
body: { type: "stdout", stream: "stdout", data: "ok\n" },
|
|
}),
|
|
);
|
|
expect(stdout.status).toBe(201);
|
|
|
|
const read = await fleet.fetch(request("GET", `/v1/runs/${run.id}`, { headers: ownerHeaders }));
|
|
const readBody = (await read.json()) as {
|
|
run: { leaseID: string; slug: string; phase: string; eventCount: number };
|
|
};
|
|
expect(readBody.run.leaseID).toBe("cbx_000000000001");
|
|
expect(readBody.run.slug).toBe("blue-lobster");
|
|
expect(readBody.run.phase).toBe("command");
|
|
expect(readBody.run.eventCount).toBe(3);
|
|
|
|
const finish = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/finish`, {
|
|
headers: ownerHeaders,
|
|
body: { exitCode: 0, log: "ok\n" },
|
|
}),
|
|
);
|
|
expect(finish.status).toBe(200);
|
|
|
|
const events = await fleet.fetch(
|
|
request("GET", `/v1/runs/${run.id}/events`, { headers: ownerHeaders }),
|
|
);
|
|
const eventsBody = (await events.json()) as {
|
|
events: Array<{ seq: number; type: string; data?: string }>;
|
|
};
|
|
expect(eventsBody.events.map((event) => event.type)).toEqual([
|
|
"run.started",
|
|
"lease.created",
|
|
"stdout",
|
|
"command.finished",
|
|
]);
|
|
expect(eventsBody.events.map((event) => event.seq)).toEqual([1, 2, 3, 4]);
|
|
|
|
const pagedEvents = await fleet.fetch(
|
|
request("GET", `/v1/runs/${run.id}/events?after=1&limit=2`, {
|
|
headers: ownerHeaders,
|
|
}),
|
|
);
|
|
expect(pagedEvents.status).toBe(200);
|
|
const pagedEventsBody = (await pagedEvents.json()) as {
|
|
events: Array<{ seq: number; type: string }>;
|
|
};
|
|
expect(pagedEventsBody.events.map((event) => [event.seq, event.type])).toEqual([
|
|
[2, "lease.created"],
|
|
[3, "stdout"],
|
|
]);
|
|
});
|
|
|
|
it("records finished runs and serves logs", async () => {
|
|
const fleet = testFleet();
|
|
const ownerHeaders = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/runs", {
|
|
headers: ownerHeaders,
|
|
body: {
|
|
leaseID: "cbx_000000000001",
|
|
provider: "aws",
|
|
class: "beast",
|
|
serverType: "c7a.48xlarge",
|
|
command: ["go", "test", "./..."],
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { run } = (await create.json()) as { run: { id: string } };
|
|
|
|
const finish = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/finish`, {
|
|
headers: ownerHeaders,
|
|
body: {
|
|
exitCode: 0,
|
|
syncMs: 12,
|
|
commandMs: 34,
|
|
log: "ok\n",
|
|
telemetry: {
|
|
start: {
|
|
capturedAt: "2026-05-01T00:00:00Z",
|
|
source: "ssh-linux",
|
|
load1: 0.1,
|
|
memoryUsedBytes: 1024,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 50,
|
|
},
|
|
end: {
|
|
capturedAt: "2026-05-01T00:00:02Z",
|
|
source: "ssh-linux",
|
|
load1: 0.2,
|
|
memoryUsedBytes: 1536,
|
|
memoryTotalBytes: 2048,
|
|
memoryPercent: 75,
|
|
},
|
|
},
|
|
results: {
|
|
format: "junit",
|
|
files: ["junit.xml"],
|
|
suites: 1,
|
|
tests: 2,
|
|
failures: 1,
|
|
errors: 0,
|
|
skipped: 0,
|
|
timeSeconds: 1.2,
|
|
failed: [{ suite: "pkg", name: "fails", kind: "failure" }],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(finish.status).toBe(200);
|
|
const finished = (await finish.json()) as {
|
|
run: {
|
|
state: string;
|
|
logBytes: number;
|
|
results?: { tests: number };
|
|
telemetry?: { end?: { load1?: number; memoryPercent?: number } };
|
|
};
|
|
};
|
|
expect(finished.run.state).toBe("succeeded");
|
|
expect(finished.run.logBytes).toBe(3);
|
|
expect(finished.run.results?.tests).toBe(2);
|
|
expect(finished.run.telemetry?.end).toMatchObject({ load1: 0.2, memoryPercent: 75 });
|
|
|
|
const listed = await fleet.fetch(
|
|
request("GET", "/v1/runs?leaseID=cbx_000000000001", { headers: ownerHeaders }),
|
|
);
|
|
const listBody = (await listed.json()) as { runs: Array<{ id: string; owner: string }> };
|
|
expect(listBody.runs).toHaveLength(1);
|
|
expect(listBody.runs[0]?.id).toBe(run.id);
|
|
expect(listBody.runs[0]?.owner).toBe("peter@example.com");
|
|
|
|
const logs = await fleet.fetch(
|
|
request("GET", `/v1/runs/${run.id}/logs`, { headers: ownerHeaders }),
|
|
);
|
|
expect(await logs.text()).toBe("ok\n");
|
|
});
|
|
|
|
it("appends live run telemetry samples and preserves them on finish", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const ownerHeaders = {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
slug: "blue-lobster",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/runs", {
|
|
headers: ownerHeaders,
|
|
body: { leaseID: "cbx_000000000001", command: ["sleep", "60"] },
|
|
}),
|
|
);
|
|
const { run } = (await create.json()) as { run: RunRecord };
|
|
|
|
const firstSample = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/telemetry`, {
|
|
headers: ownerHeaders,
|
|
body: {
|
|
telemetry: {
|
|
capturedAt: "2026-05-01T00:00:10Z",
|
|
source: "ssh-linux",
|
|
load1: 0.4,
|
|
memoryPercent: 40,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(firstSample.status).toBe(200);
|
|
const sampled = (await firstSample.json()) as { run: RunRecord };
|
|
expect(sampled.run.telemetry?.start).toMatchObject({ load1: 0.4, memoryPercent: 40 });
|
|
expect(sampled.run.telemetry?.samples).toHaveLength(1);
|
|
|
|
await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/telemetry`, {
|
|
headers: ownerHeaders,
|
|
body: {
|
|
telemetry: {
|
|
capturedAt: "2026-05-01T00:00:20Z",
|
|
source: "ssh-linux",
|
|
load1: 0.9,
|
|
memoryPercent: 55,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const finish = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/finish`, {
|
|
headers: ownerHeaders,
|
|
body: {
|
|
exitCode: 0,
|
|
telemetry: {
|
|
end: {
|
|
capturedAt: "2026-05-01T00:00:30Z",
|
|
source: "ssh-linux",
|
|
load1: 1.2,
|
|
memoryPercent: 60,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(finish.status).toBe(200);
|
|
const finished = (await finish.json()) as { run: RunRecord };
|
|
expect(finished.run.telemetry?.end).toMatchObject({ load1: 1.2, memoryPercent: 60 });
|
|
expect(finished.run.telemetry?.samples?.map((sample) => sample.load1)).toEqual([0.4, 0.9]);
|
|
});
|
|
|
|
it("accepts Go nil slices in passing test results", async () => {
|
|
const fleet = testFleet();
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/runs", {
|
|
body: {
|
|
leaseID: "cbx_000000000001",
|
|
provider: "aws",
|
|
class: "beast",
|
|
serverType: "c7a.48xlarge",
|
|
command: ["go", "test", "./..."],
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { run } = (await create.json()) as { run: { id: string } };
|
|
|
|
const finish = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/finish`, {
|
|
body: {
|
|
exitCode: 0,
|
|
log: "ok\n",
|
|
results: {
|
|
format: "junit",
|
|
files: null,
|
|
suites: 1,
|
|
tests: 1,
|
|
failures: 0,
|
|
errors: 0,
|
|
skipped: 0,
|
|
timeSeconds: 0.001,
|
|
failed: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(finish.status).toBe(200);
|
|
const finished = (await finish.json()) as {
|
|
run: { results?: { files: string[]; failed: unknown[] } };
|
|
};
|
|
expect(finished.run.results?.files).toEqual([]);
|
|
expect(finished.run.results?.failed).toEqual([]);
|
|
});
|
|
|
|
it("records chunked run logs so failures do not disappear from long output", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/runs", {
|
|
body: {
|
|
leaseID: "cbx_000000000001",
|
|
provider: "aws",
|
|
class: "beast",
|
|
serverType: "c7a.48xlarge",
|
|
command: ["pnpm", "test"],
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { run } = (await create.json()) as { run: { id: string } };
|
|
const chunkA = `${"a".repeat(70_000)}\nFAIL src/example.test.ts\n`;
|
|
const chunkB = `${"b".repeat(70_000)}\nELIFECYCLE Test failed\n`;
|
|
|
|
const finish = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/finish`, {
|
|
body: {
|
|
exitCode: 1,
|
|
log: "fallback tail only\n",
|
|
logChunks: [chunkA, chunkB],
|
|
},
|
|
}),
|
|
);
|
|
expect(finish.status).toBe(200);
|
|
const finished = (await finish.json()) as {
|
|
run: { state: string; logBytes: number; logTruncated: boolean };
|
|
};
|
|
expect(finished.run.state).toBe("failed");
|
|
expect(finished.run.logBytes).toBe(chunkA.length + chunkB.length);
|
|
expect(finished.run.logTruncated).toBe(false);
|
|
expect(storage.value<string>(`runlog:${run.id}`)).toBe("");
|
|
|
|
const logs = await fleet.fetch(request("GET", `/v1/runs/${run.id}/logs`));
|
|
const logText = await logs.text();
|
|
expect(logText).toContain("FAIL src/example.test.ts");
|
|
expect(logText).toContain("ELIFECYCLE Test failed");
|
|
expect(logText).not.toContain("fallback tail only");
|
|
});
|
|
|
|
it("records resolved lease metadata instead of caller-supplied fallback guesses", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"lease:cbx_000000000001",
|
|
testLease({
|
|
id: "cbx_000000000001",
|
|
provider: "aws",
|
|
class: "beast",
|
|
serverType: "c7i.24xlarge",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/runs", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
body: {
|
|
leaseID: "cbx_000000000001",
|
|
provider: "aws",
|
|
class: "beast",
|
|
serverType: "c7a.48xlarge",
|
|
command: ["go", "test", "./..."],
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { run } = (await create.json()) as { run: RunRecord };
|
|
expect(run.provider).toBe("aws");
|
|
expect(run.class).toBe("beast");
|
|
expect(run.serverType).toBe("c7i.24xlarge");
|
|
});
|
|
|
|
it("hides run records and logs from other non-admin users", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = testFleet(storage);
|
|
storage.seed(
|
|
"run:run_000000000001",
|
|
testRun({
|
|
id: "run_000000000001",
|
|
leaseID: "cbx_000000000001",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
storage.seed("runlog:run_000000000001", "secret log\n");
|
|
storage.seed(
|
|
"run:run_000000000002",
|
|
testRun({
|
|
id: "run_000000000002",
|
|
leaseID: "cbx_000000000002",
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
}),
|
|
);
|
|
const friendHeaders = {
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
};
|
|
|
|
const list = await fleet.fetch(request("GET", "/v1/runs", { headers: friendHeaders }));
|
|
const listBody = (await list.json()) as { runs: RunRecord[] };
|
|
expect(listBody.runs.map((run) => run.id)).toEqual(["run_000000000002"]);
|
|
|
|
const read = await fleet.fetch(
|
|
request("GET", "/v1/runs/run_000000000001", { headers: friendHeaders }),
|
|
);
|
|
expect(read.status).toBe(404);
|
|
|
|
const logs = await fleet.fetch(
|
|
request("GET", "/v1/runs/run_000000000001/logs", { headers: friendHeaders }),
|
|
);
|
|
expect(logs.status).toBe(404);
|
|
|
|
const finish = await fleet.fetch(
|
|
request("POST", "/v1/runs/run_000000000001/finish", {
|
|
headers: friendHeaders,
|
|
body: { exitCode: 0, log: "overwrite\n" },
|
|
}),
|
|
);
|
|
expect(finish.status).toBe(404);
|
|
expect(storage.value<string>("runlog:run_000000000001")).toBe("secret log\n");
|
|
});
|
|
|
|
it("bounds stored result summaries", async () => {
|
|
const fleet = testFleet();
|
|
const create = await fleet.fetch(
|
|
request("POST", "/v1/runs", {
|
|
body: {
|
|
leaseID: "cbx_000000000001",
|
|
provider: "aws",
|
|
class: "beast",
|
|
serverType: "c7a.48xlarge",
|
|
command: ["go", "test", "./..."],
|
|
},
|
|
}),
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { run } = (await create.json()) as { run: { id: string } };
|
|
const failed = Array.from({ length: 150 }, (_, index) => ({
|
|
suite: "pkg",
|
|
name: `fails-${index}`,
|
|
kind: "failure" as const,
|
|
message: "x".repeat(5000),
|
|
}));
|
|
|
|
const finish = await fleet.fetch(
|
|
request("POST", `/v1/runs/${run.id}/finish`, {
|
|
body: {
|
|
exitCode: 1,
|
|
log: "",
|
|
results: {
|
|
format: "junit",
|
|
files: Array.from({ length: 80 }, (_, index) => `junit-${index}.xml`),
|
|
suites: 1,
|
|
tests: 150,
|
|
failures: 150,
|
|
errors: 0,
|
|
skipped: 0,
|
|
timeSeconds: 1.2,
|
|
failed,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(finish.status).toBe(200);
|
|
const finished = (await finish.json()) as {
|
|
run: { results?: { files: string[]; failed: Array<{ message?: string }> } };
|
|
};
|
|
expect(finished.run.results?.files).toHaveLength(50);
|
|
expect(finished.run.results?.failed).toHaveLength(100);
|
|
expect(
|
|
new TextEncoder().encode(finished.run.results?.failed[0]?.message ?? "").byteLength,
|
|
).toBe(4096);
|
|
});
|
|
});
|
|
|
|
describe("fleet identity", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("reports owner and org from request context", async () => {
|
|
const fleet = testFleet();
|
|
const response = await fleet.fetch(
|
|
request("GET", "/v1/whoami", {
|
|
headers: {
|
|
"x-crabbox-owner": "peter@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(await response.json()).toEqual({
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
auth: "bearer",
|
|
});
|
|
});
|
|
|
|
it("reports forwarded GitHub auth mode", async () => {
|
|
const fleet = testFleet();
|
|
const response = await fleet.fetch(
|
|
request("GET", "/v1/whoami", {
|
|
headers: {
|
|
"x-crabbox-auth": "github",
|
|
"x-crabbox-owner": "friend@example.com",
|
|
"x-crabbox-org": "openclaw",
|
|
},
|
|
}),
|
|
);
|
|
expect(await response.json()).toEqual({
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
auth: "github",
|
|
});
|
|
});
|
|
|
|
it("rejects admin routes without an admin token context", async () => {
|
|
const fleet = testFleet();
|
|
const response = await fleet.fetch(request("GET", "/v1/admin/leases"));
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it("starts GitHub login and keeps polling secret server-side", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = new FleetDurableObject(
|
|
{ storage } as unknown as DurableObjectState,
|
|
{
|
|
CRABBOX_DEFAULT_ORG: "openclaw",
|
|
CRABBOX_GITHUB_CLIENT_ID: "github-client",
|
|
CRABBOX_GITHUB_CLIENT_SECRET: "github-secret",
|
|
CRABBOX_SHARED_TOKEN: "shared",
|
|
} as Env,
|
|
);
|
|
const pollSecret = "local-poll-secret";
|
|
const start = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/start", {
|
|
body: {
|
|
pollSecretHash: await sha256HexForTest(pollSecret),
|
|
provider: "aws",
|
|
},
|
|
}),
|
|
);
|
|
expect(start.status).toBe(200);
|
|
const body = (await start.json()) as { loginID: string; url: string };
|
|
expect(body.loginID).toMatch(/^login_/);
|
|
const url = new URL(body.url);
|
|
expect(url.origin + url.pathname).toBe("https://github.com/login/oauth/authorize");
|
|
expect(url.searchParams.get("client_id")).toBe("github-client");
|
|
expect(url.searchParams.get("scope")).toBe("read:user user:email read:org");
|
|
|
|
const poll = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/poll", {
|
|
body: {
|
|
loginID: body.loginID,
|
|
pollSecret,
|
|
},
|
|
}),
|
|
);
|
|
expect(poll.status).toBe(200);
|
|
await expect(poll.json()).resolves.toMatchObject({ status: "pending" });
|
|
});
|
|
|
|
it("sets a portal session cookie after GitHub login", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = new FleetDurableObject(
|
|
{ storage } as unknown as DurableObjectState,
|
|
{
|
|
CRABBOX_DEFAULT_ORG: "openclaw",
|
|
CRABBOX_GITHUB_CLIENT_ID: "github-client",
|
|
CRABBOX_GITHUB_CLIENT_SECRET: "github-secret",
|
|
CRABBOX_SHARED_TOKEN: "shared",
|
|
CRABBOX_SESSION_SECRET: "session-secret",
|
|
} as Env,
|
|
);
|
|
const start = await fleet.fetch(
|
|
request("GET", "/portal/login?returnTo=/portal/leases/cbx_000000000001/vnc"),
|
|
);
|
|
expect(start.status).toBe(302);
|
|
const location = start.headers.get("location") ?? "";
|
|
const state = new URL(location).searchParams.get("state");
|
|
expect(state).toBeTruthy();
|
|
|
|
vi.stubGlobal("fetch", githubFetchMock({ member: true }));
|
|
const callback = await fleet.fetch(
|
|
request("GET", `/v1/auth/github/callback?code=ok&state=${state}`),
|
|
);
|
|
expect(callback.status).toBe(302);
|
|
expect(callback.headers.get("location")).toBe("/portal/leases/cbx_000000000001/vnc");
|
|
expect(callback.headers.get("set-cookie")).toContain("crabbox_session=cbxu_");
|
|
});
|
|
|
|
it("clears portal session on logout without restarting OAuth", async () => {
|
|
const fleet = testFleet();
|
|
const logout = await fleet.fetch(request("GET", "/portal/logout"));
|
|
expect(logout.status).toBe(200);
|
|
expect(logout.headers.get("location")).toBeNull();
|
|
expect(logout.headers.get("set-cookie")).toContain("crabbox_session=");
|
|
expect(logout.headers.get("set-cookie")).toContain("Max-Age=0");
|
|
const body = await logout.text();
|
|
expect(body).toContain("Crabbox logged out");
|
|
expect(body).toContain("/portal/login");
|
|
});
|
|
|
|
it("cleans expired GitHub login attempts before rate limiting", async () => {
|
|
const storage = new MemoryStorage();
|
|
const fleet = new FleetDurableObject(
|
|
{ storage } as unknown as DurableObjectState,
|
|
{
|
|
CRABBOX_DEFAULT_ORG: "openclaw",
|
|
CRABBOX_GITHUB_CLIENT_ID: "github-client",
|
|
CRABBOX_GITHUB_CLIENT_SECRET: "github-secret",
|
|
CRABBOX_SHARED_TOKEN: "shared",
|
|
} as Env,
|
|
);
|
|
storage.seed("oauth:login_old", {
|
|
id: "login_old",
|
|
state: "state_old",
|
|
pollSecretHash: "0".repeat(64),
|
|
createdAt: "2026-05-01T00:00:00.000Z",
|
|
expiresAt: "2026-05-01T00:00:00.000Z",
|
|
});
|
|
storage.seed("oauth_state:state_old", "login_old");
|
|
|
|
const start = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/start", {
|
|
body: {
|
|
pollSecretHash: await sha256HexForTest("new-secret"),
|
|
provider: "aws",
|
|
},
|
|
}),
|
|
);
|
|
expect(start.status).toBe(200);
|
|
expect(storage.value("oauth:login_old")).toBeUndefined();
|
|
expect(storage.value("oauth_state:state_old")).toBeUndefined();
|
|
});
|
|
|
|
it("requires GitHub org membership before completing login", async () => {
|
|
const { fleet, loginID, state, pollSecret } = await startGitHubLogin();
|
|
vi.stubGlobal("fetch", githubFetchMock({ member: false }));
|
|
|
|
const callback = await fleet.fetch(
|
|
request("GET", `/v1/auth/github/callback?code=ok&state=${state}`),
|
|
);
|
|
expect(callback.status).toBe(403);
|
|
|
|
const poll = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/poll", {
|
|
body: {
|
|
loginID,
|
|
pollSecret,
|
|
},
|
|
}),
|
|
);
|
|
expect(poll.status).toBe(400);
|
|
await expect(poll.json()).resolves.toMatchObject({
|
|
status: "failed",
|
|
error: "GitHub user friend is not an active member of openclaw.",
|
|
});
|
|
});
|
|
|
|
it("mints GitHub login tokens for allowed org members", async () => {
|
|
const { fleet, loginID, state, pollSecret } = await startGitHubLogin();
|
|
vi.stubGlobal("fetch", githubFetchMock({ member: true }));
|
|
|
|
const callback = await fleet.fetch(
|
|
request("GET", `/v1/auth/github/callback?code=ok&state=${state}`),
|
|
);
|
|
expect(callback.status).toBe(200);
|
|
|
|
const poll = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/poll", {
|
|
body: {
|
|
loginID,
|
|
pollSecret,
|
|
},
|
|
}),
|
|
);
|
|
expect(poll.status).toBe(200);
|
|
const body = (await poll.json()) as {
|
|
status: string;
|
|
token?: string;
|
|
owner?: string;
|
|
org?: string;
|
|
login?: string;
|
|
};
|
|
expect(body).toMatchObject({
|
|
status: "complete",
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
login: "friend",
|
|
});
|
|
expect(body.token).toMatch(/^cbxu_/);
|
|
});
|
|
|
|
it("requires configured GitHub team membership before completing login", async () => {
|
|
const { fleet, loginID, state, pollSecret } = await startGitHubLogin({
|
|
CRABBOX_GITHUB_ALLOWED_TEAMS: "maintainers",
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
githubFetchMock({
|
|
member: true,
|
|
teams: [{ slug: "contributors", organization: { login: "openclaw" } }],
|
|
}),
|
|
);
|
|
|
|
const callback = await fleet.fetch(
|
|
request("GET", `/v1/auth/github/callback?code=ok&state=${state}`),
|
|
);
|
|
expect(callback.status).toBe(403);
|
|
|
|
const poll = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/poll", {
|
|
body: {
|
|
loginID,
|
|
pollSecret,
|
|
},
|
|
}),
|
|
);
|
|
expect(poll.status).toBe(400);
|
|
await expect(poll.json()).resolves.toMatchObject({
|
|
status: "failed",
|
|
error: "GitHub user friend is not a member of an allowed team in openclaw.",
|
|
});
|
|
});
|
|
|
|
it("mints GitHub login tokens for allowed team members", async () => {
|
|
const { fleet, loginID, state, pollSecret } = await startGitHubLogin({
|
|
CRABBOX_GITHUB_ALLOWED_TEAMS: "openclaw/maintainers,openclaw/release-captains",
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
githubFetchMock({
|
|
member: true,
|
|
teams: [{ slug: "maintainers", organization: { login: "openclaw" } }],
|
|
}),
|
|
);
|
|
|
|
const callback = await fleet.fetch(
|
|
request("GET", `/v1/auth/github/callback?code=ok&state=${state}`),
|
|
);
|
|
expect(callback.status).toBe(200);
|
|
|
|
const poll = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/poll", {
|
|
body: {
|
|
loginID,
|
|
pollSecret,
|
|
},
|
|
}),
|
|
);
|
|
expect(poll.status).toBe(200);
|
|
await expect(poll.json()).resolves.toMatchObject({
|
|
status: "complete",
|
|
owner: "friend@example.com",
|
|
org: "openclaw",
|
|
login: "friend",
|
|
});
|
|
});
|
|
});
|
|
|
|
async function startGitHubLogin(env: Partial<Env> = {}): Promise<{
|
|
fleet: FleetDurableObject;
|
|
loginID: string;
|
|
pollSecret: string;
|
|
state: string;
|
|
}> {
|
|
const storage = new MemoryStorage();
|
|
const fleet = new FleetDurableObject(
|
|
{ storage } as unknown as DurableObjectState,
|
|
{
|
|
CRABBOX_DEFAULT_ORG: "openclaw",
|
|
CRABBOX_GITHUB_CLIENT_ID: "github-client",
|
|
CRABBOX_GITHUB_CLIENT_SECRET: "github-secret",
|
|
CRABBOX_SHARED_TOKEN: "shared",
|
|
CRABBOX_SESSION_SECRET: "session-secret",
|
|
...env,
|
|
} as Env,
|
|
);
|
|
const pollSecret = "local-poll-secret";
|
|
const start = await fleet.fetch(
|
|
request("POST", "/v1/auth/github/start", {
|
|
body: {
|
|
pollSecretHash: await sha256HexForTest(pollSecret),
|
|
provider: "aws",
|
|
},
|
|
}),
|
|
);
|
|
expect(start.status).toBe(200);
|
|
const body = (await start.json()) as { loginID: string; url: string };
|
|
const url = new URL(body.url);
|
|
const state = url.searchParams.get("state");
|
|
expect(state).toBeTruthy();
|
|
return { fleet, loginID: body.loginID, pollSecret, state: state || "" };
|
|
}
|
|
|
|
function githubFetchMock({
|
|
member,
|
|
teams = [],
|
|
}: {
|
|
member: boolean;
|
|
teams?: Array<{ slug: string; organization: { login: string } }>;
|
|
}) {
|
|
return vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
|
|
const url =
|
|
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url === "https://github.com/login/oauth/access_token") {
|
|
return jsonResponse({ access_token: "github-access-token" });
|
|
}
|
|
if (url === "https://api.github.com/user") {
|
|
return jsonResponse({ login: "friend", name: "Friendly User", email: null });
|
|
}
|
|
if (url === "https://api.github.com/user/emails") {
|
|
return jsonResponse([{ email: "friend@example.com", primary: true, verified: true }]);
|
|
}
|
|
if (url === "https://api.github.com/user/memberships/orgs/openclaw") {
|
|
return member
|
|
? jsonResponse({ state: "active", organization: { login: "openclaw" } })
|
|
: jsonResponse({ message: "Not Found" }, 404);
|
|
}
|
|
if (url === "https://api.github.com/user/teams?per_page=100&page=1") {
|
|
return jsonResponse(teams);
|
|
}
|
|
return jsonResponse({ message: `unexpected ${url}` }, 500);
|
|
});
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function testFleet(
|
|
storage = new MemoryStorage(),
|
|
providers = {},
|
|
env: Partial<Env> = {},
|
|
): FleetDurableObject {
|
|
return new FleetDurableObject(
|
|
{ storage } as unknown as DurableObjectState,
|
|
{ CRABBOX_DEFAULT_ORG: "default-org", ...env } as Env,
|
|
providers,
|
|
);
|
|
}
|
|
|
|
function fakeProvider(
|
|
onCreate?: (config: {
|
|
awsSSHCIDRs: string[];
|
|
tailscale?: boolean;
|
|
tailscaleAuthKey?: string;
|
|
tailscaleHostname?: string;
|
|
tailscaleTags?: string[];
|
|
tailscaleExitNode?: string;
|
|
tailscaleExitNodeAllowLanAccess?: boolean;
|
|
}) => void,
|
|
result: {
|
|
provider?: "hetzner" | "aws";
|
|
serverType?: string;
|
|
cloudID?: string;
|
|
market?: string;
|
|
attempts?: ProvisioningAttempt[];
|
|
} = {},
|
|
) {
|
|
return {
|
|
async listCrabboxServers() {
|
|
return [];
|
|
},
|
|
async createServerWithFallback(
|
|
config: { awsSSHCIDRs: string[] },
|
|
_leaseID: string,
|
|
slug: string,
|
|
) {
|
|
onCreate?.(config);
|
|
return {
|
|
server: {
|
|
provider: result.provider ?? "hetzner",
|
|
id: 123,
|
|
cloudID: result.cloudID ?? "123",
|
|
name: `crabbox-${slug}`,
|
|
status: "running",
|
|
serverType: result.serverType ?? "cpx62",
|
|
host: "192.0.2.10",
|
|
region: result.provider === "aws" ? "eu-west-2" : undefined,
|
|
labels: {},
|
|
},
|
|
serverType: result.serverType ?? "cpx62",
|
|
market: result.market,
|
|
attempts: result.attempts,
|
|
};
|
|
},
|
|
async deleteServer() {},
|
|
async createImage(_instanceID: string, name: string) {
|
|
return { id: "ami-000000000001", name, state: "pending", region: "eu-west-1" };
|
|
},
|
|
async getImage(imageID: string) {
|
|
return {
|
|
id: imageID,
|
|
name: "openclaw-crabbox-test",
|
|
state: "available",
|
|
region: "eu-west-1",
|
|
};
|
|
},
|
|
async deleteSSHKey() {},
|
|
async hourlyPriceUSD() {
|
|
return 0.1;
|
|
},
|
|
};
|
|
}
|
|
|
|
function testLease(overrides: Partial<LeaseRecord>): LeaseRecord {
|
|
return {
|
|
id: "cbx_000000000000",
|
|
provider: "hetzner",
|
|
cloudID: "123",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
profile: "default",
|
|
class: "beast",
|
|
serverType: "ccx63",
|
|
serverID: 123,
|
|
serverName: "crabbox-blue-lobster",
|
|
providerKey: "crabbox-cbx-000000000000",
|
|
host: "192.0.2.1",
|
|
sshUser: "crabbox",
|
|
sshPort: "2222",
|
|
sshFallbackPorts: ["22"],
|
|
workRoot: "/work/crabbox",
|
|
keep: true,
|
|
ttlSeconds: 5400,
|
|
estimatedHourlyUSD: 1,
|
|
maxEstimatedUSD: 1.5,
|
|
state: "active",
|
|
createdAt: "2026-05-01T00:00:00.000Z",
|
|
updatedAt: "2026-05-01T00:00:00.000Z",
|
|
expiresAt: "2026-05-01T01:30:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function testRun(overrides: Partial<RunRecord>): RunRecord {
|
|
return {
|
|
id: "run_000000000000",
|
|
leaseID: "cbx_000000000000",
|
|
owner: "peter@example.com",
|
|
org: "openclaw",
|
|
provider: "hetzner",
|
|
class: "standard",
|
|
serverType: "cpx62",
|
|
command: ["echo", "ok"],
|
|
state: "running",
|
|
logBytes: 0,
|
|
logTruncated: false,
|
|
startedAt: "2026-05-01T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function request(
|
|
method: string,
|
|
path: string,
|
|
init: { headers?: Record<string, string>; body?: unknown } = {},
|
|
): Request {
|
|
return new Request(`https://crabbox.test${path}`, {
|
|
method,
|
|
headers: {
|
|
...(init.body === undefined ? {} : { "content-type": "application/json" }),
|
|
...init.headers,
|
|
},
|
|
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
});
|
|
}
|
|
|
|
async function sha256HexForTest(value: string): Promise<string> {
|
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|