Skip to content
Merged
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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"name": "agent-cdp-root",
"private": true,
"license": "MIT",
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
"workspaces": [
"packages/*"
],
Expand Down
27 changes: 22 additions & 5 deletions packages/agent-cdp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

| Environment | Notes |
|-------------|--------|
| **Chrome / Chromium** | Requires a CDP debug endpoint, typically with remote debugging enabled (for example port `9222`). You point the CLI at the `/json/list` URL for that endpoint. |
| **React Native** | Works with the Metro / dev tooling that exposes a CDP-compatible target list (often `http://127.0.0.1:8081` during development). Same flow as Chrome: `target list` with the dev server URL. |
| **Node.js** | Supports attaching to Node processes started with **`--inspect`** or **`--inspect-brk`** (or the equivalent `NODE_OPTIONS`). They expose the same CDP discovery model as Chrome; point `target list` at the inspector base URL (often `http://127.0.0.1:9229` for the default port, or your `--inspect=host:port` value). |
| **Chrome / Chromium** | Requires a CDP debug endpoint, typically with remote debugging enabled (for example port `9222`). You can point the CLI at that endpoint explicitly, or let `target list` scan the default local ports. |
| **React Native** | Works with the Metro / dev tooling that exposes a CDP-compatible target list (often `http://127.0.0.1:8081` during development). `target list` scans that port by default, or you can pass the dev server URL explicitly. |
| **Node.js** | Supports attaching to Node processes started with **`--inspect`** or **`--inspect-brk`** (or the equivalent `NODE_OPTIONS`). They expose the same CDP discovery model as Chrome; `target list` scans the default inspect port (`http://127.0.0.1:9229`) automatically, or you can pass your `--inspect=host:port` URL explicitly. |

Anything that exposes the same style of CDP HTTP discovery (`/json/list`) and WebSocket debugging should work; behavior depends on what the target implements.

Expand Down Expand Up @@ -45,11 +45,26 @@ agent-cdp status

**2. List targets and select one**

By default, `target list` scans these local discovery URLs in parallel:

- `http://127.0.0.1:9222`
- `http://127.0.0.1:9229`
- `http://127.0.0.1:8081`

Returned target IDs embed the discovery URL, so `target select <target-id>` does not require `--url`.

Default local scan:

```sh
agent-cdp target list
agent-cdp target select <target-id>
```

Chrome (example port):

```sh
agent-cdp target list --url http://127.0.0.1:9222
agent-cdp target select <target-id> --url http://127.0.0.1:9222
agent-cdp target select <target-id>
```

React Native (example Metro URL):
Expand All @@ -62,9 +77,11 @@ Node.js (example default inspect port after starting your app with `node --inspe

```sh
agent-cdp target list --url http://127.0.0.1:9229
agent-cdp target select <target-id> --url http://127.0.0.1:9229
agent-cdp target select <target-id>
```

If you pass `--url` to `target select`, it must match the discovery URL encoded in the target ID.

Clear the current selection when needed:

```sh
Expand Down
11 changes: 7 additions & 4 deletions packages/agent-cdp/skills/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,27 @@ Always start the daemon before any other commands. `start` is idempotent.
A "target" is a Chrome tab or Node.js process exposing a CDP endpoint.

```bash
agent-cdp target list # scan default local CDP URLs (9222, 9229, 8081)
agent-cdp target list --url http://localhost:9229 # list targets for a Node.js process
agent-cdp target list --url http://localhost:9222 # list targets for Chrome
agent-cdp target list --url http://localhost:8081 # list targets for React Native (Metro)
agent-cdp target select <id> --url URL # select a specific target
agent-cdp target select <id> # select a specific target using the URL encoded in the id
agent-cdp target select <id> --url URL # optional URL consistency check
agent-cdp target clear # deselect the current target
```

The `--url` flag is the CDP discovery URL (the `--inspect` address for Node.js,
or Chrome's remote debugging port). After `target select`, subsequent commands
use that target automatically.
or Chrome's remote debugging port). When omitted, `target list` scans the local
default URLs and encodes the discovery URL into each target id. After
`target select`, subsequent commands use that target automatically.

### React Native

React Native apps expose a CDP endpoint through the Metro bundler on port 8081.

```bash
agent-cdp target list --url http://localhost:8081
agent-cdp target select <id> --url http://localhost:8081
agent-cdp target select <id>
```

Requirements:
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-cdp/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ describe("cli", () => {
url: "http://127.0.0.1:9222",
},
});
expect(parseArgs(["target", "select", "chrome:MTI3LjAuMC4xOjkyMjI:page-1"])).toEqual({
command: ["target", "select", "chrome:MTI3LjAuMC4xOjkyMjI:page-1"],
flags: {},
});
});

it("prints the available daemon commands", () => {
expect(usage()).toContain("start");
expect(usage()).toContain("status");
expect(usage()).toContain("stop");
expect(usage()).toContain("target list");
expect(usage()).toContain("target list [--url URL]");
expect(usage()).toContain("target select <id> [--url URL]");
expect(usage()).toContain("js-allocation start");
expect(usage()).toContain("js-allocation-timeline start");
});
Expand Down
90 changes: 87 additions & 3 deletions packages/agent-cdp/src/__tests__/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
import { buildTargetId, getDiscoveryUrl, mapChromeTarget, mapReactNativeTarget } from "../discovery.js";
import {
buildTargetId,
decodeTargetSource,
DEFAULT_DISCOVERY_URLS,
discoverTargets,
encodeTargetSource,
getDiscoveryUrl,
getDiscoveryUrls,
mapChromeTarget,
mapReactNativeTarget,
parseTargetId,
} from "../discovery.js";

describe("discovery helpers", () => {
it("builds deterministic target ids", () => {
expect(buildTargetId("chrome", "page-1")).toBe("chrome:page-1");
expect(buildTargetId("chrome", "http://127.0.0.1:9222", "page-1")).toBe(
"chrome:MTI3LjAuMC4xOjkyMjI:page-1",
);
});

it("strips only http scheme when encoding the source", () => {
expect(encodeTargetSource("http://127.0.0.1:9222")).toBe("MTI3LjAuMC4xOjkyMjI");
expect(decodeTargetSource("MTI3LjAuMC4xOjkyMjI")).toBe("http://127.0.0.1:9222");
});

it("preserves non-http schemes when encoding the source", () => {
const encoded = encodeTargetSource("https://example.test:8443/devtools?foo=1");
expect(decodeTargetSource(encoded)).toBe("https://example.test:8443/devtools?foo=1");
});

it("parses target ids back to their source url", () => {
expect(parseTargetId("chrome:MTI3LjAuMC4xOjkyMjI:page-1")).toEqual({
kind: "chrome",
encodedSource: "MTI3LjAuMC4xOjkyMjI",
rawId: "page-1",
sourceUrl: "http://127.0.0.1:9222",
});
});

it("maps the configured discovery url", () => {
expect(getDiscoveryUrl({ url: "http://127.0.0.1:9222/" })).toBe("http://127.0.0.1:9222");
expect(getDiscoveryUrl({ url: "127.0.0.1:9222/" })).toBe("http://127.0.0.1:9222");
});

it("returns default discovery urls when none are configured", () => {
expect(getDiscoveryUrls({})).toEqual([...DEFAULT_DISCOVERY_URLS]);
});

it("maps chrome targets", () => {
Expand All @@ -18,6 +54,7 @@ describe("discovery helpers", () => {
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/1",
}),
).toMatchObject({
id: "chrome:MTI3LjAuMC4xOjkyMjI:page-1",
rawId: "page-1",
kind: "chrome",
title: "Example",
Expand All @@ -39,11 +76,58 @@ describe("discovery helpers", () => {
},
}),
).toMatchObject({
id: "react-native:MTI3LjAuMC4xOjgwODE:device-page",
kind: "react-native",
appId: "com.example.app",
reactNative: {
logicalDeviceId: "device-1",
},
});
});

it("keeps ids unique across different source urls", () => {
expect(buildTargetId("chrome", "http://127.0.0.1:9222", "page-1")).not.toBe(
buildTargetId("chrome", "http://127.0.0.1:9229", "page-1"),
);
});

it("merges successful targets across default discovery urls", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response(
JSON.stringify([{ id: "page-1", title: "Chrome", webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/1" }]),
),
)
.mockRejectedValueOnce(new Error("connect ECONNREFUSED"))
.mockResolvedValueOnce(
new Response(
JSON.stringify([
{
id: "device-page",
title: "React Native Experimental",
appId: "com.example.app",
webSocketDebuggerUrl: "ws://127.0.0.1:8081/inspector/debug?page=1",
reactNative: { logicalDeviceId: "device-1", capabilities: {} },
},
]),
),
);

vi.stubGlobal("fetch", fetchMock);

await expect(discoverTargets({})).resolves.toMatchObject([
{ id: "chrome:MTI3LjAuMC4xOjkyMjI:page-1", kind: "chrome" },
{ id: "react-native:MTI3LjAuMC4xOjgwODE:device-page", kind: "react-native" },
]);
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it("throws for explicit discovery url failures", async () => {
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 500 })));

await expect(discoverTargets({ url: "127.0.0.1:9222" })).rejects.toThrow(
"Target discovery failed for http://127.0.0.1:9222: HTTP 500",
);
});
});
34 changes: 28 additions & 6 deletions packages/agent-cdp/src/__tests__/session-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { SessionManager } from "../session-manager.js";
import type { CdpEventMessage, CdpTransport, TargetDescriptor, TargetProvider } from "../types.js";

const CHROME_TEST_ID = "chrome:ZXhhbXBsZS50ZXN0:page-1";
const REACT_NATIVE_TEST_ID = "react-native:ZXhhbXBsZS50ZXN0:page-1";

class FakeTransport implements CdpTransport {
connected = false;

Expand Down Expand Up @@ -39,7 +42,7 @@ describe("SessionManager", () => {
it("lists targets from configured providers", async () => {
const targets = [
{
id: "chrome:test:page-1",
id: CHROME_TEST_ID,
rawId: "page-1",
title: "Example",
kind: "chrome" as const,
Expand All @@ -55,7 +58,7 @@ describe("SessionManager", () => {
it("selects and clears a target", async () => {
const targets = [
{
id: "chrome:test:page-1",
id: CHROME_TEST_ID,
rawId: "page-1",
title: "Example",
kind: "chrome" as const,
Expand All @@ -65,7 +68,7 @@ describe("SessionManager", () => {
},
];
const manager = new SessionManager([new FakeProvider()], () => Promise.resolve(targets));
await expect(manager.selectTarget("chrome:test:page-1", { url: "http://example.test" })).resolves.toMatchObject({
await expect(manager.selectTarget(CHROME_TEST_ID, {})).resolves.toMatchObject({
title: "Example",
});
expect(manager.getSessionState()).toBe("connected");
Expand All @@ -74,6 +77,25 @@ describe("SessionManager", () => {
expect(manager.getSessionState()).toBe("disconnected");
});

it("rejects mismatched explicit urls when selecting a target", async () => {
const targets = [
{
id: CHROME_TEST_ID,
rawId: "page-1",
title: "Example",
kind: "chrome" as const,
description: "Test page",
webSocketDebuggerUrl: "ws://example.test/devtools/page/1",
sourceUrl: "http://example.test",
},
];
const manager = new SessionManager([new FakeProvider()], () => Promise.resolve(targets));

await expect(manager.selectTarget(CHROME_TEST_ID, { url: "http://other.test" })).rejects.toThrow(
`Target id source does not match --url: ${CHROME_TEST_ID}`,
);
});

it("reconnects react native targets by logical device id", async () => {
class FakeReactNativeProvider implements TargetProvider {
readonly kind = "react-native" as const;
Expand All @@ -83,7 +105,7 @@ describe("SessionManager", () => {
this.attempt += 1;
return [
{
id: `react-native:test:page-${this.attempt}`,
id: `react-native:ZXhhbXBsZS50ZXN0:page-${this.attempt}`,
rawId: `page-${this.attempt}`,
title: "React Native Experimental",
kind: "react-native",
Expand Down Expand Up @@ -113,7 +135,7 @@ describe("SessionManager", () => {
attempt += 1;
return Promise.resolve([
{
id: `react-native:test:page-${attempt}`,
id: `react-native:ZXhhbXBsZS50ZXN0:page-${attempt}`,
rawId: `page-${attempt}`,
title: "React Native Experimental",
kind: "react-native" as const,
Expand All @@ -133,7 +155,7 @@ describe("SessionManager", () => {
})();

const manager = new SessionManager([new FakeReactNativeProvider()], discoverTargetsImpl);
await manager.selectTarget("react-native:test:page-1", { url: "http://example.test" });
await manager.selectTarget(REACT_NATIVE_TEST_ID, {});
const session = manager.getSession();
if (!session) {
throw new Error("Expected session to exist");
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-cdp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ Daemon:
status Show daemon status

Targets:
target list --url URL
target select <id> --url URL
target list [--url URL]
target select <id> [--url URL]
target clear

Console:
Expand Down Expand Up @@ -308,7 +308,7 @@ export async function main(): Promise<void> {
if (cmd === "target" && command[1] === "select") {
const targetId = command[2];
if (!targetId) {
throw new Error("Usage: agent-cdp target select <id> --url URL");
throw new Error("Usage: agent-cdp target select <id> [--url URL]");
}
await ensureDaemon();
const response = await sendCommand({
Expand Down
Loading
Loading