mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 22:54:01 -06:00
b5d5ec340f
* feat(cloud-workers): add desktop apps and browser autonomy provider-attested Browser/Terminal launchers, shared visible loopback CDP Browser tool, no MCP/cookies/generic command. * feat(ui): add cloud desktop app launcher * docs(gateway): document cloud desktop apps and browser autonomy * perf(ui): trim desktop launcher startup copy * refactor(ui): simplify desktop launch feedback * perf(ui): reuse desktop app labels * fix(ui): keep desktop launch failures actionable * fix(crabbox): allow browser bootstrap to finish * fix(cloud-workers): honor provider provision budgets * fix(cloud-workers): persist browser screenshot receipts * fix(cloud-workers): bound browser screenshot lifecycle * fix(cloud-workers): avoid replaying desktop launches * test(cloud-workers): isolate browser runtime integration * refactor(cloud-workers): tighten desktop runtime boundaries * test(cloud-workers): keep browser runtime mock synchronous * fix(cloud-workers): break gateway type import cycle * fix(ci): settle admitted setup sessions in tests * build(plugin-sdk): refresh desktop app contract * ci: refresh merge-tree validation * build(plugin-sdk): regenerate desktop app baseline * style(gateway): format merged method order test
142 lines
5.3 KiB
TypeScript
142 lines
5.3 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
createBrowserTool: vi.fn(),
|
|
startBrowserBridgeServer: vi.fn(),
|
|
stopBrowserBridgeServer: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./browser-tool.js", () => ({
|
|
createBrowserTool: mocks.createBrowserTool,
|
|
}));
|
|
|
|
vi.mock("./browser/bridge-server.js", () => ({
|
|
startBrowserBridgeServer: mocks.startBrowserBridgeServer,
|
|
stopBrowserBridgeServer: mocks.stopBrowserBridgeServer,
|
|
}));
|
|
|
|
import { createAttachedBrowserToolRuntime } from "./attached-browser-tool-runtime.js";
|
|
|
|
describe("attached Browser tool runtime", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.createBrowserTool.mockReturnValue({ name: "browser" });
|
|
mocks.startBrowserBridgeServer.mockResolvedValue({
|
|
baseUrl: "http://127.0.0.1:18443",
|
|
server: { marker: "bridge-server" },
|
|
});
|
|
mocks.stopBrowserBridgeServer.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("exposes only one raw attach-only CDP profile through an authenticated loopback bridge", async () => {
|
|
const ensureAttachTarget = vi.fn().mockResolvedValue(undefined);
|
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "attached-browser-workspace-"));
|
|
const runtime = await createAttachedBrowserToolRuntime({
|
|
cdpUrl: "http://127.0.0.1:9222",
|
|
ensureAttachTarget,
|
|
agentSessionKey: "worker:session-1",
|
|
agentDir: "/tmp/worker-state",
|
|
workspaceDir,
|
|
});
|
|
|
|
expect(mocks.startBrowserBridgeServer).toHaveBeenCalledOnce();
|
|
const bridgeParams = mocks.startBrowserBridgeServer.mock.calls[0]?.[0];
|
|
expect(bridgeParams).toMatchObject({
|
|
host: "127.0.0.1",
|
|
port: 0,
|
|
authToken: expect.stringMatching(/^[A-Za-z0-9_-]{40,}$/u),
|
|
resolved: {
|
|
enabled: true,
|
|
attachOnly: true,
|
|
defaultProfile: "worker",
|
|
profiles: {
|
|
worker: {
|
|
driver: "openclaw",
|
|
attachOnly: true,
|
|
cdpUrl: "http://127.0.0.1:9222",
|
|
},
|
|
},
|
|
extensionRelayPorts: {},
|
|
extensionRelayInternalTokens: {},
|
|
},
|
|
});
|
|
expect(Object.keys(bridgeParams.resolved.profiles)).toEqual(["worker"]);
|
|
|
|
await bridgeParams.onEnsureAttachTarget();
|
|
expect(ensureAttachTarget).toHaveBeenCalledOnce();
|
|
expect(mocks.createBrowserTool).toHaveBeenCalledWith({
|
|
sandboxBridgeUrl: "http://127.0.0.1:18443",
|
|
allowHostControl: false,
|
|
agentSessionKey: "worker:session-1",
|
|
agentDir: "/tmp/worker-state",
|
|
workspaceDir,
|
|
screenshotResultMode: "path",
|
|
persistScreenshot: expect.any(Function),
|
|
});
|
|
const toolParams = mocks.createBrowserTool.mock.calls[0]?.[0];
|
|
const sourcePath = path.join(workspaceDir, "source.png");
|
|
await fs.writeFile(sourcePath, "screenshot-bytes");
|
|
const artifactPath = await toolParams.persistScreenshot({ sourcePath, type: "png" });
|
|
expect(artifactPath).toMatch(
|
|
/\.artifacts\/cloud-worker-browser\/screenshot-[a-f0-9]{16}\.png$/u,
|
|
);
|
|
expect(await fs.readFile(artifactPath, "utf8")).toBe("screenshot-bytes");
|
|
expect((await fs.stat(artifactPath)).mode & 0o777).toBe(0o600);
|
|
const filesBeforeFailure = await fs.readdir(path.dirname(artifactPath));
|
|
await expect(
|
|
toolParams.persistScreenshot({
|
|
sourcePath: path.join(workspaceDir, "missing.png"),
|
|
type: "png",
|
|
}),
|
|
).rejects.toThrow();
|
|
expect(await fs.readdir(path.dirname(artifactPath))).toEqual(filesBeforeFailure);
|
|
expect(runtime.tool).toEqual({ name: "browser" });
|
|
|
|
await runtime.dispose();
|
|
expect(mocks.stopBrowserBridgeServer).toHaveBeenCalledWith({ marker: "bridge-server" });
|
|
expect(ensureAttachTarget).toHaveBeenCalledOnce();
|
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects screenshot artifact symlink escapes",
|
|
async () => {
|
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "attached-browser-workspace-"));
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "attached-browser-outside-"));
|
|
await fs.symlink(outsideDir, path.join(workspaceDir, ".artifacts"));
|
|
await createAttachedBrowserToolRuntime({
|
|
cdpUrl: "http://127.0.0.1:9222",
|
|
ensureAttachTarget: async () => {},
|
|
workspaceDir,
|
|
});
|
|
const toolParams = mocks.createBrowserTool.mock.calls.at(-1)?.[0];
|
|
const sourcePath = path.join(workspaceDir, "source.png");
|
|
await fs.writeFile(sourcePath, "screenshot-bytes");
|
|
|
|
await expect(toolParams.persistScreenshot({ sourcePath, type: "png" })).rejects.toThrow();
|
|
expect(await fs.readdir(outsideDir)).toEqual([]);
|
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
"http://localhost:9222",
|
|
"https://127.0.0.1:9222",
|
|
"http://127.0.0.1",
|
|
"http://127.0.0.1:9222/devtools/browser/target",
|
|
])("rejects non-canonical raw CDP URL %s before starting a bridge", async (cdpUrl) => {
|
|
await expect(
|
|
createAttachedBrowserToolRuntime({
|
|
cdpUrl,
|
|
ensureAttachTarget: async () => {},
|
|
workspaceDir: "/tmp/workspace",
|
|
}),
|
|
).rejects.toThrow("loopback HTTP URL with an explicit port");
|
|
expect(mocks.startBrowserBridgeServer).not.toHaveBeenCalled();
|
|
});
|
|
});
|