Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/docs/content/docs/browser.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ For full interaction (clicking, typing, scrolling, form flows), `superset browse

Agents pick this up automatically through the [`superset:browser` skill](/skills). A pane runs against the browser's real, logged-in session; agents are instructed to treat it accordingly and to confirm before consequential actions.

For a remote host authenticated with an API key, `browser cdp` starts a local proxy that keeps the exported URL usable after the command exits. It refreshes the remote token when a client connects. Repeating the command with the same API key, API address, organization, host, workspace and pane reuses that proxy while the remote endpoint is unchanged.

The URL is a secret capability for that pane. It listens only on the CLI machine's loopback interface; clients on another machine need deliberate port forwarding. The proxy keeps the API key in memory and stops after 24 hours without an active connection. Active connections keep it running. Stop it explicitly with the `proxyId` returned by `--json`:

```bash
superset browser cdp-stop --id <proxyId>
```

Stopping does not require the original API key. Local-host and OAuth-authenticated endpoints retain their existing token lifetime.

## Pane or standalone browser?

The `superset:browser` skill is engine-generic with the in-app pane as the default: when a page is (or can be) open in a workspace pane (previewing a dev server, verifying UI you're watching, clicking through a flow), agents use the `superset browser` commands above.
Expand Down
19 changes: 18 additions & 1 deletion apps/docs/content/docs/cli/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1272,11 +1272,28 @@ Read the pane's captured console output.
{ flag: "--pane <id>", required: true, description: "Pane ID." },
{ flag: "--host <id>", description: "Host the workspace lives on (default: this machine)." },
]}
output={`{ url: string } // a CDP WebSocket URL`}
output={`{ url: string, proxyId?: string, idleTimeoutSeconds?: number }`}
>
Print a raw Chrome DevTools Protocol WebSocket endpoint for the pane, for
browser-use / Playwright-class tools (mouse, keyboard, scroll, DOM, network).
The URL embeds an auth token; treat it as a secret.
For remote hosts authenticated with an API key, the URL connects through a local
proxy that refreshes remote credentials after this command exits. It works on
the CLI machine; remote consumers need deliberate port forwarding. Repeating
the same target and credentials reuses the proxy. It exits after 24 hours without
an active connection. Use `proxyId` to stop it sooner.
</Command>

<Command
name="superset browser cdp-stop"
options={[
{ flag: "--id <proxyId>", required: true, description: "Proxy ID returned by browser cdp." },
]}
output={`{ proxyId: string, stopped: boolean }`}
>
Stop a local exported CDP proxy and disconnect its clients. This command uses
the local proxy's management credential so it works even if the original API
key has been removed or revoked. Returns `stopped: false` if no proxy was found.
</Command>

---
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/browser/cdp-proxy/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { string } from "@superset/cli-framework";
import { runCdpProxyWorker } from "../../../lib/cdp-proxy/worker";
import { command } from "../../../lib/command";

export default command({
description: "Run the local CDP proxy worker",
hidden: true,
skipMiddleware: true,
options: { instance: string().required() },
run: async ({ signal, options }) => {
try {
await runCdpProxyWorker(signal, options.instance);
} catch {
throw new Error("CDP proxy worker failed");
}
},
});
16 changes: 16 additions & 0 deletions packages/cli/src/commands/browser/cdp-stop/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { string } from "@superset/cli-framework";
import { stopCdpProxy } from "../../../lib/cdp-proxy/client";
import { command } from "../../../lib/command";

export default command({
description: "Stop an exported local CDP proxy",
skipMiddleware: true,
options: { id: string().required().desc("Proxy ID from browser cdp") },
run: async ({ options }) => {
const stopped = await stopCdpProxy(options.id);
return {
data: { proxyId: options.id, stopped },
message: stopped ? "CDP proxy stopped" : "No CDP proxy found",
};
},
});
189 changes: 189 additions & 0 deletions packages/cli/src/commands/browser/cdp/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { afterEach, expect, test } from "bun:test";
import {
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const homes: string[] = [];
const stops: Array<() => Promise<void>> = [];

afterEach(async () => {
for (const stop of stops.splice(0)) await stop();
for (const home of homes.splice(0))
rmSync(home, { recursive: true, force: true });
});

const launchers = [
{
name: "source",
argv: [
process.execPath,
"run",
join(import.meta.dir, "fixtures/export.ts"),
],
direct: true,
},
{
name: "dev",
argv: [
process.execPath,
join(import.meta.dir, "../../../../../cli-framework/src/bin.ts"),
"dev",
],
direct: false,
},
...(process.env.CDP_TEST_BINARY
? [{ name: "compiled", argv: [process.env.CDP_TEST_BINARY], direct: false }]
: []),
];

test.each(
launchers,
)("$name exported CDP URL survives CLI exit and the original JWT expiry", async (launcher) => {
let clock = Math.floor(Date.now() / 1000);
const originalClock = clock;
let exchanges = 0;
const server = Bun.serve({
port: 0,
fetch(request, server) {
const url = new URL(request.url);
if (url.pathname === "/api/auth/token") {
exchanges++;
return Response.json({
token: `header.${Buffer.from(JSON.stringify({ exp: clock + 3600 })).toString("base64url")}.signature`,
});
}
if (url.pathname === "/api/trpc/host.relayEndpoint")
return Response.json([
{ result: { data: { json: { url: server.url.origin } } } },
]);
if (url.pathname.endsWith("/trpc/browser.list"))
return Response.json([
{
result: { data: { json: { panes: [{ paneId: "pane-fixture" }] } } },
},
]);
if (url.pathname.endsWith("/cdp")) {
const token = url.searchParams.get("token") ?? "";
const payload = JSON.parse(
Buffer.from(token.split(".")[1] ?? "", "base64url").toString(),
);
if (payload.exp <= clock) return new Response(null, { status: 401 });
if (server.upgrade(request)) return;
}
return new Response(null, { status: 404 });
},
websocket: {
message(ws, message) {
ws.send(message);
},
},
});
stops.push(async () => {
server.stop(true);
});
const home = mkdtempSync(join(tmpdir(), "superset-cdp-export-"));
homes.push(home);
const child = Bun.spawn(
[
...launcher.argv,
...(launcher.direct
? []
: [
"browser",
"cdp",
"--host",
"remote-fixture",
"--workspace",
"workspace-fixture",
"--pane",
"pane-fixture",
"--json",
]),
],
{
cwd: join(import.meta.dir, "../../../.."),
env: {
...process.env,
SUPERSET_HOME_DIR: home,
SUPERSET_API_URL: server.url.origin,
SUPERSET_API_KEY: ["sk", "test", "cdp", "fixture"].join("_"),
SUPERSET_ORGANIZATION_ID: "org-fixture",
},
stdout: "pipe",
stderr: "pipe",
},
);
const output = await new Response(child.stdout).text();
expect(await child.exited).toBe(0);
const parsed = JSON.parse(output);
const result = (launcher.direct ? parsed : { data: parsed }) as {
data: { url: string; proxyId?: string };
};
if (result.data.proxyId) {
const manifestPath = join(
home,
"cdp-proxies",
`${result.data.proxyId}.json`,
);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
stops.unshift(async () => {
const stop = Bun.spawn(
[
process.execPath,
"run",
join(import.meta.dir, "fixtures/stop.ts"),
manifest.id,
],
{
env: {
...process.env,
SUPERSET_HOME_DIR: home,
SUPERSET_API_KEY: undefined,
},
stdout: "pipe",
stderr: "pipe",
},
);
expect(await stop.exited).toBe(0);
await expect(fetch(`${manifest.endpoint}/health`)).rejects.toThrow();
});
const warning = await new Response(child.stderr).text();
if (launcher.direct) expect(parsed.message).toBe(result.data.url);
expect(warning).toContain("Treat it as a secret");
expect(warning).toContain(`superset browser cdp-stop --id ${manifest.id}`);
expect(warning).not.toContain(manifest.token);
expect(warning).not.toContain(result.data.url);
expect(warning).not.toContain(["sk", "test", "cdp", "fixture"].join("_"));
expect(statSync(manifestPath).mode & 0o777).toBe(0o600);
expect(readFileSync(manifestPath, "utf8")).not.toContain(
["sk", "test", "cdp", "fixture"].join("_"),
);
}
clock = originalClock + 3601;
const ws = new WebSocket(result.data.url);
const received = await new Promise<string>((resolve, reject) => {
ws.onopen = () => ws.send('{"id":1,"method":"Runtime.enable"}');
ws.onmessage = (event) => {
resolve(String(event.data));
ws.close();
};
ws.onerror = () =>
reject(
new Error("Exported CDP URL rejected after original token expiry"),
);
});
expect(received).toBe('{"id":1,"method":"Runtime.enable"}');
expect(exchanges).toBeGreaterThan(1);
expect(result.data.url).toStartWith("ws://127.0.0.1:");
expect(
readdirSync(join(home, "cdp-proxies")).filter((name) =>
name.endsWith(".json"),
),
).toHaveLength(1);
});
32 changes: 29 additions & 3 deletions packages/cli/src/commands/browser/cdp/command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CLIError, string } from "@superset/cli-framework";
import { ensureCdpProxy } from "../../../lib/cdp-proxy/client";
import { command } from "../../../lib/command";
import { resolveBrowserTarget } from "../shared";

Expand All @@ -11,7 +12,10 @@ export default command({
pane: string().required().desc("Pane ID (from `superset browser list`)"),
},
run: async ({ ctx, options }) => {
const { client, ws } = await resolveBrowserTarget(ctx, options);
const { client, ws, kind, hostId } = await resolveBrowserTarget(
ctx,
options,
);
// Verify the pane exists in this workspace before handing out a URL — a
// dead/foreign pane id would otherwise fail only once the tool dials in.
const { panes } = await client.browser.list.query({
Expand All @@ -23,9 +27,31 @@ export default command({
"Run: superset browser list --workspace <id>",
);
}
const url = `${ws.baseWsUrl}/browser/${encodeURIComponent(
const endpoint = `${ws.baseWsUrl}/browser/${encodeURIComponent(
options.pane,
)}/cdp?workspaceId=${encodeURIComponent(options.workspace)}&token=${encodeURIComponent(ws.token)}`;
)}/cdp?workspaceId=${encodeURIComponent(options.workspace)}`;
if (
kind === "remote" &&
(ctx.bearer.startsWith("sk_live_") || ctx.bearer.startsWith("sk_test_"))
) {
const proxy = await ensureCdpProxy({
apiKey: ctx.bearer,
organizationId: ctx.config.organizationId!,
hostId,
workspaceId: options.workspace,
paneId: options.pane,
upstreamUrl: endpoint,
});
process.stderr.write(
`This local URL grants CDP control of the pane. Treat it as a secret.\nStop the proxy with: superset browser cdp-stop --id ${proxy.proxyId}\n`,
);
return {
data: proxy,
message: proxy.url,
};
Comment thread
kr1shna-exe marked this conversation as resolved.
Resolved
}
const token = ws.getToken ? await ws.getToken() : ws.token;
const url = `${endpoint}&token=${encodeURIComponent(token)}`;
return {
data: { url },
// The URL embeds a bearer token — treat it as a credential (keep it out
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/commands/browser/cdp/fixtures/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createApiClient } from "../../../../lib/api-client";
import cdp from "../command";

const bearer = ["sk", "test", "cdp", "fixture"].join("_");
const organizationId = "org-fixture";
const result = await cdp.run({
ctx: {
bearer,
authSource: "override",
config: { organizationId },
api: createApiClient({ bearer, organizationId }),
},
options: {
workspace: "workspace-fixture",
host: "remote-fixture",
pane: "pane-fixture",
},
args: {},
signal: new AbortController().signal,
});
process.stdout.write(JSON.stringify(result));
8 changes: 8 additions & 0 deletions packages/cli/src/commands/browser/cdp/fixtures/stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import stop from "../../cdp-stop/command";

await stop.run({
ctx: {},
options: { id: process.argv[2] ?? "" },
args: {},
signal: new AbortController().signal,
});
9 changes: 7 additions & 2 deletions packages/cli/src/commands/browser/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
export async function resolveBrowserTarget(
ctx: CliContext,
options: { workspace: string; host?: string | null },
): Promise<{ client: HostServiceClient; hostId: string; ws: HostWsEndpoint }> {
): Promise<{
client: HostServiceClient;
hostId: string;
ws: HostWsEndpoint;
kind: "local" | "remote" | "cloud";
}> {
const organizationId = ctx.config.organizationId;
if (!organizationId) {
throw new CLIError("No active organization", "Run: superset auth login");
Expand All @@ -26,5 +31,5 @@ export async function resolveBrowserTarget(
userJwt: ctx.bearer,
api: ctx.api,
});
return { client: target.client, hostId, ws: target.ws };
return { client: target.client, hostId, ws: target.ws, kind: target.kind };
}
Loading